Hexagonal vs. Onion vs. Clean Architecture: Differences and When to Use Each

0 0

Hexagonal Architecture, Onion Architecture, and Clean Architecture are three closely related approaches to designing maintainable software systems. Each attempts to solve a common problem: business logic becoming tightly coupled to databases, web frameworks, external APIs, and other technologies that change throughout an application's lifetime. The three approaches share several principles, particularly dependency inversion, separation of concerns, and independent testing, but describe architectural boundaries in different ways.

Hexagonal Architecture emphasizes ports and adapters around an application core. Onion Architecture emphasizes a domain model surrounded by layers whose dependencies point inward. Clean Architecture explicitly distinguishes entities, use cases, interface adapters, and frameworks. Understanding their differences helps developers avoid unnecessary abstractions and select an approach that matches the application's complexity. This guide compares their principles, shows how the same Python order management use case can be organized using each approach, and explains when the additional structure provides practical value.

Table of Contents

Understanding the Three Architecture Patterns

These patterns emerged from a common concern in software engineering: business rules should not be forced to change whenever a database library, HTTP framework, or external integration changes. They emphasize that application behavior should be testable without requiring a complete production environment.

The terminology and diagrams differ, but their similarities are intentional. Robert C. Martin explicitly referenced Hexagonal and Onion Architecture when introducing Clean Architecture in 2012. They should be viewed as related architectural ideas rather than three competing products.

Hexagonal Architecture: Ports and Adapters

Hexagonal Architecture was introduced by Alistair Cockburn in 2005. Its central concept is an application core communicating with external actors through ports and adapters.

A port defines a meaningful interaction at the application's boundary. An adapter translates that interaction into a technology-specific operation or translates external input into something the application understands.

Consider an order management service:

  • Application core: Order entities and order creation behavior.
  • Driving port: A contract for submitting a new order.
  • Driving adapter: FastAPI endpoint, command-line interface, or queue consumer.
  • Driven port: A contract for storing orders.
  • Driven adapter: PostgreSQL repository, test repository, or another supported implementation.

The same business operation can be initiated through different interfaces without duplicating application logic.

Similarly, an in-memory repository can replace PostgreSQL for unit testing without requiring changes to the use case.

Hexagonal Architecture deliberately avoids prescribing an exact internal layer structure. Its most important distinction is between the application and external technologies.

The hexagon is only a visual metaphor. Six sides do not imply six ports, layers, interfaces, or adapters.

Onion Architecture: Domain-Centered Layers

Jeffrey Palermo introduced Onion Architecture in 2008. It places the domain model at the center and arranges other application responsibilities around it in concentric layers.

The defining rule is that code may depend on layers closer to the center, but not on layers farther away.

Common conceptual layers include:

  • Domain model: Entities, value objects, and business invariants.
  • Domain services: Business rules that do not naturally belong to one entity.
  • Application services: Use cases coordinating domain behavior and required capabilities.
  • Infrastructure: Database implementations, external integrations, and technical services.
  • Presentation: HTTP controllers, CLI interfaces, and other delivery mechanisms.

Unlike a conventional database-centered layered application, Onion Architecture treats persistence as a detail outside the application core.

For example, the domain can enforce valid order quantities and calculate totals without knowing about SQLAlchemy models or PostgreSQL tables.

Onion Architecture fits especially well with domain-driven design because it makes business objects and their behavior the center of the application.

Clean Architecture: Entities and Use Cases

Robert C. Martin described Clean Architecture in 2012. Its classic model contains four conceptual circles, ordered from the center outward:

  1. Entities: Fundamental business rules and domain concepts.
  2. Use Cases: Application-specific behavior and orchestration.
  3. Interface Adapters: Controllers, presenters, and data gateways.
  4. Frameworks and Drivers: Web frameworks, databases, external services, and other technical mechanisms.

Like Onion Architecture, Clean Architecture requires source-code dependencies to point inward.

Its emphasis on input and output boundaries makes the distinctions between application behavior, transport data, and persistence data particularly explicit.

