SOLID en NestJS

Por: Artiko
nestjsbuenas-practicassolidinyeccion-dependenciasarquitectura-hexagonaltypescripttesting

SOLID en NestJS

NestJS trae un contenedor IoC de fábrica, y eso genera una ilusión peligrosa: como todo se inyecta, uno cree que ya está aplicando inversión de dependencias. No es así. Inyectar una clase concreta por su constructor es inyección de dependencias, pero no es inversión: seguís acoplado a la implementación. SOLID no es un adorno académico acá; es exactamente lo que separa un módulo de Nest que podés testear en aislamiento y reemplazar sin dolor, de uno donde tocar el proveedor de pagos te obliga a reescribir media docena de archivos.

Este capítulo recorre los cinco principios aplicados al framework, cada uno con su antipatrón concreto y su refactor. La idea rectora: el sistema de DI de Nest ya te da todas las herramientas para cumplir SOLID; el trabajo es usarlas a propósito y no por accidente. Damos por sentada la organización modular y hexagonal del capítulo 2, y la mecánica fina de providers la profundizamos en el capítulo 4.

SRP — Una responsabilidad por provider

El Single Responsibility Principle dice que una clase debe tener una sola razón para cambiar. En Nest, la violación más común y más cara es el god-service: un UsersService que valida, orquesta reglas de negocio, arma queries SQL, manda mails, sube archivos a S3 y firma tokens. Cambia cualquiera de esas cosas y tocás el mismo archivo, con el mismo riesgo de regresión en todo lo demás.

El controller que hace de más

El primer síntoma aparece en el controller. Un controller es una capa de transporte: traduce HTTP a una llamada de aplicación y de vuelta. Cuando ves lógica de negocio ahí, ya hay una responsabilidad en el lugar equivocado.

// ANTES (antipatrón): el controller decide reglas de negocio
@Controller('orders')
export class OrdersController {
  constructor(
    @InjectRepository(Order) private readonly repo: Repository<Order>,
    private readonly mailer: MailerService,
  ) {}

  @Post()
  async create(@Body() dto: CreateOrderDto) {
    if (dto.items.length === 0) {
      throw new BadRequestException('Orden vacía');
    }
    const total = dto.items.reduce((acc, i) => acc + i.price * i.qty, 0);
    if (total > 10_000) {
      dto.status = 'needs_review';
    }
    const order = this.repo.create({ ...dto, total });
    await this.repo.save(order);
    await this.mailer.send(dto.email, 'Orden creada', `Total: ${total}`);
    return order;
  }
}

Ese controller tiene al menos cuatro razones para cambiar: el formato HTTP, la regla del monto de revisión, la persistencia y la notificación. Y encima depende directo del Repository de TypeORM y del MailerService: imposible testear la regla de negocio sin levantar una base y un servidor de mail.

// DESPUÉS (mejor): controller fino, negocio en un servicio de aplicación
@Controller('orders')
export class OrdersController {
  constructor(private readonly createOrder: CreateOrderService) {}

  @Post()
  create(@Body() dto: CreateOrderDto): Promise<OrderResponse> {
    return this.createOrder.execute(dto);
  }
}

El controller ahora solo transporta. La regla vive en un servicio de aplicación con una única responsabilidad: orquestar el caso de uso “crear orden”.

// Servicio de negocio: orquesta, no conoce HTTP ni SQL
@Injectable()
export class CreateOrderService {
  private static readonly UMBRAL_REVISION = 10_000;

  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
    @Inject(ORDER_NOTIFIER) private readonly notifier: OrderNotifier,
  ) {}

  async execute(dto: CreateOrderDto): Promise<OrderResponse> {
    const order = Order.create(dto.items, dto.email); // reglas de dominio adentro
    if (order.total > CreateOrderService.UMBRAL_REVISION) {
      order.markForReview();
    }
    await this.orders.save(order);
    await this.notifier.orderCreated(order);
    return OrderResponse.from(order);
  }
}

Fijate cómo quedó cada pieza con una razón para cambiar:

Separar servicios de negocio de servicios de infraestructura

