Principios de Clean Architecture en aplicaciones reales
Aplica patrones de arquitectura limpia que separan la lógica de negocio de la infraestructura: aplicaciones testeables, mantenibles y adaptables.

La arquitectura limpia no trata de seguir una estructura de carpetas específica. Trata de una sola regla: las dependencias apuntan hacia adentro. La lógica de negocio no conoce bases de datos, frameworks HTTP ni APIs externas. Esta inversión de control hace que el núcleo de tu aplicación sea testeable sin infraestructura, reemplazable sin reescrituras y comprensible sin leer cada detalle de integración.
La regla de dependencias en la práctica
La regla de dependencias establece que las capas internas no pueden referenciar a las capas externas. Las entidades de negocio no importan clientes de base de datos. Los casos de uso no importan Express ni Next.js.
// ❌ Business logic coupled to infrastructure
import { prisma } from "../lib/prisma";
import { sendgrid } from "../lib/email";
async function createOrder(userId: string, items: CartItem[]): Promise<Order> {
// Business logic directly depends on Prisma and SendGrid
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const order = await prisma.order.create({
data: { userId, total, items: { create: items } },
});
await sendgrid.send({
to: user.email,
subject: "Order Confirmation",
text: `Your order #${order.id} total: $${total}`,
});
return order;
}
// Can't test without a database and email service running
// Can't switch from Prisma to another ORM without rewriting business logic// ✅ Business logic depends only on interfaces (ports)
// domain/entities/order.ts
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: OrderStatus;
createdAt: Date;
}
type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered";
interface OrderItem {
productId: string;
name: string;
price: number;
quantity: number;
}
// domain/ports/order-repository.ts
interface OrderRepository {
create(order: Omit<Order, "id" | "createdAt">): Promise<Order>;
findById(id: string): Promise<Order | null>;
findByUserId(userId: string): Promise<Order[]>;
}
// domain/ports/user-repository.ts
interface UserRepository {
findById(id: string): Promise<User | null>;
}
// domain/ports/notification-service.ts
interface NotificationService {
sendOrderConfirmation(email: string, order: Order): Promise<void>;
}Casos de uso: la capa de aplicación
Los casos de uso orquestan la lógica de negocio. Dependen de interfaces del dominio (puertos) y están completamente desacoplados de los detalles de infraestructura.
// application/use-cases/create-order.ts
class CreateOrderUseCase {
constructor(
private readonly orderRepo: OrderRepository,
private readonly userRepo: UserRepository,
private readonly notifier: NotificationService
) {}
async execute(input: CreateOrderInput): Promise<Order> {
// Validate user exists
const user = await this.userRepo.findById(input.userId);
if (!user) {
throw new DomainError("USER_NOT_FOUND", "User does not exist");
}
// Apply business rules
const total = this.calculateTotal(input.items);
if (total <= 0) {
throw new DomainError("INVALID_ORDER", "Order total must be positive");
}
if (input.items.length > 50) {
throw new DomainError("ORDER_TOO_LARGE", "Maximum 50 items per order");
}
// Create order through repository interface
const order = await this.orderRepo.create({
userId: input.userId,
items: input.items,
total,
status: "pending",
});
// Notify user through notification interface
await this.notifier.sendOrderConfirmation(user.email, order);
return order;
}
private calculateTotal(items: OrderItem[]): number {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
}
interface CreateOrderInput {
userId: string;
items: OrderItem[];
}
class DomainError extends Error {
constructor(
public readonly code: string,
message: string
) {
super(message);
this.name = "DomainError";
}
}Adaptadores de infraestructura
Los adaptadores implementan las interfaces definidas por el dominio. Contienen todo el código específico de infraestructura.
// infrastructure/repositories/prisma-order-repository.ts
class PrismaOrderRepository implements OrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async create(
data: Omit<Order, "id" | "createdAt">
): Promise<Order> {
const record = await this.prisma.order.create({
data: {
userId: data.userId,
total: data.total,
status: data.status,
items: {
create: data.items.map(item => ({
productId: item.productId,
name: item.name,
price: item.price,
quantity: item.quantity,
})),
},
},
include: { items: true },
});
return this.toDomain(record);
}
async findById(id: string): Promise<Order | null> {
const record = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
});
return record ? this.toDomain(record) : null;
}
async findByUserId(userId: string): Promise<Order[]> {
const records = await this.prisma.order.findMany({
where: { userId },
include: { items: true },
orderBy: { createdAt: "desc" },
});
return records.map(r => this.toDomain(r));
}
private toDomain(record: PrismaOrder & { items: PrismaOrderItem[] }): Order {
return {
id: record.id,
userId: record.userId,
total: record.total.toNumber(),
status: record.status as OrderStatus,
createdAt: record.createdAt,
items: record.items.map(i => ({
productId: i.productId,
name: i.name,
price: i.price.toNumber(),
quantity: i.quantity,
})),
};
}
}// infrastructure/notifications/email-notification-service.ts
class EmailNotificationService implements NotificationService {
constructor(
private readonly emailClient: EmailClient,
private readonly templateEngine: TemplateEngine
) {}
async sendOrderConfirmation(
email: string,
order: Order
): Promise<void> {
const html = this.templateEngine.render("order-confirmation", {
orderId: order.id,
total: order.total.toFixed(2),
items: order.items,
});
await this.emailClient.send({
to: email,
subject: `Order #${order.id} Confirmed`,
html,
});
}
}Tests sin infraestructura
La recompensa de la arquitectura limpia es el testing. Los tests de la lógica de negocio se ejecutan en milisegundos sin bases de datos, servicios externos ni llamadas de red.
describe("CreateOrderUseCase", () => {
const mockOrderRepo: OrderRepository = {
create: async (data) => ({
id: "order-1",
createdAt: new Date(),
...data,
}),
findById: async () => null,
findByUserId: async () => [],
};
const mockUserRepo: UserRepository = {
findById: async (id) =>
id === "user-1"
? { id: "user-1", email: "test@example.com", name: "Test" }
: null,
};
const mockNotifier: NotificationService = {
sendOrderConfirmation: async () => {},
};
const useCase = new CreateOrderUseCase(
mockOrderRepo,
mockUserRepo,
mockNotifier
);
test("creates order with correct total", async () => {
const order = await useCase.execute({
userId: "user-1",
items: [
{ productId: "p1", name: "Widget", price: 10, quantity: 3 },
{ productId: "p2", name: "Gadget", price: 25, quantity: 1 },
],
});
expect(order.total).toBe(55);
expect(order.status).toBe("pending");
});
test("rejects order for non-existent user", async () => {
await expect(
useCase.execute({
userId: "non-existent",
items: [{ productId: "p1", name: "X", price: 10, quantity: 1 }],
})
).rejects.toThrow("User does not exist");
});
test("rejects order with too many items", async () => {
const items = Array.from({ length: 51 }, (_, i) => ({
productId: `p${i}`,
name: `Item ${i}`,
price: 1,
quantity: 1,
}));
await expect(
useCase.execute({ userId: "user-1", items })
).rejects.toThrow("Maximum 50 items per order");
});
});Composition Root: conectándolo todo
El composition root es donde creas las implementaciones concretas y las inyectas en los casos de uso. Es el único lugar que conoce todos los detalles de infraestructura.
// infrastructure/composition-root.ts
function createOrderModule(config: AppConfig) {
const prisma = new PrismaClient();
const emailClient = new SendGridClient(config.sendgridApiKey);
const templateEngine = new HandlebarsTemplateEngine();
const orderRepo = new PrismaOrderRepository(prisma);
const userRepo = new PrismaUserRepository(prisma);
const notifier = new EmailNotificationService(emailClient, templateEngine);
const createOrder = new CreateOrderUseCase(orderRepo, userRepo, notifier);
return { createOrder };
}
// api/routes/orders.ts — thin adapter layer
function orderRoutes(app: Express, modules: ReturnType<typeof createOrderModule>) {
app.post("/orders", async (req, res) => {
try {
const order = await modules.createOrder.execute({
userId: req.user.id,
items: req.body.items,
});
res.status(201).json(order);
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({ code: error.code, message: error.message });
} else {
res.status(500).json({ message: "Internal server error" });
}
}
});
}Conclusiones clave
La arquitectura limpia trata de una regla aplicada de forma consistente: las capas internas nunca dependen de las externas. Define tus entidades de negocio y casos de uso en la capa de dominio, expresa las dependencias externas como interfaces (puertos) e implementa esas interfaces en adaptadores de infraestructura. El composition root conecta todo al arrancar la aplicación. La recompensa inmediata es la testeabilidad: los casos de uso se pueden probar con simples objetos mock en milisegundos. La recompensa a largo plazo es la adaptabilidad: cambiar de base de datos, proveedor de email o framework web significa escribir un adaptador nuevo, no reescribir la lógica de negocio. Mantén la arquitectura pragmática: no todas las aplicaciones necesitan cuatro capas. La arquitectura limpia mínima viable es lógica de dominio que depende de interfaces, no de implementaciones.