For example, a FastAPI controller converts an HTTP request into an application input object. The use case performs the operation and returns an application output object. A presenter converts that output into a response suitable for the client.

This organization can make application behavior independent of HTTP and database representations, although introducing every possible boundary is not necessary for every application.

Hexagonal vs. Onion vs. Clean: Key Differences

The differences become clearer when we compare the responsibility of each architectural boundary rather than comparing their diagrams alone.

All three allow infrastructure to implement interfaces required by application logic. Their main distinction is how explicitly they describe the structure inside that application core.

Side-by-Side Architecture Comparison

This table summarizes the primary characteristics of each architecture.

Aspect Hexagonal Onion Clean
Introduced 2005 2008 2012
Originator Alistair Cockburn Jeffrey Palermo Robert C. Martin
Primary emphasis Ports and adapters Domain-centered layers Entities, use cases, interface boundaries
Central concern Isolate application from external actors Protect the domain model Protect business policies and use cases
Inner organization Flexible Domain-oriented layers Entities and use cases
External integration Driving/driven adapters Infrastructure implementations Controllers, presenters, gateways
Dependency inversion Yes Yes Yes
Independent testing Core objective Core objective Core objective

These are differences in emphasis, not exclusive capabilities. Hexagonal Architecture can have application and domain layers. Onion Architecture can use ports and adapters. Clean Architecture can use domain services and the same repository abstractions.

A useful way to choose between them is to ask which conceptual model most clearly communicates the boundaries your team needs.

Dependency Direction and Interface Ownership

All three favor the dependency inversion principle. High-level application code should not import concrete database or framework implementations just to perform business operations.

For example, an order use case can depend on an application-defined OrderRepository contract, while a PostgreSQL repository implements that contract.

from typing import Protocol


class OrderRepository(Protocol):
    def create(self, order: "Order") -> bool:
        """Return False when the ID already exists."""


class CreateOrder:
    def __init__(
        self,
        repository: OrderRepository,
    ) -> None:
        self.repository = repository

    def execute(self, order: "Order") -> None:
        if not self.repository.create(order):
            raise ValueError("Order already exists")

Here, Order represents an application domain model defined independently of persistence. The concrete implementation is supplied through constructor injection.

The application calls the repository at runtime, but its source code depends on the interface rather than the database implementation.

This is how the three patterns protect business behavior without preventing it from interacting with external systems.

Application Boundaries and Responsibilities

Consider the same order-creation operation under the three approaches.

  • Hexagonal: The important distinction is that HTTP and PostgreSQL are adapters connected to application ports.
  • Onion: The important distinction is that order validation belongs to the domain and dependencies flow toward that domain.
  • Clean: The important distinction is that the entity, create-order use case, HTTP controller, presenter, and database gateway have separate responsibilities.

The resulting behavior can be identical. In fact, much of the implementation code may be shared.

This is why architecture selection should be driven by clarity and maintainability, not the expectation that each pattern requires a completely different set of algorithms or infrastructure services.

Production Design Example: Order Management System

To make the comparison concrete, consider a backend service that creates and retrieves customer orders. It uses FastAPI to receive requests and PostgreSQL to persist data.

The system is expected to grow. Future requirements may include payment authorization, inventory reservation, queue-based processing, and an administrative CLI.

Business Requirements and Application Components

The initial functional requirements are straightforward:

  • Each order has a unique identifier.
  • Every order belongs to a customer.
  • An order contains one or more line items.
  • Quantities must be positive.
  • Order totals are calculated using integer cents.
  • Duplicate order identifiers must be rejected.
  • Order creation must be testable without a real database.

The implementation uses an Order entity, a create-order operation, a repository contract, a PostgreSQL implementation, and an HTTP endpoint.

The question is not whether the system needs all these responsibilities. It does. The architectural choice concerns how those responsibilities are arranged and how their dependencies are expressed.

How the Three Approaches Organize the Same System

The three approaches could use the following project structures.

