Skip to content
Published on

Clean Architecture + DDD Practical Guide: Domain-Centric Design with Spring Boot

Authors
  • Name
    Twitter

What is Clean Architecture?

Clean Architecture, proposed by Robert C. Martin (Uncle Bob), is an architectural pattern that protects business logic from external dependencies. The core principle is the Dependency Rule: inner layers know nothing about outer layers.

[Frameworks & Drivers][Interface Adapters][Use Cases][Entities]
       External                                                Internal (Core)

Relationship with DDD

ConceptClean ArchitectureDDD
CoreDependency direction controlDomain modeling
FocusArchitecture layersBusiness domain
ResultTestable codeBusiness correctness

The two approaches are complementary. Clean Architecture tells you how to structure, while DDD tells you what to model.

Project Structure

Package Layout

com.example.order/
├── domain/                     # Entities, Value Objects, Domain Services
│   ├── model/
│   │   ├── Order.java
│   │   ├── OrderItem.java
│   │   ├── OrderStatus.java    # Enum
│   │   └── Money.java          # Value Object
│   ├── event/
│   │   └── OrderPlacedEvent.java
│   ├── exception/
│   │   └── InsufficientStockException.java
│   └── repository/
│       └── OrderRepository.java  # Interface (Port)
├── application/                # Use Cases, Application Services
│   ├── port/
│   │   ├── in/
│   │   │   ├── PlaceOrderUseCase.java
│   │   │   └── PlaceOrderCommand.java
│   │   └── out/
│   │       ├── LoadProductPort.java
│   │       ├── SaveOrderPort.java
│   │       └── SendNotificationPort.java
│   └── service/
│       └── PlaceOrderService.java
├── adapter/                    # Adapters (Infrastructure)
│   ├── in/
│   │   └── web/
│   │       ├── OrderController.java
│   │       ├── OrderRequest.java
│   │       └── OrderResponse.java
│   └── out/
│       ├── persistence/
│       │   ├── OrderJpaEntity.java
│       │   ├── OrderJpaRepository.java
│       │   └── OrderPersistenceAdapter.java
│       ├── messaging/
│       │   └── KafkaNotificationAdapter.java
│       └── external/
│           └── ProductServiceAdapter.java
└── config/
    └── BeanConfig.java

Domain Layer Implementation

Entity: Order

// domain/model/Order.java
package com.example.order.domain.model;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

public class Order {
    private final String id;
    private final String customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private Money totalAmount;
    private final LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // Factory method — encapsulate creation logic in the domain
    public static Order create(String customerId, List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("Order items cannot be empty");
        }

        Money total = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);

        return new Order(
            UUID.randomUUID().toString(),
            customerId,
            new ArrayList<>(items),
            OrderStatus.PLACED,
            total,
            LocalDateTime.now(),
            LocalDateTime.now()
        );
    }

    // Business logic belongs inside the entity!
    public void confirm() {
        if (this.status != OrderStatus.PLACED) {
            throw new IllegalStateException(
                "Can only confirm from PLACED status. Current: " + this.status
            );
        }
        this.status = OrderStatus.CONFIRMED;
        this.updatedAt = LocalDateTime.now();
    }

    public void cancel(String reason) {
        if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException(
                "Cannot cancel after shipment has started"
            );
        }
        this.status = OrderStatus.CANCELLED;
        this.updatedAt = LocalDateTime.now();
    }

    public void addItem(OrderItem item) {
        if (this.status != OrderStatus.PLACED) {
            throw new IllegalStateException("Items can only be added before confirmation");
        }
        this.items.add(item);
        this.totalAmount = this.totalAmount.add(item.getSubtotal());
        this.updatedAt = LocalDateTime.now();
    }

    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);
    }

    // private constructor
    private Order(String id, String customerId, List<OrderItem> items,
                  OrderStatus status, Money totalAmount,
                  LocalDateTime createdAt, LocalDateTime updatedAt) {
        this.id = id;
        this.customerId = customerId;
        this.items = items;
        this.status = status;
        this.totalAmount = totalAmount;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    // getters...
    public String getId() { return id; }
    public String getCustomerId() { return customerId; }
    public OrderStatus getStatus() { return status; }
    public Money getTotalAmount() { return totalAmount; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}

Value Object: Money

// domain/model/Money.java
package com.example.order.domain.model;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public final class Money {
    public static final Money ZERO = new Money(BigDecimal.ZERO);

    private final BigDecimal amount;

    public Money(BigDecimal amount) {
        if (amount == null) throw new IllegalArgumentException("Amount cannot be null");
        this.amount = amount.setScale(2, RoundingMode.HALF_UP);
    }

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

    public static Money of(String amount) {
        return new Money(new BigDecimal(amount));
    }

    public Money add(Money other) {
        return new Money(this.amount.add(other.amount));
    }

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

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

    public BigDecimal getAmount() { return amount; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.compareTo(money.amount) == 0;
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount);
    }

    @Override
    public String toString() {
        return amount.toPlainString();
    }
}

