Guia Prático de Domain-Driven Design (DDD) - Design Tático com TypeScript

2025.12.02

Domain-Driven Design (DDD) é uma metodologia para projetar e implementar software com lógica de negócio complexa de forma eficaz. Este artigo explica como implementar padrões de design tático de DDD em TypeScript.

Conceitos Básicos de DDD

Design Estratégico e Design Tático

flowchart TB
    subgraph Strategic["Design Estratégico"]
        S1["Linguagem Ubíqua<br/>(Definição de linguagem comum)"]
        S2["Bounded Context<br/>(Divisão do sistema)"]
        S3["Context Map<br/>(Relacionamento entre contextos)"]
        S4["Subdomínios<br/>(Core/Suporte/Genérico)"]
    end

    subgraph Tactical["Design Tático"]
        T1["Entidades<br/>(Objetos com identidade)"]
        T2["Value Objects<br/>(Valores imutáveis)"]
        T3["Agregados<br/>(Fronteira de consistência)"]
        T4["Repositórios<br/>(Abstração de persistência)"]
        T5["Domain Services<br/>(Lógica que não pertence a entidades)"]
        T6["Domain Events<br/>(Notificação de mudanças de estado)"]
        T7["Factories<br/>(Criação de objetos complexos)"]
    end

    Strategic --> Tactical

Arquitetura em Camadas

flowchart TB
    subgraph P["Camada de Apresentação"]
        P1["Controllers, API, UI"]
    end

    subgraph A["Camada de Aplicação"]
        A1["Use Cases, Application Services, DTOs"]
    end

    subgraph D["Camada de Domínio"]
        D1["Entities, Value Objects, Aggregates, Domain Services"]
    end

    subgraph I["Camada de Infraestrutura"]
        I1["Repositories, External Services, DB"]
    end

    P --> A --> D --> I

Value Objects

Value Object Básico

// domain/value-objects/email.ts

export class Email {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static create(value: string): Email {
    if (!value || !this.isValid(value)) {
      throw new Error('Invalid email format');
    }
    return new Email(value.toLowerCase().trim());
  }

  private static isValid(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

  getValue(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  getDomain(): string {
    return this.value.split('@')[1];
  }

  toString(): string {
    return this.value;
  }
}

Value Object Composto

// domain/value-objects/money.ts

export type Currency = 'JPY' | 'USD' | 'EUR';

export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: Currency
  ) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative');
    }
  }

  static create(amount: number, currency: Currency): Money {
    return new Money(amount, currency);
  }

  static zero(currency: Currency): Money {
    return new Money(0, currency);
  }

  getAmount(): number {
    return this.amount;
  }

  getCurrency(): Currency {
    return this.currency;
  }

  add(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money(this.amount + other.amount, this.currency);
  }

  subtract(other: Money): Money {
    this.assertSameCurrency(other);
    const result = this.amount - other.amount;
    if (result < 0) {
      throw new Error('Result cannot be negative');
    }
    return new Money(result, this.currency);
  }

  multiply(factor: number): Money {
    if (factor < 0) {
      throw new Error('Factor cannot be negative');
    }
    return new Money(Math.round(this.amount * factor), this.currency);
  }

  isGreaterThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amount > other.amount;
  }

  isLessThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amount < other.amount;
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  private assertSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch');
    }
  }

  format(): string {
    const formatter = new Intl.NumberFormat('ja-JP', {
      style: 'currency',
      currency: this.currency,
    });
    return formatter.format(this.amount);
  }
}

Value Object de Endereço

// domain/value-objects/address.ts

interface AddressProps {
  postalCode: string;
  prefecture: string;
  city: string;
  street: string;
  building?: string;
}

export class Address {
  private constructor(
    private readonly postalCode: string,
    private readonly prefecture: string,
    private readonly city: string,
    private readonly street: string,
    private readonly building?: string
  ) {}