# Hexagonal Architecture
app/
  domain/
    order.py
  application/
    create_order.py
    ports.py
  adapters/
    inbound/
      http.py
      cli.py
    outbound/
      postgres.py
      in_memory.py
  bootstrap.py


# Onion Architecture
app/
  domain/
    order.py
    pricing.py
  application/
    create_order.py
    repositories.py
  infrastructure/
    postgres.py
  presentation/
    api.py


# Clean Architecture
app/
  entities/
    order.py
  use_cases/
    create_order.py
    boundaries.py
  interface_adapters/
    controller.py
    presenter.py
    postgres_gateway.py
  frameworks/
    api.py
    database.py

These structures are illustrative rather than mandatory. Hexagonal Architecture does not require exactly two adapter directories, and Clean Architecture does not require one Python file for every conceptual boundary.

In all three designs, the domain entity should remain independent of FastAPI and PostgreSQL.

The meaningful differences appear in naming, responsibility allocation, and how teams choose to make integration boundaries visible.

Transactions, Idempotency, and Failure Recovery

Architectural boundaries are valuable, but they do not automatically solve production consistency problems.

For example, two requests may attempt to create the same order ID concurrently. Checking whether an order exists and then inserting it is unsafe because both requests could pass the initial check.

A correct implementation requires an atomic database operation or constraint, such as a PostgreSQL primary key combined with suitable conflict handling.

Now consider a larger checkout workflow:

  • Create an order.
  • Reserve inventory.
  • Authorize payment.
  • Publish an order-created event.

These operations may span several systems and cannot necessarily be committed within one database transaction.

The application needs explicit policies for idempotency, retries, timeouts, compensating operations, and durable workflow state.

A transactional outbox can coordinate an order database change with recording a pending event in the same database transaction. A separate publisher then delivers that event.

The key distinction is that Hexagonal, Onion, and Clean Architecture help organize the code responsible for these operations. They do not replace distributed transaction design.

Ready-to-Use Example: The Same Use Case in Python

Comparing three separate applications with unrelated business rules makes architectural differences harder to identify. A better exercise is to keep one domain model and one observable behavior while changing how responsibilities are organized.

The following example uses Python 3.12 or newer and pytest. It does not require PostgreSQL or FastAPI because the goal is to compare application boundaries and demonstrate isolated testing. Real HTTP and database adapters can be added around the same interfaces.

1. Shared Domain Model and Business Rules

First, define an immutable order entity shared by the three conceptual implementations.

Create domain.py:

from dataclasses import dataclass


@dataclass(frozen=True)
class OrderItem:
    sku: str
    quantity: int
    unit_price_cents: int

    def __post_init__(self) -> None:
        if not self.sku.strip():
            raise ValueError("SKU is required")

        if type(self.quantity) is not int or self.quantity <= 0:
            raise ValueError("Quantity must be positive")

        if (
            type(self.unit_price_cents) is not int
            or self.unit_price_cents < 0
        ):
            raise ValueError("Invalid unit price")

    @property
    def subtotal_cents(self) -> int:
        return self.quantity * self.unit_price_cents


@dataclass(frozen=True)
class Order:
    order_id: str
    customer_id: str
    items: tuple[OrderItem, ...]

    def __post_init__(self) -> None:
        if not self.order_id.strip():
            raise ValueError("Order ID is required")

        if not self.customer_id.strip():
            raise ValueError("Customer ID is required")

        if not self.items:
            raise ValueError("Order needs items")

        skus = [item.sku for item in self.items]

        if len(skus) != len(set(skus)):
            raise ValueError("Duplicate SKUs")

    @property
    def total_cents(self) -> int:
        return sum(
            item.subtotal_cents
            for item in self.items
        )

The domain model enforces valid order state and calculates totals. It has no dependency on HTTP, SQLAlchemy, or an external SDK.

This entity can be used unchanged in a Hexagonal, Onion, or Clean Architecture application.

The model does not automatically become more correct simply because it is placed inside a directory called entities or domain. What matters is the behavior it owns and the dependencies it avoids.

