Loading learning content...
Understanding Onion Architecture at a surface level is straightforward: domain in the center, infrastructure at the edge. But applying it effectively requires deep knowledge of each layer's responsibilities and the precise rules governing dependencies between them.
This page dissects each layer of the onion, examining what code belongs where, how layers communicate, and—critically—what happens when these boundaries are violated. Mastering these details is what separates architects who use Onion Architecture successfully from those who merely reference it in diagrams.
By the end of this page, you will understand the exact responsibilities of each Onion Architecture layer, the dependency rules that govern layer relationships, how data flows through the layers, and how to detect and correct layer violations.
While the basic onion visualization shows a few concentric circles, a mature Onion Architecture implementation typically contains four to five distinct layers. Let's examine the complete model:
┌─────────────────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ • Web Controllers • Database Repositories • Message Queue Adapters │
│ • API Clients • File System Services • IoC Container Config │
│ • External SDKs • Caching Implementations • Logging Implementations│
├─────────────────────────────────────────────────────────────────────────────┤
│ APPLICATION LAYER │
│ • Use Cases / Commands • Queries & Handlers • DTOs │
│ • Application Events • Orchestration Logic • Port Interfaces │
├─────────────────────────────────────────────────────────────────────────────┤
│ DOMAIN SERVICES LAYER │
│ • Cross-Entity Logic • Domain Calculations • Business Rule Engines │
│ • Domain Policies • Specifications • Invariant Enforcement │
├─────────────────────────────────────────────────────────────────────────────┤
│ DOMAIN MODEL LAYER (CORE) │
│ • Entities • Value Objects • Aggregates │
│ • Domain Events • Repository Interfaces • Domain Exceptions │
└─────────────────────────────────────────────────────────────────────────────┘
Each layer has a distinct purpose and a precise set of allowed dependencies. Violating these boundaries leads to the same problems Onion Architecture was designed to solve.
Some implementations combine Domain Services into the Domain Model layer, or split Infrastructure into multiple sub-layers (Primary Adapters, Secondary Adapters). The exact layer count is less important than maintaining the fundamental principle: dependencies always point inward, never outward.
The Domain Model layer is the heart of your application. It contains the pure business logic—the rules and behaviors that exist independently of any user interface, database, or external service. This layer should be technology-agnostic: no framework annotations, no database concepts, no HTTP concerns.
Core Responsibilities:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
// ============================================// DOMAIN MODEL LAYER - Pure business logic// ============================================ // Value Object: Immutable, equality by attributesclass OrderId { constructor(readonly value: string) { if (!value || value.length < 10) { throw new InvalidOrderIdError(value); } } equals(other: OrderId): boolean { return this.value === other.value; }} class Money { private constructor( readonly amount: number, readonly currency: Currency ) {} static of(amount: number, currency: Currency): Money { if (amount < 0) throw new NegativeAmountError(amount); return new Money(amount, currency); } add(other: Money): Money { this.assertSameCurrency(other); return Money.of(this.amount + other.amount, this.currency); } multiply(factor: number): Money { return Money.of(this.amount * factor, this.currency); } private assertSameCurrency(other: Money): void { if (this.currency !== other.currency) { throw new CurrencyMismatchError(this.currency, other.currency); } }} // Entity: Identity-based, state changes through methodsclass Order { private items: OrderItem[] = []; private status: OrderStatus = OrderStatus.PENDING; private domainEvents: DomainEvent[] = []; constructor( readonly id: OrderId, readonly customerId: CustomerId, private readonly createdAt: Date ) {} addItem(product: ProductId, quantity: number, unitPrice: Money): void { if (this.status !== OrderStatus.PENDING) { throw new OrderNotModifiableError(this.id); } const existingItem = this.items.find(i => i.productId.equals(product)); if (existingItem) { existingItem.increaseQuantity(quantity); } else { this.items.push(new OrderItem(product, quantity, unitPrice)); } } submit(): void { if (this.items.length === 0) { throw new EmptyOrderError(this.id); } if (this.status !== OrderStatus.PENDING) { throw new InvalidOrderStateTransitionError(this.status, OrderStatus.SUBMITTED); } this.status = OrderStatus.SUBMITTED; this.domainEvents.push(new OrderSubmittedEvent(this.id, this.getTotal())); } getTotal(): Money { return this.items.reduce( (sum, item) => sum.add(item.getSubtotal()), Money.of(0, Currency.USD) ); } pullDomainEvents(): DomainEvent[] { const events = [...this.domainEvents]; this.domainEvents = []; return events; }} // Domain Event: Immutable record of what happenedclass OrderSubmittedEvent implements DomainEvent { readonly occurredAt: Date = new Date(); constructor( readonly orderId: OrderId, readonly orderTotal: Money ) {}} // Repository Interface: Defined in domain, implemented in infrastructureinterface IOrderRepository { findById(id: OrderId): Promise<Order | null>; save(order: Order): Promise<void>; nextId(): OrderId;} // Domain Exception: Business-rule specificclass OrderNotModifiableError extends Error { constructor(orderId: OrderId) { super(`Order ${orderId.value} is not in a modifiable state`); this.name = 'OrderNotModifiableError'; }}The Domain Model must not contain: ORM annotations (@Entity, @Column), framework decorators, JSON serialization attributes, HTTP concerns, logging frameworks, or any reference to infrastructure technologies. If you see SqlConnection, HttpClient, or @JsonProperty in your domain model, you have a boundary violation.
Some domain logic doesn't naturally belong to a single entity or value object. When a business operation involves multiple aggregates, or when it's semantically awkward to place logic within an entity, that logic lives in a Domain Service.
When to use Domain Services:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// ============================================// DOMAIN SERVICES LAYER - Cross-entity operations// ============================================ // Domain Service: Funds transfer between accountsclass FundsTransferService { // Note: This is a pure domain service with no infrastructure dependencies transfer( source: Account, destination: Account, amount: Money ): TransferResult { // Business rules enforced here if (!source.canWithdraw(amount)) { return TransferResult.failed( new InsufficientFundsError(source.id, amount) ); } if (source.owner.equals(destination.owner)) { // Same-owner transfers don't have daily limits return this.executeTransfer(source, destination, amount); } // Cross-customer transfers have daily limits const dailyTransferred = source.getTodaysTransferTotal(); const limit = source.getDailyTransferLimit(); if (dailyTransferred.add(amount).exceeds(limit)) { return TransferResult.failed( new DailyLimitExceededError(source.id, limit) ); } return this.executeTransfer(source, destination, amount); } private executeTransfer( source: Account, destination: Account, amount: Money ): TransferResult { source.withdraw(amount); destination.deposit(amount); return TransferResult.success( new FundsTransferredEvent(source.id, destination.id, amount) ); }} // Domain Service: Shipping cost calculationclass ShippingCostCalculator { calculate( origin: Address, destination: Address, packages: Package[], shippingMethod: ShippingMethod ): Money { const baseRate = this.getBaseRate(shippingMethod); const distanceMultiplier = this.calculateDistanceMultiplier(origin, destination); const weightCharge = this.calculateWeightCharge(packages); const dimensionalCharge = this.calculateDimensionalCharge(packages); // Business rule: charge the higher of weight or dimensional const variableCharge = weightCharge.exceeds(dimensionalCharge) ? weightCharge : dimensionalCharge; return baseRate.add(variableCharge).multiply(distanceMultiplier); } private getBaseRate(method: ShippingMethod): Money { switch (method) { case ShippingMethod.EXPRESS: return Money.of(15.99, Currency.USD); case ShippingMethod.STANDARD: return Money.of(5.99, Currency.USD); case ShippingMethod.ECONOMY: return Money.of(2.99, Currency.USD); } } private calculateDistanceMultiplier(origin: Address, destination: Address): number { // Domain logic for zone-based distance calculation if (origin.isInternational(destination)) return 3.5; if (origin.state !== destination.state) return 1.5; return 1.0; }} // Domain Service: Discount eligibility specificationclass PromotionEligibilityService { isEligible( customer: Customer, promotion: Promotion, cart: ShoppingCart ): EligibilityResult { const checks: EligibilityCheck[] = [ this.checkMinimumPurchase(cart, promotion), this.checkCustomerTier(customer, promotion), this.checkNotAlreadyUsed(customer, promotion), this.checkValidDateRange(promotion), this.checkProductCategories(cart, promotion), ]; const failures = checks.filter(c => !c.passed); return failures.length === 0 ? EligibilityResult.eligible(promotion) : EligibilityResult.ineligible(failures.map(f => f.reason)); }}Domain Services contain domain logic and know nothing about infrastructure. Application Services orchestrate domain objects and coordinate with infrastructure (repositories, message queues). A FundsTransferService that handles business rules is a Domain Service. A TransferFundsUseCase that loads accounts, calls the domain service, and saves results is an Application Service.
The Application Services layer (sometimes called the Application Layer or Use Case Layer) orchestrates the application's behavior. It defines the use cases the system supports and coordinates domain objects and infrastructure to execute them.
Key characteristics of Application Services:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
// ============================================// APPLICATION SERVICES LAYER - Use case orchestration// ============================================ // Command: Represents intent from outside the domaininterface TransferFundsCommand { sourceAccountId: string; destinationAccountId: string; amount: number; currency: string; initiatedBy: string;} // Result: Use case outcomeinterface TransferFundsResult { success: boolean; transactionId?: string; error?: string;} // Application Service: Orchestrates the use caseclass TransferFundsUseCase { constructor( private readonly accountRepository: IAccountRepository, private readonly transferService: FundsTransferService, private readonly eventPublisher: IEventPublisher, private readonly transactionManager: ITransactionManager, private readonly auditLog: IAuditLog ) {} async execute(command: TransferFundsCommand): Promise<TransferFundsResult> { // Input validation (application-level, not domain-level) if (!command.sourceAccountId || !command.destinationAccountId) { return { success: false, error: 'Account IDs are required' }; } // Begin transaction boundary return await this.transactionManager.executeInTransaction(async () => { // 1. Load aggregates from repository const sourceAccount = await this.accountRepository.findById( new AccountId(command.sourceAccountId) ); const destAccount = await this.accountRepository.findById( new AccountId(command.destinationAccountId) ); if (!sourceAccount || !destAccount) { return { success: false, error: 'Account not found' }; } // 2. Execute domain logic via domain service const transferResult = this.transferService.transfer( sourceAccount, destAccount, Money.of(command.amount, command.currency as Currency) ); if (!transferResult.isSuccess) { await this.auditLog.logFailedTransfer( command, transferResult.error! ); return { success: false, error: transferResult.error!.message }; } // 3. Persist changes await this.accountRepository.save(sourceAccount); await this.accountRepository.save(destAccount); // 4. Publish domain events const events = [ ...sourceAccount.pullDomainEvents(), ...destAccount.pullDomainEvents(), transferResult.event! ]; for (const event of events) { await this.eventPublisher.publish(event); } // 5. Audit logging await this.auditLog.logSuccessfulTransfer(command, events); return { success: true, transactionId: transferResult.event!.transactionId }; }); }} // Another Application Service: Query handlerinterface GetAccountBalanceQuery { accountId: string; asOf?: Date;} interface AccountBalanceResult { accountId: string; balance: { amount: number; currency: string }; lastUpdated: Date;} class GetAccountBalanceHandler { constructor( private readonly accountRepository: IAccountRepository, private readonly balanceCache: IBalanceCache ) {} async handle(query: GetAccountBalanceQuery): Promise<AccountBalanceResult | null> { // Try cache first const cached = await this.balanceCache.get(query.accountId); if (cached && !query.asOf) { return cached; } // Load from repository const account = await this.accountRepository.findById( new AccountId(query.accountId) ); if (!account) return null; const result: AccountBalanceResult = { accountId: account.id.value, balance: { amount: account.getBalance().amount, currency: account.getBalance().currency }, lastUpdated: account.getLastModified() }; // Update cache if (!query.asOf) { await this.balanceCache.set(query.accountId, result); } return result; }}What the Application Layer does NOT do:
The Application Layer is an orchestrator, not a decision-maker. It coordinates the workflow but delegates business decisions to the domain.
The Infrastructure Layer is the outermost layer of the onion. It contains all technology-specific implementations: database access, HTTP handling, message queue integration, file I/O, and external API clients. This is where "the messy real world" lives.
Infrastructure Category Classification:
| Category | Components | Examples |
|---|---|---|
| Persistence | Repository Implementations, ORM Configuration | PostgresOrderRepository, MongoCustomerRepository, Entity Framework DbContext |
| Web/API | Controllers, Middleware, Route Configuration | REST Controllers, GraphQL Resolvers, gRPC Services |
| Messaging | Message Producers, Consumers, Serializers | RabbitMQ Publisher, Kafka Consumer, Event Serializer |
| External Services | API Clients, SDK Wrappers | Stripe Payment Gateway, SendGrid Email Adapter |
| Cross-Cutting | Logging, Caching, Metrics, Tracing | Serilog Logger, Redis Cache, Prometheus Metrics |
| Configuration | IoC Container, Feature Flags, Secrets | DI Registration, LaunchDarkly Client, Vault Reader |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
// ============================================// INFRASTRUCTURE LAYER - Technology implementations// ============================================ // Repository implementation with PostgreSQLclass PostgresAccountRepository implements IAccountRepository { constructor( private readonly pool: Pool, private readonly mapper: AccountMapper ) {} async findById(id: AccountId): Promise<Account | null> { const result = await this.pool.query( `SELECT id, owner_id, balance, currency, status, created_at, updated_at FROM accounts WHERE id = $1`, [id.value] ); if (result.rowCount === 0) return null; return this.mapper.toDomain(result.rows[0]); } async save(account: Account): Promise<void> { const data = this.mapper.toPersistence(account); await this.pool.query( `INSERT INTO accounts (id, owner_id, balance, currency, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance, status = EXCLUDED.status, updated_at = NOW()`, [data.id, data.ownerId, data.balance, data.currency, data.status, data.createdAt] ); } nextId(): AccountId { return new AccountId(crypto.randomUUID()); }} // Mapper: Translates between persistence and domain modelsclass AccountMapper { toDomain(row: AccountRow): Account { return Account.reconstitute({ id: new AccountId(row.id), owner: new CustomerId(row.owner_id), balance: Money.of(parseFloat(row.balance), row.currency as Currency), status: row.status as AccountStatus, createdAt: row.created_at }); } toPersistence(account: Account): AccountRow { const snapshot = account.toSnapshot(); return { id: snapshot.id, owner_id: snapshot.ownerId, balance: snapshot.balance.toString(), currency: snapshot.currency, status: snapshot.status, created_at: snapshot.createdAt }; }} // Event Publisher implementation with RabbitMQclass RabbitMQEventPublisher implements IEventPublisher { constructor( private readonly channel: Channel, private readonly serializer: EventSerializer ) {} async publish(event: DomainEvent): Promise<void> { const routingKey = this.getRoutingKey(event); const message = this.serializer.serialize(event); await this.channel.publish( 'domain-events', routingKey, Buffer.from(message), { persistent: true, contentType: 'application/json', headers: { eventType: event.constructor.name, occurredAt: event.occurredAt.toISOString() } } ); } private getRoutingKey(event: DomainEvent): string { // Convention: domain.aggregate.event return `banking.account.${event.constructor.name.toLowerCase()}`; }} // Web Controller (Primary Adapter)class AccountController { constructor( private readonly transferUseCase: TransferFundsUseCase, private readonly balanceQuery: GetAccountBalanceHandler ) {} async handleTransfer(req: Request, res: Response): Promise<void> { const command: TransferFundsCommand = { sourceAccountId: req.body.from, destinationAccountId: req.body.to, amount: parseFloat(req.body.amount), currency: req.body.currency, initiatedBy: req.user.id }; const result = await this.transferUseCase.execute(command); if (result.success) { res.status(200).json({ transactionId: result.transactionId, status: 'completed' }); } else { res.status(400).json({ error: result.error }); } } async handleGetBalance(req: Request, res: Response): Promise<void> { const result = await this.balanceQuery.handle({ accountId: req.params.accountId }); if (result) { res.status(200).json(result); } else { res.status(404).json({ error: 'Account not found' }); } }}Infrastructure is often split into Primary Adapters (driving adapters that invoke the application: controllers, CLI, message consumers) and Secondary Adapters (driven adapters that the application invokes: repositories, external APIs, message publishers). This distinction helps organize infrastructure code by responsibility.
The power of Onion Architecture comes from strict adherence to dependency rules. These rules aren't suggestions—they're the architectural laws that make the benefits possible. Violating them reintroduces the coupling problems the architecture was designed to solve.
Every dependency rule violation re-couples your domain to infrastructure. One @Entity annotation in your domain model means your business logic now depends on your ORM. One HttpClient in a domain service means business rules can't be tested without network mocks. Guard these boundaries zealously.
Understanding how data flows through an Onion Architecture is essential for implementing it correctly. The key insight: data representation changes as it crosses layer boundaries.
A complete request flow:
┌─────────────────────────────────────────────────────────────────────────────────┐
│ REQUEST FLOW │
│ │
│ HTTP Request → Controller → Command DTO → Application Service │
│ │
│ ┌──────────────────────────────────────────────────────────┐│
│ │ ││
│ │ Application Service: ││
│ │ 1. Validates command ││
│ │ 2. Loads domain objects via Repository ││
│ │ 3. Invokes domain methods ││
│ │ 4. Saves changes via Repository ││
│ │ 5. Returns Result DTO ││
│ │ ││
│ └──────────────────────────────────────────────────────────┘│
│ │
│ HTTP Response ← Controller ← Result DTO ← Application Service │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
// ============================================// COMPLETE DATA FLOW THROUGH LAYERS// ============================================ // 1. INFRASTRUCTURE: HTTP Request arrives// Raw HTTP data, framework-specific types class OrderController { constructor(private readonly submitOrderUseCase: SubmitOrderUseCase) {} async handleSubmitOrder(req: Request, res: Response): Promise<void> { // Transform HTTP request to Command DTO const command: SubmitOrderCommand = { orderId: req.params.orderId, requestedBy: req.user.id, requestedAt: new Date() }; // Call Application Layer with DTO const result = await this.submitOrderUseCase.execute(command); // Transform Result DTO to HTTP response if (result.success) { res.status(200).json({ orderId: result.orderId, status: result.newStatus, estimatedDelivery: result.estimatedDelivery }); } else { res.status(400).json({ error: result.errorMessage }); } }} // 2. APPLICATION LAYER: Command DTO received// Orchestrates domain objects, uses DTOs for input/output interface SubmitOrderCommand { orderId: string; requestedBy: string; requestedAt: Date;} interface SubmitOrderResult { success: boolean; orderId?: string; newStatus?: string; estimatedDelivery?: Date; errorMessage?: string;} class SubmitOrderUseCase { constructor( private readonly orderRepository: IOrderRepository, private readonly eventPublisher: IEventPublisher ) {} async execute(command: SubmitOrderCommand): Promise<SubmitOrderResult> { try { // Load domain object from repository const order = await this.orderRepository.findById( new OrderId(command.orderId) ); if (!order) { return { success: false, errorMessage: 'Order not found' }; } // Invoke domain logic (returns void, mutates entity) order.submit(); // Save domain object via repository await this.orderRepository.save(order); // Publish domain events const events = order.pullDomainEvents(); for (const event of events) { await this.eventPublisher.publish(event); } // Return Result DTO (transformed from domain) return { success: true, orderId: order.id.value, newStatus: order.getStatus().toString(), estimatedDelivery: order.getEstimatedDelivery() }; } catch (error) { if (error instanceof DomainException) { return { success: false, errorMessage: error.message }; } throw error; } }} // 3. DOMAIN LAYER: Pure business logic// No knowledge of how it's called or where data comes from class Order { private status: OrderStatus = OrderStatus.PENDING; private items: OrderItem[] = []; private domainEvents: DomainEvent[] = []; submit(): void { // Pure business validation - no external dependencies if (this.items.length === 0) { throw new EmptyOrderException(this.id); } if (this.status !== OrderStatus.PENDING) { throw new InvalidStateTransitionException( this.status, OrderStatus.SUBMITTED ); } // State change this.status = OrderStatus.SUBMITTED; // Record domain event this.domainEvents.push( new OrderSubmittedEvent(this.id, this.getTotal()) ); } getStatus(): OrderStatus { return this.status; } getEstimatedDelivery(): Date { // Domain logic for delivery estimation const baseDays = this.shippingMethod === ShippingMethod.EXPRESS ? 2 : 5; const date = new Date(); date.setDate(date.getDate() + baseDays); return date; }} // 4. INFRASTRUCTURE: Repository Implementation// Transforms between domain objects and persistence format class PostgresOrderRepository implements IOrderRepository { async findById(id: OrderId): Promise<Order | null> { // Query database (infrastructure concern) const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id.value]); if (!row) return null; // Transform persistence format to domain object return this.mapper.toDomain(row); } async save(order: Order): Promise<void> { // Transform domain object to persistence format const row = this.mapper.toPersistence(order); // Save to database (infrastructure concern) await this.db.query( 'UPDATE orders SET status = $2, updated_at = NOW() WHERE id = $1', [row.id, row.status] ); }}Notice the transformation points: HTTP → Command at the controller boundary, Domain → Result at the use case return, Domain ↔ Persistence at the repository boundary. Each transformation isolates layers from changes in their neighbors. If your JSON structure changes, only the controller's transformation logic changes—the domain remains untouched.
We've taken a deep dive into the layers of Onion Architecture and the dependency rules that govern them. Let's consolidate the key insights:
What's next:
Onion Architecture shares significant DNA with Hexagonal Architecture (Ports and Adapters). In the next page, we'll compare these two architectural styles, understand their similarities and differences, and learn when each is most appropriate.
You now have a detailed understanding of each layer in Onion Architecture—what belongs where, how they communicate, and the dependency rules that make the architecture work. This foundation is essential for both implementing Onion Architecture and understanding how it compares to related approaches.