ddd-architecture
Domain-Driven Design and Clean Architecture implementation guide
When & Why to Use This Skill
This Claude skill provides a comprehensive implementation guide for Domain-Driven Design (DDD) and Clean Architecture, specifically optimized for TypeScript environments. It offers structured patterns, boilerplate templates, and best practices for building scalable, maintainable, and decoupled software systems by focusing on core business logic and clear architectural boundaries.
Use Cases
- Designing complex domain models and aggregate roots to enforce business invariants and consistency boundaries.
- Implementing Clean Architecture layers to decouple core business logic from infrastructure concerns like databases and external APIs.
- Creating immutable value objects and domain events to capture significant business facts and state changes.
- Reviewing existing codebases for architectural compliance and refactoring anemic domain models into rich, behavior-oriented designs.
- Establishing standardized project structures and repository interfaces for large-scale TypeScript applications.
| name | ddd-architecture |
|---|---|
| description | Domain-Driven Design and Clean Architecture implementation guide |
| version | 1.0.0 |
| author | DDD Boilerplate |
| tags | [ddd, clean-architecture, design-patterns, typescript] |
DDD Architecture Skill
This skill provides guidance for implementing Domain-Driven Design (DDD) patterns with Clean Architecture principles in TypeScript projects.
When to Use This Skill
Use this skill when:
- Implementing domain models and aggregates
- Designing bounded contexts
- Creating repositories and domain services
- Applying tactical DDD patterns
- Reviewing code for architectural compliance
- Creating new entities, value objects, or domain events
Domain Layer Patterns
1. Aggregate Root
Purpose: Transaction boundary and consistency enforcement
import { AggregateRoot } from '@shared/domain/AggregateRoot';
import { DomainEvent } from '@shared/domain/DomainEvent';
export class Order extends AggregateRoot<OrderId> {
private items: OrderItem[];
private status: OrderStatus;
private constructor(id: OrderId, items: OrderItem[], status: OrderStatus) {
super(id);
this.items = items;
this.status = status;
}
// Factory method - only way to create instances
static create(customerId: CustomerId, items: OrderItem[]): Order {
if (items.length === 0) {
throw new InvalidOrderError('Order must have at least one item');
}
const order = new Order(
OrderId.generate(),
items,
OrderStatus.Pending
);
// Publish domain event
order.addDomainEvent(new OrderCreatedEvent(order.id, customerId));
return order;
}
// Business method with invariant protection
confirm(): void {
if (this.status !== OrderStatus.Pending) {
throw new InvalidOrderError('Only pending orders can be confirmed');
}
this.status = OrderStatus.Confirmed;
this.addDomainEvent(new OrderConfirmedEvent(this.id));
}
// Business method
addItem(item: OrderItem): void {
if (this.status !== OrderStatus.Pending) {
throw new InvalidOrderError('Cannot modify non-pending order');
}
this.items.push(item);
}
// Query method
get totalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.subtotal),
Money.zero(Currency.USD)
);
}
}
Key Points:
- Private constructor forces use of factory methods
- All state changes through business methods
- Invariants validated before state changes
- Domain events published for significant changes
2. Value Object
Purpose: Immutable, identity-less domain concepts
import { ValueObject } from '@shared/domain/ValueObject';
export class Money extends ValueObject<{ amount: number; currency: Currency }> {
private constructor(props: { amount: number; currency: Currency }) {
super(props);
}
static create(amount: number, currency: Currency): Money {
if (amount < 0) {
throw new InvalidMoneyError('Amount cannot be negative');
}
return new Money({ amount, currency });
}
static zero(currency: Currency): Money {
return new Money({ amount: 0, currency });
}
get amount(): number {
return this.props.amount;
}
get currency(): Currency {
return this.props.currency;
}
add(other: Money): Money {
if (!this.props.currency.equals(other.currency)) {
throw new InvalidMoneyError('Cannot add different currencies');
}
return Money.create(this.props.amount + other.amount, this.props.currency);
}
multiply(factor: number): Money {
return Money.create(this.props.amount * factor, this.props.currency);
}
}
Key Points:
- Extends ValueObject base class
- All properties readonly
- Factory method with validation
- Returns new instances for operations
3. Entity
Purpose: Objects with identity that can change over time
import { Entity } from '@shared/domain/Entity';
export class OrderItem extends Entity<OrderItemId> {
private quantity: Quantity;
private unitPrice: Money;
private readonly productId: ProductId;
private constructor(
id: OrderItemId,
productId: ProductId,
quantity: Quantity,
unitPrice: Money
) {
super(id);
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
static create(
productId: ProductId,
quantity: Quantity,
unitPrice: Money
): OrderItem {
return new OrderItem(
OrderItemId.generate(),
productId,
quantity,
unitPrice
);
}
get subtotal(): Money {
return this.unitPrice.multiply(this.quantity.value);
}
updateQuantity(newQuantity: Quantity): void {
this.quantity = newQuantity;
}
}
4. Domain Event
Purpose: Record of something significant that happened in the domain
import { DomainEvent } from '@shared/domain/DomainEvent';
export class OrderConfirmedEvent implements DomainEvent {
readonly occurredAt: Date;
readonly eventType = 'OrderConfirmed';
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
readonly totalAmount: Money
) {
this.occurredAt = new Date();
}
}
Key Points:
- All properties readonly (immutable)
- Past tense naming
- Contains all relevant data
- Includes timestamp
5. Repository Interface
Purpose: Abstraction for aggregate persistence
// Domain layer - interface only
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByCustomerId(customerId: CustomerId): Promise<Order[]>;
delete(order: Order): Promise<void>;
}
// Infrastructure layer - implementation
export class SqlOrderRepository implements OrderRepository {
constructor(private readonly db: Database) {}
async save(order: Order): Promise<void> {
const model = OrderMapper.toModel(order);
await this.db.orders.upsert(model);
}
async findById(id: OrderId): Promise<Order | null> {
const model = await this.db.orders.findUnique({
where: { id: id.value }
});
return model ? OrderMapper.toDomain(model) : null;
}
}
Application Layer Patterns
Use Case / Command Handler
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly productRepository: ProductRepository,
private readonly eventPublisher: EventPublisher
) {}
async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
// 1. Validate and create value objects
const customerId = CustomerId.create(command.customerId);
// 2. Load necessary aggregates
const items = await Promise.all(
command.items.map(async (item) => {
const product = await this.productRepository.findById(
ProductId.create(item.productId)
);
if (!product) {
throw new ProductNotFoundError(item.productId);
}
return OrderItem.create(
product.id,
Quantity.create(item.quantity),
product.price
);
})
);
// 3. Execute domain logic
const order = Order.create(customerId, items);
// 4. Persist
await this.orderRepository.save(order);
// 5. Publish events
for (const event of order.domainEvents) {
await this.eventPublisher.publish(event);
}
// 6. Return result
return new CreateOrderResult(order.id.value);
}
}
DO's and DON'Ts
DO
- ✅ Start with domain modeling before technical concerns
- ✅ Use ubiquitous language consistently
- ✅ Keep aggregates small and focused
- ✅ Validate invariants in domain objects
- ✅ Use factory methods for object creation
- ✅ Publish domain events for state changes
- ✅ Write unit tests for domain logic without mocks
- ✅ Use dependency injection for infrastructure
DON'T
- ❌ Create anemic domain models (data without behavior)
- ❌ Let ORM/database concerns leak into domain
- ❌ Modify multiple aggregates in one transaction
- ❌ Import infrastructure in domain layer
- ❌ Use primitive types for domain concepts
- ❌ Skip validation in value objects
- ❌ Return database models from repositories
File Structure Template
When creating a new bounded context:
src/[context]/
├── domain/
│ ├── [Aggregate].ts # Aggregate root
│ ├── [Entity].ts # Entities
│ ├── [ValueObject].ts # Value objects
│ ├── [Repository].ts # Repository interface
│ ├── [DomainService].ts # Domain services
│ └── events/
│ └── [Event]Event.ts # Domain events
├── application/
│ ├── commands/
│ │ └── [Action][Entity]Command.ts
│ ├── queries/
│ │ └── Get[Entity]Query.ts
│ ├── handlers/
│ │ ├── [Action][Entity]Handler.ts
│ │ └── Get[Entity]Handler.ts
│ └── dtos/
│ └── [Entity]Dto.ts
├── infrastructure/
│ ├── [Repository]Impl.ts # Repository implementation
│ ├── mappers/
│ │ └── [Entity]Mapper.ts # Domain <-> Model mapping
│ └── models/
│ └── [Entity]Model.ts # Database model
└── interface/
└── [Entity]Controller.ts # API endpoints
Common Patterns Reference
Specification Pattern
interface Specification<T> {
isSatisfiedBy(entity: T): boolean;
}
class OrderEligibleForDiscount implements Specification<Order> {
isSatisfiedBy(order: Order): boolean {
return order.totalAmount.amount > 100;
}
}
Domain Service
class PricingService {
calculateDiscount(order: Order, customer: Customer): Money {
// Complex logic that doesn't belong to a single aggregate
if (customer.isVip && order.totalAmount.amount > 500) {
return order.totalAmount.multiply(0.1);
}
return Money.zero(order.totalAmount.currency);
}
}
Factory Pattern
class OrderFactory {
constructor(private readonly productRepository: ProductRepository) {}
async createFromCart(cart: Cart): Promise<Order> {
const items = await Promise.all(
cart.items.map(async (cartItem) => {
const product = await this.productRepository.findById(cartItem.productId);
return OrderItem.create(product.id, cartItem.quantity, product.price);
})
);
return Order.create(cart.customerId, items);
}
}