Introducción a Clean Architecture - Inversión de Dependencias y Diseño por Capas

Avanzado | 2025.12.02

¿Qué es Clean Architecture?

Clean Architecture es un principio de diseño de software propuesto por Robert C. Martin (Uncle Bob). Separa la lógica de negocio de los detalles externos (frameworks, bases de datos, UI) y construye sistemas testeables y mantenibles.

flowchart TB
    subgraph Outer["Frameworks & Drivers<br/>(Web, DB, External Interfaces, UI)"]
        subgraph Adapters["Interface Adapters<br/>(Controllers, Gateways, Presenters)"]
            subgraph Application["Application Business<br/>(Use Cases)"]
                subgraph Enterprise["Enterprise Business"]
                    Entities["Entities<br/>(Reglas de negocio)"]
                end
            end
        end
    end

    Note["Regla de dependencias: Exterior → Interior (el interior no conoce el exterior)"]

Principio de Inversión de Dependencias (DIP)

El núcleo de Clean Architecture es el principio de inversión de dependencias.

flowchart TB
    subgraph Before["Dependencias Tradicionales"]
        C1["Controller"] --> S1["Service"] --> R1["Repository<br/>(implementación)"]
        Note1["Alto nivel depende de bajo nivel"]
    end

    subgraph After["Después de la Inversión de Dependencias"]
        C2["Controller"] --> UC["UseCase"]
        RI["Repository<br/>Interface"] --> UC
        RI --> Impl["Repository<br/>Impl"]
        Note2["Depende de abstracciones (oculta detalles de implementación)"]
    end

Composición de Capas

Estructura de Directorios

src/
├── domain/                    # Reglas de negocio empresarial
│   ├── entities/              # Entidades
│   │   ├── User.ts
│   │   ├── Order.ts
│   │   └── Product.ts
│   ├── value-objects/         # Objetos de valor
│   │   ├── Email.ts
│   │   ├── Money.ts
│   │   └── Address.ts
│   ├── repositories/          # Interfaces de repositorio
│   │   ├── IUserRepository.ts
│   │   └── IOrderRepository.ts
│   ├── services/              # Servicios de dominio
│   │   └── PricingService.ts
│   └── errors/                # Errores de dominio
│       └── DomainError.ts
├── application/               # Reglas de negocio de aplicación
│   ├── use-cases/             # Casos de uso
│   │   ├── user/
│   │   │   ├── CreateUserUseCase.ts
│   │   │   └── GetUserByIdUseCase.ts
│   │   └── order/
│   │       ├── CreateOrderUseCase.ts
│   │       └── CancelOrderUseCase.ts
│   ├── dto/                   # Objetos de transferencia de datos
│   │   ├── CreateUserDTO.ts
│   │   └── OrderResponseDTO.ts
│   └── services/              # Servicios de aplicación
│       └── NotificationService.ts
├── infrastructure/            # Frameworks y drivers
│   ├── database/
│   │   ├── prisma/
│   │   │   └── PrismaUserRepository.ts
│   │   └── drizzle/
│   │       └── DrizzleOrderRepository.ts
│   ├── external/
│   │   ├── StripePaymentGateway.ts
│   │   └── SendGridEmailService.ts
│   └── config/
│       └── database.ts
└── presentation/              # Adaptadores de interfaz
    ├── http/
    │   ├── controllers/
    │   │   ├── UserController.ts
    │   │   └── OrderController.ts
    │   ├── middleware/
    │   │   └── authMiddleware.ts
    │   └── routes/
    │       └── index.ts
    └── graphql/
        ├── resolvers/
        └── schema/

Implementación de Entidades

// domain/entities/User.ts
import { Email } from '../value-objects/Email';
import { UserId } from '../value-objects/UserId';
import { DomainError } from '../errors/DomainError';

export interface UserProps {
  id: UserId;
  email: Email;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

export class User {
  private constructor(private readonly props: UserProps) {}

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

    return new User({
      id,
      email,
      name: input.name,
      createdAt: now,
      updatedAt: now,
    });
  }

  // Para reconstrucción (lectura desde BD)
  static reconstruct(props: UserProps): User {
    return new User(props);
  }

  // Getters
  get id(): UserId {
    return this.props.id;
  }

  get email(): Email {
    return this.props.email;
  }

  get name(): string {
    return this.props.name;
  }

  // Lógica de negocio
  changeName(newName: string): void {
    if (newName.length < 2 || newName.length > 100) {
      throw new DomainError('Name must be between 2 and 100 characters');
    }
    this.props.name = newName;
    this.props.updatedAt = new Date();
  }

  changeEmail(newEmail: string): void {
    this.props.email = Email.create(newEmail);
    this.props.updatedAt = new Date();
  }
}