2. Hexagonal Implementation with Ports and Adapters

In the Hexagonal implementation, we emphasize the interaction contracts. A repository is a driven port, while an in-memory repository is an adapter fulfilling that port.

Create hexagonal.py:

from typing import Protocol

from domain import Order


class OrderRepositoryPort(Protocol):
    def save(self, order: Order) -> bool:
        """Return False if order_id already exists."""


class InMemoryOrderAdapter:
    def __init__(self) -> None:
        self.orders: dict[str, Order] = {}

    def save(self, order: Order) -> bool:
        if order.order_id in self.orders:
            return False

        self.orders[order.order_id] = order
        return True


class CreateOrder:
    def __init__(
        self,
        repository: OrderRepositoryPort,
    ) -> None:
        self.repository = repository

    def execute(self, order: Order) -> Order:
        if not self.repository.save(order):
            raise ValueError("Order already exists")

        return order


def create_demo_service() -> CreateOrder:
    # Composition root chooses an adapter.
    adapter = InMemoryOrderAdapter()
    return CreateOrder(adapter)

The repository port belongs to the application boundary. The in-memory implementation can later be replaced by a PostgreSQL adapter.

An HTTP adapter could convert incoming JSON into an Order and invoke CreateOrder.execute. A CLI adapter could do the same from command-line arguments.

The application service would not need to know which driving adapter initiated the operation.

3. Onion Implementation with Domain Services

In the Onion implementation, the domain model remains central, but the example makes an additional domain-level pricing policy explicit.

The application service coordinates the Order entity, a domain service, and an application-defined repository abstraction.

Create onion.py:

from typing import Protocol

from domain import Order


class OrderRepository(Protocol):
    def save(
        self,
        order: Order,
        total_cents: int,
    ) -> bool:
        """Persist order with its calculated total."""


class PricingPolicy:
    def calculate_total(self, order: Order) -> int:
        # Illustrative domain policy:
        # discount orders containing at least 10 units.
        total_quantity = sum(
            item.quantity
            for item in order.items
        )

        if total_quantity >= 10:
            return order.total_cents * 95 // 100

        return order.total_cents


class InMemoryOrderRepository:
    def __init__(self) -> None:
        self.orders: dict[
            str,
            tuple[Order, int],
        ] = {}

    def save(
        self,
        order: Order,
        total_cents: int,
    ) -> bool:
        if order.order_id in self.orders:
            return False

        self.orders[order.order_id] = (
            order,
            total_cents,
        )
        return True


class CreateOrder:
    def __init__(
        self,
        repository: OrderRepository,
        pricing: PricingPolicy,
    ) -> None:
        self.repository = repository
        self.pricing = pricing

    def execute(self, order: Order) -> int:
        total = self.pricing.calculate_total(order)

        if not self.repository.save(order, total):
            raise ValueError("Order already exists")

        return total

The PricingPolicy is a domain service because it expresses a business rule rather than a technical integration.

The application service does not calculate discounts directly. It coordinates domain behavior and requests persistence through the repository abstraction.

This arrangement makes it easy to see the domain model, domain service, application service, and infrastructure adapter as distinct responsibilities.

Importantly, Hexagonal and Clean Architecture can use exactly the same pricing service. Its presence is an example of Onion's domain-layer emphasis, not a feature exclusive to Onion Architecture.

4. Clean Implementation with Input and Output Boundaries

Clean Architecture places explicit emphasis on input and output models around the use case.

The controller receives external input and converts it into a use-case input object. The use case returns an application result, and a presenter converts that result for the client.

Create clean.py:

from dataclasses import asdict, dataclass
from typing import Protocol

from domain import Order, OrderItem


class OrderGateway(Protocol):
    def create(self, order: Order) -> bool:
        """Atomically create an order."""


@dataclass(frozen=True)
class ItemInput:
    sku: str
    quantity: int
    unit_price_cents: int


@dataclass(frozen=True)
class CreateOrderInput:
    order_id: str
    customer_id: str
    items: tuple[ItemInput, ...]