Una regla operativa que rinde mucho: un servicio de negocio no importa nada de infraestructura. Nada de Repository<T>, HttpService, S3Client, Redis, MailerService. Si un servicio de aplicación importa un cliente concreto, mezcló dos responsabilidades y perdiste testeabilidad. La infraestructura se esconde detrás de un puerto (una interface o clase abstracta) y se inyecta por token; eso lo desarrollamos en DIP más abajo.

Cómo detectar el god-service en tu código sin leerlo entero:

flowchart TD
  A[Revisar un service] --> B{Cuántos providers<br/>inyecta el constructor?}
  B -->|más de 4-5| C[Sospechoso]
  B -->|1 a 3| D{Importa clientes<br/>de infraestructura?}
  C --> E{Los métodos comparten<br/>estado o son islas?}
  E -->|islas independientes| F[God-service:<br/>partir por caso de uso]
  E -->|comparten estado| G[Revisar cohesión<br/>caso por caso]
  D -->|sí, TypeORM/HTTP/S3| H[Fuga de infraestructura:<br/>esconder tras un puerto]
  D -->|no| I[Probablemente OK]

Métrica concreta: si un provider supera ~5 dependencias inyectadas o ~300 líneas con métodos que no comparten estado entre sí, casi siempre son varios casos de uso disfrazados de uno. Partirlo en servicios por caso de uso baja el acoplamiento y hace que cada test unitario monte solo los dobles que ese caso necesita, en vez de una maraña de mocks.

OCP — Abierto a extensión, cerrado a modificación

El Open/Closed Principle pide poder agregar comportamiento sin editar el código existente. El antipatrón clásico es el switch que crece: cada nuevo caso te obliga a abrir el mismo método y agregar una rama, con riesgo de romper las anteriores.

// ANTES (antipatrón): un switch que hay que editar en cada feature nueva
@Injectable()
export class PaymentService {
  async pay(method: string, amount: number): Promise<PaymentResult> {
    switch (method) {
      case 'stripe':
        // ... 20 líneas de Stripe
        return this.stripe(amount);
      case 'paypal':
        // ... 20 líneas de PayPal
        return this.paypal(amount);
      // agregar MercadoPago = editar esta clase = O(n) ramas y test de todo
      default:
        throw new BadRequestException(`Método no soportado: ${method}`);
    }
  }
}

Strategy inyectado con provider tokens múltiples

La solución idiomática en Nest combina el patrón Strategy con un provider token que registra varias implementaciones. Nest permite mapear un mismo token a un array de providers y resolverlo como colección; cada estrategia se auto-describe con la clave que maneja.

// Puerto común: contrato de una estrategia de pago
export interface PaymentStrategy {
  readonly method: string; // 'stripe' | 'paypal' | 'mercadopago'
  pay(amount: number): Promise<PaymentResult>;
}

export const PAYMENT_STRATEGIES = Symbol('PAYMENT_STRATEGIES');
// Cada estrategia es un provider independiente (SRP + OCP)
@Injectable()
export class StripeStrategy implements PaymentStrategy {
  readonly method = 'stripe';
  pay(amount: number) {
    /* ... */
  }
}

@Injectable()
export class MercadoPagoStrategy implements PaymentStrategy {
  readonly method = 'mercadopago';
  pay(amount: number) {
    /* ... */
  }
}
// Registramos el array bajo un token con useFactory
@Module({
  providers: [
    StripeStrategy,
    PayPalStrategy,
    MercadoPagoStrategy,
    {
      provide: PAYMENT_STRATEGIES,
      useFactory: (...strategies: PaymentStrategy[]) => strategies,
      inject: [StripeStrategy, PayPalStrategy, MercadoPagoStrategy],
    },
  ],
})
export class PaymentModule {}
// El servicio resuelve por clave; ya no tiene switch que editar
@Injectable()
export class PaymentService {
  private readonly byMethod: Map<string, PaymentStrategy>;

  constructor(
    @Inject(PAYMENT_STRATEGIES) strategies: PaymentStrategy[],
  ) {
    this.byMethod = new Map(strategies.map((s) => [s.method, s]));
  }

  pay(method: string, amount: number): Promise<PaymentResult> {
    const strategy = this.byMethod.get(method);
    if (!strategy) {
      throw new BadRequestException(`Método no soportado: ${method}`);
    }
    return strategy.pay(amount);
  }
}

