Architecture Pattern

Onion Architecture

Layer the system around a pure domain core, dependencies flow inward.

Onion Architecture: A Complete Guide with Examples in 11 Programming Languages

Onion Architecture — introduced by Jeffrey Palermo in 2008 — is a software architectural style that organizes an application as a series of concentric rings, with the Domain Model at the absolute center. Each outer ring depends only on the rings inside it, never on the rings outside it. The innermost ring contains pure domain entities and business rules. The next ring outward holds domain services. The application services that orchestrate use cases form the next ring. The outermost ring contains all infrastructure — databases, frameworks, UI, external APIs. The defining characteristic of Onion Architecture is its iron rule: the center knows nothing about the outside; the outside knows everything about the center.

In this comprehensive guide, we'll explore Onion Architecture with real-world examples in Python, TypeScript, Java, JavaScript, C#, PHP, Go, Rust, Dart, Swift, and Kotlin — complete with language-specific best practices, common pitfalls, and when to use this powerful pattern.

Table of Contents

What is Onion Architecture?

Onion Architecture solves the same fundamental problem as Layered Architecture — tangled dependencies that make applications rigid, untestable, and resistant to change — but it solves it more aggressively by inverting the traditional dependency direction at every layer boundary.

In classic Layered Architecture, the dependency flows downward: Presentation depends on Application, Application depends on Domain, Domain depends on Infrastructure. This seems logical until you realize that the Domain — which contains your most valuable business knowledge — is at the mercy of the Infrastructure layer below it. If the database schema changes, the Domain may break. If the ORM version changes, domain tests fail. The most important layer is the most vulnerable.

Onion Architecture eliminates this vulnerability with a single structural rule: all dependencies point inward, toward the domain. The domain has no outward dependencies at all. Infrastructure, frameworks, and UI all depend on the domain — the domain never depends on any of them. This is the Dependency Inversion Principle applied at the architectural scale, not just the class scale.