@dataclass(frozen=True)
class CreatedOrderOutput:
    order_id: str
    total_cents: int


class InMemoryOrderGateway:
    def __init__(self) -> None:
        self.orders: dict[str, Order] = {}

    def create(self, order: Order) -> bool:
        if order.order_id in self.orders:
            return False

        self.orders[order.order_id] = order
        return True


class CreateOrderUseCase:
    def __init__(
        self,
        gateway: OrderGateway,
    ) -> None:
        self.gateway = gateway

    def execute(
        self,
        data: CreateOrderInput,
    ) -> CreatedOrderOutput:
        order = Order(
            order_id=data.order_id,
            customer_id=data.customer_id,
            items=tuple(
                OrderItem(
                    sku=item.sku,
                    quantity=item.quantity,
                    unit_price_cents=item.unit_price_cents,
                )
                for item in data.items
            ),
        )

        if not self.gateway.create(order):
            raise ValueError("Order already exists")

        return CreatedOrderOutput(
            order_id=order.order_id,
            total_cents=order.total_cents,
        )


class OrderPresenter:
    def present(
        self,
        output: CreatedOrderOutput,
    ) -> dict:
        return asdict(output)


class CreateOrderController:
    def __init__(
        self,
        use_case: CreateOrderUseCase,
        presenter: OrderPresenter,
    ) -> None:
        self.use_case = use_case
        self.presenter = presenter

    def handle(self, payload: dict) -> dict:
        # Convert external data into an
        # application-defined input model.
        request = CreateOrderInput(
            order_id=payload["order_id"],
            customer_id=payload["customer_id"],
            items=tuple(
                ItemInput(**item)
                for item in payload["items"]
            ),
        )

        result = self.use_case.execute(request)

        return self.presenter.present(result)

The controller and presenter operate outside the use case. The use case itself does not import HTTP or framework-specific response classes.

The input DTO provides an explicit boundary for use-case data. The output DTO prevents the entity or a database row from becoming the public API response by accident.

A production HTTP adapter would add request validation, map application errors into HTTP statuses, and create the controller through the composition root.

This implementation deliberately keeps the presenter as a small adapter. More complex applications may use an output-port interface called by the use case, but not every project needs that additional indirection.

5. Test and Compare the Implementations

Now test all three implementations without starting a database or HTTP server. The purpose is to verify the business behavior and show that the core can run in isolation.

Create test_architectures.py:

import pytest

from domain import Order, OrderItem
from hexagonal import (
    CreateOrder as HexagonalCreateOrder,
    InMemoryOrderAdapter,
)
from onion import (
    CreateOrder as OnionCreateOrder,
    InMemoryOrderRepository,
    PricingPolicy,
)
from clean import (
    CreateOrderController,
    CreateOrderUseCase,
    InMemoryOrderGateway,
    OrderPresenter,
)


def sample_order(quantity: int = 2) -> Order:
    return Order(
        order_id="order-1001",
        customer_id="customer-42",
        items=(
            OrderItem(
                sku="keyboard",
                quantity=quantity,
                unit_price_cents=2000,
            ),
        ),
    )


def test_hexagonal():
    adapter = InMemoryOrderAdapter()
    use_case = HexagonalCreateOrder(adapter)

    result = use_case.execute(sample_order())

    assert result.total_cents == 4000

    with pytest.raises(ValueError):
        use_case.execute(sample_order())


def test_onion():
    repository = InMemoryOrderRepository()

    use_case = OnionCreateOrder(
        repository,
        PricingPolicy(),
    )

    # Ten units receive the example discount.
    total = use_case.execute(
        sample_order(quantity=10)
    )

    assert total == 19000

    with pytest.raises(ValueError):
        use_case.execute(sample_order(quantity=10))


