- Published on
Clean Architecture Complete Guide: From SOLID to Hexagonal, DDD, and Layered — Design Principles for Senior Engineers
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. Why Architecture Matters
- 2. SOLID Principles Deep Dive
- 3. Layered Architecture
- 4. Clean Architecture (Uncle Bob)
- 5. Hexagonal Architecture (Ports and Adapters)
- 6. DDD (Domain-Driven Design)
- 7. Architecture Comparison Summary
- 8. Hands-On Project: E-Commerce Order System
- 9. Testing Strategy
- 10. Anti-Patterns and Pitfalls
- Quiz
- References
Introduction
What is the most expensive part of software development? It is not the initial build. It is maintenance. Research shows that 60-80% of the total software lifecycle cost goes into maintenance. And the single biggest factor determining maintenance cost is architecture.
Robert C. Martin (Uncle Bob) stated: "Good architecture makes the system easy to understand, easy to develop, easy to maintain, and easy to deploy." This is not just a motto — it is a core principle that runs through decades of software project successes and failures.
There is a reason why questions like "Explain the difference between Clean Architecture and Hexagonal Architecture" and "What is a Bounded Context in DDD?" are standard in senior engineer interviews. Understanding architecture goes beyond writing code — it is about having the ability to design systems.
In this guide, we will deeply analyze the core patterns of modern software design — from SOLID principles to Layered, Clean, Hexagonal, and DDD — with production-ready code examples. We will cover the philosophy, implementation, trade-offs, and when to choose which pattern.
1. Why Architecture Matters
1.1 The Reality of Technical Debt
Technical debt compounds over time. Projects that start without architectural thinking follow a steep cost curve:
Cost
^
| / No architecture
| /
| /
| /
| / ___--- Good architecture
| / ---
| / --
| /--
+-------------------> Time
A 2018 study by Stripe found that developers spend approximately 33% of their work time dealing with technical debt. This translates to billions of dollars in lost productivity annually.
1.2 Four Characteristics of Good Architecture
Uncle Bob defined the characteristics of good architecture as:
- Easy to understand — New team members can quickly grasp the system
- Easy to develop — Feature additions have limited blast radius
- Easy to maintain — Bug fixes and refactoring are safe
- Easy to deploy — Independent deployments and rollbacks are possible
1.3 Architecture in Senior Interviews
Architecture questions are mandatory in senior engineering interviews. Common topics include:
- How you applied each SOLID principle in real projects
- Differences between Layered, Clean, and Hexagonal and your selection criteria
- Experience applying DDD and designing Bounded Contexts
- Writing Architecture Decision Records (ADRs)
- How you managed and resolved technical debt
You need to give answers with depth rooted in real experience, not just textbook theory.
2. SOLID Principles Deep Dive
SOLID represents the five core principles of object-oriented design, formulated by Robert C. Martin. These principles form the foundation of every architecture pattern.
2.1 S — Single Responsibility Principle
A class should have only one reason to change.
The key insight is not "do only one thing" but rather "have only one reason to change."
BAD: A class with multiple reasons to change
// BAD: UserService handles business logic, persistence, and notifications
class UserService {
createUser(name: string, email: string) {
// Validation logic
if (!email.includes('@')) throw new Error('Invalid email')
// DB persistence (persistence concern)
const query = `INSERT INTO users (name, email) VALUES ('${name}', '${email}')`
database.execute(query)
// Email sending (notification concern)
const mailClient = new SmtpClient()
mailClient.send(email, 'Welcome!', 'Thanks for joining')
// Logging (infrastructure concern)
console.log(`User created: ${name}`)
}
}
GOOD: Separated responsibilities
// GOOD: Each class has only one reason to change
class UserValidator {
validate(name: string, email: string): void {
if (!email.includes('@')) throw new Error('Invalid email')
if (name.length < 2) throw new Error('Name too short')
}
}
class UserRepository {
save(user: User): Promise<User> {
return this.db.users.create({ data: user })
}
}
class WelcomeEmailSender {
send(email: string): Promise<void> {
return this.mailService.send({
to: email,
subject: 'Welcome!',
body: 'Thanks for joining',
})
}
}
class CreateUserUseCase {
constructor(
private validator: UserValidator,
private repository: UserRepository,
private emailSender: WelcomeEmailSender
) {}
async execute(name: string, email: string): Promise<User> {
this.validator.validate(name, email)
const user = await this.repository.save({ name, email })
await this.emailSender.send(email)
return user
}
}
2.2 O — Open/Closed Principle
Software entities should be open for extension but closed for modification.
BAD: Adding new payment methods requires modifying existing code
// BAD: Every new payment method requires another if statement
class PaymentProcessor {
process(payment: Payment) {
if (payment.type === 'credit_card') {
this.processCreditCard(payment)
} else if (payment.type === 'paypal') {
this.processPayPal(payment)
} else if (payment.type === 'bitcoin') {
// Modifying existing code for every new payment method!
this.processBitcoin(payment)
}
}
}
GOOD: Strategy pattern makes it open for extension
// GOOD: Adding new payment methods requires no modification to existing code
interface PaymentStrategy {
process(payment: Payment): Promise<PaymentResult>
}
class CreditCardStrategy implements PaymentStrategy {
async process(payment: Payment): Promise<PaymentResult> {
return { success: true, transactionId: 'cc-123' }
}
}
class PayPalStrategy implements PaymentStrategy {
async process(payment: Payment): Promise<PaymentResult> {
return { success: true, transactionId: 'pp-456' }
}
}
// New payment method = just add a new class
class KakaoPayStrategy implements PaymentStrategy {
async process(payment: Payment): Promise<PaymentResult> {
return { success: true, transactionId: 'kp-789' }
}
}
class PaymentProcessor {
private strategies: Map<string, PaymentStrategy>
constructor(strategies: Map<string, PaymentStrategy>) {
this.strategies = strategies
}
async process(payment: Payment): Promise<PaymentResult> {
const strategy = this.strategies.get(payment.type)
if (!strategy) throw new Error(`Unknown payment type: ${payment.type}`)
return strategy.process(payment)
}
}
2.3 L — Liskov Substitution Principle
Subtypes must be substitutable for their base types.
BAD: The Square-Rectangle problem
// BAD: Square violates Rectangle's invariants
class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int getArea() { return width * height; }
}
class Square extends Rectangle {
// LSP violation: setWidth also changes height
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // Different behavior from parent!
}
@Override
public void setHeight(int h) {
this.width = h;
this.height = h;
}
}
// Client code breaks
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.getArea() == 50; // Fails for Square! (100)
}
GOOD: Common interface approach
// GOOD: Design with a common interface
interface Shape {
int getArea();
}
class Rectangle implements Shape {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getArea() { return width * height; }
}
class Square implements Shape {
private final int side;
Square(int side) { this.side = side; }
public int getArea() { return side * side; }
}
2.4 I — Interface Segregation Principle
Clients should not be forced to depend on methods they do not use.
BAD: Fat interface
// BAD: All devices must implement all functions
interface SmartDevice {
print(): void
scan(): void
fax(): void
copyDocument(): void
}
// A printer cannot scan or fax
class SimplePrinter implements SmartDevice {
print() {
/* OK */
}
scan() {
throw new Error('Not supported')
} // Forced implementation
fax() {
throw new Error('Not supported')
} // Forced implementation
copyDocument() {
throw new Error('Not supported')
}
}
GOOD: Segregated interfaces
// GOOD: Role-based segregated interfaces
interface Printer {
print(): void
}
interface Scanner {
scan(): void
}
interface FaxMachine {
fax(): void
}
// Implement only the interfaces you need
class SimplePrinter implements Printer {
print() {
/* handle printing */
}
}
class AllInOnePrinter implements Printer, Scanner, FaxMachine {
print() {
/* print */
}
scan() {
/* scan */
}
fax() {
/* fax */
}
}
2.5 D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
This is the core principle behind every architecture pattern.
BAD: High-level directly depends on low-level
// BAD: Business logic depends on concrete implementations
class OrderService {
private mysqlDb = new MySQLDatabase() // Depends on concrete class
private smtpMail = new SmtpEmailClient() // Depends on concrete class
createOrder(order: Order) {
this.mysqlDb.insert('orders', order)
this.smtpMail.send(order.userEmail, 'Order confirmed')
}
}
// Switching from MySQL to PostgreSQL requires modifying OrderService!
GOOD: Depend on abstractions
// GOOD: Depend on interfaces (abstractions)
interface OrderRepository {
save(order: Order): Promise<Order>
}
interface NotificationService {
notify(userId: string, message: string): Promise<void>
}
class OrderService {
constructor(
private repository: OrderRepository, // Depends on abstraction
private notification: NotificationService // Depends on abstraction
) {}
async createOrder(order: Order): Promise<Order> {
const saved = await this.repository.save(order)
await this.notification.notify(order.userId, 'Order confirmed')
return saved
}
}
// Implementations live in the infrastructure layer
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<Order> {
return this.prisma.order.create({ data: order })
}
}
class SlackNotificationService implements NotificationService {
async notify(userId: string, message: string): Promise<void> {
await this.slackClient.postMessage({ channel: userId, text: message })
}
}
3. Layered Architecture
3.1 Basic Structure
Layered Architecture is the most traditional and widely used pattern. Clear separation of responsibilities between layers is the key.
+-----------------------------+
| Presentation Layer | <- Controller, View
+-----------------------------+
| Application Layer | <- Service, DTO
+-----------------------------+
| Domain Layer | <- Entity, Business Logic
+-----------------------------+
| Infrastructure Layer | <- Repository, External API
+-----------------------------+
Dependency direction: Top -> Bottom (Presentation -> Infrastructure)
3.2 Spring Boot Project Structure
src/main/java/com/example/shop/
controller/
OrderController.java
service/
OrderService.java
domain/
Order.java
OrderItem.java
repository/
OrderRepository.java
JpaOrderRepository.java
dto/
CreateOrderRequest.java
OrderResponse.java
3.3 Implementation Example
// Presentation Layer
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@RequestBody CreateOrderRequest request) {
OrderResponse response = orderService.createOrder(request);
return ResponseEntity.status(201).body(response);
}
}
// Application Layer
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
public OrderResponse createOrder(CreateOrderRequest request) {
Product product = productRepository.findById(request.getProductId())
.orElseThrow(() -> new NotFoundException("Product not found"));
Order order = Order.create(
request.getUserId(),
product,
request.getQuantity()
);
Order saved = orderRepository.save(order);
return OrderResponse.from(saved);
}
}
// Domain Layer
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String userId;
private BigDecimal totalAmount;
private OrderStatus status;
public static Order create(String userId, Product product, int quantity) {
Order order = new Order();
order.userId = userId;
order.totalAmount = product.getPrice().multiply(BigDecimal.valueOf(quantity));
order.status = OrderStatus.CREATED;
return order;
}
public void cancel() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Only CREATED orders can be cancelled");
}
this.status = OrderStatus.CANCELLED;
}
}
3.4 Pros and Cons
Pros:
- Easy to understand — familiar to most developers
- Fast development — suitable for simple CRUD apps
- Separation of concerns — provides basic layer separation
Cons:
- Domain logic tends to scatter across the service layer (Anemic Domain Model)
- Top-down dependency means DB changes have wide impact
- Tests require mocking infrastructure dependencies
- Difficult to express complex business logic
3.5 When to Use
- Simple CRUD applications
- Small teams that need rapid prototyping
- When business logic is not complex
- When using a stable technology stack that is unlikely to change
4. Clean Architecture (Uncle Bob)
4.1 Core Concept: Concentric Circles
Clean Architecture, published by Robert C. Martin in 2012, is built around the Dependency Rule: all dependencies must point inward.
+--------------------------------------------------+
| Frameworks & Drivers (Web, DB, External) |
| +------------------------------------------+ |
| | Interface Adapters (Controllers, GW) | |
| | +----------------------------------+ | |
| | | Application (Use Cases) | | |
| | | +------------------------+ | | |
| | | | Entities | | | |
| | | | (Domain Models) | | | |
| | | +------------------------+ | | |
| | +----------------------------------+ | |
| +------------------------------------------+ |
+--------------------------------------------------+
Dependency direction: Outside -> Inside (Frameworks -> Entities)
4.2 Role of Each Layer
Entities: Encapsulate business rules. The most stable, least-changing core logic.
// Entities - No dependency on any framework
class Order {
private items: OrderItem[] = []
private status: OrderStatus = 'CREATED'
addItem(product: Product, quantity: number): void {
if (quantity <= 0) throw new Error('Quantity must be positive')
if (this.status !== 'CREATED') throw new Error('Cannot modify confirmed order')
this.items.push(new OrderItem(product, quantity))
}
get totalAmount(): Money {
return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
}
confirm(): void {
if (this.items.length === 0) throw new Error('Cannot confirm empty order')
this.status = 'CONFIRMED'
}
}
Use Cases: Application-specific business rules. Orchestrate entities to implement scenarios.
// Use Cases
interface OrderRepository {
save(order: Order): Promise<Order>
findById(id: string): Promise<Order | null>
}
interface PaymentGateway {
charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>
}
class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private paymentGateway: PaymentGateway,
private eventPublisher: DomainEventPublisher
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const order = new Order(input.userId)
for (const item of input.items) {
order.addItem(item.product, item.quantity)
}
order.confirm()
const paymentResult = await this.paymentGateway.charge(order.totalAmount, input.paymentMethod)
if (!paymentResult.success) {
throw new PaymentFailedError(paymentResult.reason)
}
const saved = await this.orderRepo.save(order)
await this.eventPublisher.publish(new OrderCreatedEvent(saved.id))
return { orderId: saved.id, totalAmount: order.totalAmount.toString() }
}
}
Interface Adapters: Connect Use Cases to the external world. Controllers, Presenters, Gateways.
// Interface Adapter - Controller
class OrderController {
constructor(private createOrderUseCase: CreateOrderUseCase) {}
async handleCreateOrder(req: HttpRequest): Promise<HttpResponse> {
try {
const input = this.mapToInput(req.body)
const output = await this.createOrderUseCase.execute(input)
return { status: 201, body: output }
} catch (error) {
if (error instanceof PaymentFailedError) {
return { status: 402, body: { error: error.message } }
}
return { status: 500, body: { error: 'Internal server error' } }
}
}
private mapToInput(body: any): CreateOrderInput {
return {
userId: body.userId,
items: body.items,
paymentMethod: body.paymentMethod,
}
}
}
4.3 Spring Boot Project Structure
src/main/java/com/example/shop/
domain/ # Entities
model/
Order.java
OrderItem.java
Money.java
event/
OrderCreatedEvent.java
application/ # Use Cases
port/
in/
CreateOrderUseCase.java
out/
OrderRepository.java
PaymentGateway.java
service/
OrderApplicationService.java
adapter/ # Interface Adapters + Frameworks
in/
web/
OrderController.java
CreateOrderRequest.java
out/
persistence/
JpaOrderRepository.java
OrderJpaEntity.java
payment/
StripePaymentGateway.java
config/
BeanConfiguration.java
4.4 NestJS Project Structure
src/
domain/
entities/
order.entity.ts
order-item.entity.ts
money.value-object.ts
events/
order-created.event.ts
application/
ports/
order.repository.port.ts
payment.gateway.port.ts
use-cases/
create-order.use-case.ts
cancel-order.use-case.ts
infrastructure/
adapters/
in/
http/
order.controller.ts
dto/
create-order.dto.ts
out/
persistence/
prisma-order.repository.ts
payment/
stripe-payment.gateway.ts
config/
module.config.ts
4.5 The Power of the Dependency Rule
The heart of Clean Architecture is the Dependency Rule. Inner circles know nothing about outer circles.
// Domain layer - ZERO external dependencies
// If you look at imports in Order.ts:
// - No express
// - No prisma
// - No typeorm
// Only pure TypeScript/Java code
class Order {
// Only pure business logic
// Does not care if DB is MySQL or MongoDB
// Does not care if web framework is Express or Fastify
}
Thanks to this rule, replacing frameworks or databases does not affect your core business logic.
5. Hexagonal Architecture (Ports and Adapters)
5.1 Alistair Cockburn's Original Concept
Hexagonal Architecture was proposed by Alistair Cockburn in 2005. Its official name is "Ports and Adapters." The hexagonal shape represents that the number of ports is flexible — it does not literally mean six.
+----------+
HTTP --> | | --> PostgreSQL
| Port |
CLI --> | & | --> Redis
| Domain |
gRPC --> | Core | --> Kafka
| |
Test --> | | --> Mock DB
+----------+
[Driving Adapters] [Core] [Driven Adapters]
(Input side) (Business) (Output side)
5.2 Ports and Adapters Concepts
Port: An interface for business logic to communicate with the outside world. Ports come in two types:
- Driving Port (Inbound Port): Interface for the outside world to call the application
- Driven Port (Outbound Port): Interface for the application to call external systems
Adapter: A concrete implementation of a port.
- Driving Adapter (Inbound Adapter): REST Controller, CLI, gRPC Handler
- Driven Adapter (Outbound Adapter): JPA Repository, HTTP Client, Message Publisher
5.3 Full Implementation Example
// ===== PORTS (Interface definitions) =====
// Driving Port (Inbound) - External calls the application
interface CreateOrderPort {
createOrder(command: CreateOrderCommand): Promise<OrderId>
}
interface CancelOrderPort {
cancelOrder(orderId: string): Promise<void>
}
// Driven Port (Outbound) - Application calls external
interface LoadOrderPort {
findById(id: string): Promise<Order | null>
}
interface SaveOrderPort {
save(order: Order): Promise<Order>
}
interface SendNotificationPort {
sendOrderConfirmation(order: Order): Promise<void>
}
// ===== DOMAIN CORE (Core business logic) =====
class Order {
private readonly id: string
private items: OrderItem[] = []
private status: OrderStatus = 'PENDING'
constructor(
id: string,
private readonly customerId: string
) {
this.id = id
}
addItem(productId: string, price: Money, quantity: number): void {
this.ensureModifiable()
this.items.push(new OrderItem(productId, price, quantity))
}
confirm(): void {
if (this.items.length === 0) {
throw new DomainError('Cannot confirm empty order')
}
this.status = 'CONFIRMED'
}
cancel(): void {
if (this.status === 'SHIPPED') {
throw new DomainError('Cannot cancel shipped order')
}
this.status = 'CANCELLED'
}
private ensureModifiable(): void {
if (this.status !== 'PENDING') {
throw new DomainError('Order is not modifiable')
}
}
get totalAmount(): Money {
return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
}
}
// ===== APPLICATION SERVICE (Use case implementation) =====
class OrderService implements CreateOrderPort, CancelOrderPort {
constructor(
private readonly loadOrder: LoadOrderPort,
private readonly saveOrder: SaveOrderPort,
private readonly notification: SendNotificationPort
) {}
async createOrder(command: CreateOrderCommand): Promise<OrderId> {
const order = new Order(generateId(), command.customerId)
for (const item of command.items) {
order.addItem(item.productId, new Money(item.price, 'KRW'), item.quantity)
}
order.confirm()
const saved = await this.saveOrder.save(order)
await this.notification.sendOrderConfirmation(saved)
return saved.id
}
async cancelOrder(orderId: string): Promise<void> {
const order = await this.loadOrder.findById(orderId)
if (!order) throw new NotFoundError('Order not found')
order.cancel()
await this.saveOrder.save(order)
}
}
// ===== ADAPTERS (Concrete implementations) =====
// Driving Adapter - REST Controller
class OrderRestController {
constructor(private createOrderPort: CreateOrderPort) {}
async handlePost(req: Request, res: Response): Promise<void> {
const command: CreateOrderCommand = {
customerId: req.body.customerId,
items: req.body.items,
}
const orderId = await this.createOrderPort.createOrder(command)
res.status(201).json({ orderId })
}
}
// Driven Adapter - Database
class PostgresOrderAdapter implements LoadOrderPort, SaveOrderPort {
constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise<Order | null> {
const data = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
})
return data ? this.toDomain(data) : null
}
async save(order: Order): Promise<Order> {
await this.prisma.order.upsert({
where: { id: order.id },
create: this.toPersistence(order),
update: this.toPersistence(order),
})
return order
}
private toDomain(data: PrismaOrder): Order {
/* mapping */
}
private toPersistence(order: Order): PrismaOrderData {
/* mapping */
}
}
// Driven Adapter - Notification
class EmailNotificationAdapter implements SendNotificationPort {
constructor(private mailer: MailClient) {}
async sendOrderConfirmation(order: Order): Promise<void> {
await this.mailer.send({
to: order.customerEmail,
subject: 'Order Confirmed',
body: `Your order #${order.id} has been confirmed.`,
})
}
}
5.4 Clean Architecture vs Hexagonal Architecture
| Aspect | Clean Architecture | Hexagonal Architecture |
|---|---|---|
| Proposed by | Robert C. Martin (2012) | Alistair Cockburn (2005) |
| Visualization | Concentric Circles | Hexagon |
| Core idea | Dependencies point inward | Separation via Ports and Adapters |
| Number of layers | 4 (Entities, Use Cases, Adapters, Frameworks) | 3 (Adapters, Ports, Domain) |
| Emphasis | Dependency Rule | Input/Output symmetry |
| Testing | Easy Use Case unit testing | Easy adapter swapping for tests |
| Essence | Essentially the same principle, different expression | Essentially the same principle, different expression |
Both architectures are based on the same fundamental principle of Dependency Inversion. In practice, they are often used interchangeably.
6. DDD (Domain-Driven Design)
6.1 What Is DDD
DDD (Domain-Driven Design) is a methodology introduced by Eric Evans in his 2003 book. It places the complex business domain at the center of the software model.
DDD is divided into Strategic DDD (big-picture design) and Tactical DDD (code-level design).
6.2 Strategic DDD — Big Picture Design
Ubiquitous Language
Developers and business experts use the same terminology. Class names and method names in code must match business terms.
Business: "A customer creates an order, and when payment is approved, the order is confirmed"
Code: customer.createOrder() -> payment.approve() -> order.confirm()
Business: "An order cannot be cancelled once shipping has started"
Code: order.cancel() -> if (status === 'SHIPPED') throw Error
Bounded Context
Even within a single system, the same term can have different meanings. A Bounded Context explicitly defines the boundary where a particular model is valid.
+------------------+ +------------------+ +------------------+
| Order Context | | Payment Context | | Shipping Context |
| | | | | |
| Order | | Payment | | Shipment |
| OrderItem | | Transaction | | Package |
| Customer (id, | | Customer (id, | | Customer (id, |
| name, email) | | paymentMethod) | | address) |
| | | | | |
| "Customer" = | | "Customer" = | | "Customer" = |
| the orderer | | the payer | | the recipient |
+--------+---------+ +--------+---------+ +--------+---------+
| | |
+------ Context Map (Context Mapping) ------+
Context Mapping Patterns
Patterns that define relationships between Bounded Contexts:
- Shared Kernel: Two contexts share a common model
- Customer-Supplier: One side is the supplier, the other is the consumer
- Anti-Corruption Layer (ACL): A translation layer to prevent external models from corrupting internal ones
- Published Language: Integration through a well-documented shared language
- Separate Ways: Each context operates independently without integration
6.3 Tactical DDD — Code-Level Design
Entity
An object with a unique identity that persists throughout its lifecycle.
public class Order {
private final OrderId id; // Unique identifier
private OrderStatus status;
private List<OrderItem> items;
private Money totalAmount;
// Business logic lives inside the entity (Rich Domain Model)
public void addItem(Product product, int quantity) {
validateModifiable();
OrderItem item = new OrderItem(product, quantity);
this.items.add(item);
recalculateTotal();
}
public void confirm() {
if (items.isEmpty()) {
throw new OrderEmptyException("Cannot confirm empty order");
}
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(this.id));
}
// equals/hashCode based on id
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order)) return false;
return id.equals(((Order) o).id);
}
}
Value Object
No identity. Equality is determined by attribute values. Immutable.
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
Objects.requireNonNull(currency);
}
public Money add(Money other) {
validateSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
private void validateSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(
"Cannot operate on different currencies"
);
}
}
}
// Usage example
Money price = new Money(BigDecimal.valueOf(15000), Currency.KRW);
Money total = price.multiply(3); // 45000 KRW
Aggregate
A cluster of related entities and value objects. Accessible only through the Aggregate Root.
// Order is the Aggregate Root
// OrderItem is only accessible through Order
public class Order { // Aggregate Root
private final OrderId id;
private final CustomerId customerId;
private List<OrderItem> items = new ArrayList<>(); // Internal entity
private ShippingAddress address; // Value Object
private Money totalAmount; // Value Object
// External code cannot create/modify OrderItem directly
// Must go through Order
public void addItem(ProductId productId, Money price, int qty) {
OrderItem item = new OrderItem(productId, price, qty);
this.items.add(item);
recalculateTotal();
}
public void removeItem(ProductId productId) {
this.items.removeIf(item -> item.getProductId().equals(productId));
recalculateTotal();
}
}
Aggregate Design Rules:
- Outside the Aggregate, only reference the Root Entity
- Inter-Aggregate references use IDs only (no direct object references)
- Modify only one Aggregate per transaction
- Keep Aggregates small (1 Root + a few internal objects)
Domain Event
An object representing an important occurrence in the domain.
public record OrderConfirmedEvent(
OrderId orderId,
CustomerId customerId,
Money totalAmount,
Instant occurredAt
) implements DomainEvent {
public OrderConfirmedEvent(OrderId orderId, CustomerId customerId, Money totalAmount) {
this(orderId, customerId, totalAmount, Instant.now());
}
}
// Registering events in the Aggregate Root
public class Order extends AbstractAggregateRoot<Order> {
public void confirm() {
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
}
}
// Event handler
@Component
public class OrderConfirmedEventHandler {
@EventListener
public void handleOrderConfirmed(OrderConfirmedEvent event) {
// Trigger payment processing
// Trigger inventory deduction
// Send confirmation email
}
}
Repository
Handles persistence for Aggregates. Interface in the Domain layer, implementation in Infrastructure.
// Interface defined in the Domain layer
public interface OrderRepository {
Order findById(OrderId id);
OrderId save(Order order);
void delete(Order order);
List<Order> findByCustomerId(CustomerId customerId);
}
// Implementation in the Infrastructure layer
@Repository
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository springDataRepo;
private final OrderMapper mapper;
@Override
public Order findById(OrderId id) {
return springDataRepo.findById(id.getValue())
.map(mapper::toDomain)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@Override
public OrderId save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = springDataRepo.save(entity);
return new OrderId(saved.getId());
}
}
6.4 Event Storming
Event Storming is a workshop technique in DDD for exploring the domain and discovering Bounded Contexts.
1. Domain Events (orange sticky notes) - Written in past tense
"Order was created" "Payment was approved" "Product was shipped"
2. Commands (blue sticky notes) - Actions that trigger events
"Create order" "Approve payment" "Start shipping"
3. Aggregates (yellow sticky notes) - Entities that handle commands
"Order" "Payment" "Shipment"
4. Discover Bounded Contexts - Group related elements
Order Context | Payment Context | Shipping Context
6.5 DDD + Hexagonal = A Powerful Combination
Placing DDD's tactical patterns within a Hexagonal Architecture creates the most powerful combination.
src/
order/ # Bounded Context
domain/ # Hexagonal Core
model/
Order.java # Aggregate Root (Entity)
OrderItem.java # Entity
Money.java # Value Object
OrderStatus.java # Value Object (Enum)
event/
OrderConfirmedEvent.java # Domain Event
service/
OrderDomainService.java # Domain Service
application/ # Hexagonal Ports + Use Cases
port/
in/
CreateOrderUseCase.java # Driving Port
CancelOrderUseCase.java
out/
LoadOrderPort.java # Driven Port
SaveOrderPort.java
PaymentPort.java
service/
OrderApplicationService.java # Use Case Implementation
infrastructure/ # Hexagonal Adapters
adapter/
in/
web/
OrderController.java # Driving Adapter
messaging/
OrderEventListener.java
out/
persistence/
JpaOrderRepository.java # Driven Adapter
OrderJpaEntity.java
payment/
StripePaymentAdapter.java
6.6 When NOT to Use DDD
DDD is powerful but not suitable for every project.
Do not use when:
- Simple CRUD application
- Data pipelines with little business logic
- Prototypes or MVPs
- The team lacks understanding of DDD
- Collaboration with domain experts is not possible
DDD Complexity Decision Guide:
Business Logic Complexity
^
| DDD + Hexagonal
| +--------------+
| | |
| | Sweet Spot |
| | |
| +--------------+
| Clean Architecture
| +--------------+
| | |
| +--------------+
| Layered
| +--------------+
| +--------------+
+-----------------------> Project Size / Team Size
7. Architecture Comparison Summary
7.1 Comparison Table
| Aspect | Layered | Clean | Hexagonal | DDD |
|---|---|---|---|---|
| Complexity | Low | Medium | Medium-High | High |
| Learning Curve | Low | Medium | Medium | High |
| Best For | Simple CRUD | Medium complexity | Complex domains, many integrations | Complex business logic |
| Key Benefit | Easy to understand | Framework independence | Swappable adapters | Business alignment |
| Testability | Average | High | High | Very High |
| Team Size | Small | Medium | Medium-Large | Large |
| DB Swap Ease | Difficult | Easy | Very Easy | Very Easy |
7.2 Selection Flowchart
Project Start
|
+-- Is business logic simple? --YES--> Layered Architecture
|
NO
|
+-- Many external integrations? --YES--> Hexagonal Architecture
|
NO
|
+-- Domain expert collaboration possible? --YES--+
| |
NO v
| Is business logic very complex?
| | |
| YES NO
| | |
| v v
| DDD + Hexagonal Clean Architecture
|
+--> Clean Architecture (safe default choice)
7.3 Practical Tips
- Incremental adoption: Do not apply DDD to everything from the start. Begin with the most complex module
- Mix and match: Different modules in one system can use different architectures (DDD for core, Layered for the rest)
- Consider team capability: Choose architecture that matches your team's experience and understanding
- YAGNI principle: Do not create abstractions you do not need yet
8. Hands-On Project: E-Commerce Order System
8.1 Domain Model Design
A full implementation of an e-commerce order system using Clean/Hexagonal + DDD.
Money (Value Object)
public record Money(BigDecimal amount, String currency) {
public Money {
Objects.requireNonNull(amount, "Amount must not be null");
Objects.requireNonNull(currency, "Currency must not be null");
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
}
public static Money of(long amount, String currency) {
return new Money(BigDecimal.valueOf(amount), currency);
}
public static Money zero(String currency) {
return new Money(BigDecimal.ZERO, currency);
}
public Money add(Money other) {
assertSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
public boolean isGreaterThan(Money other) {
assertSameCurrency(other);
return this.amount.compareTo(other.amount) > 0;
}
private void assertSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currency mismatch: " + this.currency + " vs " + other.currency
);
}
}
}
Order (Aggregate Root)
public class Order {
private final String id;
private final String customerId;
private final List<OrderItem> items = new ArrayList<>();
private OrderStatus status;
private Money totalAmount;
private final Instant createdAt;
private final List<DomainEvent> domainEvents = new ArrayList<>();
public Order(String id, String customerId) {
this.id = id;
this.customerId = customerId;
this.status = OrderStatus.PENDING;
this.totalAmount = Money.zero("KRW");
this.createdAt = Instant.now();
}
public void addItem(String productId, String productName, Money price, int quantity) {
ensureModifiable();
this.items.add(new OrderItem(productId, productName, price, quantity));
recalculateTotal();
}
public void confirm() {
if (items.isEmpty()) {
throw new OrderDomainException("Cannot confirm empty order");
}
if (status != OrderStatus.PENDING) {
throw new OrderDomainException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
domainEvents.add(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
}
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new OrderDomainException("Cannot cancel order in status: " + status);
}
this.status = OrderStatus.CANCELLED;
domainEvents.add(new OrderCancelledEvent(this.id, this.customerId));
}
public void markAsShipped(String trackingNumber) {
if (status != OrderStatus.CONFIRMED) {
throw new OrderDomainException("Only confirmed orders can be shipped");
}
this.status = OrderStatus.SHIPPED;
domainEvents.add(new OrderShippedEvent(this.id, trackingNumber));
}
private void ensureModifiable() {
if (status != OrderStatus.PENDING) {
throw new OrderDomainException("Order is not modifiable");
}
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.zero("KRW"), Money::add);
}
public List<DomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
8.2 Application Layer (Use Cases)
// Driving Port
public interface CreateOrderUseCase {
CreateOrderResult execute(CreateOrderCommand command);
}
// Driven Ports
public interface OrderRepository {
Optional<Order> findById(String id);
Order save(Order order);
}
public interface EventPublisher {
void publish(List<DomainEvent> events);
}
// Use Case Implementation
@Service
@Transactional
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final EventPublisher eventPublisher;
private final IdGenerator idGenerator;
@Override
public CreateOrderResult execute(CreateOrderCommand command) {
Order order = new Order(idGenerator.generate(), command.customerId());
for (OrderItemCommand item : command.items()) {
order.addItem(
item.productId(),
item.productName(),
Money.of(item.price(), "KRW"),
item.quantity()
);
}
order.confirm();
Order saved = orderRepository.save(order);
eventPublisher.publish(saved.getDomainEvents());
saved.clearDomainEvents();
return new CreateOrderResult(saved.getId(), saved.getTotalAmount().toString());
}
}
8.3 Infrastructure Layer (Adapters)
// Driven Adapter - JPA Repository
@Repository
public class JpaOrderRepositoryAdapter implements OrderRepository {
private final SpringDataOrderRepository jpaRepo;
private final OrderPersistenceMapper mapper;
@Override
public Optional<Order> findById(String id) {
return jpaRepo.findById(id).map(mapper::toDomain);
}
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = jpaRepo.save(entity);
return mapper.toDomain(saved);
}
}
// Driven Adapter - Kafka Event Publisher
@Component
public class KafkaEventPublisher implements EventPublisher {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Override
public void publish(List<DomainEvent> events) {
for (DomainEvent event : events) {
String topic = resolveTopic(event);
String payload = serialize(event);
kafkaTemplate.send(topic, event.getAggregateId(), payload);
}
}
private String resolveTopic(DomainEvent event) {
if (event instanceof OrderConfirmedEvent) return "order.confirmed";
if (event instanceof OrderCancelledEvent) return "order.cancelled";
if (event instanceof OrderShippedEvent) return "order.shipped";
return "order.unknown";
}
}
// Driving Adapter - REST Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
private final CancelOrderUseCase cancelOrderUseCase;
@PostMapping
public ResponseEntity<CreateOrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
CreateOrderCommand command = request.toCommand();
CreateOrderResult result = createOrderUseCase.execute(command);
return ResponseEntity.status(201).body(CreateOrderResponse.from(result));
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> cancelOrder(@PathVariable String orderId) {
cancelOrderUseCase.execute(orderId);
return ResponseEntity.noContent().build();
}
}
9. Testing Strategy
9.1 Domain Logic Unit Tests
Domain logic can be tested purely without external dependencies. This is the greatest advantage of Clean/Hexagonal Architecture.
class OrderTest {
@Test
void shouldCalculateTotalAmount() {
Order order = new Order("order-1", "customer-1");
order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 2);
order.addItem("prod-2", "Mouse", Money.of(30000, "KRW"), 1);
assertEquals(Money.of(130000, "KRW"), order.getTotalAmount());
}
@Test
void shouldNotConfirmEmptyOrder() {
Order order = new Order("order-1", "customer-1");
assertThrows(OrderDomainException.class, () -> order.confirm());
}
@Test
void shouldNotCancelShippedOrder() {
Order order = createConfirmedOrder();
order.markAsShipped("TRACK-123");
assertThrows(OrderDomainException.class, () -> order.cancel());
}
@Test
void shouldRegisterDomainEventOnConfirm() {
Order order = new Order("order-1", "customer-1");
order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 1);
order.confirm();
List<DomainEvent> events = order.getDomainEvents();
assertEquals(1, events.size());
assertInstanceOf(OrderConfirmedEvent.class, events.get(0));
}
}
class MoneyTest {
@Test
void shouldAddSameCurrency() {
Money a = Money.of(1000, "KRW");
Money b = Money.of(2000, "KRW");
assertEquals(Money.of(3000, "KRW"), a.add(b));
}
@Test
void shouldRejectDifferentCurrency() {
Money krw = Money.of(1000, "KRW");
Money usd = Money.of(1, "USD");
assertThrows(IllegalArgumentException.class, () -> krw.add(usd));
}
}
9.2 Use Case Integration Tests
class CreateOrderServiceTest {
private CreateOrderService sut;
private OrderRepository fakeRepository;
private EventPublisher fakePublisher;
@BeforeEach
void setUp() {
fakeRepository = new InMemoryOrderRepository();
fakePublisher = new FakeEventPublisher();
sut = new CreateOrderService(fakeRepository, fakePublisher, new UuidIdGenerator());
}
@Test
void shouldCreateAndConfirmOrder() {
CreateOrderCommand command = new CreateOrderCommand(
"customer-1",
List.of(new OrderItemCommand("prod-1", "Keyboard", 50000, 2))
);
CreateOrderResult result = sut.execute(command);
assertNotNull(result.orderId());
Order saved = fakeRepository.findById(result.orderId()).orElseThrow();
assertEquals(OrderStatus.CONFIRMED, saved.getStatus());
}
}
// In-Memory Repository for testing
class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
@Override
public Optional<Order> findById(String id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Order save(Order order) {
store.put(order.getId(), order);
return order;
}
}
9.3 Architecture Tests with ArchUnit
ArchUnit lets you automatically verify architecture rules.
class ArchitectureTest {
private final JavaClasses classes = new ClassFileImporter()
.importPackages("com.example.shop");
@Test
void domainShouldNotDependOnInfrastructure() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..")
.check(classes);
}
@Test
void domainShouldNotDependOnApplication() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..application..")
.check(classes);
}
@Test
void domainShouldNotUseSpringAnnotations() {
noClasses()
.that().resideInAPackage("..domain..")
.should().beAnnotatedWith("org.springframework.stereotype.Service")
.orShould().beAnnotatedWith("org.springframework.stereotype.Component")
.check(classes);
}
@Test
void useCasesShouldOnlyBeAccessedByAdapters() {
classes()
.that().resideInAPackage("..application.service..")
.should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage("..adapter..", "..config..", "..application..")
.check(classes);
}
}
9.4 Test Pyramid
/ E2E Tests \ <- Few, Slow, Expensive
/ (Cypress, etc) \
/------------------\
/ Integration Tests \ <- Medium
/ (Adapter Tests) \
/------------------------\
/ Unit Tests \ <- Many, Fast, Cheap
/ (Domain + Use Case) \
/-----------------------------\
In Clean/Hexagonal Architecture, domain logic unit tests make up the majority of all tests. These tests have no external dependencies, making them extremely fast and reliable.
10. Anti-Patterns and Pitfalls
10.1 Over-Engineering
The most common mistake. Applying DDD + Hexagonal to a simple CRUD app actually reduces productivity.
// BAD: Changing a user's name requires 7 files?
// Command -> CommandHandler -> UseCase -> DomainService
// -> Repository -> Adapter -> Mapper
// Overkill for a simple update!
// GOOD: Choose architecture matching the complexity
// Simple CRUD = Layered + Service pattern is sufficient
10.2 Anemic Domain Model
Entities with only getters/setters while business logic is scattered across the service layer. An anti-pattern named by Martin Fowler.
// BAD: Anemic Domain Model
class Order {
private String status;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
class OrderService {
void cancelOrder(Order order) {
// Business logic lives in the service
if (order.getStatus().equals("SHIPPED")) {
throw new Exception("Cannot cancel");
}
order.setStatus("CANCELLED");
}
}
// GOOD: Rich Domain Model
class Order {
private OrderStatus status;
public void cancel() {
// Business logic lives inside the entity
if (this.status == OrderStatus.SHIPPED) {
throw new OrderDomainException("Cannot cancel shipped order");
}
this.status = OrderStatus.CANCELLED;
}
}
10.3 God Class
A single class that takes on too many responsibilities. An extreme form of SRP violation.
// BAD: One service handles everything
class OrderGodService {
void createOrder() { /* ... */ }
void cancelOrder() { /* ... */ }
void processPayment() { /* ... */ }
void sendNotification() { /* ... */ }
void generateInvoice() { /* ... */ }
void updateInventory() { /* ... */ }
void calculateShipping() { /* ... */ }
// ... thousands of lines
}
// GOOD: Separated by responsibility
class CreateOrderUseCase { /* order creation only */ }
class CancelOrderUseCase { /* order cancellation only */ }
class PaymentService { /* payment only */ }
class NotificationService { /* notifications only */ }
10.4 Leaky Abstraction
Infrastructure details leaking into the domain layer.
// BAD: JPA annotations on domain entities
@Entity
@Table(name = "orders")
class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItem> items;
// JPA infrastructure details leak into the domain
}
// GOOD: Separate domain entities from JPA entities
// domain/Order.java - Pure domain object
class Order {
private final String id;
private List<OrderItem> items;
// No framework dependencies
}
// infrastructure/OrderJpaEntity.java - JPA-specific
@Entity @Table(name = "orders")
class OrderJpaEntity {
@Id private String id;
@OneToMany(cascade = CascadeType.ALL)
private List<OrderItemJpaEntity> items;
}
10.5 Resume-Driven Architecture
Adopting excessive technologies not for project requirements, but to make a resume look impressive.
"Our project needs Microservices + CQRS + Event Sourcing +
Saga Pattern + GraphQL Federation!"
Actual requirement: An internal tool for 5 users
Result: Still not deployed after 6 months
Principle: Choose solutions that match the problem.
Quiz
Test your understanding with these questions.
Q1: What is the core of the Dependency Inversion Principle (DIP) in SOLID?
A: High-level modules (business logic) should not directly depend on low-level modules (DB, external services). Both should depend on abstractions (interfaces). This is the fundamental foundation of Clean Architecture and Hexagonal Architecture.
Q2: Explain the Dependency Rule in Clean Architecture.
A: All source code dependencies must point inward (toward higher levels). Frameworks (outer) -> Adapters -> Use Cases -> Entities (inner). Inner circles know nothing about outer circles. Entities do not know about Use Cases, and Use Cases do not know about Controllers.
Q3: What is the difference between Driving Adapters and Driven Adapters in Hexagonal Architecture?
A: Driving Adapters (Inbound) are adapters that allow the outside world to call into the application (REST Controllers, CLI, gRPC Handlers). Driven Adapters (Outbound) are adapters that allow the application to call external systems (DB Repositories, HTTP Clients, Message Publishers). The symmetry between input and output is the key concept.
Q4: Explain the four Aggregate design rules in DDD.
A:
- Outside the Aggregate, only reference the Root Entity
- Inter-Aggregate references use IDs only (no direct object references)
- Modify only one Aggregate per transaction
- Keep Aggregates small (1 Root + a few internal objects)
These rules ensure data consistency and scalability.
Q5: Why is the Anemic Domain Model considered an anti-pattern?
A: The Anemic Domain Model has entities with only getters/setters while business logic is scattered across the service layer. It is an anti-pattern because: (1) business rules are duplicated/scattered across multiple services, making consistency fragile, (2) domain objects degrade to mere data containers, losing the benefits of OOP (encapsulation), and (3) it becomes difficult to assess the blast radius of business rule changes. In a Rich Domain Model, business logic lives inside entities, providing high cohesion and safe changeability.
References
Books
- Robert C. Martin, Clean Architecture: A Craftsman's Guide to Software Structure and Design (2017)
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)
- Vaughn Vernon, Implementing Domain-Driven Design (2013)
- Vaughn Vernon, Domain-Driven Design Distilled (2016)
- Martin Fowler, Patterns of Enterprise Application Architecture (2002)
- Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship (2008)
Articles and Blogs
- Alistair Cockburn, Hexagonal Architecture — https://alistair.cockburn.us/hexagonal-architecture/
- Robert C. Martin, The Clean Architecture (2012) — https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- Martin Fowler, AnemicDomainModel — https://martinfowler.com/bliki/AnemicDomainModel.html
- Martin Fowler, BoundedContext — https://martinfowler.com/bliki/BoundedContext.html
Tools and Frameworks
- ArchUnit — https://www.archunit.org/
- Spring Modulith — https://spring.io/projects/spring-modulith
- NestJS CQRS Module — https://docs.nestjs.com/recipes/cqrs
Talks and Videos
- Uncle Bob, Clean Architecture and Design (NDC Conference)
- Vaughn Vernon, Strategic Domain-Driven Design (InfoQ)
- Alberto Brandolini, Event Storming — https://www.eventstorming.com/
- Netflix Engineering Blog, Domain-Driven Design at Netflix