Introducción
En el post anterior vimos una implementación de Clean Architecture en un frontend con Next.js. Hoy vamos a bajar un nivel: construir un caso de uso pequeño desde cero y comprobar qué significa realmente que las dependencias apunten hacia dentro.
El ejemplo será una reserva de una actividad. Una persona elige plazas, el sistema comprueba la disponibilidad y registra la reserva. No necesitamos decidir si los datos vienen de PostgreSQL, de una API o de un mock para entender la regla de negocio.
El caso de uso antes que las carpetas
Antes de crear una estructura enorme, escribamos la operación que queremos proteger:
Una reserva solo puede confirmarse si hay suficientes plazas. Al confirmarla, se guardan sus datos y se notifica al cliente.
Esta frase ya nos da tres colaboraciones: consultar disponibilidad, guardar una reserva y enviar una notificación. Son necesidades de la aplicación, no tecnologías concretas.
src/reservas/application/ports.ts export type Reservation = {
id: string
activityId: string
customerEmail: string
seats: number
status: "confirmed"
}
export interface AvailabilityPort {
hasAvailableSeats(activityId: string, seats: number): Promise<boolean>
}
export interface ReservationRepository {
save(reservation: Reservation): Promise<void>
}
export interface NotificationPort {
sendReservationConfirmed(input: {
email: string
reservationId: string
}): Promise<void>
}
Son puertos, también llamados interfaces de salida. Están definidos junto al caso de uso porque es la aplicación quien decide qué necesita del exterior.
El caso de uso recibe abstracciones
Ahora podemos orquestar la regla sin importar un SDK, fetch ni un cliente de correo.
src/reservas/application/create-reservation.ts import {
AvailabilityPort,
NotificationPort,
Reservation,
ReservationRepository,
} from "./ports"
type Dependencies = {
availability: AvailabilityPort
reservations: ReservationRepository
notifications: NotificationPort
createId: () => string
}
type CreateReservationInput = {
activityId: string
customerEmail: string
seats: number
}
export class NoSeatsAvailableError extends Error {}
export function createReservation(dependencies: Dependencies) {
return async (input: CreateReservationInput): Promise<Reservation> => {
if (!Number.isInteger(input.seats) || input.seats <= 0) {
throw new Error("El número de plazas debe ser mayor que cero")
}
const hasSeats = await dependencies.availability.hasAvailableSeats(
input.activityId,
input.seats
)
if (!hasSeats) {
throw new NoSeatsAvailableError("No hay plazas suficientes")
}
const reservation: Reservation = {
id: dependencies.createId(),
activityId: input.activityId,
customerEmail: input.customerEmail,
seats: input.seats,
status: "confirmed",
}
await dependencies.reservations.save(reservation)
await dependencies.notifications.sendReservationConfirmed({
email: reservation.customerEmail,
reservationId: reservation.id,
})
return reservation
}
}
La dependencia createId puede parecer un detalle menor, pero es una buena muestra de inversión de dependencias. Si llamamos directamente a crypto.randomUUID(), el test se vuelve menos determinista. Al recibir esa decisión desde fuera, el caso de uso conserva el control de su comportamiento.
Un test que no levanta infraestructura
Como el caso de uso solo conoce interfaces, el test puede usar adaptadores en memoria. Es rápido y deja clara la regla que estamos protegiendo.
src/reservas/application/create-reservation.test.ts import { createReservation, NoSeatsAvailableError } from "./create-reservation"
describe("createReservation", () => {
it("guarda y notifica una reserva cuando hay plazas", async () => {
const saved = [] as unknown[]
const notified = [] as unknown[]
const useCase = createReservation({
availability: { hasAvailableSeats: async () => true },
reservations: { save: async (reservation) => { saved.push(reservation) } },
notifications: {
sendReservationConfirmed: async (message) => { notified.push(message) },
},
createId: () => "reservation-123",
})
const reservation = await useCase({
activityId: "kayak",
customerEmail: "ana@example.com",
seats: 2,
})
expect(reservation.id).toBe("reservation-123")
expect(saved).toHaveLength(1)
expect(notified).toEqual([
{ email: "ana@example.com", reservationId: "reservation-123" },
])
})
it("no guarda ni notifica cuando no hay plazas", async () => {
const useCase = createReservation({
availability: { hasAvailableSeats: async () => false },
reservations: { save: async () => { throw new Error("No debería guardar") } },
notifications: { sendReservationConfirmed: async () => { throw new Error("No debería notificar") } },
createId: () => "reservation-123",
})
await expect(useCase({ activityId: "kayak", customerEmail: "ana@example.com", seats: 2 }))
.rejects.toBeInstanceOf(NoSeatsAvailableError)
})
})
No hemos mockeado módulos ni montado un servidor. Estamos probando una decisión de negocio con colaboradores que se comportan como los puertos reales.
Los adaptadores viven fuera
Una implementación con HTTP puede vivir en infraestructura. Depende de la interfaz interna, pero la interfaz no sabe que existe esta clase.
src/reservas/infrastructure/http-availability.ts import { AvailabilityPort } from "../application/ports"
export class HttpAvailabilityAdapter implements AvailabilityPort {
constructor(private readonly baseUrl: string) {}
async hasAvailableSeats(activityId: string, seats: number): Promise<boolean> {
const response = await fetch(`${this.baseUrl}/activities/${activityId}/availability`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ seats }),
})
if (!response.ok) {
throw new Error("No se pudo consultar la disponibilidad")
}
const data: { available: boolean } = await response.json()
return data.available
}
}
Podríamos crear del mismo modo PostgresReservationRepository y EmailNotificationAdapter. La aplicación no necesita cambiar cuando pasamos de un correo real a una cola o de REST a GraphQL.
El lugar correcto para unirlo todo
La composición ocurre en el borde de la aplicación: un módulo de inyección, un route handler o un servidor de Next.js. Ahí sí conocemos tecnologías concretas.
src/reservas/composition/create-reservation-controller.ts import { createReservation } from "../application/create-reservation"
import { HttpAvailabilityAdapter } from "../infrastructure/http-availability"
import { PostgresReservationRepository } from "../infrastructure/postgres-reservation-repository"
import { EmailNotificationAdapter } from "../infrastructure/email-notification-adapter"
const createReservationUseCase = createReservation({
availability: new HttpAvailabilityAdapter(process.env.ACTIVITIES_API_URL!),
reservations: new PostgresReservationRepository(),
notifications: new EmailNotificationAdapter(),
createId: crypto.randomUUID,
})
export async function createReservationController(request: Request) {
const input = await request.json()
const reservation = await createReservationUseCase(input)
return Response.json(reservation, { status: 201 })
}
Este es el único sitio que necesita conocer PostgreSQL, correo, variables de entorno y HTTP. Si algo externo cambia, buscamos desde fuera hacia dentro; las reglas centrales permanecen estables.
¿Cuándo merece la pena?
Clean Architecture añade interfaces y puntos de composición, así que no es una obligación para un formulario aislado. Empieza a compensar cuando hay reglas que sobreviven a la UI, integraciones que pueden cambiar o necesidades de testear decisiones sin arrancar media aplicación.
Una estructura razonable puede empezar así:
src/reservas/
application/
create-reservation.ts
ports.ts
infrastructure/
http-availability.ts
postgres-reservation-repository.ts
composition/
create-reservation-controller.ts
No hace falta crear todas las capas desde el primer día. Lo importante es que cuando aparezca una dependencia externa, la dirección de las importaciones siga respetando la regla: el negocio no depende del detalle.
Conclusión
La arquitectura limpia no consiste en añadir carpetas; consiste en proteger las decisiones que más cambian el valor del producto. En TypeScript, los puertos expresan qué necesita el caso de uso y los adaptadores se ocupan de cómo conseguirlo.
Si puedes ejecutar el corazón de una operación con objetos en memoria y sin saber qué framework la invoca, vas por muy buen camino.