def test_clean():
    gateway = InMemoryOrderGateway()

    controller = CreateOrderController(
        use_case=CreateOrderUseCase(gateway),
        presenter=OrderPresenter(),
    )

    payload = {
        "order_id": "order-1001",
        "customer_id": "customer-42",
        "items": [
            {
                "sku": "keyboard",
                "quantity": 2,
                "unit_price_cents": 2000,
            }
        ],
    }

    response = controller.handle(payload)

    assert response == {
        "order_id": "order-1001",
        "total_cents": 4000,
    }

    with pytest.raises(ValueError):
        controller.handle(payload)


def test_shared_domain_validation():
    with pytest.raises(ValueError):
        OrderItem(
            sku="keyboard",
            quantity=0,
            unit_price_cents=2000,
        )

Install pytest and run the tests in the directory containing these files:

python3 -m venv .venv
source .venv/bin/activate

pip install pytest

pytest -q test_architectures.py

The tests exercise a common order domain while exposing different architectural responsibilities.

The Hexagonal implementation highlights port and adapter substitution. The Onion implementation highlights domain services and application-layer orchestration. The Clean implementation highlights explicit controller, use-case, output, and presenter boundaries.

The Onion example additionally applies a discount policy to demonstrate the role of a domain service. This difference is illustrative rather than a claim that the three architectures must produce different business results.

All three could produce exactly the same response with the same policy and persistence semantics. Their main difference is how the code communicates architectural responsibilities.

When to Use Each Architecture

The best choice depends on the application's domain complexity, integration requirements, expected lifetime, and team conventions.

There is no universal rule that Clean Architecture is more advanced than Onion, or that Hexagonal is inherently simpler. A careful implementation of any of them can be more maintainable than a poorly designed implementation of another.

When Hexagonal Architecture Fits Best

Hexagonal Architecture is a particularly useful starting point when the main challenge involves external integrations or multiple ways of invoking application behavior.

Consider a document-processing service that receives requests through HTTP, scheduled jobs, and SQS messages, and stores results through a repository.

Ports and adapters make it natural to organize these interactions:

  • HTTP and SQS consumers are driving adapters.
  • Object storage and database integrations are driven adapters.
  • The application core coordinates document processing.
  • Unit tests substitute in-memory or fake adapters.

Hexagonal terminology is useful here because the application has several external actors and the integration boundary is the most important design concern.

It works especially well for API services, integration platforms, background processing systems, and applications expected to support multiple infrastructure implementations.

When Onion Architecture Fits Best

Onion Architecture is useful when business rules are complex and the domain model deserves a clear central position.

Consider an insurance system with policies, claims, eligibility rules, coverage calculations, and state transitions.

Those business rules should remain independent of the HTTP API, database schema, and external insurer integrations.

Organizing the domain into entities, value objects, and domain services can make the code easier to understand and evolve.

Onion Architecture provides a clear mental model for separating domain behavior from application orchestration and technical infrastructure.

It is a strong fit for long-lived enterprise applications, modular monoliths, and systems using domain-driven design concepts.

When Clean Architecture Fits Best

Clean Architecture is valuable when application use cases and their input/output boundaries need to remain explicit.

Consider a financial platform with workflows for transferring funds, approving accounts, and reviewing transactions.

Each workflow may have different input validation, authorization, business policy, and output representation requirements.

Separating entities, use cases, controllers, presenters, and gateways can help teams reason about these responsibilities independently.

It can be particularly useful for large business applications, long-lived APIs, multi-interface systems, and codebases with extensive automated testing requirements.

However, implementing a presenter interface for every simple response is not necessary. Choose boundaries according to actual application complexity.

When a Simpler Architecture Is Better

Not every backend needs a full set of ports, gateways, presenters, command objects, and domain services.

A small CRUD API with minimal business behavior may be easier to maintain using a straightforward arrangement of routes, services, and repositories.

For example, an administrative service that manages a small list of configuration records may not benefit from several layers of translation objects.

Before adding an abstraction, ask what boundary it protects and which likely change or testing requirement justifies it.

A useful principle is to start simple and introduce stronger boundaries when business rules, external integrations, or testing needs make them valuable.

Testing, Performance, and Production Trade-offs

