El desarrollo de software cambia todo el tiempo. La arquitectura débil se vuelve mantenimiento caro, feature lenta, prueba difícil y bug difícil de aislar. Vale invertir en una estructura que soporte evolución sin reescribir el sistema ante cada presión del negocio.
Un poco de historia
Clean Architecture es el nombre que Robert C. Martin (Uncle Bob) dio, en 2012, en el libro Clean Architecture: A Craftsman’s Guide to Software Structure and Design. La propuesta huye de la rigidez de arquitecturas acopladas a framework y base: el núcleo queda estable; los detalles externos cambian. La idea bebe de DDD, SOLID, Onion Architecture y Hexagonal Architecture.
Propuesta general
Este artículo describe Clean Architecture y una derivación práctica para backends en TypeScript: tres capas — Core, Adapters e Infra.
- Core — regla de negocio y entidades del dominio. Capa más interna.
- Infra — conexiones externas: repositorios concretos, controllers REST, módulos de DI, boilerplate de framework.
- Adapters — intermediación en ambos sentidos. El controller no llama al usecase “crudo”: pasa por un servicio. El usecase no habla con la base: habla con un protocolo que un adapter (repositorio, connector, handler) implementa.
Cada capa tiene capacidades y restricciones distintas; SOLID pesa más en el Core. Sirve para CRUD HTTP y para sistemas con varios frameworks y canales.
Beneficios concretos: responsabilidades claras (lectura y mantenimiento), flexibilidad para cambiar plugin sin reescribir regla, y pruebas aisladas por capa.
Guía de capas
Ejemplo: CRUD de usuarios vía REST con NestJS. Detalles de instalación quedan fuera. Escritura core-to-infra (de dentro hacia fuera).
Core
En el diseño clásico, domain y entities quedan muy próximas. Aquí forman el Core: todo lo que la regla de negocio es — funcionalidades y representaciones del dominio.
En el ejemplo, la entidad principal es Usuario (id, name), en core/entities.
Entities
// core/entities/UserEntity.ts
export interface UserEntityProps {
id?: string;
name: string;
}
export class UserEntity {
constructor(private readonly props: UserEntityProps) {}
get id(): string {
return this.props.id ?? "";
}
get name(): string {
return this.props.name;
}
}
La entidad recibe props tipadas y expone getters. Depende de una interfaz que cualquier DTO de transferencia puede satisfacer después.
Features y usecases
El CRUD necesita crear, buscar, actualizar y eliminar. En el Core, cada usecase implementa un contrato (feature) con un único método público — alineado a Liskov, abierto/cerrado, segregación de interfaz y responsabilidad única. El usecase no accede a la base: conoce protocols que describen la acción externa (inversión de dependencia).
Registro: nombre obligatorio; si ya existe, error; si no, retorna UserEntity.
- contrato
CreateUser - implementación
CreateUserUsecase
En TypeScript, clase abstracta con métodos abstractos funciona como contrato y valor — útil para DI (const createUserSymbol = CreateUser):
// core/features/CreateUser.ts
export abstract class CreateUser {
abstract execute(name: string): Promise<UserEntity>;
}
// core/usecases/CreateUserUsecase.ts
export class CreateUserUsecase implements CreateUser {
constructor(
private readonly createUserProtocol: CreateUserProtocol,
private readonly getByNameProtocol: GetUserByNameProtocol,
) {}
async execute(name: string): Promise<UserEntity> {
const existsName = await this.getByNameProtocol.getByName(name);
if (existsName) {
throw new UserAlreadyExistsException(
`the name ${name} already exists`,
);
}
return this.createUserProtocol.register(name);
}
}
El usecase define qué (validar nombre, registrar). No define cómo buscar o persistir. La regla queda independiente de lib, framework y base.
Cuidado: un usecase que solo delega al protocol sin validar puede estar empujando regla de negocio hacia el adapter. En CreateUserUsecase, la verificación de nombre duplicado es obligación del Core.
Exceptions
UserAlreadyExistsException pertenece al Core: el flujo inválido de la regla también es regla. Cada fallo mapeado a una excepción conocida ayuda al mantenimiento. Base con code (después se vuelve status HTTP en el borde):
// core/exceptions/IBaseException.ts
export abstract class IBaseException extends Error {
code: number;
constructor(message: string) {
super(message);
}
}
// core/exceptions/UserAlreadyExistsException.ts
export class UserAlreadyExistsException extends IBaseException {
constructor(message?: string) {
super(message ?? "User already exists");
this.code = 400;
}
}
El usecase lanza excepciones; no las trata. Mapear tipo desconocido → tipo conocido queda en adapter o infra.
Protocols
CreateUserProtocol y GetUserByNameProtocol son contratos de acceso a dispositivo externo. El protocol existe para informar o disparar acción externa — no para procesar regla de negocio. Preferencia: un método público por protocol.
// core/protocols/CreateUserProtocol.ts
export abstract class CreateUserProtocol {
abstract register(name: string): Promise<UserEntity>;
}
// core/protocols/GetUserByNameProtocol.ts
export abstract class GetUserByNameProtocol {
abstract getByName(name: string): Promise<UserEntity | null>;
}
El Core es el centro; la capa de adaptación conecta el resto.
Adapter
Los adapters controlan el tráfico bidireccional: externo → regla y regla → externo. Adaptan objetos, parámetros y excepciones — el mismo espíritu del patrón Adapter.
Dos grupos:
- Llamados por el Core — implementan al menos un protocol.
- Llamados por la Infra — en general services.
Connectors, handlers y repositories
Clases que implementan protocols. Cada una adapta un dispositivo externo (ORM, cliente HTTP, cola, filesystem).
Convención de nombres:
- Repositories — protocol ligado a base (vocabulario familiar).
- Connectors — retornan datos sin ser “tabla” (ej.:
ClientHttpFetchConnector,ClientHttpAxiosConnector). - Handlers — procesan sin retorno síncrono (ej.: publicar en Kafka).
Otros nombres son válidos; el criterio es un adapter por dispositivo.
En el CRUD, solo repository (mock):
// adapters/repositories/UsersMockRepository.ts
export class UsersMockRepository
implements
GetUserByIdProtocol,
GetUserByNameProtocol,
CreateUserProtocol,
UpdateUserProtocol,
DeleteUserProtocol
{
private db: DbConnector;
constructor() {
this.db = mockDbConnector;
}
async getById(id: string): Promise<UserEntity> {
return this.db.users.getById(id);
}
async getByName(name: string): Promise<UserEntity | null> {
return this.db.users.getByName(name);
}
async register(name: string): Promise<UserEntity> {
return this.db.users.register(name);
}
async update(id: string, name: string): Promise<UserEntity> {
return this.db.users.update(id, name);
}
async delete(id: string): Promise<void> {
return this.db.users.delete(id);
}
}
Mock del conector:
export const mockDbConnector: DbConnector = {
users: {
getById: async (id: string) =>
Promise.resolve(new UserEntity({ id, name: "Test" })),
getByName: async (name: string) =>
Promise.resolve(new UserEntity({ id: "1", name })),
register: async (name: string) =>
Promise.resolve(new UserEntity({ id: "2", name })),
update: async (id: string, name: string) =>
Promise.resolve(new UserEntity({ id, name })),
delete: async (_id: string) => Promise.resolve(),
},
profiles: {
getById: async (_id: string) => Promise.resolve(null),
getByName: async (_name: string) => Promise.resolve(null),
register: async (_name: string) => Promise.resolve(null),
update: async (_id: string, _name: string) => Promise.resolve(null),
delete: async (_id: string) => Promise.resolve(),
},
};
UserEntity (Core) no es la tabla de la base. Son cosas distintas.
“¿Implementar varios protocols hiere la S de SOLID?” Depende. Separar cada protocol en clase propia maximiza SRP. Mantener un repository por entidad/agregado también es coherente: el alcance es “datos de User en este conector”. Criterios prácticos para no juntar A y B en la misma clase:
- Implementar B exigiría otra dependencia externa.
- A y B trabajan entidades/alcances distintos.
- La complejidad ciclomática (o cognitiva) sube demasiado.
- La clase pasa de ~500 líneas — umbral subjetivo; usa criterio de equipo.
Con DI y segregación de interfaz, CreateUserUsecase solo conoce CreateUserProtocol y GetUserByNameProtocol. En runtime puede recibir la misma instancia de UsersMockRepository en los dos parámetros — sin acoplarse a la clase concreta.
Services
Los services adaptan llamadas de la Infra hacia el Core: REST → service → usecase → protocol → repository → base. El mismo diseño vale para handler de cola o resolver GraphQL: entrada distinta, service en el medio.
export class UserService {
constructor(
private createUserUsecase: CreateUser,
private updateUserUsecase: UpdateUser,
private deleteUserUsecase: DeleteUser,
private getUserUsecase: GetUser,
) {}
async getUser(id: string): Promise<UserEntity> {
try {
return await this.getUserUsecase.execute(id);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
async createUser(name: string): Promise<UserEntity> {
try {
return await this.createUserUsecase.execute(name);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
async updateUser(id: string, name: string): Promise<UserEntity> {
try {
return await this.updateUserUsecase.execute(id, name);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
async deleteUser(id: string): Promise<void> {
try {
return await this.deleteUserUsecase.execute(id);
} catch (error) {
console.error(error);
throw RestError.fromBaseException(error);
}
}
}
El service depende de las features (contratos), no de las clases concretas de usecase. Mapea excepción de Core a RestError que la Infra traduce en respuesta HTTP.
Buena práctica: services solo para Infra; un service por “instrumento” de entrada (controller HTTP ≠ handler de cola), salvo middleware del mismo stack que reutiliza el mismo service.
Infra
Framework, DI, controllers, DTOs y boilerplate que no es negocio ni adapter.
Controller NestJS:
// infra/controllers/UserController.ts
@Controller("users")
export class UserController {
constructor(private service: UserService) {}
@Get(":id")
async getUser(@Param("id") id: string): Promise<UserEntity> {
return this.service.getUser(id);
}
@Post()
async createUser(@Body() user: UserDto): Promise<UserEntity> {
return this.service.createUser(user.name);
}
@Put(":id")
async updateUser(
@Param("id") id: string,
@Body() user: UserDto,
): Promise<UserEntity> {
return this.service.updateUser(id, user.name);
}
@Delete(":id")
async deleteUser(@Param("id") id: string): Promise<void> {
return this.service.deleteUser(id);
}
}
DTO:
// infra/dtos/UserDto.ts
export class UserDto implements UserEntityProps {
id?: string;
name: string;
constructor(props: UserEntityProps) {
this.id = props.id;
this.name = props.name;
}
}
Módulo de DI:
// infra/modules/UserModule.ts
@Module({
imports: [],
controllers: [UserController],
providers: [
UserService,
{ provide: CreateUser, useClass: CreateUserUsecase },
{ provide: UpdateUser, useClass: UpdateUserUsecase },
{ provide: DeleteUser, useClass: DeleteUserUsecase },
{ provide: GetUser, useClass: GetUserUsecase },
{ provide: CreateUserProtocol, useClass: UserPrismaRepository },
{ provide: UpdateUserProtocol, useClass: UserPrismaRepository },
{ provide: DeleteUserProtocol, useClass: UserPrismaRepository },
{ provide: GetUserByNameProtocol, useClass: UserPrismaRepository },
{ provide: GetUserByIdProtocol, useClass: UserPrismaRepository },
],
})
export class UserModule {}
Infra es más “libre”: depende de las herramientas y existe para sostener el Core.
Flujo de ejecución
Controller (Infra) → Service (Adapter) → Usecase (Core) → Protocol (contrato) → Repository (Adapter) → base (Infra). El usecase no importa el repository concreto; el service no importa la clase concreta del usecase. La flecha de dependencia apunta hacia dentro.
CRUD complementario
Además del registro:
Búsqueda
// core/features/GetUser.ts
export abstract class GetUser {
abstract execute(id: string): Promise<UserEntity>;
}
// core/usecases/GetUserUsecase.ts
export class GetUserUsecase implements GetUser {
constructor(private readonly getProtocol: GetUserByIdProtocol) {}
async execute(id: string): Promise<UserEntity> {
return this.getProtocol.getById(id);
}
}
Actualización
// core/features/UpdateUser.ts
export abstract class UpdateUser {
abstract execute(id: string, name: string): Promise<UserEntity>;
}
// core/usecases/UpdateUserUsecase.ts
export class UpdateUserUsecase implements UpdateUser {
constructor(
private readonly updateProtocol: UpdateUserProtocol,
private readonly getByIdProtocol: GetUserByIdProtocol,
) {}
async execute(id: string, name: string): Promise<UserEntity> {
const exists = await this.getByIdProtocol.getById(id);
if (!exists) {
throw new UserNotExistsException(`User with id ${id} not exists`);
}
return this.updateProtocol.update(id, name);
}
}
Eliminación
// core/features/DeleteUser.ts
export abstract class DeleteUser {
abstract execute(id: string): Promise<void>;
}
// core/usecases/DeleteUserUsecase.ts
export class DeleteUserUsecase implements DeleteUser {
constructor(
private readonly deleteProtocol: DeleteUserProtocol,
private readonly getByIdProtocol: GetUserByIdProtocol,
) {}
async execute(id: string): Promise<void> {
const exists = await this.getByIdProtocol.getById(id);
if (!exists) {
throw new UserNotExistsException(`User with id ${id} not exists`);
}
return this.deleteProtocol.delete(id);
}
}
Exception y protocols restantes
export class UserNotExistsException extends IBaseException {
constructor(message: string) {
super(message);
this.code = 404;
}
}
// core/protocols/GetUserByIdProtocol.ts
export abstract class GetUserByIdProtocol {
abstract getById(id: string): Promise<UserEntity>;
}
// core/protocols/UpdateUserProtocol.ts
export abstract class UpdateUserProtocol {
abstract update(id: string, name: string): Promise<UserEntity>;
}
// core/protocols/DeleteUserProtocol.ts
export abstract class DeleteUserProtocol {
abstract delete(id: string): Promise<void>;
}
Conclusión
Separar Core, Adapters e Infra deja la regla de negocio testeable sin Nest, Prisma o HTTP. Cambiar base o canal de entrada se vuelve cambio de adapter y wiring de DI — no reescritura del usecase. El costo es más archivos y disciplina de frontera; la ganancia aparece cuando el sistema necesita cambiar sin arrastrar el dominio.
Publicado originalmente en Medium (15/03/2023).