Implementación de Objetos de Valor

// domain/value-objects/Email.ts
import { DomainError } from '../errors/DomainError';

export class Email {
  private constructor(private readonly value: string) {}

  static create(value: string): Email {
    if (!this.isValid(value)) {
      throw new DomainError(`Invalid email format: ${value}`);
    }
    return new Email(value.toLowerCase());
  }

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

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

// domain/value-objects/Money.ts
import { DomainError } from '../errors/DomainError';

export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {}

  static create(amount: number, currency: string = 'JPY'): Money {
    if (amount < 0) {
      throw new DomainError('Amount cannot be negative');
    }
    return new Money(amount, currency);
  }

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

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

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

  subtract(other: Money): Money {
    this.ensureSameCurrency(other);
    const result = this.amount - other.amount;
    if (result < 0) {
      throw new DomainError('Insufficient funds');
    }
    return Money.create(result, this.currency);
  }

  multiply(factor: number): Money {
    return Money.create(Math.round(this.amount * factor), this.currency);
  }

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

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

Interfaces de Repositorio

// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';
import { UserId } from '../value-objects/UserId';
import { Email } from '../value-objects/Email';

export interface IUserRepository {
  save(user: User): Promise<void>;
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  findAll(options?: { limit?: number; offset?: number }): Promise<User[]>;
  delete(id: UserId): Promise<void>;
  exists(id: UserId): Promise<boolean>;
}

// domain/repositories/IOrderRepository.ts
import { Order } from '../entities/Order';
import { OrderId } from '../value-objects/OrderId';
import { UserId } from '../value-objects/UserId';

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

Implementación de Casos de Uso

// application/use-cases/user/CreateUserUseCase.ts
import { User } from '../../../domain/entities/User';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { Email } from '../../../domain/value-objects/Email';
import { ApplicationError } from '../../errors/ApplicationError';

export interface CreateUserInput {
  email: string;
  name: string;
}

export interface CreateUserOutput {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

export class CreateUserUseCase {
  constructor(private readonly userRepository: IUserRepository) {}

  async execute(input: CreateUserInput): Promise<CreateUserOutput> {
    // Verificación de duplicados
    const email = Email.create(input.email);
    const existingUser = await this.userRepository.findByEmail(email);

    if (existingUser) {
      throw new ApplicationError('User with this email already exists');
    }

    // Creación de entidad
    const user = User.create({
      email: input.email,
      name: input.name,
    });

    // Persistencia
    await this.userRepository.save(user);

    // Transformación de respuesta
    return {
      id: user.id.getValue(),
      email: user.email.getValue(),
      name: user.name,
      createdAt: user.createdAt,
    };
  }
}

// application/use-cases/order/CreateOrderUseCase.ts
import { Order, OrderItem } from '../../../domain/entities/Order';
import { IOrderRepository } from '../../../domain/repositories/IOrderRepository';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { IProductRepository } from '../../../domain/repositories/IProductRepository';
import { IPaymentGateway } from '../../ports/IPaymentGateway';
import { INotificationService } from '../../ports/INotificationService';
import { UserId } from '../../../domain/value-objects/UserId';
import { ApplicationError } from '../../errors/ApplicationError';

export interface CreateOrderInput {
  userId: string;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
}

export class CreateOrderUseCase {
  constructor(
    private readonly orderRepository: IOrderRepository,
    private readonly userRepository: IUserRepository,
    private readonly productRepository: IProductRepository,
    private readonly paymentGateway: IPaymentGateway,
    private readonly notificationService: INotificationService
  ) {}

  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    // Verificación de existencia de usuario
    const userId = UserId.create(input.userId);
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new ApplicationError('User not found');
    }

    // Obtención de información de productos
    const orderItems: OrderItem[] = [];
    for (const item of input.items) {
      const product = await this.productRepository.findById(item.productId);
      if (!product) {
        throw new ApplicationError(`Product not found: ${item.productId}`);
      }
      if (product.stock < item.quantity) {
        throw new ApplicationError(`Insufficient stock: ${product.name}`);
      }
      orderItems.push(OrderItem.create(product, item.quantity));
    }

    // Creación del pedido
    const order = Order.create({
      userId,
      items: orderItems,
    });

    // Procesamiento del pago
    const paymentResult = await this.paymentGateway.charge({
      amount: order.totalAmount,
      userId: userId.getValue(),
    });

    if (!paymentResult.success) {
      throw new ApplicationError('Payment failed');
    }

    order.markAsPaid(paymentResult.transactionId);

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

    // Notificación
    await this.notificationService.sendOrderConfirmation(user.email, order);

    return this.toOutput(order);
  }
}