Architecture decisions affect how developers work with a codebase, but they do not eliminate production concerns. Business correctness, database behavior, performance, failure recovery, and deployment practices still need explicit attention.

The most useful measure of an architecture is whether it makes real system changes safer and easier, not whether the directory structure resembles a particular diagram.

Unit, Integration, and Contract Testing

All three architectures support independent testing of application behavior.

Typical testing responsibilities include:

  • Domain tests: Validate entities, value objects, calculations, and business invariants.
  • Use-case tests: Exercise workflows using fake external dependencies.
  • Adapter tests: Verify database queries, message publishing, and external API integrations.
  • HTTP tests: Verify request validation, error mapping, and response contracts.
  • Integration tests: Exercise realistic infrastructure behavior such as PostgreSQL transactions.
  • Contract tests: Confirm that different implementations satisfy the expected application boundary semantics.

Replacing PostgreSQL with an in-memory adapter in unit tests does not prove that PostgreSQL behaves identically.

For example, an in-memory dictionary may implement uniqueness through a simple conditional check. A production database must enforce uniqueness atomically under concurrent requests.

Test doubles are valuable for isolating business behavior, but production adapters require their own tests.

Performance and Maintainability

The overhead of ordinary Python method calls and small DTO conversions is usually minor compared with network and database operations.

However, architectural abstraction can create indirect performance problems if it hides the actual behavior of infrastructure.

For example, a generic repository may make it difficult to recognize that each order item requires a separate SQL query. Such a design can introduce N+1 query behavior even though the layers appear clean.

Likewise, a database adapter may open a new connection for every operation when a use case should execute several queries within one transaction and connection scope.

The architecture should allow performance characteristics to remain observable. Database query counts, connection lifetimes, cache hit rates, and external API timings still matter.

Maintainability is also affected by how many files a developer must open to understand a simple operation.

Excessive translation code, pass-through interfaces, and generic base classes can reduce readability without improving separation of concerns.

Common Mistakes

Common problems across all three approaches include:

  • Choosing the architecture by diagram: Replicating folder names without establishing real dependency boundaries.
  • Creating interfaces for every class: Adding indirection without meaningful substitution or separation.
  • Leaking ORM models into domain logic: Coupling business rules to database representations.
  • Throwing HTTP exceptions from use cases: Making application behavior dependent on web transport.
  • Using generic repositories for everything: Hiding important queries, transaction behavior, or storage constraints.
  • Confusing layers with microservices: Splitting source-code responsibilities does not require separate deployments.
  • Assuming an abstraction makes technologies interchangeable: PostgreSQL and DynamoDB have different consistency and query semantics.
  • Ignoring transaction boundaries: Separate commits can break atomic business operations.
  • Testing only with mocks: Infrastructure behavior remains unverified.
  • Overusing presenters and DTOs: Simple workflows become difficult to follow.
  • Mixing orchestration and business rules: Complex domain policies become scattered across controllers.
  • Ignoring distributed failure recovery: Ports and interfaces do not guarantee event delivery or exactly-once business effects.

A team can avoid many of these problems by reviewing concrete dependency graphs and testing the actual workflows rather than relying on architecture terminology alone.

Production Checklist

Before applying any of these architectures in a production system, verify that:

  • The domain model is independent of frameworks and persistence libraries.
  • Application operations have clear responsibilities.
  • External dependencies are isolated at meaningful boundaries.
  • Interfaces are defined around application needs rather than generic infrastructure APIs.
  • Dependency injection is explicit and understandable.
  • Database constraints enforce critical concurrency requirements.
  • Transaction boundaries cover complete business operations.
  • External operations have defined timeout, retry, and idempotency behavior.
  • Event-driven workflows include failure recovery and duplicate handling.
  • Domain and use-case tests run independently of infrastructure.
  • Adapters have integration tests against realistic dependencies.
  • Schema migrations are managed separately from business logic.
  • Structured logs, metrics, and traces reveal production failures.
  • Developers can follow the behavior of a use case without navigating unnecessary abstraction layers.

