Target Audience: Developers looking for an architecture that works well with DDD Prerequisites: Hexagonal Architecture and Dependency Inversion Principle Estimated Time: About 20 minutes

Onion Architecture#

An architecture proposed by Jeffrey Palermo in 2008. It places the domain model at the very center and wraps it in concentric layers, like an onion.

Onion architecture starts from the limitations of traditional layered architecture. In layered architecture, upper layers depend on lower layers, so changes in the database layer cascade into the service layer. In contrast, onion architecture is designed so that the domain model depends on nothing, and infrastructure depends on the domain. This allows domain logic to express pure business rules regardless of changes in databases, frameworks, or external services. This is precisely why it pairs so well with DDD (Domain-Driven Design).

One-Line Summary#

The domain model is king. Everything else serves the domain.

flowchart TB
    subgraph Outer["🔵 Infrastructure"]
        subgraph App["🟢 Application Services"]
            subgraph DomainSvc["🟡 Domain Services"]
                subgraph Model["💎 Domain Model"]
                    E["Entity"]
                    VO["Value Object"]
                    AGG["Aggregate"]
                end
                DS["Domain Service"]
            end
            AS["Application Service"]
        end
        INF["UI, Database, External"]
    end

    INF --> AS --> DS --> Model

Understanding Through the “Onion”#

Analogy: A Real Onion

When you cut an onion, you see layer upon layer:

Onion LayerSoftware LayerCharacteristics
Outer skin (disposable part)InfrastructureReplaceable, technical details
Outer layerApplication ServicesFlow orchestration
Inner layerDomain ServicesDomain logic composition
Core (hardest part)Domain ModelBusiness rules that never change

Core idea: The domain model sits at the innermost layer and depends on nothing. The outer skin (Infrastructure) can be peeled off and replaced at any time, but the core remains intact.

The diagram below visually represents the layered structure of the onion:

flowchart TB
    subgraph Onion["🧅 Onion Structure"]
        L1["Outer skin<br>(disposable)"]
        L2["Outer layer"]
        L3["Inner layer"]
        L4["💎 Core<br>(hardest and most important)"]
    end

    L1 --> L2 --> L3 --> L4

Difference from Clean Architecture#

Both have a “concentric circle” structure, but they emphasize different things:

AspectCleanOnion
CenterEntity (business rules)Domain Model (DDD concepts)
EmphasisDependency rulesDomain purity
Domain ServiceIncluded in EntitySeparate layer
Use CaseInteractorApplication Service
DDD AffinityModerateHigh
flowchart LR
    subgraph Clean["Clean Architecture"]
        C1["Entity"]
        C2["Use Case"]
        C3["Adapter"]
    end

    subgraph Onion["Onion Architecture"]
        O1["Domain Model"]
        O2["Domain Service"]
        O3["Application Service"]
        O4["Infrastructure"]
    end

    Clean -.->|"DDD Enhancement"| Onion

Why Onion is more suitable for DDD:

  • Clearly separates Domain Model and Domain Service
  • Aggregate, Entity, and Value Object concepts fit naturally
  • Repository interfaces reside in the Domain

4 Layers in Detail#

1. Domain Model - Innermost Layer#

This is where core business concepts and rules reside.

flowchart TB
    subgraph DM["Domain Model"]
        E["Entity<br>(identity-based object)"]
        VO["Value Object<br>(compared by value)"]
        AGG["Aggregate<br>(consistency boundary)"]
        DE["Domain Event<br>(domain event)"]
    end
// Entity: distinguished by unique identifier
public class Order {
    private final OrderId id;  // identifier
    private CustomerId customerId;
    private List<OrderLine> orderLines;
    private OrderStatus status;
    private Money totalAmount;