  static create(props: AddressProps): Address {
    this.validatePostalCode(props.postalCode);
    this.validatePrefecture(props.prefecture);

    return new Address(
      props.postalCode,
      props.prefecture,
      props.city,
      props.street,
      props.building
    );
  }

  private static validatePostalCode(postalCode: string): void {
    if (!/^\d{3}-?\d{4}$/.test(postalCode)) {
      throw new Error('Invalid postal code format');
    }
  }

  private static validatePrefecture(prefecture: string): void {
    const prefectures = [
      'Hokkaido', 'Aomori', 'Iwate', /* ... */
    ];
    if (!prefectures.includes(prefecture)) {
      throw new Error('Invalid prefecture');
    }
  }

  getPostalCode(): string {
    return this.postalCode;
  }

  getPrefecture(): string {
    return this.prefecture;
  }

  getFullAddress(): string {
    const parts = [
      this.postalCode,
      this.prefecture,
      this.city,
      this.street,
    ];
    if (this.building) {
      parts.push(this.building);
    }
    return parts.join(' ');
  }

  equals(other: Address): boolean {
    return (
      this.postalCode === other.postalCode &&
      this.prefecture === other.prefecture &&
      this.city === other.city &&
      this.street === other.street &&
      this.building === other.building
    );
  }
}

Entidades

Classe Base de Entidade

// domain/shared/entity.ts

export abstract class Entity<T> {
  protected readonly _id: T;

  constructor(id: T) {
    this._id = id;
  }

  get id(): T {
    return this._id;
  }

  equals(other: Entity<T>): boolean {
    if (other === null || other === undefined) {
      return false;
    }
    if (!(other instanceof Entity)) {
      return false;
    }
    return this._id === other._id;
  }
}

Entidade de Usuário

// domain/entities/user.ts

import { Entity } from '../shared/entity';
import { Email } from '../value-objects/email';
import { UserId } from '../value-objects/user-id';
import { UserCreatedEvent } from '../events/user-created';

interface UserProps {
  email: Email;
  name: string;
  role: UserRole;
  status: UserStatus;
  createdAt: Date;
  updatedAt: Date;
}

type UserRole = 'admin' | 'member' | 'guest';
type UserStatus = 'active' | 'inactive' | 'suspended';

export class User extends Entity<UserId> {
  private email: Email;
  private name: string;
  private role: UserRole;
  private status: UserStatus;
  private readonly createdAt: Date;
  private updatedAt: Date;

