Skip to content
Bitloops - Git captures what changed. Bitloops captures why.
HomeAbout usDocsBlog
ResourcesSoftware ArchitectureOnion Architecture: Concentric Layers Without Compromise

Onion Architecture: Concentric Layers Without Compromise

Concentric layers with domain sacred at the core. Everything else—UI, persistence, external services—is infrastructure that adapts to the domain. Clearer layer guidance than Clean Architecture; explicit about what belongs where.

10 min readUpdated March 4, 2026Software Architecture

Definition

Onion Architecture, introduced by Jeffrey Palermo, organizes systems in concentric circles. The center contains domain models and business rules. Moving outward, layers add application logic, infrastructure concerns, and finally external systems.

It's not fundamentally different from Clean Architecture—both have inward-pointing dependencies. But Onion Architecture provides more specific guidance on where things belong and how layers interact. It's clearer about what the "domain" includes and how infrastructure wraps around it.

The name comes from the layer structure: peel away the outer layers, and the core domain remains unchanged.

Why This Matters

Many developers hear "layered architecture" and implement horizontal slicing: presentation, business logic, data access. This often creates tight coupling between layers (presentation layer talks to database layer directly) and muddy responsibility (where does complex business logic go?).

Onion Architecture fixes this by being explicit: domain logic is sacred and isolated. Everything else—UI, persistence, external services—is infrastructure that adapts to the domain, not the reverse. These layer boundaries must be enforced as architectural constraints to prevent AI agents from blurring the boundaries when generating code.

This means:

Domain is never contaminated. Your core business rules depend on nothing external. No database libraries, no web frameworks, no external APIs. This protects the most valuable code.

Layer responsibility is explicit. You know exactly where each concern belongs. Database code? Infrastructure layer. User input validation? Application layer. Complex business rules? Domain layer.

Testing isolates concerns. Domain logic tests run without infrastructure. Application services can test with mocked repositories. Integration tests can verify the whole stack.

The Layers: Core to Edge

Core Domain Layer

The innermost circle contains entities and value objects that encapsulate business rules.

Entities are objects with identity that persist over time:

// Entity: business object with identity
class Order {
  private id: string;
  private customerId: string;
  private items: OrderItem[];
  private status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
  private createdAt: Date;

  constructor(id: string, customerId: string) {
    this.id = id;
    this.customerId = customerId;
    this.items = [];
    this.status = 'pending';
    this.createdAt = new Date();
  }

  addItem(product: Product, quantity: number): void {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    const item = new OrderItem(product, quantity);
    this.items.push(item);
  }

  getTotalPrice(): number {
    return this.items.reduce((sum, item) => sum + item.getPrice(), 0);
  }

  canShip(): boolean {
    return this.status === 'confirmed' && this.items.length > 0;
  }

  ship(): void {
    if (!this.canShip()) {
      throw new Error('Order cannot be shipped in current state');
    }
    this.status = 'shipped';
  }
}

// Value Object: immutable, no identity, only value
class Money {
  readonly amount: number;
  readonly currency: string;

  constructor(amount: number, currency: string) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative');
    }
    this.amount = amount;
    this.currency = 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;
  }
}
typescript

Entities have identity (id) that persists. Value Objects are immutable and defined by their values. Neither depends on anything external.

Domain Services Layer

Stateless services that orchestrate domain logic when it spans multiple entities or can't belong to a single entity.

// Domain Service: pure business logic
class PricingService {
  calculateDiscount(order: Order): number {
    const total = order.getTotalPrice();

    // Business rule: 10% discount on orders over $100
    if (total > 100) {
      return total * 0.1;
    }

    // Business rule: loyalty program
    if (order.getCustomerStatus() === 'gold') {
      return total * 0.15;
    }

    return 0;
  }

  applyTax(order: Order): Money {
    const total = order.getTotalPrice();
    const tax = total * 0.08; // 8% tax
    return new Money(total + tax, 'USD');
  }
}

// Domain Service: another example
class InventoryService {
  canFulfill(order: Order): boolean {
    // Check if all items in order are in stock
    // This is a business rule, not a database query
    return order.getItems().every(item => item.isInStock());
  }
}
javascript

Domain services contain business logic that doesn't fit neatly in a single entity. They're still purely domain—they know nothing about the database, HTTP, or messaging.

Application Services Layer

The application layer orchestrates domain objects to deliver use cases. It handles transactions, coordinates services, but doesn't contain business logic itself.