    // Factory method
    public static Order create(CustomerId customerId, List<OrderLine> lines) {
        if (lines.isEmpty()) {
            throw new EmptyOrderException();
        }

        Order order = new Order(OrderId.generate());
        order.customerId = customerId;
        order.orderLines = new ArrayList<>(lines);
        order.status = OrderStatus.PENDING;
        order.recalculateTotal();

        return order;
    }

    // business logic
    public void addLine(OrderLine line) {
        validateModifiable();
        this.orderLines.add(line);
        recalculateTotal();
    }

    public void confirm() {
        validateCanConfirm();
        this.status = OrderStatus.CONFIRMED;
    }

    public void cancel() {
        validateCanCancel();
        this.status = OrderStatus.CANCELLED;
    }

    // Invariant validation
    private void validateModifiable() {
        if (status != OrderStatus.PENDING) {
            throw new OrderNotModifiableException(id, status);
        }
    }

    private void validateCanConfirm() {
        if (status != OrderStatus.PENDING) {
            throw new InvalidOrderStateException("Can only confirm from PENDING state");
        }
        if (totalAmount.isLessThan(Money.of(1000))) {
            throw new MinimumOrderAmountException();
        }
    }
}
// Value Object: compared by value, immutable
public record Money(BigDecimal amount, Currency currency) {

    public static final Money ZERO = Money.of(0);

    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new NegativeAmountException();
        }
    }

    public static Money of(long amount) {
        return new Money(BigDecimal.valueOf(amount), Currency.KRW);
    }

    public Money add(Money other) {
        validateSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }

    public Money multiply(int quantity) {
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }

    public boolean isLessThan(Money other) {
        validateSameCurrency(other);
        return amount.compareTo(other.amount) < 0;
    }

    public boolean isGreaterThan(Money other) {
        validateSameCurrency(other);
        return amount.compareTo(other.amount) > 0;
    }

    private void validateSameCurrency(Money other) {
        if (this.currency != other.currency) {
            throw new CurrencyMismatchException(this.currency, other.currency);
        }
    }
}
// Aggregate Root: consistency boundary
public class Order {  // Order is the Aggregate Root
    private OrderId id;
    private List<OrderLine> orderLines;  // OrderLine is managed only within Order

    // External access to OrderLine is only through Order
    public void addLine(ProductId productId, int quantity, Money unitPrice) {
        OrderLine line = new OrderLine(productId, quantity, unitPrice);
        this.orderLines.add(line);
        recalculateTotal();
    }

    public void removeLine(ProductId productId) {
        orderLines.removeIf(line -> line.getProductId().equals(productId));
        recalculateTotal();
    }
}

Characteristics of Domain Model

  • Pure Java object - does not depend on any framework
  • Rich domain logic - must not have only getters/setters
  • Self-defending - cannot be put into an invalid state
  • Easy to test - testable without external dependencies

2. Domain Services#

This is where logic that combines multiple domain objects resides. It contains logic that is difficult to place in a single Entity.

Where does this logic go?
- In Order? In Customer?
-> If neither, then Domain Service!
// Domain Service: combines multiple Aggregates
public class PricingService {

    // Discount calculation - needs both Order and Customer information
    public Money calculateFinalPrice(Order order, Customer customer, DiscountPolicy policy) {
        Money basePrice = order.getTotalAmount();

        // Discount based on customer grade
        Percentage discount = policy.getDiscountFor(customer.getGrade());
        Money discounted = basePrice.applyDiscount(discount);

        // Additional VIP discount
        if (customer.isVip() && order.getTotalAmount().isGreaterThan(Money.of(100000))) {
            discounted = discounted.applyDiscount(Percentage.of(5));
        }

        return discounted;
    }
}
// Domain Service: inventory check + reservation
public class InventoryDomainService {

