Table of contents
- What Is Layered Architecture?
- Responsibilities of Each Layer
- The Typical Flow in Spring MVC
- Package Structure
- Strengths of Layered Architecture
- Limitations of Layered Architecture
- The Dependency Direction Problem
- Recognizing the Limitations
- Key Takeaways
What Is Layered Architecture?
Layered architecture divides code into horizontal layers. Each layer has its own responsibility, and upper layers call lower layers in a unidirectional flow. The most typical form is the 3-layer structure.
flowchart TB
subgraph layered["Layered Architecture — 3 Layers"]
direction TB
P["Presentation Layer<br/>Receives user requests, returns responses"]
B["Business Layer<br/>Processes business logic"]
D["Persistence Layer<br/>Stores and retrieves data"]
end
P -->|"calls"| B
B -->|"calls"| D
style P fill:#339af0,color:#fff
style B fill:#51cf66,color:#fff
style D fill:#ffd43b,color:#000
The unidirectional dependency flowing from top to bottom is the core of this architecture. Presentation knows Business but Business doesn’t know Presentation. Business knows Persistence but Persistence doesn’t know Business.
This structure has been the default pattern for enterprise applications since the 1990s. It was systematically organized in Martin Fowler’s 2003 Patterns of Enterprise Application Architecture, and became the de facto standard in the Java/Kotlin ecosystem as Spring Framework adopted this pattern directly.
Responsibilities of Each Layer
Presentation Layer — Entry and Exit for Requests
The Presentation layer connects the external world to the application. It receives HTTP requests, parses them, delegates to the business layer, and converts results into HTTP responses. That’s all it does.
In Spring MVC, @Controller or @RestController takes this role.
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val orderService: OrderService
) {
@PostMapping
fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<OrderResponse> {
val order = orderService.create(request.toCommand())
return ResponseEntity.status(HttpStatus.CREATED)
.body(OrderResponse.from(order))
}
@GetMapping("/{id}")
fun getOrder(@PathVariable id: Long): ResponseEntity<OrderResponse> {
val order = orderService.findById(id)
return ResponseEntity.ok(OrderResponse.from(order))
}
}
The key principle in this layer is keeping business logic out. Discounting orders over 100,000 should be in the service, not the controller. Controllers do only three things: receive requests, delegate, and build responses.
Business Layer — Home of the Core Logic
The Business layer is the reason the application exists. Business rules like “create an order,” “process a payment,” and “check if stock is sufficient” live here.
In Spring, the convention is to mark these with @Service.
@Service
@Transactional
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
private val paymentService: PaymentService
) {
fun create(command: CreateOrderCommand): Order {
val product = productRepository.findById(command.productId)
?: throw ProductNotFoundException(command.productId)
// Business rule: check stock
require(product.stock >= command.quantity) {
"Insufficient stock. Current stock: ${product.stock}"
}
// Deduct stock
product.decreaseStock(command.quantity)
productRepository.save(product)
// Create order
val order = Order.create(
productId = product.id,
quantity = command.quantity,
totalAmount = product.price * command.quantity
)
orderRepository.save(order)
// Process payment
paymentService.pay(order)
return order
}
fun findById(id: Long): Order {
return orderRepository.findById(id)
?: throw OrderNotFoundException(id)
}
}
The Business layer calls Persistence layer repositories to fetch data, applies business rules, and saves results back. Transaction boundaries are typically determined in this layer.
Persistence Layer — Storage and Retrieval of Data
The Persistence layer is where direct communication with the database happens. It executes SQL or maps objects to tables through ORM (Object-Relational Mapping).
With Spring Data JPA, just declaring an interface automatically generates the implementation.
@Repository
interface OrderRepository : JpaRepository<OrderEntity, Long> {
fun findByStatus(status: OrderStatus): List<OrderEntity>
fun findByUserIdOrderByCreatedAtDesc(userId: Long): List<OrderEntity>
}
@Repository
interface ProductRepository : JpaRepository<ProductEntity, Long>
It’s natural for this layer to be coupled to DB technology. Whether you use JPA, MyBatis, or plain JDBC — that’s a decision made within the Persistence layer. Other layers don’t need to know about it (ideally).
The Typical Flow in Spring MVC
Let’s look at the typical flow where layered architecture is realized in a Spring Boot project through a sequence diagram.
sequenceDiagram
participant Client as Client
participant Controller as OrderController
participant Service as OrderService
participant Repository as OrderRepository
participant DB as Database
Client->>Controller: POST /api/orders
Controller->>Controller: Parse request, validate
Controller->>Service: create(command)
Service->>Repository: findById(productId)
Repository->>DB: SELECT * FROM products
DB-->>Repository: ProductEntity
Repository-->>Service: Product
Service->>Service: Apply business rules
Service->>Repository: save(order)
Repository->>DB: INSERT INTO orders
DB-->>Repository: OK
Repository-->>Service: Order
Service-->>Controller: Order
Controller->>Controller: Convert to response
Controller-->>Client: 201 Created + OrderResponse
When a request arrives, it flows down through Controller -> Service -> Repository -> DB, and responses travel back up in reverse. This predictable flow is the greatest advantage of layered architecture.
Package Structure
When applying layered architecture to a Spring project, two packaging strategies typically emerge.
Package by Layer
com.example.shop/
├── controller/
│ ├── OrderController.kt
│ ├── ProductController.kt
│ └── UserController.kt
├── service/
│ ├── OrderService.kt
│ ├── ProductService.kt
│ └── UserService.kt
├── repository/
│ ├── OrderRepository.kt
│ ├── ProductRepository.kt
│ └── UserRepository.kt
├── entity/
│ ├── OrderEntity.kt
│ ├── ProductEntity.kt
│ └── UserEntity.kt
└── dto/
├── CreateOrderRequest.kt
├── OrderResponse.kt
└── ...
Intuitive with low learning cost. The rule that controllers go in the controller package and services in the service package is clear. But problems emerge as the project grows. Fifty service classes pile up in the service/ package, and you can’t tell from the package alone which service belongs to which domain.
Package by Feature
com.example.shop/
├── order/
│ ├── OrderController.kt
│ ├── OrderService.kt
│ ├── OrderRepository.kt
│ ├── OrderEntity.kt
│ └── dto/
├── product/
│ ├── ProductController.kt
│ ├── ProductService.kt
│ ├── ProductRepository.kt
│ └── ProductEntity.kt
└── user/
├── UserController.kt
├── UserService.kt
├── UserRepository.kt
└── UserEntity.kt
Grouping by domain creates a highly cohesive structure where all order-related code is in the order package. Layers are naturally distinguished by class name suffixes (Controller, Service, Repository).
In practice, the latter is used more often. As projects grow, the desire to see all code related to a feature in one place becomes stronger.
Strengths of Layered Architecture
Separation of Concerns
Each layer focuses on a single concern. HTTP handling is Presentation, business logic is Business, data access is Persistence. When reading code in one layer, you don’t need to think about the details of other layers.
Easy to Learn
Since most Spring tutorials and introductory materials follow this structure, team onboarding costs are low. The rule that Controller calls Service, and Service calls Repository, is something even junior developers pick up quickly.
Independent Replaceability (Theoretically)
Replace only the Persistence layer to switch from JPA to MyBatis. Change only the Presentation layer to convert a REST API to gRPC. This is possible when interfaces between layers are well-defined.
Test Layer Separation
You can devise strategies to test each layer independently. Controllers with @WebMvcTest, Services with unit tests, Repositories with @DataJpaTest.
Limitations of Layered Architecture
If there were only strengths, there’d be no reason to look for other architectures. Layered architecture has limitations that teams repeatedly encounter in practice.
Domain Becomes Coupled to Infrastructure
In layered architecture, the direction of dependency flows from top to bottom.
flowchart TB
C["Controller"] --> S["Service"]
S --> R["Repository"]
S --> E["Entity<br/>Contains JPA annotations"]
R --> E
style E fill:#ff6b6b,color:#fff
Entity has JPA annotations like @Entity, @Column, @Id. Since the Service layer directly handles this Entity, business logic becomes coupled to the specific technology of JPA.
Look at this code. Business logic is tightly coupled to the Entity class.
@Entity
@Table(name = "orders")
class OrderEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
var status: String = "CREATED",
@Column(nullable = false)
val totalAmount: Long = 0,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
val user: UserEntity? = null
) {
fun cancel() {
if (status != "CREATED") {
throw IllegalStateException("Cannot cancel in this state: $status")
}
status = "CANCELLED"
}
}
A business method called cancel() lives inside a class annotated with @Entity. The business rule (“can only cancel in CREATED state”) is joined at the hip with the JPA Entity. To test this rule, you need to worry about the JPA context.
Database-Centric Thinking
When starting development with layered architecture, the typical sequence is:
- Design the table schema
- Create Entity classes (table mapping)
- Create Repositories
- Create Services (processing Entities)
- Create Controllers
Bottom to top, starting from the DB and building up to the UI. This is database-driven design. The starting point of design becomes the table structure, not the business rules.
The problem is that table structure and business models don’t necessarily align. Normalized tables are a structure optimized for storage, while business rules are a structure optimized for behavior. Expressing both in a single class (Entity) forces one side to compromise.
The Temptation to Skip Layers
“Can’t the Controller just call the Repository directly?” This temptation arises when building simple query APIs.
// Code that yielded to temptation — Controller directly calls Repository
@GetMapping("/{id}")
fun getOrder(@PathVariable id: Long): ResponseEntity<OrderEntity> {
val order = orderRepository.findById(id)
.orElseThrow { OrderNotFoundException(id) }
return ResponseEntity.ok(order)
}
It works. But once this becomes precedent and the whole team starts skipping layers, layers lose their meaning. Since the threshold for “this is simple enough to skip” varies from person to person, once allowed, it’s hard to control.
But creating a Service delegation method with no logic just for a simple query is frustrating too.
// A pass-through method with no logic
@Service
class OrderService(private val orderRepository: OrderRepository) {
fun findById(id: Long): Order {
return orderRepository.findById(id)
.orElseThrow { OrderNotFoundException(id) }
}
}
When this kind of code accumulates, the question “What’s the point of the Service layer?” naturally arises. This is a structural dilemma of layered architecture.
Handling Cross-Cutting Concerns
Cross-cutting concerns like logging, authentication, and transactions span multiple layers. Layered architecture is strong at horizontal separation but lacks structural means to handle concerns that cut vertically across layers. Spring AOP and interceptors address this, but that’s more of a framework supplement than an architectural solution.
The Dependency Direction Problem
The most fundamental limitation of layered architecture lies in dependency direction. The Business layer depending on the Persistence layer means business logic knows about data access technology.
flowchart LR
subgraph problem["Layered dependency direction"]
direction LR
BL["Business Layer<br/>Core logic"]
PL["Persistence Layer<br/>JPA, MyBatis, JDBC"]
BL -->|"depends on"| PL
end
subgraph ideal["Ideal dependency direction"]
direction LR
BL2["Business Layer<br/>Core logic"]
PL2["Persistence Layer<br/>JPA, MyBatis, JDBC"]
PL2 -->|"depends on"| BL2
end
style BL fill:#51cf66,color:#fff
style PL fill:#ff6b6b,color:#fff
style BL2 fill:#51cf66,color:#fff
style PL2 fill:#ffd43b,color:#000
The left is the reality of layered architecture, and the right is the ideal direction with DIP (Dependency Inversion Principle) applied. Since business logic is the core of the application, other layers should naturally depend toward it. But layered architecture has it reversed.
This directional problem manifests concretely as follows.
Service classes end up writing code tailored to JpaRepository method signatures. JPA-specific types like Page<Entity> and Optional<Entity> seep into the business layer. If you later need to remove JPA and use a different technology, the entire Service layer must be modified. The theoretical advantage of “replaceability” is hard to achieve in practice.
Recognizing the Limitations
We’ve listed the limitations of layered architecture, but this doesn’t mean “don’t use layered.” This structure is still a good choice in these situations:
- CRUD-centric applications with low domain complexity
- Projects where most team members are junior and learning costs need to be kept low
- Prototypes or MVPs (Minimum Viable Products)
- When you want to maximize Spring Boot’s auto-configuration and conventions
On the other hand, if the domain is complex, business logic changes frequently, and independence from infrastructure technology is needed, layered architecture’s limitations emerge quickly. What comes next in such cases is the hexagonal architecture covered in the next part.
Recognizing limitations and knowing alternatives are different matters. Only someone who precisely understands which parts of layered architecture are painful and why can realize practical benefits when transitioning to hexagonal or clean architecture. Switching just because “I heard hexagonal is good” can actually increase complexity.
Key Takeaways
| Item | Content |
|---|---|
| Structure | Presentation -> Business -> Persistence unidirectional dependency |
| Spring Mapping | Controller -> Service -> Repository |
| Strengths | Separation of concerns, low learning cost, predictable flow |
| Limitations | Domain-infrastructure coupling, DB-centric design, layer-skipping temptation |
| Fundamental Problem | Business depends toward infrastructure (DIP violation) |
| Suitable Situations | CRUD-centric, low domain complexity, quick start |
In the next part, we’ll cover an architecture that inverts layered architecture’s fundamental problem of “dependency direction.” The hexagonal architecture proposed by Alistair Cockburn, also known as the Ports & Adapters pattern. Let’s see what changes when you clearly separate inside from outside.
-> Part 3: Hexagonal Architecture — Separating Inside and Outside with Ports & Adapters




Loading comments...