Skip to content
ioob.dev
Go back

Software Architecture Part 4 — Clean Architecture and Onion: Commonalities and Differences Among Concentric Structures

· 8 min read
Software Architecture Series (4/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

The Common Language of Concentric Circles

In Part 3, we covered hexagonal architecture. It separated the inside and outside of an application using the concept of ports and adapters. But around the same time, two other architectures tackled similar concerns under different names: Robert C. Martin’s Clean Architecture and Jeffrey Palermo’s Onion Architecture.

All three architectures share the same core idea: Place the domain at the center, and make the outside depend on the inside. But each emphasizes different aspects and divides layers differently. In this part, we’ll examine Clean Architecture and Onion Architecture in detail, then compare all three including hexagonal.

Clean Architecture — Uncle Bob’s Concentric Circles

Robert C. Martin (Uncle Bob) published Clean Architecture in 2012, extracting common principles from prior architectures including hexagonal, onion, and BCE (Boundary-Control-Entity). In one sentence: Source code dependencies must point only inward.

flowchart TB
    subgraph FD["Frameworks & Drivers"]
        direction TB
        F1["Web Framework"]
        F2["DB Driver"]
        F3["External API Client"]
    end

    subgraph IA["Interface Adapters"]
        direction TB
        A1["Controller"]
        A2["Presenter"]
        A3["Gateway Implementation"]
    end

    subgraph UC["Use Cases"]
        direction TB
        U1["Application Service"]
        U2["Input/Output Port"]
    end

    subgraph EN["Entities"]
        direction TB
        E1["Domain Model"]
        E2["Business Rules"]
    end

    FD --> IA
    IA --> UC
    UC --> EN

    style EN fill:#4a9eff,color:#fff
    style UC fill:#6ab7ff,color:#fff
    style IA fill:#a0d4ff,color:#333
    style FD fill:#d6ebff,color:#333

In this diagram, the arrow direction is the dependency direction. Outer circles know about inner circles, but inner circles don’t know outer circles exist.

Entities — The Innermost Circle

The layer that holds enterprise business rules. Not just for a single application, but core business logic that could be shared across an entire organization. Things like rules for calculating order amounts or criteria for determining membership grades.

This layer depends on nothing — not frameworks, databases, or UIs. It consists purely of domain objects and business rules.

Here’s an example of an order entity.

class Order(
    val id: OrderId,
    val customerId: CustomerId,
    private val _items: MutableList<OrderItem> = mutableListOf()
) {
    val items: List<OrderItem> get() = _items.toList()

    fun addItem(product: Product, quantity: Int) {
        require(quantity > 0) { "Quantity must be at least 1" }
        _items.add(OrderItem(product.id, product.price, quantity))
    }

    fun totalAmount(): Money {
        return _items.fold(Money.ZERO) { acc, item ->
            acc + item.price * item.quantity
        }
    }
}

The Order class doesn’t know Spring or JPA. It’s pure Kotlin code.

Use Cases — Application Business Rules

The Use Cases layer encapsulates application-specific business rules. While the Entities layer’s rules are like “order total is calculated this way,” Use Cases rules are orchestration logic like “when a user creates an order, check stock, save the order, and request payment.”

In Clean Architecture, use cases define Input Ports and Output Ports.

// Input Port — entry point to the use case
interface CreateOrderUseCase {
    fun execute(command: CreateOrderCommand): OrderResult
}

// Output Port — interfaces the use case requests from outside
interface OrderRepository {
    fun save(order: Order): Order
    fun findById(id: OrderId): Order?
}

interface PaymentGateway {
    fun requestPayment(orderId: OrderId, amount: Money): PaymentResult
}

The use case implementation composes these ports to complete a flow.

class CreateOrderService(
    private val orderRepository: OrderRepository,
    private val paymentGateway: PaymentGateway,
    private val inventoryChecker: InventoryChecker
) : CreateOrderUseCase {

    override fun execute(command: CreateOrderCommand): OrderResult {
        val order = Order(
            id = OrderId.generate(),
            customerId = command.customerId
        )

        command.items.forEach { item ->
            inventoryChecker.ensureAvailable(item.productId, item.quantity)
            order.addItem(item.toProduct(), item.quantity)
        }

        val savedOrder = orderRepository.save(order)
        val paymentResult = paymentGateway.requestPayment(
            savedOrder.id, savedOrder.totalAmount()
        )

        return OrderResult(savedOrder.id, paymentResult.status)
    }
}

The key point is that OrderRepository and PaymentGateway are interfaces. The use case merely requests “save this data” — it doesn’t need to know whether that saving happens in MySQL or MongoDB.

Interface Adapters — The Translation Layer

Controllers, presenters, and gateway implementations belong to this layer. They convert data formats from the external world (HTTP, DB, message queues) into formats the use cases understand, and convert use case outputs back into formats the external world understands.

@RestController
@RequestMapping("/api/orders")
class OrderController(
    private val createOrderUseCase: CreateOrderUseCase
) {
    @PostMapping
    fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<OrderResponse> {
        val command = request.toCommand()  // HTTP → UseCase format conversion
        val result = createOrderUseCase.execute(command)
        return ResponseEntity.ok(result.toResponse())  // UseCase → HTTP format conversion
    }
}

Repository implementations also live in this layer.

@Repository
class JpaOrderRepository(
    private val jpaRepository: OrderJpaRepository
) : OrderRepository {

    override fun save(order: Order): Order {
        val entity = OrderEntity.from(order)  // Domain → JPA entity conversion
        val saved = jpaRepository.save(entity)
        return saved.toDomain()  // JPA entity → Domain conversion
    }

    override fun findById(id: OrderId): Order? {
        return jpaRepository.findById(id.value)
            .map { it.toDomain() }
            .orElse(null)
    }
}

Frameworks & Drivers — The Outermost Circle

Spring Boot, JPA, Jackson, web server configuration, and similar frameworks and tools belong to this layer. It’s also the layer where you write the least code directly. Most of it consists of configuration and glue code.

The Dependency Rule Is the Core

The single most important principle in Clean Architecture is the Dependency Rule. Source code dependencies must point only from outside to inside. Code in inner circles must know nothing about outer circles — not function names, class names, or even variable names declared in outer circles.

What’s the benefit when this rule is followed?

Framework replacement becomes possible. Switching Spring to Ktor doesn’t require modifying Entities or Use Cases. Only Interface Adapters need to be rebuilt. Of course, replacing an entire framework is rare in practice, but this illustrates how well the core business logic is isolated from external dependencies.

Testing becomes easy. No database needed when testing use cases. Just replace OrderRepository with an in-memory implementation.

class CreateOrderServiceTest {
    private val orderRepository = InMemoryOrderRepository()
    private val paymentGateway = FakePaymentGateway()
    private val inventoryChecker = AlwaysAvailableInventoryChecker()

    private val sut = CreateOrderService(
        orderRepository, paymentGateway, inventoryChecker
    )

    @Test
    fun `total amount is calculated correctly when creating an order`() {
        val command = CreateOrderCommand(
            customerId = CustomerId("C001"),
            items = listOf(
                OrderItemCommand(ProductId("P001"), 2, Money(10_000))
            )
        )

        val result = sut.execute(command)

        val savedOrder = orderRepository.findById(result.orderId)
        assertThat(savedOrder?.totalAmount()).isEqualTo(Money(20_000))
    }
}

No DB, no Spring context. A pure unit test.

Onion Architecture — Palermo’s Onion Layers

Jeffrey Palermo proposed the Onion Architecture in 2008. It also uses concentric circles. It came 4 years before Clean Architecture and 3 years after hexagonal architecture (2005).

The layers of onion architecture are as follows.

flowchart TB
    subgraph INFRA["Infrastructure"]
        direction TB
        I1["DB / ORM"]
        I2["External Service Clients"]
        I3["File System"]
    end

    subgraph APP["Application Services"]
        direction TB
        AP1["Application Service"]
        AP2["DTO / Command"]
    end

    subgraph DS["Domain Services"]
        direction TB
        DS1["Domain Service"]
        DS2["Repository Interface"]
    end

    subgraph DM["Domain Model"]
        direction TB
        DM1["Entity"]
        DM2["Value Object"]
        DM3["Domain Event"]
    end

    INFRA --> APP
    APP --> DS
    DS --> DM

    style DM fill:#e67e22,color:#fff
    style DS fill:#f39c12,color:#fff
    style APP fill:#f7c948,color:#333
    style INFRA fill:#fde68a,color:#333

Compared to Clean Architecture, a notable difference stands out. Onion splits the domain into two layers: Domain Model and Domain Services.

Domain Model — The Core of the Core

Entities, Value Objects, and Domain Events belong to this layer. It depends on no other layer and expresses only pure business concepts.

// Value Object
data class Money(val amount: Long) {
    init {
        require(amount >= 0) { "Amount must be 0 or greater" }
    }

    operator fun plus(other: Money) = Money(amount + other.amount)
    operator fun times(multiplier: Int) = Money(amount * multiplier)

    companion object {
        val ZERO = Money(0)
    }
}

// Domain Event
data class OrderPlaced(
    val orderId: OrderId,
    val customerId: CustomerId,
    val totalAmount: Money,
    val occurredAt: Instant = Instant.now()
)

Domain Services — Composition of Domain Logic

Business logic that doesn’t naturally belong to a single entity goes here. Repository interfaces are also defined in this layer.

// Domain service — logic that applies pricing policy
class PricingService {
    fun applyDiscount(order: Order, membership: Membership): Money {
        val base = order.totalAmount()
        val discountRate = when (membership.grade) {
            Grade.VIP -> 0.15
            Grade.GOLD -> 0.10
            Grade.SILVER -> 0.05
            Grade.NORMAL -> 0.0
        }
        return Money((base.amount * (1 - discountRate)).toLong())
    }
}

This service uses only domain objects: Order and Membership. Zero dependency on frameworks or infrastructure.

Application Services — Use Case Orchestration

Same role as Clean Architecture’s Use Cases. It defines transaction boundaries and composes domain services and repositories to execute a single use case.

class PlaceOrderApplicationService(
    private val orderRepository: OrderRepository,
    private val pricingService: PricingService,
    private val membershipRepository: MembershipRepository,
    private val eventPublisher: DomainEventPublisher
) {
    fun placeOrder(command: PlaceOrderCommand): OrderId {
        val membership = membershipRepository.findByCustomerId(command.customerId)
            ?: throw CustomerNotFoundException(command.customerId)

        val order = Order(OrderId.generate(), command.customerId)
        command.items.forEach { order.addItem(it.product, it.quantity) }

        val finalAmount = pricingService.applyDiscount(order, membership)

        val savedOrder = orderRepository.save(order)
        eventPublisher.publish(
            OrderPlaced(savedOrder.id, command.customerId, finalAmount)
        )

        return savedOrder.id
    }
}

Infrastructure — Technical Implementation

Repository implementations, ORM mappings, external API clients, message broker connections, and all other technical details are concentrated in this layer. This is the only layer that depends on external libraries and frameworks.

Clean vs Onion — What’s Different?

The two architectures fundamentally share the same principles. Differences lie mainly in naming and granularity of layers.

AspectClean ArchitectureOnion Architecture
PublishedRobert C. Martin, 2012Jeffrey Palermo, 2008
InnermostEntitiesDomain Model
Domain ServicesIncluded in EntitiesSeparated as Domain Services
Application logicUse CasesApplication Services
Outer translationInterface AdaptersNo explicit layer
OutermostFrameworks & DriversInfrastructure
Port conceptInput/Output PortRepository Interface, etc.

Clean Architecture places a separate layer called Interface Adapters to clearly delineate the roles of controllers and presenters, while in Onion this tends to be merged into Infrastructure. Conversely, Onion subdivides the domain into Model and Services, while Clean Architecture’s Entities is a broader concept that includes domain services.

In practice, this difference rarely drives design decisions when applying to a project. Regardless of which names you use, the resulting package structure tends to look similar.

Hexagonal, Clean, Onion — Three Architecture Comparison

flowchart LR
    subgraph HEX["Hexagonal"]
        direction TB
        H1["Driving Adapter"]
        H2["Application<br/>Port + Use Case"]
        H3["Domain"]
        H4["Driven Adapter"]
        H1 --> H2
        H2 --> H3
        H4 --> H2
    end

    subgraph CLEAN["Clean"]
        direction TB
        C1["Frameworks & Drivers"]
        C2["Interface Adapters"]
        C3["Use Cases"]
        C4["Entities"]
        C1 --> C2
        C2 --> C3
        C3 --> C4
    end

    subgraph ONION["Onion"]
        direction TB
        O1["Infrastructure"]
        O2["Application Services"]
        O3["Domain Services"]
        O4["Domain Model"]
        O1 --> O2
        O2 --> O3
        O3 --> O4
    end

    style H3 fill:#2ecc71,color:#fff
    style C4 fill:#4a9eff,color:#fff
    style O4 fill:#e67e22,color:#fff

The common principles across all three architectures can be summarized as follows.

1. The domain is the center. Business logic sits at the innermost layer, protected from technical details. Even if the framework changes, the domain stays intact.

2. Dependencies point inward. Outer layers depend on inner layers, never the reverse. The Dependency Inversion Principle (DIP) is used to achieve this.

3. Interfaces create boundaries. To prevent inner layers from knowing about outer implementations, interfaces (ports) are placed between them. Implementations are injected from outer layers.

The differences are in emphasis.

ArchitectureKey EmphasisMetaphor
HexagonalSymmetry of ports and adaptersEach side of the hexagon is an adapter
CleanStrict application of the dependency ruleConcentric circles and arrow direction
OnionGranular subdivision of domain layersPeel the onion to reveal the core

Drawing school-like distinctions — “our team uses Clean,” “we’re Onion” — has little practical meaning. In practice, it’s common to mix ideas from all three architectures and adapt them to the project.

Practical Selection Criteria

“So which one should I use?” The answer depends on the situation.

If the project has complex domain logic, the Onion Architecture’s Domain Model / Domain Services separation helps. As domain services grow, the value of this separation becomes apparent.

If the project integrates with many external systems, hexagonal’s port-adapter model is intuitive. Each external system maps to one adapter, making the structure clear.

If the team has little architecture experience, Clean Architecture’s four-layer structure is the easiest to explain. Just remember one rule: “dependencies only point inward.”

Regardless of which architecture you choose, the core is the same: place the domain at the center and push technical details to the outside. As long as this principle is maintained, whether packages are called adapter or infrastructure, and whether there are three or four layers, is something the team can agree upon.

Common Mistakes

There are a few common mistakes when adopting Clean or Onion.

Putting JPA annotations on entities. Attaching @Entity and @Column directly to domain entities makes the innermost layer depend on a framework. This breaks the architecture’s core principle. The solution is to separate JPA entities from domain entities and add mapping logic.

// Domain entity — no framework dependency
class Order(val id: OrderId, val customerId: CustomerId, ...)

// JPA entity — Infrastructure layer
@Entity
@Table(name = "orders")
class OrderEntity(
    @Id val id: Long,
    @Column val customerId: String,
    ...
) {
    fun toDomain() = Order(OrderId(id), CustomerId(customerId), ...)

    companion object {
        fun from(order: Order) = OrderEntity(order.id.value, order.customerId.value, ...)
    }
}

The mapping code does increase, but it’s a worthwhile price for freeing the domain from the framework.

Unconditionally separating every layer. Applying all four concentric circles to a CRUD-heavy, simple application just increases boilerplate. If the domain logic amounts to “save and query,” the layered architecture covered in Part 2 is sufficient. Architecture is a tool for managing complexity, not adding it.

Lumping all Use Cases into one service class. Putting order creation, order retrieval, order cancellation, and order modification all into OrderService nullifies the meaning of the Use Cases layer. Creating one class per use case aligns with Clean Architecture’s intent. Separating into CreateOrderUseCase, CancelOrderUseCase, etc. ensures each use case changes independently.

Summary

Clean Architecture and Onion Architecture, along with hexagonal, are architectures that share the same philosophy of “domain-centric, dependencies inward.” Names and layer divisions differ, but they converge to similar forms when applied to projects. What matters is not blindly following a specific architecture’s name, but understanding the problems it was designed to solve — domain protection, testability, preventing technology lock-in — and choosing based on the project’s complexity.

In the next part, we’ll shift perspective and look at CQRS and event-driven architecture, which separate reads from writes.

-> Part 5: CQRS and Event-Driven


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Software Architecture Part 3 — Hexagonal Architecture
Next Post
Software Architecture Part 5 — CQRS and Event-Driven: From Read/Write Separation to Event-Based Systems