Frequently Asked Questions

Hexagonal, Onion, and Clean Architecture are often presented as competing patterns, but the distinctions are more subtle. Their value comes from protecting important behavior and controlling dependencies rather than complying with a particular framework or template.

Which Architecture Is Best?

There is no universally best architecture.

Hexagonal Architecture emphasizes ports and adapters, Onion Architecture emphasizes a domain-centered dependency structure, and Clean Architecture emphasizes entities, use cases, and interface boundaries.

Choose the model that makes your application's important responsibilities and dependencies easiest to understand.

Can You Combine Hexagonal, Onion, and Clean Architecture?

Yes. These approaches share important principles and can be combined naturally.

A Clean Architecture application can use Hexagonal ports and adapters, while its domain model and surrounding application responsibilities follow Onion Architecture's inward dependency rule.

In many systems, the differences are mostly in terminology and organization.

Do These Architectures Require Domain-Driven Design?

No. Domain-driven design can complement all three architectures, especially when the application has complex business rules.

However, you do not need to implement every DDD concept, such as aggregates, bounded contexts, and domain events, simply to apply dependency inversion.

Introduce domain modeling techniques according to business complexity.

Are These Architectures Only for Microservices?

No. They can be applied within monoliths, modular monoliths, microservices, desktop applications, and command-line tools.

They describe how source-code responsibilities and dependencies are organized inside an application, not how many services must be deployed.

Do You Need Repository Interfaces Everywhere?

No. Repository interfaces are useful when they establish meaningful boundaries between application behavior and persistence.

Creating a repository abstraction for every table or simple query may introduce unnecessary complexity.

Choose abstractions around the actual operations and consistency guarantees the application requires.

Should You Refactor an Existing Application?

Not necessarily. Refactoring solely to match a named architecture can introduce risk without meaningful benefit.

Consider stronger boundaries when framework dependencies make testing difficult, important business rules are duplicated, infrastructure changes affect unrelated application code, or use cases have become hard to understand.

Incremental refactoring is often preferable to rewriting the whole system.

Conclusion

Hexagonal, Onion, and Clean Architecture share the same fundamental goal: business logic should remain independent of unnecessary technology dependencies.

Hexagonal Architecture makes application boundaries visible through ports and adapters. Onion Architecture organizes responsibilities around a central domain model with inward-pointing dependencies. Clean Architecture explicitly separates entities, application use cases, interface adapters, and frameworks.

All three can produce the same functional application, use the same domain entities, support the same persistence technologies, and provide independently testable business behavior.

The best choice depends on which responsibilities your team needs to make explicit. Integration-heavy systems may benefit from Hexagonal terminology, domain-heavy applications from Onion's layered model, and complex workflow applications from Clean Architecture's use-case and interface boundaries.

For simple CRUD services, a smaller architecture with a few carefully chosen boundaries may be sufficient.

Key Takeaway: Do not optimize for an architectural label. Protect important business rules, direct dependencies toward stable application abstractions, keep infrastructure replaceable where it matters, and introduce only the layers necessary to make the code easier to understand, test, and maintain.

Continue exploring the individual patterns and related system design concepts on SysDesPro:

  • What Is Hexagonal Architecture? Ports and Adapters Explained — A detailed guide to driving and driven adapters.
  • What Is Onion Architecture? Layers and Dependency Rules Explained — Domain-centered layers and dependency inversion.
  • What Is Clean Architecture? Principles, Layers, and Practical Examples — Entities, use cases, controllers, presenters, and gateways.
  • What Is Domain-Driven Design? — Domain modeling, business invariants, and bounded contexts.
  • What Is the Repository Pattern? — Separating persistence operations from application behavior.
  • What Is Dependency Injection? — Supplying concrete infrastructure implementations to application code.

For the original architecture descriptions, see Alistair Cockburn's Hexagonal Architecture, Jeffrey Palermo's Onion Architecture, and Robert C. Martin's Clean Architecture.

Comments (0)

Related articles