  private constructor(id: UserId, props: UserProps) {
    super(id);
    this.email = props.email;
    this.name = props.name;
    this.role = props.role;
    this.status = props.status;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  // Método factory
  static create(email: Email, name: string): User {
    const id = UserId.create();
    const now = new Date();

    const user = new User(id, {
      email,
      name,
      role: 'member',
      status: 'active',
      createdAt: now,
      updatedAt: now,
    });

    // Publicar evento de domínio
    user.addDomainEvent(new UserCreatedEvent(user.id, email));

    return user;
  }

  // Método de reconstituição (ao restaurar do repositório)
  static reconstruct(id: UserId, props: UserProps): User {
    return new User(id, props);
  }

  // Lógica de negócio
  changeName(newName: string): void {
    if (!newName || newName.trim().length < 2) {
      throw new Error('Name must be at least 2 characters');
    }
    this.name = newName.trim();
    this.updatedAt = new Date();
  }

  changeEmail(newEmail: Email): void {
    if (this.email.equals(newEmail)) {
      return;
    }
    this.email = newEmail;
    this.updatedAt = new Date();
  }

  promote(): void {
    if (this.role === 'admin') {
      throw new Error('User is already an admin');
    }
    this.role = 'admin';
    this.updatedAt = new Date();
  }

  suspend(): void {
    if (this.status === 'suspended') {
      throw new Error('User is already suspended');
    }
    this.status = 'suspended';
    this.updatedAt = new Date();
  }

  activate(): void {
    if (this.status === 'active') {
      throw new Error('User is already active');
    }
    this.status = 'active';
    this.updatedAt = new Date();
  }

  isActive(): boolean {
    return this.status === 'active';
  }

  isAdmin(): boolean {
    return this.role === 'admin';
  }

  // Getters
  getEmail(): Email {
    return this.email;
  }

  getName(): string {
    return this.name;
  }

  getRole(): UserRole {
    return this.role;
  }

  getStatus(): UserStatus {
    return this.status;
  }

  getCreatedAt(): Date {
    return this.createdAt;
  }
}

Agregados

Agregado de Pedido

// domain/aggregates/order/order.ts

import { AggregateRoot } from '../../shared/aggregate-root';
import { OrderId } from './order-id';
import { OrderItem } from './order-item';
import { OrderStatus } from './order-status';
import { Money } from '../../value-objects/money';
import { UserId } from '../../value-objects/user-id';
import { OrderCreatedEvent } from '../../events/order-created';
import { OrderCompletedEvent } from '../../events/order-completed';

interface OrderProps {
  userId: UserId;
  items: OrderItem[];
  status: OrderStatus;
  shippingAddress: Address;
  createdAt: Date;
  updatedAt: Date;
}

export class Order extends AggregateRoot<OrderId> {
  private userId: UserId;
  private items: OrderItem[];
  private status: OrderStatus;
  private shippingAddress: Address;
  private readonly createdAt: Date;
  private updatedAt: Date;

  private constructor(id: OrderId, props: OrderProps) {
    super(id);
    this.userId = props.userId;
    this.items = props.items;
    this.status = props.status;
    this.shippingAddress = props.shippingAddress;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  static create(userId: UserId, shippingAddress: Address): Order {
    const id = OrderId.create();
    const now = new Date();

    const order = new Order(id, {
      userId,
      items: [],
      status: OrderStatus.DRAFT,
      shippingAddress,
      createdAt: now,
      updatedAt: now,
    });

    return order;
  }

  // Adicionar item
  addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
    this.assertCanModify();

    const existingItem = this.items.find(
      item => item.getProductId().equals(productId)
    );

    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      this.items.push(
        OrderItem.create(productId, quantity, unitPrice)
      );
    }

    this.updatedAt = new Date();
  }

  // Remover item
  removeItem(productId: ProductId): void {
    this.assertCanModify();

    const index = this.items.findIndex(
      item => item.getProductId().equals(productId)
    );

    if (index === -1) {
      throw new Error('Item not found');
    }

    this.items.splice(index, 1);
    this.updatedAt = new Date();
  }

  // Confirmar pedido
  place(): void {
    if (this.items.length === 0) {
      throw new Error('Cannot place order with no items');
    }

    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Order is not in draft status');
    }

    this.status = OrderStatus.PLACED;
    this.updatedAt = new Date();

    this.addDomainEvent(new OrderCreatedEvent(
      this.id,
      this.userId,
      this.calculateTotal()
    ));
  }

  // Pagamento concluído
  markAsPaid(): void {
    if (this.status !== OrderStatus.PLACED) {
      throw new Error('Order is not placed');
    }

    this.status = OrderStatus.PAID;
    this.updatedAt = new Date();
  }

  // Enviar
  ship(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new Error('Order is not paid');
    }

    this.status = OrderStatus.SHIPPED;
    this.updatedAt = new Date();
  }

  // Entrega concluída
  complete(): void {
    if (this.status !== OrderStatus.SHIPPED) {
      throw new Error('Order is not shipped');
    }

    this.status = OrderStatus.COMPLETED;
    this.updatedAt = new Date();

    this.addDomainEvent(new OrderCompletedEvent(this.id));
  }

  // Cancelar
  cancel(): void {
    if (this.status === OrderStatus.SHIPPED ||
        this.status === OrderStatus.COMPLETED) {
      throw new Error('Cannot cancel shipped or completed order');
    }

    this.status = OrderStatus.CANCELLED;
    this.updatedAt = new Date();
  }

  // Calcular valor total
  calculateTotal(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.calculateSubtotal()),
      Money.zero('JPY')
    );
  }

  private assertCanModify(): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Cannot modify non-draft order');
    }
  }

  // Getters
  getUserId(): UserId {
    return this.userId;
  }

  getItems(): ReadonlyArray<OrderItem> {
    return [...this.items];
  }

  getStatus(): OrderStatus {
    return this.status;
  }

  getShippingAddress(): Address {
    return this.shippingAddress;
  }
}