Agregar MercadoPago ahora es: crear una clase nueva y sumarla al array de providers. No se toca PaymentService, no hay que re-testear las estrategias existentes, y el lookup por Map es O(1) en vez del switch que en el peor caso recorre todas las ramas. Extensión sin modificación, literal.

Handlers por descubrimiento (discovery)

Cuando las implementaciones se multiplican (event handlers, comandos CQRS, jobs), listar cada una a mano en el array de inject se vuelve tedioso y frágil. Nest ofrece DiscoveryService (paquete @nestjs/core) para descubrir en tiempo de arranque todos los providers marcados con un decorador de metadatos, sin enumerarlos.

// Decorador de metadatos para marcar handlers
export const HANDLES = 'HANDLES_EVENT';
export const Handles = (event: string) => SetMetadata(HANDLES, event);

@Injectable()
@Handles('order.created')
export class SendReceiptHandler implements EventHandler {
  handle(event: DomainEvent) {
    /* ... */
  }
}
// Registro automático: descubre todo lo marcado con @Handles
@Injectable()
export class HandlerRegistry implements OnModuleInit {
  private readonly registry = new Map<string, EventHandler>();

  constructor(private readonly discovery: DiscoveryService) {}

  onModuleInit() {
    for (const wrapper of this.discovery.getProviders()) {
      const { instance } = wrapper;
      if (!instance) continue;
      const event = this.discovery.getMetadataByDecorator(Handles, wrapper);
      if (event) this.registry.set(event, instance as EventHandler);
    }
  }

  dispatch(event: DomainEvent) {
    return this.registry.get(event.name)?.handle(event);
  }
}

Un handler nuevo solo necesita el decorador @Handles(...); el registro lo recoge solo en onModuleInit. El sistema queda abierto a extensión (agregás clases) y cerrado a modificación (el dispatcher no cambia nunca). En NestJS 12, con el ecosistema migrando a ESM y Rspack, este patrón de descubrimiento por metadatos sigue vigente; lo que conviene revisar es que tu tooling de build no haga tree-shaking de providers que solo se referencian por metadatos.

LSP — Sustitución sin romper contratos

El Liskov Substitution Principle exige que cualquier implementación de un puerto sea intercambiable sin que el consumidor note la diferencia ni cambie su comportamiento observable. En Nest esto se juega en repositorios y gateways: si tenés un PaymentGateway, cambiar de Stripe a PayPal debe ser transparente para quien lo usa.

Las violaciones de LSP son silenciosas y por eso peligrosas. No fallan al compilar; fallan en producción cuando una implementación rompe una precondición o poscondición implícita del contrato.

// ANTES (antipatrón): una implementación viola el contrato del puerto
export interface UserRepository {
  findById(id: string): Promise<User | null>; // contrato: null si no existe
}

@Injectable()
export class HttpUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    const res = await this.http.get(`/users/${id}`);
    // Rompe LSP: el contrato dice "null si no existe", esta tira excepción
    if (res.status === 404) {
      throw new NotFoundException();
    }
    return res.data;
  }
}

El consumidor que hacía const user = await repo.findById(id); if (!user) ... funciona con la implementación de base de datos, pero explota con la HTTP: nunca recibe null, recibe una excepción que no esperaba. Son sustituibles según TypeScript (ambas implementan la interface) pero no según el comportamiento.

// DESPUÉS (mejor): toda implementación honra la misma poscondición
@Injectable()
export class HttpUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    const res = await this.http.get(`/users/${id}`, {
      validateStatus: (s) => s === 200 || s === 404,
    });
    return res.status === 404 ? null : User.fromApi(res.data);
  }
}

Reglas prácticas para no romper LSP entre implementaciones de un mismo puerto:

El pago de esto es directo: podés tener InMemoryUserRepository para tests, TypeOrmUserRepository en producción y HttpUserRepository para un microservicio, y el servicio de negocio se comporta idéntico con los tres. Eso es LSP haciendo su trabajo.

ISP — Interfaces pequeñas y cohesivas

El Interface Segregation Principle dice que ningún consumidor debe depender de métodos que no usa. En Nest el antipatrón es el puerto gordo: una interface UserRepository con veinte métodos que todos inyectan, aunque cada consumidor use dos o tres.