// Application Service: orchestrates the domain
class PlaceOrderUseCase {
  constructor(
    private orderRepository: OrderRepository,
    private inventoryService: InventoryService,
    private pricingService: PricingService,
    private paymentService: PaymentService,
    private eventPublisher: EventPublisher
  ) {}

  async execute(request: PlaceOrderRequest): Promise<PlaceOrderResponse> {
    // 1. Create the domain object
    const order = new Order(generateId(), request.customerId);

    // 2. Apply domain logic
    for (const item of request.items) {
      const product = await this.productRepository.findById(item.productId);
      order.addItem(product, item.quantity);
    }

    // 3. Check business rules via domain service
    if (!this.inventoryService.canFulfill(order)) {
      throw new InsufficientInventoryError('Not all items in stock');
    }

    // 4. Calculate price via domain service
    const discount = this.pricingService.calculateDiscount(order);
    const finalPrice = this.pricingService.applyTax(order);

    // 5. Persist via repository (infrastructure boundary)
    await this.orderRepository.save(order);

    // 6. Call external services (payment, events)
    await this.paymentService.charge(request.customerId, finalPrice);
    await this.eventPublisher.publish(new OrderPlacedEvent(order.id));

    return { orderId: order.id, totalPrice: finalPrice };
  }
}
javascript

Notice: the use case orchestrates the domain (Order entity, services) but doesn't contain business rules itself. The rules live in the domain layer.

Infrastructure Layer

The outermost layer implements interfaces that the domain and application layers need.

// Repository: adapts domain objects to storage
class SqlOrderRepository implements OrderRepository {
  constructor(private db: Database) {}

  async save(order: Order): Promise<void> {
    await this.db.query(
      'INSERT INTO orders (id, customer_id, status, total) VALUES ($1, $2, $3, $4)',
      [order.id, order.customerId, order.status, order.getTotalPrice()]
    );

    for (const item of order.items) {
      await this.db.query(
        'INSERT INTO order_items (order_id, product_id, quantity) VALUES ($1, $2, $3)',
        [order.id, item.productId, item.quantity]
      );
    }
  }

  async findById(id: string): Promise<Order | null> {
    const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
    if (!row) return null;
    return this.mapToOrder(row);
  }

  private mapToOrder(row: any): Order {
    const order = new Order(row.id, row.customer_id);
    order.status = row.status;
    return order;
  }
}

// Service adapter: implements payment interface
class StripePaymentService implements PaymentService {
  async charge(customerId: string, amount: Money): Promise<void> {
    const charge = await stripe.charges.create({
      amount: amount.amount * 100, // cents
      currency: amount.currency.toLowerCase(),
      customer: customerId
    });
    if (charge.status !== 'succeeded') {
      throw new PaymentFailedError('Charge failed');
    }
  }
}

// Event publisher: adapts domain events to infrastructure
class KafkaEventPublisher implements EventPublisher {
  async publish(event: DomainEvent): Promise<void> {
    await kafka.send({
      topic: 'domain-events',
      messages: [
        { value: JSON.stringify(event) }
      ]
    });
  }
}
typescript

Infrastructure adapts the domain to concrete technologies. The domain layer defines interfaces; infrastructure implements them.

Presentation/UI Layer

The outermost layer handles HTTP requests, CLI commands, or any other entry point. It's thin—mostly routing and serialization of HTTP responses.

// HTTP Controller
class PlaceOrderController {
  constructor(private useCase: PlaceOrderUseCase) {}

  async handle(req: Request, res: Response): Promise<void> {
    try {
      const response = await this.useCase.execute({
        customerId: req.user.id,
        items: req.body.items
      });
      res.status(201).json(response);
    } catch (error) {
      if (error instanceof InsufficientInventoryError) {
        res.status(400).json({ error: error.message });
      } else {
        res.status(500).json({ error: 'Internal error' });
      }
    }
  }
}
javascript

Dependency Flow

Dependencies flow inward, always:

Layered architecture

Presentation Layer

HTTP Controllers, CLI

Application Services Layer

Use Cases, Orchestration

Domain Services Layer

Business Logic, no state

Domain Model Layer

Entities, Value Objects

The domain layer never imports from outer layers. Application services know about repositories (infrastructure interfaces), but the domain doesn't. The innermost circle is completely independent.

Onion vs. Clean vs. Hexagonal

All three patterns share the same core idea: dependencies point inward, domain logic is isolated. They differ in emphasis:

Clean Architecture emphasizes the number of layers and provides a specific structure (entities, use cases, adapters, frameworks).