// order-item.ts (Entidade dentro do agregado)
export class OrderItem {
  private constructor(
    private readonly productId: ProductId,
    private quantity: number,
    private readonly unitPrice: Money
  ) {}

  static create(
    productId: ProductId,
    quantity: number,
    unitPrice: Money
  ): OrderItem {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    return new OrderItem(productId, quantity, unitPrice);
  }

  increaseQuantity(amount: number): void {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }
    this.quantity += amount;
  }

  calculateSubtotal(): Money {
    return this.unitPrice.multiply(this.quantity);
  }

  getProductId(): ProductId {
    return this.productId;
  }

  getQuantity(): number {
    return this.quantity;
  }

  getUnitPrice(): Money {
    return this.unitPrice;
  }
}

// order-status.ts (Value Object)
export enum OrderStatus {
  DRAFT = 'DRAFT',
  PLACED = 'PLACED',
  PAID = 'PAID',
  SHIPPED = 'SHIPPED',
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
}

Repositórios

Interface de Repositório

// domain/repositories/order-repository.ts

export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
  findByUserId(userId: UserId): Promise<Order[]>;
  delete(id: OrderId): Promise<void>;
  nextId(): OrderId;
}

// domain/repositories/user-repository.ts
export interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  exists(email: Email): Promise<boolean>;
  delete(id: UserId): Promise<void>;
}

Implementação de Repositório

// infrastructure/repositories/prisma-order-repository.ts

import { PrismaClient } from '@prisma/client';
import { OrderRepository } from '../../domain/repositories/order-repository';
import { Order } from '../../domain/aggregates/order/order';
import { OrderId } from '../../domain/aggregates/order/order-id';
import { OrderMapper } from '../mappers/order-mapper';

export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPersistence(order);

    await this.prisma.order.upsert({
      where: { id: data.id },
      create: {
        id: data.id,
        userId: data.userId,
        status: data.status,
        shippingAddress: data.shippingAddress,
        createdAt: data.createdAt,
        updatedAt: data.updatedAt,
        items: {
          create: data.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })),
        },
      },
      update: {
        status: data.status,
        updatedAt: data.updatedAt,
        items: {
          deleteMany: {},
          create: data.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })),
        },
      },
    });
  }

  async findById(id: OrderId): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({
      where: { id: id.getValue() },
      include: { items: true },
    });

    if (!data) return null;

    return OrderMapper.toDomain(data);
  }

  async findByUserId(userId: UserId): Promise<Order[]> {
    const orders = await this.prisma.order.findMany({
      where: { userId: userId.getValue() },
      include: { items: true },
      orderBy: { createdAt: 'desc' },
    });

    return orders.map(OrderMapper.toDomain);
  }

  async delete(id: OrderId): Promise<void> {
    await this.prisma.order.delete({
      where: { id: id.getValue() },
    });
  }

  nextId(): OrderId {
    return OrderId.create();
  }
}

Domain Services

// domain/services/order-domain-service.ts