    // Check and reserve inventory for multiple products at once
    public ReservationResult reserveInventory(Order order, InventoryRepository inventoryRepo) {
        List<ReservationItem> reservations = new ArrayList<>();

        for (OrderLine line : order.getOrderLines()) {
            Inventory inventory = inventoryRepo.findByProductId(line.getProductId())
                .orElseThrow(() -> new ProductNotFoundException(line.getProductId()));

            if (!inventory.hasEnough(line.getQuantity())) {
                return ReservationResult.failed(line.getProductId(), "Insufficient stock");
            }

            inventory.reserve(line.getQuantity());
            reservations.add(new ReservationItem(inventory.getId(), line.getQuantity()));
        }

        return ReservationResult.success(reservations);
    }
}

Additionally, Repository interfaces are located in the Domain layer:

// Repository Interface (defined in the Domain layer)
public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(OrderId id);
    List<Order> findByCustomerId(CustomerId customerId);
}

public interface CustomerRepository {
    Optional<Customer> findById(CustomerId id);
}

Domain Service vs Application Service

Domain ServiceApplication Service
Domain logic compositionFlow (workflow) orchestration
Pure business rulesTransaction, infrastructure calls
Uses only other domain objectsCalls Repository, external services
“Calculate discount amount”“Create order -> save -> notify”

3. Application Services#

Orchestrates Use Case flow. Handles transaction management, external system calls, and more.

@Service
@Transactional
public class OrderApplicationService {

    // Repositories (Infrastructure implementations are injected)
    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;

    // Domain Services
    private final PricingService pricingService;
    private final InventoryDomainService inventoryService;

    // External services
    private final PaymentService paymentService;
    private final NotificationService notificationService;
    private final EventPublisher eventPublisher;

    // Use Case: create order
    public OrderDto createOrder(CreateOrderCommand command) {
        // 1. Look up customer
        Customer customer = customerRepository.findById(command.customerId())
            .orElseThrow(() -> new CustomerNotFoundException(command.customerId()));

        // 2. Create domain object (Domain Model)
        Order order = Order.create(
            customer.getId(),
            command.toOrderLines()
        );

        // 3. Apply discount (Domain Service)
        Money finalPrice = pricingService.calculateFinalPrice(
            order,
            customer,
            DiscountPolicy.standard()
        );
        order.applyDiscount(finalPrice);

        // 4. Reserve inventory (Domain Service)
        ReservationResult reservation = inventoryService.reserveInventory(
            order,
            inventoryRepository
        );
        if (!reservation.isSuccess()) {
            throw new InsufficientInventoryException(reservation.getFailedProduct());
        }

        // 5. Save (Repository)
        Order savedOrder = orderRepository.save(order);

        // 6. Publish event
        eventPublisher.publish(new OrderCreatedEvent(savedOrder));

        // 7. Return DTO
        return OrderDto.from(savedOrder);
    }

    // Use Case: confirm order
    public void confirmOrder(OrderId orderId) {
        // 1. Look up
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        // 2. Payment (external service)
        PaymentResult payment = paymentService.process(order.getTotalAmount());
        if (!payment.isSuccess()) {
            throw new PaymentFailedException(payment.getReason());
        }

        // 3. Confirm (Domain logic)
        order.confirm();

        // 4. Save
        orderRepository.save(order);

        // 5. Send notification (external service)
        notificationService.sendConfirmation(order);

        // 6. Publish event
        eventPublisher.publish(new OrderConfirmedEvent(order));
    }
}

Role of Application Service

  • Orchestrator role - decides “what” to do, delegates “how” to the Domain
  • Transaction boundary - applies @Transactional
  • External system calls - Payment, Notification, etc.
  • Event publishing - publishes domain events
  • DTO conversion - determines the data format to return externally

4. Infrastructure - Outermost Layer#

This is where technical details reside. UI, database, external API integrations, and more.

flowchart TB
    subgraph Infra["Infrastructure Layer"]
        UI["🖥️ UI / Web"]
        CTRL["Controller"]
        REPO["Repository Impl"]
        EXT["External Service Integration"]
    end
