Claude Code Plugins

Community-maintained marketplace

Feedback

ddd-architecture

@haginot/ddd-boilerplate
0
0

Domain-Driven Design and Clean Architecture implementation guide

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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);
  }
}