Implementación de la Capa de Infraestructura

// infrastructure/database/prisma/PrismaUserRepository.ts
import { PrismaClient } from '@prisma/client';
import { IUserRepository } from '../../../domain/repositories/IUserRepository';
import { User } from '../../../domain/entities/User';
import { UserId } from '../../../domain/value-objects/UserId';
import { Email } from '../../../domain/value-objects/Email';

export class PrismaUserRepository implements IUserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async save(user: User): Promise<void> {
    await this.prisma.user.upsert({
      where: { id: user.id.getValue() },
      create: {
        id: user.id.getValue(),
        email: user.email.getValue(),
        name: user.name,
        createdAt: user.createdAt,
        updatedAt: user.updatedAt,
      },
      update: {
        email: user.email.getValue(),
        name: user.name,
        updatedAt: user.updatedAt,
      },
    });
  }

  async findById(id: UserId): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { id: id.getValue() },
    });

    if (!record) return null;

    return User.reconstruct({
      id: UserId.create(record.id),
      email: Email.create(record.email),
      name: record.name,
      createdAt: record.createdAt,
      updatedAt: record.updatedAt,
    });
  }

  async findByEmail(email: Email): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { email: email.getValue() },
    });

    if (!record) return null;

    return this.toDomain(record);
  }

  private toDomain(record: PrismaUser): User {
    return User.reconstruct({
      id: UserId.create(record.id),
      email: Email.create(record.email),
      name: record.name,
      createdAt: record.createdAt,
      updatedAt: record.updatedAt,
    });
  }
}

Configuración de Inyección de Dependencias

// infrastructure/di/container.ts
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from '../database/prisma/PrismaUserRepository';
import { PrismaOrderRepository } from '../database/prisma/PrismaOrderRepository';
import { StripePaymentGateway } from '../external/StripePaymentGateway';
import { SendGridNotificationService } from '../external/SendGridNotificationService';
import { CreateUserUseCase } from '../../application/use-cases/user/CreateUserUseCase';
import { CreateOrderUseCase } from '../../application/use-cases/order/CreateOrderUseCase';

// Contenedor DI simple
export class Container {
  private static instance: Container;
  private readonly prisma: PrismaClient;

  private constructor() {
    this.prisma = new PrismaClient();
  }

  static getInstance(): Container {
    if (!Container.instance) {
      Container.instance = new Container();
    }
    return Container.instance;
  }

  // Repositorios
  getUserRepository(): IUserRepository {
    return new PrismaUserRepository(this.prisma);
  }

  getOrderRepository(): IOrderRepository {
    return new PrismaOrderRepository(this.prisma);
  }

  // Servicios externos
  getPaymentGateway(): IPaymentGateway {
    return new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!);
  }

  getNotificationService(): INotificationService {
    return new SendGridNotificationService(process.env.SENDGRID_API_KEY!);
  }

  // Casos de uso
  getCreateUserUseCase(): CreateUserUseCase {
    return new CreateUserUseCase(this.getUserRepository());
  }

  getCreateOrderUseCase(): CreateOrderUseCase {
    return new CreateOrderUseCase(
      this.getOrderRepository(),
      this.getUserRepository(),
      this.getProductRepository(),
      this.getPaymentGateway(),
      this.getNotificationService()
    );
  }
}

Facilidad de Pruebas

// __tests__/application/CreateUserUseCase.test.ts
import { CreateUserUseCase } from '../../src/application/use-cases/user/CreateUserUseCase';
import { InMemoryUserRepository } from '../__mocks__/InMemoryUserRepository';

describe('CreateUserUseCase', () => {
  let useCase: CreateUserUseCase;
  let userRepository: InMemoryUserRepository;

  beforeEach(() => {
    userRepository = new InMemoryUserRepository();
    useCase = new CreateUserUseCase(userRepository);
  });

  it('should create a new user', async () => {
    const input = {
      email: 'test@example.com',
      name: 'Test User',
    };

    const result = await useCase.execute(input);

    expect(result.email).toBe(input.email);
    expect(result.name).toBe(input.name);
    expect(result.id).toBeDefined();
  });

  it('should throw error for duplicate email', async () => {
    const input = {
      email: 'test@example.com',
      name: 'Test User',
    };

    await useCase.execute(input);

    await expect(useCase.execute(input)).rejects.toThrow(
      'User with this email already exists'
    );
  });
});

Resumen

CapaResponsabilidadDirección de Dependencia
DomainReglas de negocio, entidadesSin dependencias
ApplicationCasos de uso, lógica de aplicaciónDomain
InfrastructureBD, implementación de servicios externosDomain, Application
PresentationControladores, enrutamientoApplication

Enlaces de Referencia

← Volver a la lista