// ANTES (antipatrón): un puerto que hace de todo
export interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
  findAllPaginated(page: number, size: number): Promise<Page<User>>;
  countActiveByPlan(plan: string): Promise<number>;
  exportToCsv(): Promise<Buffer>;
  bulkImport(rows: UserRow[]): Promise<ImportReport>;
  // ... y sigue
}

Un LoginService que solo necesita findByEmail termina acoplado a exportToCsv y bulkImport. Cuando testeás el login, tenés que mockear (o dejar en undefined) métodos que no tienen nada que ver. Y cualquier cambio de firma en bulkImport recompila y potencialmente rompe consumidores que ni lo tocan.

// DESPUÉS (mejor): puertos segregados por caso de uso
export interface UserFinder {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
}

export interface UserWriter {
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

export interface UserReporting {
  exportToCsv(): Promise<Buffer>;
  countActiveByPlan(plan: string): Promise<number>;
}

export const USER_FINDER = Symbol('USER_FINDER');
export const USER_WRITER = Symbol('USER_WRITER');
export const USER_REPORTING = Symbol('USER_REPORTING');

Cada consumidor inyecta solo el puerto que necesita:

@Injectable()
export class LoginService {
  constructor(@Inject(USER_FINDER) private readonly users: UserFinder) {}
  // Solo ve findById/findByEmail. Imposible acoplarse a exportToCsv.
}

Un detalle práctico: una misma clase de infraestructura puede implementar varios puertos a la vez, y la registrás bajo cada token con useExisting para no duplicar instancias.

@Module({
  providers: [
    TypeOrmUserRepository, // implementa UserFinder + UserWriter
    { provide: USER_FINDER, useExisting: TypeOrmUserRepository },
    { provide: USER_WRITER, useExisting: TypeOrmUserRepository },
  ],
})
export class UsersModule {}

La implementación queda una sola (una instancia compartida gracias a useExisting), pero los contratos están segregados: cada servicio depende de la superficie mínima. Si mañana el reporting se va a un servicio aparte con otra tecnología, movés USER_REPORTING sin tocar a LoginService.

DIP — Depender de abstracciones, no de concreciones

El Dependency Inversion Principle es el corazón de todo lo anterior y de la arquitectura hexagonal del capítulo 2: los módulos de alto nivel (negocio) no deben depender de los de bajo nivel (infraestructura); ambos dependen de abstracciones. Y las abstracciones no dependen de los detalles: es el detalle (el adapter de TypeORM) el que depende de la abstracción (el puerto).

El antipatrón es el más natural de escribir en Nest, justamente porque el framework lo hace fácil:

// ANTES (antipatrón): el negocio depende de la implementación concreta
@Injectable()
export class CreateOrderService {
  constructor(
    // Acoplado a TypeORM: no podés testear sin base, no podés cambiar de ORM
    @InjectRepository(Order) private readonly repo: Repository<Order>,
  ) {}
}

Repository<Order> es una clase concreta de TypeORM. Este servicio de negocio ahora sabe que persistís con TypeORM. Para testearlo necesitás una base real o un mock enorme del Repository. Cambiar a Prisma o Drizzle significa reescribir el servicio. La dependencia apunta hacia el detalle: DIP invertido al revés.

// DESPUÉS (mejor): el negocio depende de un puerto, la infra lo implementa

// 1) El puerto vive en la capa de dominio/aplicación
export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
}
export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');

// 2) El servicio de negocio depende solo del puerto
@Injectable()
export class CreateOrderService {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
  ) {}
}

// 3) El adapter de infraestructura implementa el puerto
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
  constructor(
    @InjectRepository(OrderEntity) private readonly repo: Repository<OrderEntity>,
  ) {}
  async save(order: Order): Promise<void> {
    await this.repo.save(OrderMapper.toPersistence(order));
  }
  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.repo.findOneBy({ id: id.value });
    return row ? OrderMapper.toDomain(row) : null;
  }
}

