Loading learning content...
Understanding Onion Architecture's principles is one thing; applying them to real codebases is another. The gap between architectural diagrams and working code is where many projects falter. Teams draw beautiful layer diagrams, then write code that violates those boundaries within weeks.
This page bridges that gap. We'll examine practical implementation patterns, common pitfalls that trip up teams, testing strategies that leverage Onion's structure, and migration approaches for introducing Onion Architecture to brownfield projects. By the end, you'll have the practical knowledge to implement Onion Architecture effectively.
By the end of this page, you will understand how to structure a real Onion Architecture project, implement common patterns correctly, avoid typical mistakes, leverage the architecture for effective testing, and introduce Onion principles to existing code.
A well-organized project structure makes architectural boundaries visible and enforceable. Here's a production-ready structure that reflects Onion Architecture layers clearly:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
src/│├── Domain/ # INNERMOST LAYER - Pure business logic│ ││ ├── Model/ # Domain Model│ │ ├── Entities/ # Identity-based objects│ │ │ ├── Order.ts│ │ │ ├── Customer.ts│ │ │ └── Product.ts│ │ ││ │ ├── ValueObjects/ # Immutable value types│ │ │ ├── Money.ts│ │ │ ├── OrderId.ts│ │ │ ├── Address.ts│ │ │ └── EmailAddress.ts│ │ ││ │ ├── Aggregates/ # Aggregate roots and boundaries│ │ │ ├── Order/│ │ │ │ ├── OrderAggregate.ts│ │ │ │ └── OrderItem.ts│ │ │ └── Customer/│ │ │ └── CustomerAggregate.ts│ │ ││ │ ├── Events/ # Domain events│ │ │ ├── OrderPlacedEvent.ts│ │ │ ├── OrderShippedEvent.ts│ │ │ └── PaymentReceivedEvent.ts│ │ ││ │ └── Exceptions/ # Domain-specific exceptions│ │ ├── InsufficientInventoryError.ts│ │ └── OrderNotModifiableError.ts│ ││ ├── Services/ # Domain Services│ │ ├── PricingService.ts│ │ ├── InventoryAllocationService.ts│ │ └── ShippingCostCalculator.ts│ ││ └── Ports/ # Interfaces for external needs│ ├── Repositories/│ │ ├── IOrderRepository.ts│ │ ├── ICustomerRepository.ts│ │ └── IProductRepository.ts│ ││ └── Services/│ ├── IPaymentGateway.ts│ └── INotificationService.ts│├── Application/ # APPLICATION SERVICES LAYER│ ││ ├── Commands/ # Write operations│ │ ├── PlaceOrder/│ │ │ ├── PlaceOrderCommand.ts│ │ │ ├── PlaceOrderHandler.ts│ │ │ └── PlaceOrderResult.ts│ │ ││ │ └── ShipOrder/│ │ ├── ShipOrderCommand.ts│ │ └── ShipOrderHandler.ts│ ││ ├── Queries/ # Read operations│ │ ├── GetOrderDetails/│ │ │ ├── GetOrderDetailsQuery.ts│ │ │ ├── GetOrderDetailsHandler.ts│ │ │ └── OrderDetailsDto.ts│ │ ││ │ └── ListCustomerOrders/│ │ ├── ListCustomerOrdersQuery.ts│ │ └── CustomerOrdersDto.ts│ ││ ├── Common/ # Shared application concerns│ │ ├── ITransactionManager.ts│ │ ├── IEventPublisher.ts│ │ └── BaseHandler.ts│ ││ └── Behaviors/ # Cross-cutting behaviors│ ├── LoggingBehavior.ts│ └── ValidationBehavior.ts│└── Infrastructure/ # OUTERMOST LAYER │ ├── Persistence/ # Database implementations │ ├── Repositories/ │ │ ├── PostgresOrderRepository.ts │ │ └── PostgresCustomerRepository.ts │ │ │ ├── Mappers/ # Domain <-> Persistence │ │ ├── OrderMapper.ts │ │ └── CustomerMapper.ts │ │ │ └── Config/ │ └── DatabaseConfig.ts │ ├── Web/ # HTTP API │ ├── Controllers/ │ │ ├── OrderController.ts │ │ └── CustomerController.ts │ │ │ ├── Middleware/ │ │ ├── AuthMiddleware.ts │ │ └── ErrorHandlerMiddleware.ts │ │ │ └── Mappers/ # Request/Response <-> DTOs │ └── OrderRequestMapper.ts │ ├── Messaging/ # Message queue integration │ ├── Publishers/ │ │ └── RabbitMQEventPublisher.ts │ │ │ └── Consumers/ │ └── PaymentEventConsumer.ts │ ├── External/ # Third-party integrations │ ├── StripePaymentGateway.ts │ └── SendGridNotificationService.ts │ └── DependencyInjection/ # IoC configuration └── ServiceRegistration.tsIn TypeScript, use path aliases and ESLint rules to enforce layer dependencies. In Java/C#, use separate modules/projects per layer. Physical separation makes accidental violations compile errors, not code review findings.
Certain patterns appear repeatedly in well-implemented Onion Architecture projects. Mastering these patterns enables you to build maintainable, testable systems.
Command Query Responsibility Segregation (CQRS) fits naturally with Onion Architecture. Commands modify state through the domain; queries read optimized views directly.
Key insight: Commands go through the full onion (validation → domain logic → persistence). Queries can bypass the domain and read directly from optimized views.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// Command: Goes through domain layerinterface PlaceOrderCommand { customerId: string; items: { productId: string; quantity: number }[];} class PlaceOrderHandler { constructor( private readonly orderRepository: IOrderRepository, private readonly customerRepository: ICustomerRepository, private readonly inventoryService: InventoryAllocationService ) {} async handle(command: PlaceOrderCommand): Promise<PlaceOrderResult> { const customer = await this.customerRepository.findById( new CustomerId(command.customerId) ); // Domain model creates order with business rules const order = customer.placeOrder(command.items); // Domain service validates inventory await this.inventoryService.allocate(order); // Persist through domain repository await this.orderRepository.save(order); return { orderId: order.id.value }; }} // Query: Bypasses domain, reads optimized viewsinterface GetOrderDetailsQuery { orderId: string;} class GetOrderDetailsHandler { constructor( private readonly readDb: IReadDatabase // Optimized read model ) {} async handle(query: GetOrderDetailsQuery): Promise<OrderDetailsDto | null> { // Direct read from denormalized view - no domain traversal return this.readDb.query( `SELECT o.*, c.name as customer_name, json_agg(i.*) as items FROM orders_view o JOIN customers c ON o.customer_id = c.id JOIN order_items_view i ON o.id = i.order_id WHERE o.id = $1 GROUP BY o.id, c.name`, [query.orderId] ); }}Even teams that understand Onion Architecture make mistakes. Here are the most common pitfalls and how to avoid them:
order.submit() and account.withdraw(amount) should contain business logic.1234567891011121314151617181920212223242526272829303132333435363738394041424344
// ❌ ANEMIC: Entity is just data, logic is externalclass Order { id: string; status: string; items: { productId: string; quantity: number }[]; total: number;} class OrderService { submitOrder(order: Order): void { if (order.items.length === 0) throw new Error("Empty order"); if (order.status !== 'PENDING') throw new Error("Cannot submit"); order.status = 'SUBMITTED'; // Calculate total, validate items, etc. }} // ✅ RICH: Entity contains behaviorclass Order { private readonly id: OrderId; private status: OrderStatus; private items: OrderItem[]; submit(): void { if (this.items.length === 0) { throw new EmptyOrderError(this.id); } if (this.status !== OrderStatus.PENDING) { throw new InvalidStateTransitionError(this.status, OrderStatus.SUBMITTED); } this.status = OrderStatus.SUBMITTED; } addItem(productId: ProductId, quantity: Quantity, price: Money): void { if (this.status !== OrderStatus.PENDING) { throw new OrderNotModifiableError(this.id); } this.items.push(new OrderItem(productId, quantity, price)); } getTotal(): Money { return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero()); }}@Entity, @Column, @JsonProperty annotations. Value objects know about SQL or JSON.A well-designed application service method should be roughly 10-15 lines: load aggregates, call domain methods, save changes, publish events. If your methods are 50+ lines, domain logic is likely misplaced. Extract it to domain entities or services.
Onion Architecture's layered structure enables an exceptionally effective testing strategy. Each layer has appropriate testing approaches, and the architecture's dependency rules make tests naturally isolated.
| Layer | Test Type | Dependencies | Speed | Coverage Focus |
|---|---|---|---|---|
| Domain Model | Unit Tests | None (pure logic) | Milliseconds | Business rules, invariants, state transitions |
| Domain Services | Unit Tests | Domain model (real) | Milliseconds | Cross-entity logic, calculations |
| Application Services | Integration Tests | Domain (real) + Ports (mocked) | Seconds | Orchestration, workflows, use cases |
| Infrastructure Adapters | Integration Tests | Real infrastructure | Seconds-Minutes | Data mapping, API contracts, persistence |
| Full System | E2E Tests | Everything real | Minutes | Critical paths, smoke tests |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// ============================================// TESTING STRATEGY BY LAYER// ============================================ // ✅ DOMAIN MODEL: Pure unit tests - no mocks neededdescribe('Order', () => { it('should transition to submitted when items present', () => { const order = new Order(OrderId.generate(), customerId); order.addItem(productId, Quantity.of(2), Money.of(50, 'USD')); order.submit(); expect(order.getStatus()).toBe(OrderStatus.SUBMITTED); }); it('should reject submission of empty orders', () => { const order = new Order(OrderId.generate(), customerId); expect(() => order.submit()).toThrow(EmptyOrderError); }); it('should calculate total correctly', () => { const order = new Order(OrderId.generate(), customerId); order.addItem(productA, Quantity.of(2), Money.of(10, 'USD')); order.addItem(productB, Quantity.of(1), Money.of(25, 'USD')); expect(order.getTotal()).toEqual(Money.of(45, 'USD')); });}); // ✅ DOMAIN SERVICE: Unit tests with real domain objectsdescribe('InventoryAllocationService', () => { it('should allocate inventory for order items', () => { const inventory = new Inventory([ new InventoryItem(productA, Quantity.of(100)), new InventoryItem(productB, Quantity.of(50)) ]); const service = new InventoryAllocationService(); const order = createOrderWithItems([ { product: productA, quantity: 5 }, { product: productB, quantity: 10 } ]); const result = service.allocate(order, inventory); expect(result.isSuccess()).toBe(true); expect(inventory.getAvailable(productA)).toEqual(Quantity.of(95)); expect(inventory.getAvailable(productB)).toEqual(Quantity.of(40)); });}); // ✅ APPLICATION SERVICE: Integration test with mocked portsdescribe('PlaceOrderHandler', () => { let handler: PlaceOrderHandler; let orderRepository: MockOrderRepository; let customerRepository: MockCustomerRepository; let eventPublisher: MockEventPublisher; beforeEach(() => { orderRepository = new MockOrderRepository(); customerRepository = new MockCustomerRepository(); eventPublisher = new MockEventPublisher(); handler = new PlaceOrderHandler( orderRepository, customerRepository, new InventoryAllocationService(), // Real domain service eventPublisher ); }); it('should create and persist order', async () => { customerRepository.add(new Customer(customerId, 'Test Customer')); const result = await handler.handle({ customerId: customerId.value, items: [{ productId: 'prod-1', quantity: 2 }] }); expect(result.success).toBe(true); expect(orderRepository.getAll()).toHaveLength(1); expect(eventPublisher.getPublishedEvents()).toContainEqual( expect.objectContaining({ type: 'OrderPlaced' }) ); });}); // ✅ INFRASTRUCTURE ADAPTER: Integration test with real databasedescribe('PostgresOrderRepository', () => { let repository: PostgresOrderRepository; let connection: DatabaseConnection; beforeAll(async () => { connection = await createTestConnection(); repository = new PostgresOrderRepository(connection, new OrderMapper()); }); afterEach(async () => { await connection.query('TRUNCATE orders, order_items CASCADE'); }); it('should persist and retrieve order correctly', async () => { const order = new Order(OrderId.generate(), customerId); order.addItem(productId, Quantity.of(3), Money.of(25, 'USD')); order.submit(); await repository.save(order); const retrieved = await repository.findById(order.id); expect(retrieved).not.toBeNull(); expect(retrieved!.id).toEqual(order.id); expect(retrieved!.getStatus()).toBe(OrderStatus.SUBMITTED); expect(retrieved!.getTotal()).toEqual(Money.of(75, 'USD')); });});Onion Architecture naturally produces a healthy testing pyramid. Domain tests are numerous and fast (base). Application tests are moderate (middle). Infrastructure and E2E tests are few and thorough (top). The architecture's structure makes this the path of least resistance.
Dependency Injection is the runtime mechanism that wires Onion Architecture together. The infrastructure layer configures the IoC container, registering concrete implementations for domain-defined interfaces.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
// ============================================// INFRASTRUCTURE: Dependency Injection Setup// ============================================ // This file lives in Infrastructure layer - it knows about all layers// and wires them together at application startup import { Container } from 'inversify';import { DataSource } from 'typeorm'; // Types for DI tokensconst TYPES = { // Domain Ports OrderRepository: Symbol('OrderRepository'), CustomerRepository: Symbol('CustomerRepository'), PaymentGateway: Symbol('PaymentGateway'), NotificationService: Symbol('NotificationService'), // Application Services PlaceOrderHandler: Symbol('PlaceOrderHandler'), ShipOrderHandler: Symbol('ShipOrderHandler'), GetOrderDetailsHandler: Symbol('GetOrderDetailsHandler'), // Domain Services InventoryAllocationService: Symbol('InventoryAllocationService'), ShippingCostCalculator: Symbol('ShippingCostCalculator'), // Infrastructure EventPublisher: Symbol('EventPublisher'), TransactionManager: Symbol('TransactionManager'), DataSource: Symbol('DataSource'),}; function configureContainer(dataSource: DataSource): Container { const container = new Container(); // === INFRASTRUCTURE BINDINGS === container.bind(TYPES.DataSource).toConstantValue(dataSource); // Repository implementations container.bind<IOrderRepository>(TYPES.OrderRepository) .toDynamicValue((ctx) => new PostgresOrderRepository( ctx.container.get(TYPES.DataSource), new OrderMapper() )) .inRequestScope(); container.bind<ICustomerRepository>(TYPES.CustomerRepository) .toDynamicValue((ctx) => new PostgresCustomerRepository( ctx.container.get(TYPES.DataSource), new CustomerMapper() )) .inRequestScope(); // External service adapters container.bind<IPaymentGateway>(TYPES.PaymentGateway) .to(StripePaymentGateway) .inSingletonScope(); container.bind<INotificationService>(TYPES.NotificationService) .to(SendGridNotificationService) .inSingletonScope(); container.bind<IEventPublisher>(TYPES.EventPublisher) .to(RabbitMQEventPublisher) .inSingletonScope(); container.bind<ITransactionManager>(TYPES.TransactionManager) .toDynamicValue((ctx) => new TypeORMTransactionManager( ctx.container.get(TYPES.DataSource) )) .inRequestScope(); // === DOMAIN SERVICE BINDINGS === container.bind(TYPES.InventoryAllocationService) .to(InventoryAllocationService) .inTransientScope(); container.bind(TYPES.ShippingCostCalculator) .to(ShippingCostCalculator) .inTransientScope(); // === APPLICATION SERVICE BINDINGS === container.bind(TYPES.PlaceOrderHandler) .toDynamicValue((ctx) => new PlaceOrderHandler( ctx.container.get(TYPES.OrderRepository), ctx.container.get(TYPES.CustomerRepository), ctx.container.get(TYPES.InventoryAllocationService), ctx.container.get(TYPES.EventPublisher), ctx.container.get(TYPES.TransactionManager) )) .inRequestScope(); container.bind(TYPES.ShipOrderHandler) .toDynamicValue((ctx) => new ShipOrderHandler( ctx.container.get(TYPES.OrderRepository), ctx.container.get(TYPES.EventPublisher), ctx.container.get(TYPES.TransactionManager) )) .inRequestScope(); return container;} // Environment-specific configurationfunction configureForProduction(): Container { const dataSource = new DataSource({ type: 'postgres', url: process.env.DATABASE_URL, // ... production config }); return configureContainer(dataSource);} function configureForTesting(): Container { // Use in-memory implementations for testing const container = new Container(); container.bind<IOrderRepository>(TYPES.OrderRepository) .to(InMemoryOrderRepository) .inSingletonScope(); container.bind<IPaymentGateway>(TYPES.PaymentGateway) .to(MockPaymentGateway) .inSingletonScope(); // ... other test bindings return container;}The DI configuration is infrastructure code—it references all layers and external libraries. It's the 'composition root' where the application is assembled. Neither the domain nor application layers know how they're wired together.
Most teams don't start greenfield—they inherit existing codebases. Migrating to Onion Architecture requires a phased approach that delivers value incrementally while avoiding a risky 'big bang' rewrite.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// ============================================// BEFORE: Traditional layered architecture// ============================================ // Controller directly uses repository with ORM entitiesclass OrderController { async submitOrder(req: Request, res: Response) { const orderEntity = await this.orderRepo.findOne(req.params.id); // Business logic in controller if (orderEntity.items.length === 0) { return res.status(400).json({ error: 'Empty order' }); } if (orderEntity.status !== 'PENDING') { return res.status(400).json({ error: 'Cannot submit' }); } orderEntity.status = 'SUBMITTED'; orderEntity.submittedAt = new Date(); await this.orderRepo.save(orderEntity); await this.emailService.sendOrderConfirmation(orderEntity); return res.json(orderEntity); }} // ============================================// PHASE 1: Introduce Interfaces// ============================================ // Define interface for repositoryinterface IOrderRepository { findById(id: string): Promise<OrderEntity | null>; save(order: OrderEntity): Promise<void>;} // Existing implementation now implements interfaceclass TypeORMOrderRepository implements IOrderRepository { /* ... */ } // ============================================// PHASE 2: Extract Domain Model// ============================================ // Pure domain entity (no ORM annotations)class Order { constructor( readonly id: OrderId, private status: OrderStatus, private items: OrderItem[] ) {} submit(): void { if (this.items.length === 0) throw new EmptyOrderError(this.id); if (this.status !== OrderStatus.PENDING) { throw new InvalidStateTransitionError(this.status, OrderStatus.SUBMITTED); } this.status = OrderStatus.SUBMITTED; }} // Mapper between domain and persistenceclass OrderMapper { toDomain(entity: OrderEntity): Order { /* ... */ } toPersistence(order: Order): OrderEntity { /* ... */ }} // Repository now returns domain objectsclass TypeORMOrderRepository implements IOrderRepository { async findById(id: OrderId): Promise<Order | null> { const entity = await this.repo.findOne(id.value); return entity ? this.mapper.toDomain(entity) : null; }} // ============================================// PHASE 3: Create Application Services// ============================================ class SubmitOrderService { constructor( private readonly orderRepo: IOrderRepository, private readonly notificationService: INotificationService ) {} async execute(orderId: string): Promise<SubmitOrderResult> { const order = await this.orderRepo.findById(new OrderId(orderId)); if (!order) throw new OrderNotFoundError(orderId); order.submit(); // Domain logic now in entity await this.orderRepo.save(order); await this.notificationService.sendOrderConfirmation(order); return { orderId: order.id.value, status: 'SUBMITTED' }; }} // Controller becomes thinclass OrderController { constructor(private readonly submitOrderService: SubmitOrderService) {} async submitOrder(req: Request, res: Response) { try { const result = await this.submitOrderService.execute(req.params.id); return res.json(result); } catch (e) { if (e instanceof DomainException) { return res.status(400).json({ error: e.message }); } throw e; } }}Apply the Strangler Fig pattern: new features use Onion Architecture, existing features are migrated incrementally. The old architecture is gradually 'strangled' by the new. This reduces risk and allows teams to learn the new patterns before tackling complex migrations.
Deploying Onion Architecture in production environments requires attention to cross-cutting concerns that span layers. Here's how to handle common production requirements:
| Concern | Domain | Application | Infrastructure |
|---|---|---|---|
| Validation | Business invariants | Input validation | Request/schema validation |
| Transactions | Aggregate boundaries | Transaction scope | Transaction implementation |
| Caching | N/A | Cache interface usage | Cache implementation |
| Security | Ownership rules | Authorization checks | Authentication |
| Metrics | N/A | Business metrics | Technical metrics |
Cross-cutting concerns like logging, metrics, and authorization can be implemented using AOP patterns (decorators, middleware, interceptors) in the infrastructure layer. This keeps the domain and application layers clean while ensuring consistent behavior.
We've covered the practical aspects of implementing Onion Architecture in real-world projects. Let's consolidate the key insights:
Module complete!
You now have both the theoretical understanding and practical skills to implement Onion Architecture effectively. You understand the core principles, the layer structure, how Onion compares to Hexagonal Architecture, and the patterns and practices that make implementations successful.
As you apply these concepts, remember: the goal isn't architectural purity—it's building systems that are testable, maintainable, and resilient to change. Onion Architecture is a means to that end, not an end in itself.
Congratulations! You've mastered Onion Architecture—from its philosophical foundations to practical implementation patterns. You're now equipped to design and build domain-centric systems that protect business logic from infrastructure volatility, enable comprehensive testing, and adapt gracefully to technological change.