Skip to content
ioob.dev
Go back

Software Architecture Part 2 — Layered Architecture

· 8 min read
Software Architecture Series (2/6)
  1. Software Architecture Part 1 — Why Architecture Matters
  2. Software Architecture Part 2 — Layered Architecture
  3. Software Architecture Part 3 — Hexagonal Architecture
  4. Software Architecture Part 4 — Clean Architecture and Onion: Commonalities and Differences Among Concentric Structures
  5. Software Architecture Part 5 — CQRS and Event-Driven: From Read/Write Separation to Event-Based Systems
  6. Software Architecture Part 6 — Modular Monolith: What You Can Try Before Microservices
Table of contents

Table of contents

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:

  1. Design the table schema
  2. Create Entity classes (table mapping)
  3. Create Repositories
  4. Create Services (processing Entities)
  5. 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:

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

ItemContent
StructurePresentation -> Business -> Persistence unidirectional dependency
Spring MappingController -> Service -> Repository
StrengthsSeparation of concerns, low learning cost, predictable flow
LimitationsDomain-infrastructure coupling, DB-centric design, layer-skipping temptation
Fundamental ProblemBusiness depends toward infrastructure (DIP violation)
Suitable SituationsCRUD-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


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Software Architecture Part 1 — Why Architecture Matters
Next Post
Software Architecture Part 3 — Hexagonal Architecture