Loading content...
In 2008, Jeffrey Palermo introduced Onion Architecture, a revolutionary approach to structuring applications that challenged the traditional layered architecture paradigm. At its heart lies a simple but profound insight: your domain logic—the rules and behaviors that define your business—should never depend on infrastructure concerns like databases, web frameworks, or external services.
This seemingly simple principle has far-reaching implications. It means your core business logic can be tested without a database. It means you can switch from PostgreSQL to MongoDB without touching domain code. It means your application's most valuable intellectual property—its business rules—is protected from the volatility of technology choices.
By the end of this page, you will understand the fundamental structure of Onion Architecture, why the domain sits at the center, how dependencies flow inward toward this core, and why infrastructure is deliberately pushed to the outer edges of your system.
To understand why Onion Architecture matters, we must first understand what it addresses. Traditional N-tier architecture organizes applications into horizontal layers:
┌──────────────────────────────────┐
│ Presentation Layer │
├──────────────────────────────────┤
│ Business Layer │
├──────────────────────────────────┤
│ Data Access Layer │
├──────────────────────────────────┤
│ Database │
└──────────────────────────────────┘
In this model, each layer depends on the layer directly below it. The Presentation Layer calls the Business Layer, which calls the Data Access Layer, which communicates with the Database.
The fatal flaw: Dependencies flow downward toward infrastructure. Your business logic—the most valuable part of your system—depends on the database layer. This creates several serious problems:
When your business layer depends on your data layer, every database decision becomes a business layer decision. Need to add a new index? It might change query patterns the business layer relies on. Want to split a table? You'll refactor business code too. The database—which should be an implementation detail—becomes a controlling force over your entire architecture.
Onion Architecture addresses these problems through a fundamental insight: dependencies should flow toward the center, not toward the edges.
Instead of your business logic depending on your database, your database adapters should depend on interfaces defined by your business logic. The direction of dependencies is inverted:
Traditional Onion
┌─────────────┐ ┌─────────────────┐
│ UI │ │ Infrastructure │
└──────┬──────┘ │ (Outer) │
│ depends on └────────┬────────┘
▼ │ depends on
┌─────────────┐ ▼
│ Business │ ┌─────────────────┐
└──────┬──────┘ │ Application │
│ depends on │ Services │
▼ └────────┬────────┘
┌─────────────┐ │ depends on
│ Database │ ▼
└─────────────┘ ┌─────────────────┐
│ Domain Model │
│ (Center) │
└─────────────────┘
This inversion is the key to everything else in Onion Architecture. By making dependencies flow inward, we create a protective barrier around our domain logic.
The fundamental rule of Onion Architecture: Source code dependencies can only point inward. Nothing in an inner circle can know anything about something in an outer circle. In particular, the name of something declared in an outer circle must not be mentioned by code in an inner circle—whether classes, functions, variables, or any other named software entity.
How does this work in practice?
Consider a TransferFundsService that moves money between accounts. In traditional architecture, this service might depend directly on a SqlAccountRepository. In Onion Architecture:
IAccountRepository interface (what operations we need)IAccountRepository (an abstraction)SqlAccountRepository (concrete implementation)SqlAccountRepository to the interfaceThe critical insight: the interface is defined in the inner circle, not the outer. The domain owns the contract; infrastructure adapts to it.
The name 'Onion Architecture' comes from its characteristic visualization: concentric circles, each representing a layer of abstraction, with the domain at the center and infrastructure at the outermost edge:
┌────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ (Web Controllers, Databases, Messaging, │
│ File Systems, External APIs, IoC Container) │
│ ┌──────────────────────────────────────────┐ │
│ │ APPLICATION SERVICES │ │
│ │ (Use Cases, Orchestration Logic, │ │
│ │ Application-specific Business Rules) │ │
│ │ ┌──────────────────────────────────────┐│ │
│ │ │ DOMAIN SERVICES ││ │
│ │ │ (Domain Operations that don't belong ││ │
│ │ │ to a single Entity or Value Object) ││ │
│ │ │ ┌──────────────────────────────────┐││ │
│ │ │ │ DOMAIN MODEL │││ │
│ │ │ │ (Entities, Value Objects, │││ │
│ │ │ │ Aggregates, Domain Events) │││ │
│ │ │ └──────────────────────────────────┘││ │
│ │ └──────────────────────────────────────┘│ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
Each layer encapsulates a different level of abstraction:
The critical observation: As you move from the center toward the edge, the code becomes:
By placing the most stable, most valuable, most business-specific code at the center and the most volatile, most replaceable code at the edge, we create architectures that age gracefully.
The innermost circle—the Domain Model—is the most protected part of your application. It contains the pure business logic: the rules, behaviors, and concepts that exist independent of any technology choices.
What lives in the Domain Model:
Customer, an Order, an Account. Identity matters more than attributes.Money object, an Address, a DateRange. Two values are equal if their attributes are equal.OrderPlaced, PaymentReceived, AccountOverdrawn.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Domain Model - No dependencies on infrastructure // Value Object: Immutable, equality by valueclass Money { private constructor( private readonly amount: number, private readonly currency: string ) { if (amount < 0) throw new Error("Amount cannot be negative"); if (!["USD", "EUR", "GBP"].includes(currency)) { throw new Error(`Invalid currency: ${currency}`); } } static of(amount: number, currency: string): Money { return new Money(amount, currency); } add(other: Money): Money { if (this.currency !== other.currency) { throw new Error("Cannot add different currencies"); } return new Money(this.amount + other.amount, this.currency); } equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; }} // Entity: Identity-based, mutable (through controlled operations)class Account { private balance: Money; constructor( readonly id: AccountId, private readonly owner: CustomerId, initialBalance: Money ) { this.balance = initialBalance; } deposit(amount: Money): void { this.balance = this.balance.add(amount); } withdraw(amount: Money): void { const newBalance = this.balance.subtract(amount); if (newBalance.isNegative()) { throw new InsufficientFundsError(this.id, amount); } this.balance = newBalance; } getBalance(): Money { return this.balance; }} // Repository Interface - Defined in domain, implemented in infrastructureinterface IAccountRepository { findById(id: AccountId): Promise<Account | null>; save(account: Account): Promise<void>;}Notice what's not in this code: SQL queries, HTTP requests, JSON parsing, framework annotations. The domain model is pure business logic. It doesn't know how it's persisted, transported, or presented. This purity is what makes it testable, portable, and durable.
The outermost layer is Infrastructure: everything that isn't your core domain logic. This is where "messy" code lives—HTTP parsing, database connections, file I/O, third-party API calls. But crucially, this code is isolated from your domain and can be changed or replaced without affecting business logic.
What lives in the Infrastructure layer:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Infrastructure - Implements interfaces defined in domain // Repository implementation using PostgreSQLclass PostgresAccountRepository implements IAccountRepository { constructor(private readonly db: DatabaseConnection) {} async findById(id: AccountId): Promise<Account | null> { const row = await this.db.query( `SELECT id, owner_id, balance, currency FROM accounts WHERE id = $1`, [id.value] ); if (!row) return null; // Transform database row to domain entity return new Account( new AccountId(row.id), new CustomerId(row.owner_id), Money.of(row.balance, row.currency) ); } async save(account: Account): Promise<void> { await this.db.query( `INSERT INTO accounts (id, owner_id, balance, currency) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance`, [ account.id.value, account.owner.value, account.getBalance().amount, account.getBalance().currency ] ); }} // Web Controller in infrastructure layerclass AccountController { constructor( private readonly transferService: TransferFundsService ) {} async handleTransfer(req: Request): Promise<Response> { // Parse HTTP-specific concerns const { fromAccountId, toAccountId, amount, currency } = req.body; try { // Call application service await this.transferService.execute( new AccountId(fromAccountId), new AccountId(toAccountId), Money.of(parseFloat(amount), currency) ); return { status: 200, body: { success: true } }; } catch (error) { if (error instanceof InsufficientFundsError) { return { status: 400, body: { error: error.message } }; } throw error; } }}Key observations about infrastructure code:
IAccountRepository, an interface defined in the domainPostgresAccountRepository for MongoAccountRepository without touching domain codePushing infrastructure to the edge isn't an arbitrary design choice—it's a strategic response to the forces that shape software systems over time.
Think of the onion layers as a volatility gradient. The core changes least frequently (business rules are stable). The edge changes most frequently (frameworks update, APIs evolve, databases migrate). By aligning code organization with volatility, you minimize the impact of change: volatile code is easy to replace, stable code is protected from disruption.
Interfaces are the architectural mechanism that enables inward-only dependencies. They serve as contracts between layers, with a crucial placement rule: interfaces are defined in inner layers and implemented in outer layers.
This placement is non-negotiable. If the interface were defined in the infrastructure layer, the domain would depend on infrastructure—violating the core principle.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// ===============================================// DOMAIN LAYER - Defines interfaces (contracts)// =============================================== // These interfaces express what the domain NEEDS// They don't say HOW those needs are met interface IAccountRepository { findById(id: AccountId): Promise<Account | null>; save(account: Account): Promise<void>; findByCustomer(customerId: CustomerId): Promise<Account[]>;} interface INotificationService { notifyOverdraft(account: Account, attemptedAmount: Money): Promise<void>; notifyLargeTransaction(account: Account, amount: Money): Promise<void>;} interface IAuditLogger { logTransfer(from: Account, to: Account, amount: Money): void; logFailedTransfer(from: AccountId, to: AccountId, reason: string): void;} // ===============================================// APPLICATION LAYER - Uses interfaces// =============================================== class TransferFundsService { constructor( private readonly accountRepo: IAccountRepository, private readonly notifications: INotificationService, private readonly audit: IAuditLogger ) {} async execute( fromId: AccountId, toId: AccountId, amount: Money ): Promise<void> { const fromAccount = await this.accountRepo.findById(fromId); const toAccount = await this.accountRepo.findById(toId); // Domain validation if (!fromAccount || !toAccount) { throw new AccountNotFoundError(); } try { fromAccount.withdraw(amount); toAccount.deposit(amount); // Persist changes await this.accountRepo.save(fromAccount); await this.accountRepo.save(toAccount); // Cross-cutting concerns via interfaces this.audit.logTransfer(fromAccount, toAccount, amount); if (amount.exceeds(Money.of(10000, amount.currency))) { await this.notifications.notifyLargeTransaction(fromAccount, amount); } } catch (e) { if (e instanceof InsufficientFundsError) { await this.notifications.notifyOverdraft(fromAccount, amount); this.audit.logFailedTransfer(fromId, toId, e.message); } throw e; } }} // ===============================================// INFRASTRUCTURE LAYER - Implements interfaces// =============================================== // The domain doesn't know these exist at compile timeclass PostgresAccountRepository implements IAccountRepository { /* ... */ }class EmailNotificationService implements INotificationService { /* ... */ }class FileAuditLogger implements IAuditLogger { /* ... */ }| Aspect | Inner Layer (Domain/Application) | Outer Layer (Infrastructure) |
|---|---|---|
| Creates Interfaces | Yes - defines contracts for what it needs | No - only implements contracts |
| Depends On | Its own interfaces | Inner layer interfaces |
| Knows About | Domain concepts only | Domain concepts + specific technologies |
| Compile-time Coupling | Zero to outer layers | Coupled to interface definitions |
| Replaceability | Must remain stable | Easily swappable |
We've established the fundamental structure and philosophy of Onion Architecture. Let's consolidate the key principles:
What's next:
Now that we understand the basic structure—core at center, infrastructure at edge—we'll dive deeper into the specific layers of Onion Architecture. In the next page, we'll examine each layer in detail: what belongs there, how layers communicate, and the dependency rules that govern their relationships.
You now understand the fundamental structure of Onion Architecture: domain-centric design with dependencies flowing inward and infrastructure pushed to the edge. This inversion of traditional layering protects your domain from technology volatility and creates systems that are testable, maintainable, and resilient to change.