// Controller (Infrastructure)
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderApplicationService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {

        CreateOrderCommand command = request.toCommand();
        OrderDto result = orderService.createOrder(command);

        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(OrderResponse.from(result));
    }

    @PostMapping("/{orderId}/confirm")
    public ResponseEntity<Void> confirmOrder(@PathVariable String orderId) {
        orderService.confirmOrder(OrderId.of(orderId));
        return ResponseEntity.ok().build();
    }
}
// Repository implementation (Infrastructure)
@Repository
public class JpaOrderRepository implements OrderRepository {

    private final OrderJpaRepository jpaRepository;
    private final OrderMapper mapper;

    @Override
    public Order save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        OrderEntity saved = jpaRepository.save(entity);
        return mapper.toDomain(saved);
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return jpaRepository.findById(id.getValue())
            .map(mapper::toDomain);
    }

    @Override
    public List<Order> findByCustomerId(CustomerId customerId) {
        return jpaRepository.findByCustomerId(customerId.getValue())
            .stream()
            .map(mapper::toDomain)
            .toList();
    }
}

// JPA Entity (Infrastructure only)
@Entity
@Table(name = "orders")
public class OrderEntity {
    @Id
    private String id;
    private String customerId;
    private String status;
    private BigDecimal totalAmount;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLineEntity> orderLines = new ArrayList<>();
}
// External service implementation (Infrastructure)
@Component
public class ExternalPaymentService implements PaymentService {

    private final RestTemplate restTemplate;

    @Override
    public PaymentResult process(Money amount) {
        PaymentRequest request = new PaymentRequest(
            amount.getAmount(),
            amount.getCurrency().name()
        );

        try {
            ResponseEntity<PaymentResponse> response = restTemplate.postForEntity(
                "https://payment-api.example.com/process",
                request,
                PaymentResponse.class
            );

            return PaymentResult.from(response.getBody());
        } catch (Exception e) {
            return PaymentResult.failed("Payment service error: " + e.getMessage());
        }
    }
}

Package Structure#

com.example.order/
│
├── domain/                          # Domain Layer
│   ├── model/                       # Domain Model (innermost)
│   │   ├── order/
│   │   │   ├── Order.java           # Aggregate Root
│   │   │   ├── OrderLine.java       # Entity
│   │   │   ├── OrderId.java         # Value Object
│   │   │   └── OrderStatus.java     # Enum
│   │   ├── customer/
│   │   │   ├── Customer.java
│   │   │   └── CustomerId.java
│   │   └── common/
│   │       ├── Money.java
│   │       └── Percentage.java
│   │
│   ├── service/                     # Domain Services
│   │   ├── PricingService.java
│   │   └── InventoryDomainService.java
│   │
│   ├── repository/                  # Repository Interfaces
│   │   ├── OrderRepository.java
│   │   └── CustomerRepository.java
│   │
│   └── event/                       # Domain Events
│       ├── OrderCreatedEvent.java
│       └── OrderConfirmedEvent.java
│
├── application/                     # Application Services
│   ├── service/
│   │   └── OrderApplicationService.java
│   ├── command/
│   │   └── CreateOrderCommand.java
│   ├── dto/
│   │   └── OrderDto.java
│   └── port/                        # External service interfaces
│       ├── PaymentService.java
│       └── NotificationService.java
│
└── infrastructure/                  # Infrastructure (outermost)
    ├── web/
    │   ├── OrderController.java
    │   ├── CreateOrderRequest.java
    │   └── OrderResponse.java
    ├── persistence/
    │   ├── JpaOrderRepository.java
    │   ├── OrderEntity.java
    │   ├── OrderJpaRepository.java
    │   └── OrderMapper.java
    └── external/
        ├── ExternalPaymentService.java
        └── EmailNotificationService.java

Dependency Direction#