Think of an actual onion. The innermost layers — the core, the heart — are surrounded and protected by successive outer layers. The outer layers adapt to the environment (they get cut, they brown, they're exposed to heat) while the inner layers remain unaffected. In software, the domain is the onion's core: protected by every outer ring, never directly touched by the environment (infrastructure, frameworks, user interfaces) that surrounds it.

The practical consequence is striking: you can test every business rule in your application without starting a database, launching a web server, or calling a single external API. The domain is completely self-contained. Every use case can be exercised with plain objects in memory. Infrastructure is a detail that can be replaced, upgraded, or mocked without touching the business logic that took years to encode.

Why Use Onion Architecture?

Onion Architecture offers several compelling benefits:

  1. Absolute Domain Isolation: The domain model — the most valuable and expensive-to-develop part of the system — has zero dependencies on infrastructure, frameworks, or external systems. It cannot be broken by a database migration, a framework upgrade, or a third-party API change
  2. Infrastructure as a Plugin: Databases, message brokers, HTTP clients, and file systems are plugins to the domain, not foundations beneath it. Swap PostgreSQL for MongoDB, REST for gRPC, or SendGrid for Mailgun by replacing a single outer ring component — the domain and application services are unchanged
  3. Maximally Testable: Every ring can be tested in complete isolation. Domain tests are pure function calls with no test setup. Application service tests inject fake infrastructure implementations. Infrastructure tests verify the mapping between domain and external representations
  4. Explicit Dependency Map: The rings make every dependency visible and directional. Reading the codebase, it is immediately clear what depends on what — there are no hidden imports, no circular dependencies, no mystery coupling
  5. Domain-Driven Design Alignment: Onion Architecture is the natural structural expression of DDD principles. Bounded contexts, aggregates, entities, value objects, domain services, and domain events all live in the inner rings, organized purely around business concepts with no infrastructure noise
  6. Long-Term Maintainability: Applications with properly implemented Onion Architecture remain comprehensible and changeable for years. The business logic that encodes years of domain understanding is insulated from the relentless churn of technology choices

Onion Architecture Comparison

Let's compare Onion Architecture with related architectural styles:

ArchitectureDependency DirectionDomain ProtectionInfrastructure CouplingPrimary Use Case
OnionAll inward toward domainMaximum — domain has zero outward depsZero — infra is outermost ringComplex domain, DDD, long-lived systems
Hexagonal (Ports & Adapters)Inward toward domainMaximum — ports protect domainZero — adapters are externalMulti-channel apps, strong TDD
Clean ArchitectureInward toward entitiesMaximum — entities innermostZeroEnterprise apps, strict separation
Layered (N-Tier)DownwardPartial — domain may depend on infraMedium — domain may import ORMMost web apps, simpler systems
MVCController → Model → ViewNone — model often contains ORMHighWeb apps, simple CRUD

Key Distinctions:

  • Onion vs. Hexagonal Architecture: These architectures share the same fundamental principle — dependencies point inward, domain is central, infrastructure is peripheral. The difference is primarily visual and organizational. Hexagonal Architecture emphasizes sides (ports and adapters around a hexagon), making it natural for describing multiple input and output channels. Onion Architecture emphasizes rings (concentric layers of responsibility), making it natural for describing the internal layering of the domain itself (Domain Model → Domain Services → Application Services). In practice, many teams use the terms interchangeably and combine both visualization styles.
  • Onion vs. Clean Architecture: Robert Martin's Clean Architecture (2012) is a direct elaboration of Onion Architecture with more prescribed ring names: Entities (innermost), Use Cases, Interface Adapters, Frameworks and Drivers (outermost). Onion Architecture uses Domain Model, Domain Services, Application Services, and Infrastructure/UI. The dependency rule is identical in both. Clean Architecture adds more explicit naming for the mapping/translation ring (Interface Adapters) that Onion Architecture leaves implicit.
  • Onion vs. Layered Architecture: The critical difference is the direction of the dependency between Domain and Infrastructure. In Layered Architecture, the Domain layer typically sits above Infrastructure and may depend on it. In Onion Architecture, the Domain is at the center and the Infrastructure is at the outer edge — Infrastructure depends on Domain, never the reverse. This inversion is what gives Onion Architecture its testability advantage.

Onion Architecture Explained

Onion Architecture organizes code into four concentric rings, from innermost to outermost:

The Four Rings

  1. Domain Model (Innermost Ring): The absolute core of the application. Contains domain entities, value objects, domain events, and aggregate roots. Has zero dependencies — not on any other ring, not on any library, not on any framework. This ring is pure business concepts expressed in the programming language's native constructs. An Order entity, a Money value object, an OrderPlaced domain event — all live here. This ring changes only when the business itself changes.

  2. Domain Services (Second Ring): Contains domain service interfaces and implementations that operate on domain entities but cannot belong to a single entity. A PricingService that calculates order totals considering discounts, loyalty tiers, and promotions is a domain service — its logic is pure business but spans multiple entities. This ring also contains repository interfaces — the domain defines what persistence operations it needs (OrderRepository.findById, OrderRepository.save), but does not implement them. Depends only on the Domain Model ring.

  3. Application Services (Third Ring): Contains the use case orchestrators. Application services receive commands or queries from the outside, load domain objects using repository interfaces, invoke domain logic, and coordinate the result. They represent the application's capabilities — "place an order", "cancel a subscription", "generate an invoice". This ring also contains DTOs and mappers that translate between domain objects and the outside world. Depends on Domain Model and Domain Services rings.

  4. Infrastructure / UI (Outermost Ring): Contains everything that interacts with the external world. Repository implementations (using an ORM, raw SQL, or a REST API), HTTP controllers, CLI handlers, message queue consumers, email services, caching layers, and UI components all live here. This ring implements the interfaces defined by inner rings. Depends on all inner rings, but nothing in the inner rings depends on it.

Onion Architecture Variants

  • Classic Onion (Palermo): Four rings as described — Domain Model, Domain Services, Application Services, Infrastructure/UI. Strict inward-only dependency rule
  • Onion + CQRS: Application Services ring is split into Command Handlers (write side with full domain model) and Query Handlers (read side that may bypass inner rings for efficiency)
  • Onion + Domain Events: Domain entities in the innermost ring raise domain events; Application Services collect and dispatch them after committing the unit of work — loose coupling between use cases without direct dependencies
  • Modular Onion: Each bounded context is its own onion; bounded contexts communicate through well-defined interfaces, never sharing inner rings
  • Onion + Event Sourcing: The Domain Model ring stores state as a sequence of domain events rather than mutable properties; the Infrastructure ring handles event store persistence and projection rebuilding

Diagram

Here is the canonical Onion Architecture diagram:

  ┌─────────────────────────────────────────────────────────────────┐
  │                    INFRASTRUCTURE / UI                          │
  │   ┌─────────────────────────────────────────────────────────┐  │
  │   │                 APPLICATION SERVICES                    │  │
  │   │   ┌─────────────────────────────────────────────────┐   │  │
  │   │   │               DOMAIN SERVICES                   │   │  │
  │   │   │   ┌─────────────────────────────────────────┐   │   │  │
  │   │   │   │             DOMAIN MODEL                 │   │   │  │
  │   │   │   │   • Entities       • Value Objects       │   │   │  │
  │   │   │   │   • Aggregates     • Domain Events       │   │   │  │
  │   │   │   │   • Business Rules (zero dependencies)   │   │   │  │
  │   │   │   └─────────────────────────────────────────┘   │   │  │
  │   │   │   • Repository Interfaces                        │   │  │
  │   │   │   • Domain Service Interfaces & Implementations  │   │  │
  │   │   └─────────────────────────────────────────────────┘   │  │
  │   │   • Use Case Orchestrators  • DTOs & Mappers             │  │
  │   │   • Command / Query Handlers • Application Events        │  │
  │   └─────────────────────────────────────────────────────────┘  │
  │   • Repository Implementations  • ORM / SQL                    │
  │   • HTTP Controllers / REST API • Message Brokers              │
  │   • External API Clients        • UI / CLI                     │
  └─────────────────────────────────────────────────────────────────┘
  
  Dependency Rule: every arrow points INWARD →  never outward

Beginner-Friendly Example

Let's build a library book lending system. A member can borrow a book, return it, and the system tracks overdue books. The Domain Model ring holds Book, Member, and Loan entities with all lending rules. The Domain Services ring holds the LoanRepository interface and a LoanPolicyService. The Application Services ring holds LibraryApplicationService with use case methods and LoanDTO. The Infrastructure ring provides the in-memory repository and HTTP/CLI entry points.

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
from enum import Enum

# ═══════════════════════════════════════════════════════════════════════════════
# RING 1 — DOMAIN MODEL (zero dependencies)
# ═══════════════════════════════════════════════════════════════════════════════

class BookStatus(Enum):
    AVAILABLE = "available"
    ON_LOAN   = "on_loan"
    RESERVED  = "reserved"


@dataclass
class Book:
    id: str
    title: str
    author: str
    isbn: str
    status: BookStatus = BookStatus.AVAILABLE

    def __post_init__(self) -> None:
        if not self.title.strip():
            raise ValueError("Book title cannot be blank")
        if not self.isbn.strip():
            raise ValueError("ISBN cannot be blank")

    def check_out(self) -> None:
        if self.status != BookStatus.AVAILABLE:
            raise ValueError(f"Book '{self.title}' is not available for loan")
        self.status = BookStatus.ON_LOAN

    def return_book(self) -> None:
        if self.status != BookStatus.ON_LOAN:
            raise ValueError(f"Book '{self.title}' is not currently on loan")
        self.status = BookStatus.AVAILABLE

    def is_available(self) -> bool:
        return self.status == BookStatus.AVAILABLE


@dataclass
class Member:
    id: str
    name: str
    email: str
    active_loan_count: int = 0
    max_loans: int = 5

    def __post_init__(self) -> None:
        if not self.name.strip():
            raise ValueError("Member name cannot be blank")

    def can_borrow(self) -> bool:
        return self.active_loan_count < self.max_loans

    def increment_loans(self) -> None:
        if not self.can_borrow():
            raise ValueError(f"Member '{self.name}' has reached the maximum loan limit")
        self.active_loan_count += 1

    def decrement_loans(self) -> None:
        if self.active_loan_count <= 0:
            raise ValueError("Loan count cannot go below zero")
        self.active_loan_count -= 1


@dataclass
class Loan:
    id: str
    book_id: str
    member_id: str
    borrowed_at: datetime = field(default_factory=datetime.utcnow)
    due_at: datetime = field(default_factory=lambda: datetime.utcnow() + timedelta(days=14))
    returned_at: Optional[datetime] = None

    def is_active(self) -> bool:
        return self.returned_at is None

    def is_overdue(self) -> bool:
        return self.is_active() and datetime.utcnow() > self.due_at

    def complete_return(self) -> None:
        if not self.is_active():
            raise ValueError("Loan has already been returned")
        self.returned_at = datetime.utcnow()

    def days_overdue(self) -> int:
        if not self.is_overdue():
            return 0
        return (datetime.utcnow() - self.due_at).days


# ═══════════════════════════════════════════════════════════════════════════════
# RING 2 — DOMAIN SERVICES (depends on Domain Model only)
# ═══════════════════════════════════════════════════════════════════════════════

class BookRepository:
    """Repository interface defined by the domain — implemented in Infrastructure."""
    def find_by_id(self, book_id: str) -> Optional[Book]:
        raise NotImplementedError

    def save(self, book: Book) -> None:
        raise NotImplementedError

    def find_all(self) -> list[Book]:
        raise NotImplementedError


class MemberRepository:
    def find_by_id(self, member_id: str) -> Optional[Member]:
        raise NotImplementedError

    def save(self, member: Member) -> None:
        raise NotImplementedError


class LoanRepository:
    def find_by_id(self, loan_id: str) -> Optional[Loan]:
        raise NotImplementedError

    def find_active_by_member(self, member_id: str) -> list[Loan]:
        raise NotImplementedError

    def find_overdue(self) -> list[Loan]:
        raise NotImplementedError

    def save(self, loan: Loan) -> None:
        raise NotImplementedError


class LoanPolicyService:
    """Domain service — operates across multiple domain objects."""

    def validate_borrow_eligibility(self, member: Member, book: Book) -> None:
        if not member.can_borrow():
            raise ValueError(
                f"Member '{member.name}' has reached the maximum of "
                f"{member.max_loans} concurrent loans"
            )
        if not book.is_available():
            raise ValueError(f"Book '{book.title}' is not available")

    def calculate_fine(self, loan: Loan, fine_per_day: float = 0.50) -> float:
        return loan.days_overdue() * fine_per_day


# ═══════════════════════════════════════════════════════════════════════════════
# RING 3 — APPLICATION SERVICES (depends on Rings 1 and 2)
# ═══════════════════════════════════════════════════════════════════════════════

@dataclass
class LoanDTO:
    id: str
    book_title: str
    member_name: str
    borrowed_at: str
    due_at: str
    returned_at: Optional[str]
    is_overdue: bool
    days_overdue: int
    fine_amount: float

    @staticmethod
    def from_loan(loan: Loan, book: Book, member: Member, fine: float = 0.0) -> "LoanDTO":
        return LoanDTO(
            id=loan.id,
            book_title=book.title,
            member_name=member.name,
            borrowed_at=loan.borrowed_at.isoformat(),
            due_at=loan.due_at.isoformat(),
            returned_at=loan.returned_at.isoformat() if loan.returned_at else None,
            is_overdue=loan.is_overdue(),
            days_overdue=loan.days_overdue(),
            fine_amount=fine,
        )


class LibraryApplicationService:
    def __init__(
        self,
        books: BookRepository,
        members: MemberRepository,
        loans: LoanRepository,
        loan_policy: LoanPolicyService,
    ) -> None:
        self._books = books
        self._members = members
        self._loans = loans
        self._policy = loan_policy

    def borrow_book(self, loan_id: str, book_id: str, member_id: str) -> LoanDTO:
        book = self._require_book(book_id)
        member = self._require_member(member_id)

        self._policy.validate_borrow_eligibility(member, book)

        book.check_out()
        member.increment_loans()

        loan = Loan(id=loan_id, book_id=book_id, member_id=member_id)

        self._books.save(book)
        self._members.save(member)
        self._loans.save(loan)

        return LoanDTO.from_loan(loan, book, member)

    def return_book(self, loan_id: str) -> LoanDTO:
        loan = self._loans.find_by_id(loan_id)
        if loan is None:
            raise ValueError(f"Loan '{loan_id}' not found")

        book = self._require_book(loan.book_id)
        member = self._require_member(loan.member_id)

        fine = self._policy.calculate_fine(loan)

        loan.complete_return()
        book.return_book()
        member.decrement_loans()

        self._loans.save(loan)
        self._books.save(book)
        self._members.save(member)

        return LoanDTO.from_loan(loan, book, member, fine)

    def list_overdue_loans(self) -> list[LoanDTO]:
        overdue = self._loans.find_overdue()
        result = []
        for loan in overdue:
            book = self._require_book(loan.book_id)
            member = self._require_member(loan.member_id)
            fine = self._policy.calculate_fine(loan)
            result.append(LoanDTO.from_loan(loan, book, member, fine))
        return result

    def _require_book(self, book_id: str) -> Book:
        book = self._books.find_by_id(book_id)
        if book is None:
            raise ValueError(f"Book '{book_id}' not found")
        return book

    def _require_member(self, member_id: str) -> Member:
        member = self._members.find_by_id(member_id)
        if member is None:
            raise ValueError(f"Member '{member_id}' not found")
        return member


# ═══════════════════════════════════════════════════════════════════════════════
# RING 4 — INFRASTRUCTURE / UI (depends on all inner rings)
# ═══════════════════════════════════════════════════════════════════════════════

class InMemoryBookRepository(BookRepository):
    def __init__(self) -> None:
        self._store: dict[str, Book] = {}

    def find_by_id(self, book_id: str) -> Optional[Book]:
        return self._store.get(book_id)

    def save(self, book: Book) -> None:
        self._store[book.id] = book

    def find_all(self) -> list[Book]:
        return list(self._store.values())


class InMemoryMemberRepository(MemberRepository):
    def __init__(self) -> None:
        self._store: dict[str, Member] = {}

    def find_by_id(self, member_id: str) -> Optional[Member]:
        return self._store.get(member_id)

    def save(self, member: Member) -> None:
        self._store[member.id] = member


class InMemoryLoanRepository(LoanRepository):
    def __init__(self) -> None:
        self._store: dict[str, Loan] = {}

    def find_by_id(self, loan_id: str) -> Optional[Loan]:
        return self._store.get(loan_id)

    def find_active_by_member(self, member_id: str) -> list[Loan]:
        return [l for l in self._store.values()
                if l.member_id == member_id and l.is_active()]

    def find_overdue(self) -> list[Loan]:
        return [l for l in self._store.values() if l.is_overdue()]

    def save(self, loan: Loan) -> None:
        self._store[loan.id] = loan


class LibraryCLI:
    """Outermost ring — driving adapter."""
    def __init__(self, service: LibraryApplicationService) -> None:
        self._service = service

    def run(self) -> None:
        dto = self._service.borrow_book("loan-1", "book-1", "member-1")
        print(f"Borrowed: '{dto.book_title}' by {dto.member_name}")
        print(f"  Due: {dto.due_at}")

        return_dto = self._service.return_book("loan-1")
        print(f"Returned: '{return_dto.book_title}' | Fine: £{return_dto.fine_amount:.2f}")


# ═══════════════════════════════════════════════════════════════════════════════
# COMPOSITION ROOT
# ═══════════════════════════════════════════════════════════════════════════════

if __name__ == "__main__":
    book_repo   = InMemoryBookRepository()
    member_repo = InMemoryMemberRepository()
    loan_repo   = InMemoryLoanRepository()

    book_repo.save(Book(id="book-1", title="Clean Architecture", author="Robert Martin", isbn="978-0-13-468599-1"))
    member_repo.save(Member(id="member-1", name="Alice Johnson", email="alice@library.org"))

    policy  = LoanPolicyService()
    service = LibraryApplicationService(book_repo, member_repo, loan_repo, policy)
    cli     = LibraryCLI(service)
    cli.run()

Production-Ready Example

In production, Onion Architecture's rings align cleanly with build system boundaries, test strategies, and deployment concerns.

Key Production Concerns

  • Ring isolation enforced by build system: Each ring is a separate module, package, or project. The Domain Model module has no external dependencies in its build file — only the standard library. The Infrastructure module declares dependencies on ORM libraries, HTTP clients, and the inner three rings. Build tools (Maven, Gradle, Poetry, Cargo workspaces) enforce this — the domain cannot import infrastructure because it is not on the domain's dependency list
  • Domain tests with zero setup: Because the Domain Model and Domain Services rings have no external dependencies, their tests require zero setup. No TestContainers, no mock_db, no before_each. Tests are pure function calls that run in under 10 milliseconds
  • Application Service tests with fakes: Application Service tests inject hand-written in-memory repository fakes. These fakes implement the repository interfaces from the Domain Services ring. No mocking library required — the fakes are simple, readable, and fast
  • Infrastructure tests against real systems: Only the Infrastructure ring tests require external systems. These tests run separately in CI, use real databases and message brokers (via Docker Compose or TestContainers), and verify that each adapter correctly maps between domain objects and external representations
  • Domain events for decoupling: Domain entities in the innermost ring raise domain events. The Application Services ring collects them. The Infrastructure ring dispatches them to message brokers or event buses. The domain never knows about the event bus

Production Ring-to-Module Mapping

my-app/
├── domain-model/                  # Ring 1 — zero external dependencies
│   ├── src/
│   │   ├── entities/
│   │   │   ├── book.py            # Pure domain entity
│   │   │   ├── member.py
│   │   │   └── loan.py
│   │   ├── value_objects/
│   │   │   └── isbn.py            # Validated value object
│   │   └── events/
│   │       └── book_returned.py   # Domain event
│   └── tests/                     # Zero-setup tests
│       └── test_loan_rules.py
│
├── domain-services/               # Ring 2 — depends on domain-model only
│   ├── src/
│   │   ├── repositories/          # Interfaces (not implementations)
│   │   │   ├── book_repository.py
│   │   │   └── loan_repository.py
│   │   └── services/
│   │       └── loan_policy.py     # Domain service
│   └── tests/                     # Tests with fake implementations
│       └── test_loan_policy.py
│
├── application-services/          # Ring 3 — depends on rings 1 and 2
│   ├── src/
│   │   ├── use_cases/
│   │   │   └── library_service.py
│   │   └── dtos/
│   │       └── loan_dto.py
│   └── tests/                     # Tests with in-memory fakes
│       └── test_library_service.py
│
└── infrastructure/                # Ring 4 — depends on all inner rings
    ├── src/
    │   ├── persistence/
    │   │   ├── postgres_book_repo.py   # Real DB implementation
    │   │   └── in_memory_book_repo.py  # Fake for tests
    │   ├── http/
    │   │   └── loan_controller.py      # REST controller (driving adapter)
    │   └── messaging/
    │       └── kafka_event_publisher.py
    └── tests/                      # Integration tests against real systems
        └── test_postgres_book_repo.py

Real-World Use Cases

Onion Architecture is the right choice when domain complexity, longevity, and testability requirements are high.

1. Financial and Banking Systems

Banking domain logic — interest calculation, loan amortization, regulatory compliance rules — is too valuable to be coupled to any database ORM or payment API. Onion Architecture isolates these rules in the innermost rings. When payment processors change, when banks merge and database schemas are consolidated, or when regulatory requirements add new validation rules, only the relevant ring changes. Core financial rules written in Ring 1 survive decades of infrastructure change.

2. Healthcare and Clinical Applications

Clinical decision support systems, medication dosing rules, and patient record management involve complex domain logic with regulatory and safety implications. These rules must be testable at millisecond speed without hospital database access. Onion Architecture puts all clinical rules in the domain rings — they are tested exhaustively against realistic scenarios with in-memory data. The EHR integration, the FHIR API, and the HL7 message broker live in the Infrastructure ring and can be swapped without touching a single line of clinical logic.

3. Domain-Driven Design Bounded Contexts

Every DDD bounded context is naturally implemented as an Onion. The domain model ring contains the bounded context's aggregate roots, entities, and value objects. The domain services ring contains domain services and repository interfaces. Application services orchestrate use cases. Infrastructure connects to the database and handles inter-context communication through anti-corruption layers. The concentric ring structure maps directly to the DDD building blocks.

4. Long-Lived Enterprise Systems

Enterprise systems that must survive for 10–20 years see multiple generations of infrastructure change: from Oracle to PostgreSQL, from SOAP to REST to GraphQL, from monolith to microservices. The only code that must survive all these migrations is the business logic. Onion Architecture protects that code by putting it at the center with zero dependencies — the infrastructure changes, the domain endures.

5. Multi-Platform Applications

An application that runs on web, iOS, Android, and desktop shares domain logic across all platforms. The Domain Model and Domain Services rings are pure language code with no platform-specific imports. The Infrastructure ring has platform-specific implementations: CoreDataBookRepository on iOS, RoomBookRepository on Android, PostgresBookRepository on the server. Application Services are shared too. Only the Infrastructure ring differs per platform.

6. Event-Sourced Systems

Event-sourced systems store state as a sequence of domain events. The Domain Model ring defines the events (BookCheckedOut, LoanCompleted, FineAssessed) and the aggregates that raise them. The Application Services ring handles commands, loads aggregates from the event store, and appends new events. The Infrastructure ring implements the event store, the projection builders, and the read model repositories. The domain is completely agnostic about storage — it only knows about events.

Language-Specific Mistakes and Anti-Patterns

Common Mistakes Across All Languages

  • Ring 1 imports from Ring 4: The most severe violation — a domain entity imports an ORM class, a database type, or a framework annotation. Any import from Ring 4 in Ring 1 collapses the entire architecture — the domain is no longer testable in isolation
  • Repository implementations in the wrong ring: Placing PostgresBookRepository in the Domain Services ring instead of the Infrastructure ring — the interface belongs in Ring 2; the implementation belongs in Ring 4
  • Domain services calling Application services: A domain service invoking an application service creates an upward dependency from Ring 2 to Ring 3 — the flow must always be from outer to inner
  • DTOs leaking into the domain: Defining API request/response DTOs in Ring 1 or Ring 2 couples the domain model to the API contract — DTOs belong in Ring 3 (Application Services) at the innermost, and in Ring 4 (Infrastructure) for framework-specific serialization
  • Composition root inside the domain: Wiring adapters to ports anywhere inside Rings 1, 2, or 3 — composition always happens in the outermost ring

Language-Specific Pitfalls

  • Python: Using SQLAlchemy @declarative_base or Column annotations on Ring 1 entities couples the domain to the ORM. Use plain Python dataclass or @dataclass for domain entities. Keep Ring 1 and Ring 2 in a package with no requirements.txt entries — the absence of dependencies is the test. Avoid putting pydantic.BaseModel in Ring 1 — Pydantic is a serialization library and belongs in Ring 3 or 4
  • TypeScript: Importing @Entity, @Column, or TypeORM decorators inside domain classes pollutes Ring 1. Use plain TypeScript classes with no decorators. Avoid importing express.Request in Ring 3 Application Services — those are Ring 4 concerns. Use strict tsconfig.json baseUrl and paths to make wrong-ring imports an error at compile time
  • Java: JPA @Entity, @Table, and @Id annotations on Ring 1 entities — these belong on separate JPA entity classes in Ring 4 that map to domain objects. Avoid Spring @Service, @Component, or @Autowired in Ring 1 or Ring 2. Keep Ring 1 as a Maven module with zero dependencies in pom.xml. Use @SpringBootTest only in Ring 4 integration tests — Ring 1, 2, and 3 tests use plain JUnit5 with no Spring context
  • JavaScript: Without compile-time enforcement, ring boundaries are maintained by convention and ESLint rules. Use eslint-plugin-import with no-restricted-paths to fail the build when a Ring 1 file imports from Ring 4. Avoid require('express') or require('mongoose') anywhere in Ring 1, 2, or 3 files
  • C#: Using [Table], [Column], or Entity Framework Data Annotations on Ring 1 domain classes — use separate EF Core entity configurations in Ring 4. Avoid IServiceCollection extension methods in Ring 1, 2, or 3 — dependency registration is a Ring 4 concern. Use separate C# projects (.csproj) for each ring and set project references to enforce the dependency rule
  • PHP: Laravel's Eloquent Model base class carries ORM concerns — never extend it in Ring 1. Use plain PHP classes for domain entities and implement fillFromArray or factory methods for construction. Avoid Laravel's Facade classes in Ring 1, 2, or 3 — Facades are Ring 4 infrastructure helpers. Use PHP 8 readonly properties on Ring 1 entities for immutability
  • Go: Go has no compile-time package dependency graph enforcement by default — use golang.org/x/tools/cmd/deadcode or architectural fitness functions to verify ring boundaries in CI. The domain package (package domain) must have zero third-party import paths. Avoid putting database-tagged structs (db:"column_name") in the domain package
  • Rust: Use Cargo workspace with separate crates per ring — domain-model, domain-services, application-services, infrastructure. The domain-model crate's Cargo.toml has only [dependencies] for the standard library. Avoid #[derive(Serialize, Deserialize)] on Ring 1 structs unless serde is in the domain crate's dependencies — prefer separate DTO structs in Ring 3/4
  • Dart: Keep Ring 1 in a pure Dart package (no pubspec.yaml Flutter dependency). Avoid package:http, package:dio, package:sqflite, or any package:flutter import in Ring 1 or Ring 2. Use abstract class or abstract interface class for repository interfaces in Ring 2. Test Ring 1 with dart test in isolation
  • Swift: Avoid importing Foundation, UIKit, SwiftUI, or CoreData in Ring 1 structs and classes — domain model types should be expressible with pure Swift primitives. Use protocol for Ring 2 repository interfaces. Mark Ring 4 actor types as the driven adapters — actors manage the mutable state of the infrastructure layer and are never imported by inner rings
  • Kotlin: Keep Ring 1 in a Kotlin module with zero Gradle dependencies. Avoid Android Parcelable, Room annotations, or Spring Boot annotations in Ring 1 or Ring 2 classes. Use data class for domain entities and value objects in Ring 1. Use interface for all Ring 2 repository definitions. Apply Hilt or Koin dependency injection wiring only in Ring 4 — never annotate Ring 1 or Ring 2 classes with @Inject or @Component

Frequently Asked Questions

What is the difference between Onion Architecture and Clean Architecture?

Clean Architecture (Robert Martin, 2012) is a more explicitly named elaboration of Onion Architecture (Jeffrey Palermo, 2008). Both share the identical core principle: dependencies point inward; the innermost ring has zero outward dependencies. Clean Architecture assigns specific names to four rings — Entities, Use Cases, Interface Adapters, Frameworks & Drivers — and adds explicit guidance for the "Interface Adapters" ring (Controllers, Presenters, Gateways) that translates between use cases and external systems. Onion Architecture uses Domain Model, Domain Services, Application Services, and Infrastructure/UI as ring names. The architectural rule is the same in both.

How does Onion Architecture differ from Layered Architecture?

The critical difference is the direction of the dependency between the domain and infrastructure. In Layered Architecture, the dependency flows downward — Domain typically sits above Infrastructure and may import it. In Onion Architecture, the dependency flows inward — Infrastructure is the outermost ring and imports the Domain; the Domain never imports Infrastructure. This inversion is achieved by defining repository and service interfaces in the Domain Services ring (inner) and implementing them in the Infrastructure ring (outer). The domain defines what it needs; infrastructure provides how. This is the Dependency Inversion Principle at architectural scale.

When should I choose Onion Architecture over Layered Architecture?

Choose Onion Architecture when: the domain is complex enough to warrant exhaustive isolated testing; the team practices domain-driven design and needs the architecture to reflect DDD concepts cleanly; technology choices are uncertain or likely to change; or the system is expected to have a lifespan of many years with multiple infrastructure changes. Choose Layered Architecture when: the application is primarily CRUD with simple business rules; the team is newer to architecture and needs a familiar pattern; the speed of initial delivery is more important than long-term flexibility; or the technology stack is fixed and unlikely to change.

Can repository interfaces go in Ring 1 (Domain Model) instead of Ring 2 (Domain Services)?

Yes — some implementations place repository interfaces directly in Ring 1 alongside the entities they manage. The trade-off: putting BookRepository in Ring 1 (with Book) makes the relationship explicit and keeps related concepts together. Placing it in Ring 2 (Domain Services) gives Ring 1 a purer focus on entity logic only. Both are valid. The rule that must never be violated is that repository implementations (the PostgreSQL, MongoDB, or in-memory versions) must always be in Ring 4 (Infrastructure). The interface can live in Ring 1 or Ring 2 — the implementation must never be in either.

How do I handle cross-ring communication like events and notifications?

Define notification and event interfaces in the Domain Services ring (Ring 2). For example, DomainEventPublisher as an interface in Ring 2 that the Domain Services or Application Services can call. The concrete implementation — KafkaEventPublisher, InMemoryEventBus — lives in Ring 4 (Infrastructure). Application Services collect domain events raised by Ring 1 entities, then dispatch them through the Ring 2 interface. The domain never knows whether events are published to Kafka, RabbitMQ, or a test spy — it only knows the interface.

How do I test each ring independently?

Ring 1 (Domain Model) tests are pure unit tests — instantiate entities directly and call their methods. No setup, no mocks, no fakes. Ring 2 (Domain Services) tests inject Ring 1 entities and test domain service logic in isolation. Ring 3 (Application Services) tests inject hand-written fake implementations of Ring 2 repository interfaces. The fakes are simple in-memory stores — 10–20 lines each. No mocking library needed. Ring 4 (Infrastructure) tests use real external systems (database, message broker) through Docker or TestContainers to verify adapter correctness. This four-level test strategy gives complete coverage with maximum speed.

Key Takeaways

Language-Specific Best Practices

  • Python: Enforce rings as separate Python packages (domain_model/, domain_services/, application_services/, infrastructure/); use Protocol for all Ring 2 interfaces; run Ring 1 tests with pytest and zero fixtures; use import-linter in CI to enforce that domain_model imports nothing outside the standard library
  • TypeScript: Enforce rings as separate NPM workspaces or TypeScript project references; use interface (not class) for all Ring 2 repository and service contracts; configure eslint-plugin-boundaries to fail builds on cross-ring violations; keep Ring 1 tsconfig.json with "strict": true and no external type definitions
  • Java: Enforce rings as separate Maven modules or Gradle subprojects; Ring 1 pom.xml has no dependencies; use ArchUnit in CI to verify that Ring 1 classes have no imports from Ring 4; apply record for immutable Ring 1 value objects; use Optional<T> for nullable repository returns
  • JavaScript: Use eslint-plugin-import no-restricted-paths to enforce ring boundaries at lint time; organize as src/domain-model/, src/domain-services/, src/application-services/, src/infrastructure/; document Ring 2 interfaces with JSDoc; use Jest for all ring tests with in-memory fakes
  • C#: Enforce rings as separate .csproj projects in a solution; Ring 1 project references only System.*; use xUnit for Ring 1–3 tests; use Testcontainers for Ring 4 integration tests; apply record for Ring 1 value objects and DTOs; use IServiceCollection extension methods only in Ring 4
  • PHP: Enforce rings as separate Composer packages or PSR-4 namespaces (App\DomainModel, App\DomainServices, App\ApplicationServices, App\Infrastructure); keep Ring 1 classes as plain PHP with readonly properties; use PHPUnit with in-memory fake repositories for Ring 3 tests; validate ring boundaries with phparkitect in CI
  • Go: Enforce rings as separate Go packages (domain, services, application, infrastructure); define all interfaces in the package that consumes them (dependency inversion via Go's implicit interfaces); use go test with table-driven tests for Ring 1; use architecture fitness functions in CI to verify zero third-party imports in the domain package
  • Rust: Enforce rings as separate Cargo workspace crates; domain-model crate has only std in [dependencies]; use generic type parameters over trait objects for zero-cost Ring 2 abstractions in Ring 3; use cargo test for Ring 1–3 and integration tests with testcontainers-rs for Ring 4
  • Dart: Enforce rings as separate Dart packages; Ring 1 pubspec.yaml lists no dependencies; use abstract interface class for Ring 2 contracts; test Ring 1 with dart test and zero dependencies; use dependency injection via get_it or Riverpod only in Ring 4
  • Swift: Enforce rings as separate Swift Package targets; Ring 1 target has no dependencies in Package.swift; use protocol for all Ring 2 interfaces; use XCTest for Ring 1–3 and XCTestCase with real databases for Ring 4; mark Ring 4 repositories as actor for thread safety in Swift concurrency
  • Kotlin: Enforce rings as separate Gradle modules; Ring 1 build.gradle.kts has no dependencies; use kotest or JUnit5 for Ring 1–3 tests; apply kotlin-arch-test or ArchUnit in CI to verify ring boundaries; use data class for Ring 1 entities and value objects; use coroutines (suspend fun) on Ring 2 interfaces for non-blocking I/O compatibility

📋 When to Use Onion Architecture:

  • The domain is complex with rich business rules that need exhaustive, fast, isolated testing
  • The system is expected to have a long lifespan with multiple generations of technology change
  • The team is practicing Domain-Driven Design and needs the architecture to reflect DDD concepts
  • Multiple infrastructure options must be supported simultaneously (different databases per deployment, platform-specific implementations)
  • Technology independence is a hard requirement — the domain must be provably independent of any framework or database
  • The team has the discipline to maintain ring boundaries as the codebase grows

⚠️ When NOT to Use Onion Architecture:

  • The application is primarily CRUD with minimal business logic — the ring structure adds ceremony without benefit
  • The team is new to architectural patterns — start with Layered Architecture and introduce Onion incrementally as complexity warrants
  • Delivery speed is the primary concern in early stages — Onion requires more upfront structural investment than simpler patterns
  • The technology stack is completely fixed and will never change — if you will always use the same database and framework, the infrastructure-as-plugin benefit doesn't apply
  • The domain has fewer than 5–10 meaningful entities with simple rules — the overhead of four rings is disproportionate

🛠️ Best Practices Across Languages:

  1. Enforce rings with build system boundaries — use separate packages, modules, or projects per ring; let the build tool reject violations, not just code reviews
  2. Ring 1 has zero dependencies — measure it — count the imports in Ring 1 files; any import from a third-party library is a violation; standard library imports are the only exception
  3. Repository interfaces in Ring 2, implementations in Ring 4 — the interface name is domain language (BookRepository); the implementation name is technology language (PostgresBookRepository)
  4. Test Ring 1 with zero setup — if any Ring 1 test requires a fixture, a mock, or an external resource, Ring 1 has been polluted
  5. DTOs at Ring 3, not Ring 1 — domain entities express business concepts; DTOs express API contracts; they are different things serving different masters
  6. Composition root in Ring 4 only — wiring of interfaces to implementations happens in the outermost ring; inner rings never instantiate their own dependencies
  7. Domain events collected by Ring 3, dispatched by Ring 4 — Ring 1 raises events; Ring 3 collects them after the use case completes; Ring 4 publishes them to the event bus

💡 Common Pitfalls:

  • Framework annotations in Ring 1: @Entity, @JsonProperty, @Column, @Serializable on domain classes — these couple Ring 1 to Ring 4 frameworks and break isolation completely
  • Application service logic in Ring 1: Business workflow logic (if member.hasOverdueLoans() then suspend account) belongs in Ring 3 Application Services, not Ring 1 entities — Ring 1 entities express invariants, not use cases
  • Skipping Ring 2: Having Ring 3 Application Services depend directly on Ring 4 repository implementations instead of Ring 2 interfaces — this makes Ring 3 untestable without a real database
  • Circular ring dependencies: Ring 2 importing from Ring 3, or Ring 3 importing from Ring 4 implementations — any outward dependency violates the fundamental rule
  • God Ring 1: All domain logic in one massive domain.py or Domain.java file — organize Ring 1 by aggregate (one file/module per aggregate root)
  • Missing anti-corruption layer: When Ring 4 integrates with a legacy system or third-party API, the translation from external concepts to domain concepts must happen inside Ring 4 — never let external model concepts leak into Ring 2 or Ring 1

🎁 Advanced Techniques:

  • Aggregate-per-module organization: Each Ring 1 aggregate (Book, Loan, Member) becomes its own sub-module within Ring 1 — prevents Ring 1 from becoming a god module and makes aggregate boundaries explicit
  • Domain event sourcing: Ring 1 aggregates raise and apply domain events instead of mutating state directly; Ring 4 persists the event stream; Ring 3 rebuilds aggregate state from events on load — the domain is completely storage-agnostic
  • CQRS split in Ring 3: Split Application Services into Command Services (write path — full domain model, Ring 1–4) and Query Services (read path — can bypass inner rings and read directly from optimized read models in Ring 4)
  • Specification pattern in Ring 2: Encapsulate complex query criteria as Specification domain objects in Ring 2 — OverdueLoanSpecification, AvailableBookSpecification. Ring 4 repository implementations translate specifications to SQL, Mongo queries, or in-memory predicates
  • Anti-corruption layer adapters: When Ring 4 integrates with legacy systems, wrap the legacy API in an anti-corruption layer that translates legacy concepts to domain concepts — prevents the legacy model from bleeding into Ring 2 or Ring 1
  • Architecture fitness functions: Write automated tests that verify ring boundaries as part of CI — use ArchUnit (Java), import-linter (Python), eslint-plugin-boundaries (TypeScript), or custom tools to fail the build if any Ring 1 file gains an external import

Onion Architecture is not a pattern for every project — it is a pattern for projects where the business logic is the most valuable and long-lived asset, where that logic must be tested thoroughly and quickly without infrastructure, and where the technology beneath it will change while the business rules endure. When those conditions are met, no other pattern protects the domain as completely or enables as fearless refactoring of the surrounding infrastructure. The onion's center is inviolable — and that inviolability is precisely what makes the outer rings free to change.


This guide is part of the Design Patterns in Action series, which covers:

Each guide covers all 11 programming languages with production-ready examples, language-specific best practices, and comprehensive anti-pattern analysis.