// 4) El módulo cablea el token a la implementación (el único lugar que las conoce)
@Module({
  providers: [
    CreateOrderService,
    { provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
  ],
})
export class OrdersModule {}

Ahora la flecha de dependencia apunta hacia la abstracción desde ambos lados. El servicio de negocio no importa nada de TypeORM; el TypeOrmOrderRepository importa el puerto. El módulo es el único punto que conoce a las dos partes, y es ahí donde decidís la implementación: useClass: TypeOrmOrderRepository en producción, useValue: new InMemoryOrderRepository() en un test.

flowchart TD
  subgraph alto["Alto nivel — Aplicación / Dominio"]
    S[CreateOrderService]
  end
  subgraph abstraccion["Abstracción — Puerto"]
    P["OrderRepository<br/>(interface + ORDER_REPOSITORY)"]
  end
  subgraph bajo["Bajo nivel — Infraestructura"]
    T[TypeOrmOrderRepository]
    M[InMemoryOrderRepository]
    H[HttpOrderRepository]
  end
  S -->|depende de| P
  T -.implementa.-> P
  M -.implementa.-> P
  H -.implementa.-> P
  Mod[["OrdersModule<br/>cablea el token"]] -.provee.-> P

Leé el diagrama así: las flechas sólidas son dependencias en tiempo de compilación (el negocio depende del puerto), las punteadas son “implementa” (la infra apunta hacia arriba, hacia el puerto). El bajo nivel depende de la abstracción, no al revés: eso es la inversión. El OrdersModule es el composition root que elige qué adapter concreto entra.

Por qué esto es el corazón de la testeabilidad

Con DIP bien hecho, testear CreateOrderService no toca infraestructura:

it('marca para revisión las órdenes grandes', async () => {
  const repo = new InMemoryOrderRepository(); // doble simple, sin mocks frágiles
  const notifier = new FakeNotifier();
  const service = new CreateOrderService(repo, notifier);

  await service.execute(unaOrdenDe(15_000));

  expect(repo.last()!.status).toBe('needs_review');
});

Sin base de datos, sin Test.createTestingModule pesado, sin jest.mock. El test corre en milisegundos y no es flaky. Ese es el retorno concreto de invertir dependencias: la unidad de negocio es una función pura de sus puertos.

Cómo el DI de Nest habilita naturalmente DIP e ISP

El contenedor de Nest resuelve dependencias por token, no por posición ni por tipo concreto. Ese detalle es justo lo que hace baratos a DIP e ISP:

Sobre el token en sí: las interfaces de TypeScript se borran en runtime, así que no pueden ser tokens de inyección. Tenés dos opciones canónicas:

// Variante con clase abstracta: token y contrato en un solo artefacto
export abstract class OrderRepository {
  abstract save(order: Order): Promise<void>;
  abstract findById(id: OrderId): Promise<Order | null>;
}

@Injectable()
export class CreateOrderService {
  // Sin @Inject: la clase abstracta ES el token
  constructor(private readonly orders: OrderRepository) {}
}

@Module({
  providers: [{ provide: OrderRepository, useClass: TypeOrmOrderRepository }],
})
export class OrdersModule {}

Ninguna de las dos es “la correcta” en abstracto: la clase abstracta reduce boilerplate (@Inject), el Symbol mantiene el puerto como interface pura y desacopla más. Lo que sí importa es que el servicio de alto nivel dependa del token/abstracción, nunca de TypeOrmOrderRepository directo.

Los cinco principios trabajando juntos

SOLID no son cinco reglas aisladas; se refuerzan entre sí. SRP te da piezas chicas; ISP define contratos chicos para esas piezas; DIP invierte las dependencias hacia esos contratos; LSP garantiza que cualquier implementación del contrato sea intercambiable; OCP aprovecha todo eso para agregar comportamiento sin editar lo existente.

flowchart LR
  SRP["SRP<br/>piezas de una<br/>responsabilidad"] --> ISP["ISP<br/>contratos<br/>chicos"]
  ISP --> DIP["DIP<br/>depender de<br/>los contratos"]
  DIP --> LSP["LSP<br/>implementaciones<br/>intercambiables"]
  LSP --> OCP["OCP<br/>extender sin<br/>modificar"]
  OCP -.habilita más piezas.-> SRP

El hilo conductor en Nest es siempre el mismo: el provider token es la costura. Donde ponés un token en vez de una clase concreta, ganás un punto de sustitución, de test y de extensión. Donde inyectás la clase concreta, lo perdés.

Checklist

Con SOLID como criterio y el token como costura, el próximo paso es dominar el mecanismo que lo hace posible: los custom providers, los scopes, useFactory/useClass/useValue y la resolución de dependencias circulares. Seguimos en Providers y DI avanzada →.