export class OrderDomainService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly productRepository: ProductRepository,
    private readonly inventoryService: InventoryService
  ) {}

  async canPlaceOrder(order: Order): Promise<boolean> {
    // Verificar estoque
    for (const item of order.getItems()) {
      const available = await this.inventoryService.checkAvailability(
        item.getProductId(),
        item.getQuantity()
      );

      if (!available) {
        return false;
      }
    }

    return true;
  }

  async calculateShippingFee(order: Order): Promise<Money> {
    const total = order.calculateTotal();
    const address = order.getShippingAddress();

    // Limite para frete grátis
    if (total.isGreaterThan(Money.create(10000, 'JPY'))) {
      return Money.zero('JPY');
    }

    // Frete por região
    const baseFee = this.getBaseFeeByRegion(address.getPrefecture());
    return baseFee;
  }

  private getBaseFeeByRegion(prefecture: string): Money {
    const hokkaido = ['Hokkaido'];
    const okinawa = ['Okinawa'];

    if (hokkaido.includes(prefecture)) {
      return Money.create(1500, 'JPY');
    }
    if (okinawa.includes(prefecture)) {
      return Money.create(2000, 'JPY');
    }
    return Money.create(800, 'JPY');
  }
}

Application Services

// application/services/order-application-service.ts

export class OrderApplicationService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly userRepository: UserRepository,
    private readonly orderDomainService: OrderDomainService,
    private readonly eventPublisher: DomainEventPublisher,
    private readonly unitOfWork: UnitOfWork
  ) {}

  async createOrder(command: CreateOrderCommand): Promise<OrderDto> {
    return this.unitOfWork.execute(async () => {
      // Verificar existência do usuário
      const user = await this.userRepository.findById(
        UserId.create(command.userId)
      );

      if (!user) {
        throw new UserNotFoundError(command.userId);
      }

      // Criar pedido
      const order = Order.create(
        user.id,
        Address.create(command.shippingAddress)
      );

      // Adicionar itens
      for (const item of command.items) {
        order.addItem(
          ProductId.create(item.productId),
          item.quantity,
          Money.create(item.unitPrice, 'JPY')
        );
      }

      // Verificar se é possível confirmar o pedido
      const canPlace = await this.orderDomainService.canPlaceOrder(order);
      if (!canPlace) {
        throw new InsufficientInventoryError();
      }

      // Confirmar pedido
      order.place();

      // Salvar
      await this.orderRepository.save(order);

      // Publicar eventos de domínio
      await this.eventPublisher.publishAll(order.pullDomainEvents());

      return OrderDto.fromDomain(order);
    });
  }

  async getOrder(orderId: string): Promise<OrderDto | null> {
    const order = await this.orderRepository.findById(
      OrderId.create(orderId)
    );

    if (!order) return null;

    return OrderDto.fromDomain(order);
  }

  async cancelOrder(orderId: string): Promise<void> {
    return this.unitOfWork.execute(async () => {
      const order = await this.orderRepository.findById(
        OrderId.create(orderId)
      );

      if (!order) {
        throw new OrderNotFoundError(orderId);
      }

      order.cancel();

      await this.orderRepository.save(order);
      await this.eventPublisher.publishAll(order.pullDomainEvents());
    });
  }
}

Resumo

DDD é eficaz em projetos que lidam com lógica de negócio complexa.

Escolha de Padrões Táticos

PadrãoUso
Value ObjectValores imutáveis, encapsulamento de regras
EntidadeObjetos com identidade
AgregadoFronteira de consistência, unidade de transação
RepositórioAbstração de persistência
Domain ServiceLógica que não pertence a entidades

Pontos para Adoção

  1. Linguagem Ubíqua: Criar uma linguagem comum com especialistas de domínio
  2. Bounded Context: Dividir o sistema em granularidade adequada
  3. Adoção gradual: Começar pelo domínio core
  4. Testes: Testes unitários para lógica de domínio

DDD tem alto custo de aprendizado, mas quando aplicado corretamente, permite construir sistemas altamente manuteníveis.

← Voltar para a lista