flowchart TB
    subgraph Infra["Infrastructure"]
        CTRL["Controller"]
        REPO_IMPL["Repository Impl"]
    end

    subgraph App["Application Services"]
        AS["OrderApplicationService"]
    end

    subgraph Domain["Domain"]
        DS["Domain Service"]
        DM["Domain Model"]
        RI["Repository Interface"]
    end

    CTRL --> AS
    AS --> DS --> DM
    AS --> RI
    REPO_IMPL -->|"implements"| RI

Core Rules:

  1. Dependencies only flow in the direction Infrastructure -> Application -> Domain
  2. Domain depends on nothing
  3. Repository Interface is in Domain, implementation is in Infrastructure

Comparison with Other Architectures#

Clean vs Hexagonal vs Onion#

flowchart TB
    subgraph Clean["Clean"]
        C1["Entity"]
        C2["Use Case"]
        C3["Adapter"]
        C4["Framework"]
    end

    subgraph Hex["Hexagonal"]
        H1["Domain"]
        H2["Application"]
        H3["Port"]
        H4["Adapter"]
    end

    subgraph Onion["Onion"]
        O1["Domain Model"]
        O2["Domain Service"]
        O3["Application Service"]
        O4["Infrastructure"]
    end
ComparisonCleanHexagonalOnion
Number of Layers43-44
CenterEntityCoreDomain Model
EmphasisDependency rulesExternal isolationDomain purity
Domain ServiceIncluded in EntityNo explicit distinctionSeparate layer
DDD AffinityModerateHighHighest
ComplexityHighMediumMedium

Common Mistakes#

1. Infrastructure Code in Domain#

// ❌ Wrong: JPA annotations in Domain Model
@Entity  // Infrastructure code!
@Table(name = "orders")
public class Order {
    @Id
    private String id;

    @OneToMany(cascade = CascadeType.ALL)
    private List<OrderLine> orderLines;
}

// ✅ Correct: Pure Domain Model
public class Order {
    private OrderId id;
    private List<OrderLine> orderLines;

    // Pure business logic only
}

// Separate JPA Entity in Infrastructure
@Entity
@Table(name = "orders")
public class OrderEntity {
    @Id
    private String id;
    // ...
}

2. Business Logic in Application Service#

// ❌ Wrong: Business rules in Application Service
@Service
public class OrderApplicationService {

    public void confirmOrder(OrderId orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();

        // This logic should be inside Order!
        if (order.getStatus().equals("PENDING")) {
            if (order.getTotalAmount() >= 1000) {
                order.setStatus("CONFIRMED");
            }
        }
    }
}

// ✅ Correct: Business logic in Domain Model
public class Order {
    public void confirm() {
        validateCanConfirm();  // Rule validation
        this.status = OrderStatus.CONFIRMED;
    }

    private void validateCanConfirm() {
        if (status != OrderStatus.PENDING) {
            throw new InvalidOrderStateException();
        }
        if (totalAmount.isLessThan(Money.of(1000))) {
            throw new MinimumOrderAmountException();
        }
    }
}

@Service
public class OrderApplicationService {
    public void confirmOrder(OrderId orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.confirm();  // Delegate to Domain
        orderRepository.save(order);
    }
}

3. Domain Directly Calling External Services#

// ❌ Wrong: Domain Service calls external service
public class PricingDomainService {
    private final ExternalDiscountApi discountApi;  // External API!

    public Money calculatePrice(Order order) {
        Discount discount = discountApi.getDiscount(order.getCustomerId());  // Not allowed!
        return order.getTotalAmount().applyDiscount(discount);
    }
}

// ✅ Correct: Receive needed information as parameters
public class PricingDomainService {
    public Money calculatePrice(Order order, DiscountPolicy policy) {
        // External info is fetched by Application Service and passed in
        Percentage discount = policy.getDiscountFor(order.getCustomerGrade());
        return order.getTotalAmount().applyDiscount(discount);
    }
}

// Application Service fetches external information
@Service
public class OrderApplicationService {
    private final DiscountClient discountClient;  // Infrastructure

