¿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
| Capa | Responsabilidad | Dirección de Dependencia |
|---|---|---|
| Domain | Reglas de negocio, entidades | Sin dependencias |
| Application | Casos de uso, lógica de aplicación | Domain |
| Infrastructure | BD, implementación de servicios externos | Domain, Application |
| Presentation | Controladores, enrutamiento | Application |
Enlaces de Referencia
- Clean Architecture (Robert C. Martin)
- Hexagonal Architecture
- Implementación Práctica de Clean Architecture