Domain Event

// domain/event/OrderPlacedEvent.java
package com.example.order.domain.event;

import java.time.LocalDateTime;

public record OrderPlacedEvent(
    String orderId,
    String customerId,
    String totalAmount,
    LocalDateTime occurredAt
) {
    public static OrderPlacedEvent from(String orderId, String customerId, String totalAmount) {
        return new OrderPlacedEvent(orderId, customerId, totalAmount, LocalDateTime.now());
    }
}

Application Layer: Use Cases

Port Definitions (Interfaces)

// application/port/in/PlaceOrderUseCase.java
package com.example.order.application.port.in;

public interface PlaceOrderUseCase {
    String execute(PlaceOrderCommand command);
}

// application/port/in/PlaceOrderCommand.java
public record PlaceOrderCommand(
    String customerId,
    List<OrderItemCommand> items
) {
    public record OrderItemCommand(
        String productId,
        int quantity
    ) {}
}
// application/port/out/SaveOrderPort.java
package com.example.order.application.port.out;

import com.example.order.domain.model.Order;

public interface SaveOrderPort {
    void save(Order order);
}

// application/port/out/LoadProductPort.java
public interface LoadProductPort {
    ProductInfo load(String productId);

    record ProductInfo(String id, String name, Money price, int stock) {}
}

Service Implementation

// application/service/PlaceOrderService.java
package com.example.order.application.service;

import com.example.order.application.port.in.*;
import com.example.order.application.port.out.*;
import com.example.order.domain.model.*;
import com.example.order.domain.exception.InsufficientStockException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class PlaceOrderService implements PlaceOrderUseCase {

    private final SaveOrderPort saveOrderPort;
    private final LoadProductPort loadProductPort;
    private final SendNotificationPort sendNotificationPort;

    public PlaceOrderService(
        SaveOrderPort saveOrderPort,
        LoadProductPort loadProductPort,
        SendNotificationPort sendNotificationPort
    ) {
        this.saveOrderPort = saveOrderPort;
        this.loadProductPort = loadProductPort;
        this.sendNotificationPort = sendNotificationPort;
    }

    @Override
    public String execute(PlaceOrderCommand command) {
        // 1. Fetch product info and verify stock
        List<OrderItem> orderItems = command.items().stream()
            .map(item -> {
                var product = loadProductPort.load(item.productId());
                if (product.stock() < item.quantity()) {
                    throw new InsufficientStockException(
                        product.name() + " insufficient stock: " + product.stock() + " remaining"
                    );
                }
                return new OrderItem(
                    item.productId(),
                    product.name(),
                    product.price(),
                    item.quantity()
                );
            })
            .toList();

        // 2. Create order (domain logic)
        Order order = Order.create(command.customerId(), orderItems);

        // 3. Persist
        saveOrderPort.save(order);

        // 4. Send notification
        sendNotificationPort.sendOrderConfirmation(
            command.customerId(), order.getId()
        );

        return order.getId();
    }
}

Adapter Layer: External Integration

Web Adapter (Controller)

// adapter/in/web/OrderController.java
package com.example.order.adapter.in.web;

import com.example.order.application.port.in.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final PlaceOrderUseCase placeOrderUseCase;

    public OrderController(PlaceOrderUseCase placeOrderUseCase) {
        this.placeOrderUseCase = placeOrderUseCase;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
        var command = new PlaceOrderCommand(
            request.customerId(),
            request.items().stream()
                .map(i -> new PlaceOrderCommand.OrderItemCommand(i.productId(), i.quantity()))
                .toList()
        );

        String orderId = placeOrderUseCase.execute(command);

        return ResponseEntity.ok(new OrderResponse(orderId, "Order has been placed"));
    }
}

record OrderRequest(String customerId, List<OrderItemRequest> items) {
    record OrderItemRequest(String productId, int quantity) {}
}

record OrderResponse(String orderId, String message) {}