Hexagonal Architecture emphasizes ports and adapters—external systems connect to the core via pluggable interfaces.

Onion Architecture emphasizes domain-centric design with explicit layers and clear responsibility boundaries.

In practice, they're compatible. Use Onion's layer organization, Hexagonal's ports, Clean's dependency rule. Pick what fits your problem.

Testing Onion Architecture

The concentric structure enables testing at different levels.

// Level 1: Domain layer tests (no infrastructure)
describe('Order', () => {
  it('rejects negative quantities', () => {
    const order = new Order('1', 'customer1');
    expect(() => order.addItem(product, -5)).toThrow();
  });
});

// Level 2: Domain service tests
describe('PricingService', () => {
  it('applies loyalty discount', () => {
    const service = new PricingService();
    const order = createTestOrder({ customerStatus: 'gold' });
    const discount = service.calculateDiscount(order);
    expect(discount).toBe(order.getTotalPrice() * 0.15);
  });
});

// Level 3: Application service tests (with mocked repositories)
describe('PlaceOrderUseCase', () => {
  it('persists order and publishes event', async () => {
    const mockRepository = new MemoryOrderRepository();
    const mockPublisher = new MockEventPublisher();
    const useCase = new PlaceOrderUseCase(
      mockRepository,
      new InventoryService(),
      new PricingService(),
      mockPaymentService,
      mockPublisher
    );

    await useCase.execute(request);

    expect(await mockRepository.findById(order.id)).toBeDefined();
    expect(mockPublisher.published).toContainEqual(
      expect.objectContaining({ type: 'OrderPlaced' })
    );
  });
});

// Level 4: Integration tests (with real infrastructure)
describe('PlaceOrder (integration)', () => {
  it('stores order in database and charges payment', async () => {
    // Use real database, real payment processor
    // This is slower but verifies the whole flow
  });
});
javascript

Tests are fast at the domain level, slow at the integration level. You write more fast tests than slow tests.

When Onion Works Best

Domain-heavy systems. When business logic is complex and valuable, Onion's focus on domain isolation pays off. Finance, insurance, healthcare—these systems benefit immensely.

Long-lived systems. Systems that will outlive several technology choices. Onion delays technology decisions and makes them changeable later.

Large teams. With multiple teams working on different features, clear layer structure prevents chaos. Teams know where their changes belong.

Strict quality requirements. When correctness matters—medical devices, payment systems—Onion's testability and isolation are worth the structure.

When It's Overhead

CRUD-heavy systems. If your system is mostly "read from database, write to database," Onion is overhead.

Small, focused services. A service with a single use case doesn't need four layers.

Prototypes. When learning the domain, structure slows you down. Build and learn first.

AI Agents and Onion Architecture

Onion's explicit layers make AI generation safer. When you define clear port interfaces and layer boundaries, an AI agent knows where to place code.

"This method belongs in a domain service" is concrete guidance. The agent can check its code: does this method depend on anything external? If yes, it doesn't belong in the domain layer. Bitloops can enforce this.

Explicit domain models also help. When you provide the agent with detailed entity definitions, it understands the business rules and respects them when generating code.

FAQ

Is Onion Architecture just Clean Architecture renamed?

No. They're related but different. Clean Architecture is more prescriptive about layers and provides a specific framework (entities, use cases, adapters, frameworks). Onion is more flexible about layer count and emphasizes domain-centric design. You can implement Onion with Clean's structure, but Onion leaves more room for variation.

How deep should the domain layer be?

Don't overthink it. If you have entities, value objects, and domain services, that's usually enough. Don't create additional layers just to have layers.

Where do cross-cutting concerns like logging go?

Logging belongs in the infrastructure layer. The domain doesn't log—the infrastructure that calls the domain logs. If you need domain-level auditing, that's a business rule and belongs in the domain.

Can I have a service that doesn't depend on a repository?

Yes. A domain service like PricingService doesn't need persistence. It just calculates. Only application services that change state need repositories.

What about DTOs (Data Transfer Objects)?

DTOs are presentation concerns, not domain. The presentation layer can define DTOs for HTTP requests/responses. The domain doesn't know about them. Map between DTOs and domain objects at the boundary.

Does every use case need its own application service class?

No rule that says so, but it's often clean. One class per use case. If you have five independent things to do, five classes. If they're tightly related, a single class is fine.

Primary Sources

Get Started with Bitloops.

Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.

curl -sSL https://bitloops.com/install.sh | bash