Onion Architecture
Places the rich domain model at the absolute core of the system, with dependencies pointing exclusively inward — the infrastructure sits in the outermost shell and is never known by the inner layers.
Intent
Position the domain model — entities with real behavior and domain services — at the absolute center of the system, so that no inner layer needs to know a database, a web framework, or any external technology exists. Infrastructure is treated as a replaceable detail in the outermost shell.
Onion Architecture was described by Jeffrey Palermo in 2008 and represents a direct evolution of Layered Architecture with an explicit inversion of dependencies. While classic Layered Architecture places infrastructure "below" the domain as its natural dependency, Onion inverts that relationship: infrastructure sits "outside" and implements interfaces defined by the domain.
Onion Architecture's central emphasis is the rich domain model: entities that encapsulate behavior and invariants, domain services that express operations that don't belong to any single entity, and repository interfaces defined inside the domain — not in the infrastructure. An anemic domain (entities as mere data containers) cancels out the architecture's main benefit.
Problem
Onion Architecture solves the same problems as classic Layered Architecture, with special emphasis on two of them:
- Domain depending on infrastructure: in systems with Layered Architecture and no dependency inversion, entities import ORM classes, domain services inject database connections, and switching persistence technology requires modifying the domain. Onion inverts that relationship: the domain defines the interface; the infrastructure implements it.
- Anemic domain: when business rules migrate to application services because entities are treated as passive data structures, the domain loses expressiveness. Onion Architecture explicitly encourages entities to have behavior — methods that apply rules, raise events, validate invariants.
- Difficulty testing the domain in isolation: when the domain depends on concrete infrastructure, testing a business rule requires spinning up the whole stack. With the domain at the center and interfaces defined by it, any layer can be replaced by a simple test double.
Structure
The structure is visualized as concentric layers of an onion. The center holds the most stable policies; the outer shell holds the most volatile details.
╔═══════════════════════════════════════════════════════════════════╗
║ INFRASTRUCTURE (outermost shell) ║
║ Databases, frameworks, UI, external services, queues ║
║ Implements interfaces defined by the inner layers ║
║ ┌─────────────────────────────────────────────────────────────┐ ║
║ │ APPLICATION SERVICES │ ║
║ │ Use cases, orchestration, authorization, transactions │ ║
║ │ Knows the domain; doesn't know the concrete infrastructure │ ║
║ │ ┌───────────────────────────────────────────────────────┐ │ ║
║ │ │ DOMAIN SERVICES │ │ ║
║ │ │ Domain operations that involve multiple │ │ ║
║ │ │ entities; repository interfaces │ │ ║
║ │ │ ┌─────────────────────────────────────────────────┐ │ │ ║
║ │ │ │ DOMAIN MODEL (core) │ │ │ ║
║ │ │ │ Entities with behavior, Value Objects │ │ │ ║
║ │ │ │ Invariants, domain events │ │ │ ║
║ │ │ │ No external dependency whatsoever │ │ │ ║
║ │ │ └─────────────────────────────────────────────────┘ │ │ ║
║ │ └───────────────────────────────────────────────────────┘ │ ║
║ └─────────────────────────────────────────────────────────────┘ ║
╚═══════════════════════════════════════════════════════════════════╝
Dependency rule: all arrows point toward the center.
The infrastructure implements the domain's interfaces — never the other way around.
The domain has no imports from any outer layer.
The Onion's layers
- Domain Model (core): entities with real behavior, immutable Value Objects, domain invariants and domain events. It has no dependency on anything external. It's the most stable code in the system.
- Domain Services: domain operations involving multiple entities or that don't naturally belong to any one of them. This is also where the repository interfaces live — defined by the domain, implemented by the infrastructure.
- Application Services: the application's use cases. They orchestrate entities and domain services, apply per-use-case authorization, manage transactions and convert input/output DTOs. They know the domain, but not the concrete infrastructure.
- Infrastructure (shell): concrete repository implementations, HTTP controllers, external-service adapters, queues. The most volatile layer — replaceable with no impact on the domain.
How it works — a domain with no infrastructure imports
The snippet below illustrates the central principle: the domain defines its own repository interfaces and imports nothing from the infrastructure. The outer shell implements those interfaces and points inward.
// ── DOMAIN MODEL (core — zero external imports) ───────────────
class Account {
private balance: number;
constructor(
readonly id: string,
initialBalance: number,
) {
if (initialBalance < 0) throw new Error('Initial balance cannot be negative');
this.balance = initialBalance;
}
debit(amount: number): void {
if (amount <= 0) throw new Error('Debit amount must be positive');
if (amount > this.balance) throw new Error('Insufficient balance');
this.balance -= amount;
}
credit(amount: number): void {
if (amount <= 0) throw new Error('Credit amount must be positive');
this.balance += amount;
}
get currentBalance(): number { return this.balance; }
}
// ── DOMAIN SERVICE — defines the repository interface ────────
// The interface lives INSIDE the domain; the infra implements it.
interface AccountRepository {
findById(id: string): Promise<Account | null>;
save(account: Account): Promise<void>;
}
class TransferService {
constructor(private readonly accounts: AccountRepository) {}
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
const from = await this.accounts.findById(fromId);
const to = await this.accounts.findById(toId);
if (!from || !to) throw new Error('Account not found');
from.debit(amount);
to.credit(amount);
await this.accounts.save(from);
await this.accounts.save(to);
}
}
// ── INFRASTRUCTURE (shell) — implements the domain's interface ──
// Depends on the domain; the domain never depends on it.
class PostgresAccountRepository implements AccountRepository {
async findById(id: string): Promise<Account | null> {
// reads from the database; maps to the domain entity
return null; // placeholder
}
async save(account: Account): Promise<void> {
// persists to the database
}
}
What makes this code Onion: Account and
TransferService import nothing from outside the
domain. AccountRepository is an interface declared
inside the domain. PostgresAccountRepository lives in
the infrastructure and imports the domain — never the other way
around. Swapping the database means creating a new
AccountRepository implementation; the domain doesn't
change.
When to use
- Rich domains with complex behavior: Onion shines when there are substantial business rules to model — invariant validations, domain calculations, entity lifecycle states. The richer the domain, the more the investment in the inner layers pays off.
- When domain testability is critical: with the domain at the core and repository interfaces defined by it, you can test all the business logic with simple in-memory implementations, no real database, no framework, running instantly.
- Projects with a high likelihood of swapping infrastructure: when there are plans to migrate the database, the email provider or the payment system, the separation between domain and infrastructure lets that swap happen without impacting business rules.
- Long-lived systems with a growing team: explicit boundaries between layers reduce accidental coupling and ease onboarding — the domain is the team's shared vocabulary, and the architecture ensures it stays clean.
When to avoid
- Anemic domains or pure CRUDs: when the system is essentially a persistence form without complex business rules, Onion's inner layers stay empty and the result is architectural bureaucracy with no benefit. Onion's main gain is the rich domain model — without it, there's nothing to protect.
- Teams unfamiliar with DDD or dependency inversion: without understanding that the repository interface should live in the domain (not the infrastructure), Onion is usually implemented as a Layered Architecture with dependencies inverted in the wrong direction.
- Prototypes and MVPs: the cost of structuring the inner layers well is real. In discovery phases, start simple and refactor once the domain reveals its real complexity.
Pros and cons
Pros
- Domain model completely isolated from frameworks, databases and any external technology.
- Total domain testability without infrastructure: in-memory repositories, mocked services, fast execution.
- Swapping infrastructure without impacting the domain — swapping the database means swapping only the implementation in the outer shell.
- Emphasis on the rich domain model encourages entities with real behavior, increasing expressiveness and maintainability.
- Compatible and convergent with Clean Architecture and Hexagonal Architecture — teams familiar with any of the three transition easily.
Cons
- Overhead for simple domains: the concentric layers have organization and dependency-injection configuration costs.
- An anemic domain cancels the main benefit: if the entities have no behavior, the investment in the inner layers doesn't pay off.
- The distinction between domain service and application service isn't always obvious and can spark debates about where each piece of logic belongs.
- Mandatory dependency injection to compose the layers — DI frameworks help, but add configuration complexity.
Common pitfalls
1. Anemic domain — the central pitfall
The most common and most destructive mistake in Onion Architecture is building a domain where entities are just data classes with getters and setters, and all the logic lives in application services. A domain like that defeats the architecture's purpose: the inner layers end up empty of value, and the benefit of isolating them is zero.
Domain entities should have methods that apply business rules, raise exceptions when invariants are violated, and encapsulate the behavior that would naturally be asked of that object. If the answer to "who validates this?" is always "the application service," the domain is anemic.
2. Repository interface outside the domain
The repository interface should be declared inside the domain (or
the domain services), not in the infrastructure. If
AccountRepository is in the infrastructure package and
the domain imports that package to use the interface, the
dependency was inverted the wrong way — the domain went back to
depending on the infrastructure. The interface lives inside; the
concrete implementation lives outside and implements the interface.
Rule of thumb: if you copy only the domain and domain-services folders to another project, the code should compile without errors. Any import that prevents this reveals a dependency that shouldn't exist.
3. Confusing domain service with application service
Domain services encapsulate operations that involve multiple
entities and express concepts from the domain's vocabulary (e.g.,
TransferService, PricingService).
Application services orchestrate those domain services to carry out
application-specific use cases, manage transactions and convert
DTOs. Putting domain logic in the application service is the same
as creating an anemic domain — the logic is reachable, but in the
wrong place.
4. Infrastructure inside the domain
Entities that inherit from ORM classes (Eloquent Model, JPA Entity with annotations), or domain services that receive database connections through injection, violate Onion's most important boundary. That coupling destroys testability and creates lifecycle dependencies that make it harder to evolve the domain independently of the chosen technology.
Related architectures and patterns
Clean Architecture is the closest evolution of Onion Architecture. Both place the domain at the center, invert the dependencies and treat infrastructure as a replaceable detail. Clean Architecture adds a more formal distinction between entities (enterprise business rules) and use cases (application business rules), and formalizes explicit boundaries between layers. In practice, a well-structured Onion implementation respects the same principles as Clean Architecture.
Hexagonal Architecture shares the same dependency inversion principle — the domain defines interfaces (ports), the infrastructure implements them (adapters). The difference in vocabulary is bigger than the conceptual difference: Hexagonal talks about primary (driving) and secondary (driven) ports; Onion talks about concentric layers. Both are compatible and often implemented together.
Layered Architecture is Onion's direct precursor. Onion solves classic Layered Architecture's central problem — the domain's natural dependency on infrastructure — by explicitly inverting that relationship. Onion can be seen as a Layered Architecture with dependency inversion applied systematically at the boundary between domain and infrastructure.