Persistence Adapter

// adapter/out/persistence/OrderPersistenceAdapter.java
package com.example.order.adapter.out.persistence;

import com.example.order.application.port.out.SaveOrderPort;
import com.example.order.domain.model.Order;
import org.springframework.stereotype.Component;

@Component
public class OrderPersistenceAdapter implements SaveOrderPort {

    private final OrderJpaRepository jpaRepository;

    public OrderPersistenceAdapter(OrderJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public void save(Order order) {
        OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
        jpaRepository.save(entity);
    }
}

// adapter/out/persistence/OrderJpaEntity.java
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
    @Id
    private String id;

    @Column(name = "customer_id", nullable = false)
    private String customerId;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(name = "total_amount", nullable = false)
    private BigDecimal totalAmount;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "order")
    private List<OrderItemJpaEntity> items;

    // Domain → JPA conversion
    public static OrderJpaEntity fromDomain(Order order) {
        var entity = new OrderJpaEntity();
        entity.id = order.getId();
        entity.customerId = order.getCustomerId();
        entity.status = order.getStatus();
        entity.totalAmount = order.getTotalAmount().getAmount();
        entity.items = order.getItems().stream()
            .map(item -> OrderItemJpaEntity.fromDomain(item, entity))
            .toList();
        return entity;
    }

    // JPA → Domain conversion
    public Order toDomain() {
        // Restore domain object using Mapper
        // ...
    }
}

Testing Strategy

Domain Unit Tests (No External Dependencies!)

@Test
void order_creation_success() {
    var items = List.of(
        new OrderItem("P001", "Laptop", Money.of(1500000), 1),
        new OrderItem("P002", "Mouse", Money.of(50000), 2)
    );

    Order order = Order.create("C001", items);

    assertThat(order.getStatus()).isEqualTo(OrderStatus.PLACED);
    assertThat(order.getTotalAmount()).isEqualTo(Money.of(1600000));
    assertThat(order.getItems()).hasSize(2);
}

@Test
void cannot_cancel_after_shipment() {
    Order order = createShippedOrder();

    assertThatThrownBy(() -> order.cancel("Changed my mind"))
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("Cannot cancel after shipment has started");
}

Use Case Tests (Mocking)

@ExtendWith(MockitoExtension.class)
class PlaceOrderServiceTest {

    @Mock SaveOrderPort saveOrderPort;
    @Mock LoadProductPort loadProductPort;
    @Mock SendNotificationPort sendNotificationPort;
    @InjectMocks PlaceOrderService service;

    @Test
    void successful_order_processing() {
        given(loadProductPort.load("P001"))
            .willReturn(new LoadProductPort.ProductInfo(
                "P001", "Laptop", Money.of(1500000), 10
            ));

        var command = new PlaceOrderCommand("C001",
            List.of(new PlaceOrderCommand.OrderItemCommand("P001", 1))
        );

        String orderId = service.execute(command);

        assertThat(orderId).isNotNull();
        then(saveOrderPort).should().save(any(Order.class));
        then(sendNotificationPort).should().sendOrderConfirmation(eq("C001"), any());
    }
}

Pros, Cons, and When to Apply

Good Fit

  • Domains with complex business logic
  • Projects intended for long-term maintenance
  • Systems integrating with multiple external services
  • Projects where test coverage is critical

Overkill

  • Simple CRUD applications
  • Prototypes / PoC
  • Small projects (2-3 people or fewer)

Review Quiz (6 questions)

Q1. What is the Dependency Rule in Clean Architecture?

Inner layers know nothing about outer layers. Dependencies always point inward, from the outside to the inside.

Q2. What is the difference between a Port and an Adapter?

A Port is an interface (contract), and an Adapter is a concrete implementation of that interface. Ports reside in the application layer, while Adapters reside in the infrastructure layer.

Q3. What are the characteristics of a Value Object?

Immutable, equality is compared by value (equals), encapsulates business rules. Examples: Money, Address, Email

Q4. Why should Spring annotations not be used in the domain layer?

The domain layer must be independent of the framework. Depending on Spring couples domain logic to infrastructure.

Q5. Why separate JPA Entity from Domain Entity?

JPA Entity is subject to ORM framework constraints, while Domain Entity should contain only pure business logic. Separation keeps the domain independent of persistence technology.

Q6. When is Clean Architecture + DDD overkill?

For simple CRUD, prototypes/PoC, and small projects where business logic is straightforward, it becomes over-engineering.