    public OrderDto createOrder(CreateOrderCommand command) {
        DiscountPolicy policy = discountClient.getCurrentPolicy();  // External lookup
        Money finalPrice = pricingService.calculatePrice(order, policy);  // Pass to Domain
    }
}

Testing Strategy#

Testing by layer#

flowchart TB
    subgraph Tests["Test Pyramid"]
        E2E["E2E Test<br>(Few)"]
        INT["Integration Test<br>(Medium)"]
        UNIT["Unit Test<br>(Many)"]
    end

    E2E --> INT --> UNIT

1. Domain Model Tests (Easiest)#

class OrderTest {

    @Test
    void order_creation_success() {
        List<OrderLine> lines = List.of(
            new OrderLine(ProductId.of("p1"), 2, Money.of(10000))
        );

        Order order = Order.create(CustomerId.of("c1"), lines);

        assertThat(order.getId()).isNotNull();
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
        assertThat(order.getTotalAmount()).isEqualTo(Money.of(20000));
    }

    @Test
    void empty_order_cannot_be_created() {
        assertThrows(EmptyOrderException.class,
            () -> Order.create(CustomerId.of("c1"), List.of()));
    }
}

class MoneyTest {

    @Test
    void add_amounts() {
        Money a = Money.of(10000);
        Money b = Money.of(5000);

        Money result = a.add(b);

        assertThat(result).isEqualTo(Money.of(15000));
    }

    @Test
    void negative_amount_not_allowed() {
        assertThrows(NegativeAmountException.class,
            () -> new Money(BigDecimal.valueOf(-1000), Currency.KRW));
    }
}

2. Domain Service Tests#

class PricingServiceTest {

    private PricingService pricingService = new PricingService();

    @Test
    void VIP_customer_10_percent_discount() {
        Order order = createOrderWithTotal(Money.of(100000));
        Customer vip = Customer.withGrade(Grade.VIP);
        DiscountPolicy policy = DiscountPolicy.standard();

        Money result = pricingService.calculateFinalPrice(order, vip, policy);

        assertThat(result).isEqualTo(Money.of(90000));
    }
}

3. Application Service Tests (Using Mocks)#

@ExtendWith(MockitoExtension.class)
class OrderApplicationServiceTest {

    @Mock private OrderRepository orderRepository;
    @Mock private CustomerRepository customerRepository;
    @Mock private PricingService pricingService;
    @Mock private PaymentService paymentService;
    @Mock private EventPublisher eventPublisher;

    @InjectMocks
    private OrderApplicationService service;

    @Test
    void order_confirmation_success() {
        // Given
        Order order = createPendingOrder();
        when(orderRepository.findById(any())).thenReturn(Optional.of(order));
        when(paymentService.process(any())).thenReturn(PaymentResult.success());

        // When
        service.confirmOrder(order.getId());

        // Then
        verify(orderRepository).save(any());
        verify(eventPublisher).publish(any(OrderConfirmedEvent.class));
    }

    @Test
    void exception_on_payment_failure() {
        Order order = createPendingOrder();
        when(orderRepository.findById(any())).thenReturn(Optional.of(order));
        when(paymentService.process(any())).thenReturn(PaymentResult.failed("Insufficient balance"));

        assertThrows(PaymentFailedException.class,
            () -> service.confirmOrder(order.getId()));

        verify(orderRepository, never()).save(any());
    }
}

4. Infrastructure Tests (Integration Tests)#

@DataJpaTest
class JpaOrderRepositoryTest {

    @Autowired
    private OrderJpaRepository jpaRepository;

    private JpaOrderRepository repository;

    @BeforeEach
    void setUp() {
        repository = new JpaOrderRepository(jpaRepository, new OrderMapper());
    }

