The monolith’s layered architecture, specifically the Controller-Service-Repository pattern, isn’t about enforcing strict boundaries as much as it is about managing complexity through a clear division of responsibilities.

Let’s see it in action. Imagine a simple e-commerce application where a user requests to view their order history.

// Controller Layer (e.g., OrderController.java)
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{userId}")
    public ResponseEntity<List<Order>> getUserOrders(@PathVariable Long userId) {
        List<Order> orders = orderService.findOrdersByUserId(userId);
        return ResponseEntity.ok(orders);
    }
}

// Service Layer (e.g., OrderService.java)
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public List<Order> findOrdersByUserId(Long userId) {
        // Business logic: maybe filter by status, add some calculated fields, etc.
        List<Order> rawOrders = orderRepository.findByUserId(userId);
        // Example: Filter out cancelled orders for this view
        return rawOrders.stream()
                        .filter(order -> !order.getStatus().equals("CANCELLED"))
                        .collect(Collectors.toList());
    }
}

// Repository Layer (e.g., OrderRepository.java)
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByUserId(Long userId);
}

// Domain/Model (e.g., Order.java)
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long userId;
    private String status;
    // ... other fields like items, total amount, etc.
}

This setup breaks down the request flow: the Controller receives the HTTP request, delegates the core logic to the Service, and the Service uses the Repository to interact with the database.

The fundamental problem this architecture solves is maintainability in large codebases. Without it, you’d have database queries mixed directly with HTTP request handling, leading to a tangled mess where changing one part breaks another unexpectedly. The Controller focuses on how to handle incoming requests (HTTP methods, request parameters, response formatting), the Service focuses on what the business logic is (validations, orchestrating multiple data operations, complex calculations), and the Repository focuses solely on where and how to get or save data (database queries, ORM interactions).

Internally, the magic is dependency injection. In Spring (as shown above), when OrderController is created, Spring injects an instance of OrderService. Similarly, OrderService gets an OrderRepository. This loose coupling means you can swap out implementations. For instance, you could create a MockOrderRepository for testing without touching the OrderService or OrderController. The Repository interface defines the contract for data access, and the JpaRepository (or any other implementation like MyBatis, or even a custom JDBC implementation) fulfills that contract.

The levers you control are the responsibilities each layer takes on. The Controller handles HTTP concerns: authentication, authorization (though often delegated to a separate security layer), request validation (like ensuring userId is present), and mapping request/response bodies. The Service layer contains the "business logic": it’s where you’d implement rules like "a user cannot order more than 5 items at a time" or "apply a 10% discount if the order total exceeds $100." The Repository layer is purely about data persistence and retrieval. It translates the Service’s data needs into specific database operations.

A common misconception is that the Service layer should never call another Service. In reality, complex business processes often require orchestrating multiple services. For example, an OrderService might call a PaymentService to process a payment and then call an InventoryService to update stock levels, before finally calling its own findOrdersByUserId method to return the updated order status. The key is that each service should have a cohesive set of responsibilities, and orchestration should be handled either within a top-level service or a dedicated "orchestration" service.

The next thing you’ll likely grapple with is how to manage transactions across these layers effectively.

Want structured learning?

Take the full Monolith course →