    @Test
    void save_and_find_order() {
        Order order = createOrder();

        repository.save(order);
        Optional<Order> found = repository.findById(order.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getStatus()).isEqualTo(order.getStatus());
    }
}

Trade-offs#

Onion architecture protects the domain, but at the cost of complexity and development effort. You must clearly understand the trade-offs before adopting it in a project.

Advantages#

AdvantageDescription
Domain protectionBusiness logic is not polluted by technical details
TestabilityDomain Layer can be unit tested without external dependencies
Technology independenceDatabase and framework changes do not affect the domain
DDD affinityMaps naturally to DDD concepts like Aggregate, Entity, Value Object
Clear boundariesClear responsibilities between layers facilitate team collaboration

Disadvantages#

DisadvantageDescription
Initial complexityMore files and interfaces than layered
Learning curveMust understand DDD concepts and dependency direction
Mapping overheadConversion code needed between Domain Entity and JPA Entity
Excessive for simple CRUDOver-engineering if business logic is simple
Performance costMany object conversions can cause slight performance degradation

Practical Considerations#

flowchart LR
    subgraph Decision["Adoption Decision Criteria"]
        Q1{"Is business logic<br>complex?"}
        Q2{"Is it a long-term<br>maintenance project?"}
        Q3{"Does the team<br>understand DDD?"}
    end

    Q1 -->|Yes| Q2
    Q2 -->|Yes| Q3
    Q3 -->|Yes| O["Onion suitable"]
    Q1 -->|No| L["Layered recommended"]
    Q2 -->|No| L
    Q3 -->|No| H["Hexagonal or<br>Layered recommended"]

Key point: The complexity of the architecture should be proportional to the complexity of the problem you are solving. If you apply a complex solution to a simple problem, the complexity itself becomes a new problem.


When Should You Use Onion Architecture?#

Suitable Cases#

  • Projects that fully adopt DDD
  • Cases with complex domain logic
  • Cases requiring collaboration with domain experts
  • Projects maintained long-term
  • Cases where business rules change frequently

Unsuitable Cases#

  • Simple CRUD applications
  • Small, short-term projects
  • Teams with no DDD experience – start with Layered Architecture
  • Cases with many external integrations but simple domains – use Hexagonal

Best Practice: Which Systems Fit?#

System TypeSuitabilityReason
DDD-based projectsVery suitableMaps naturally to Aggregate, Entity, Value Object
Insurance/Finance domainsVery suitableComplex business rules, domain purity
Reservation/Scheduling systemsSuitableComplex domain logic, state management
Inventory/Logistics managementSuitableBusiness rule-centric, domain expert collaboration
Healthcare systemsSuitableRegulatory compliance, complex domain
Simple query servicesUnsuitableExcessive if domain logic is simple
External integration-focusedUnsuitableHexagonal is more appropriate
MVP/PrototypeUnsuitableFast development takes priority

Gradual Adoption#

flowchart LR
    A["Step 1<br>Enrich Entity"]
    B["Step 2<br>Extract Domain Service"]
    C["Step 3<br>Separate Repository Interface"]
    D["Step 4<br>Complete Separation"]

    A --> B --> C --> D

Step 1: Add Logic to Entities#

// Before: Anemic Domain
public class Order {
    private String status;
    public void setStatus(String s) { this.status = s; }
}

// After: Rich Domain
public class Order {
    private OrderStatus status;

    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException();
        }
        this.status = OrderStatus.CONFIRMED;
    }
}

Step 2: Extract Domain Services#

// Move logic that combines multiple Entities to a Domain Service
public class PricingService {
    public Money calculatePrice(Order order, Customer customer) {
        // ...
    }
}

Step 3: Separate Repository Interfaces#

// domain/repository/OrderRepository.java (Interface)
public interface OrderRepository {
    Order save(Order order);
}

// infrastructure/persistence/JpaOrderRepository.java (implementation)
public class JpaOrderRepository implements OrderRepository {
    // ...
}

Next Steps#