Robert C. Martin acuñó el acrónimo SOLID en Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002). Cada letra nombra una regla de diseño que responde a un tipo de dolor de mantenimiento. Cada sección de abajo muestra el patrón de código que viola el principio, la versión corregida y lo que cambia después.

S: responsabilidad única

Una clase o función cambia por un motivo. Cuando combina escritura de datos, envío de emails y auditoría, cualquier cambio en esas tres frentes toca el mismo archivo.

// viola: cambia por tres motivos
class UserService {
  async createUser(data: CreateUserDto) {
    const user = await this.db.user.create({ data })
    await this.sendWelcomeEmail(user.email)
    await this.auditLog('user.created', user.id)
    return user
  }
}

// corregido: cada responsabilidad en su lugar
class UserService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly emailService: EmailService,
    private readonly auditService: AuditService
  ) {}

  async createUser(data: CreateUserDto) {
    const user = await this.userRepo.create(data)
    await this.emailService.sendWelcome(user.email)
    await this.auditService.log('user.created', user.id)
    return user
  }
}

La versión corregida cambia cuando cambia el repositorio, el template de email o la política de auditoría. Martin llama a ese número de motivos el "axis of change", y Clean Code (Prentice Hall, 2008) repite la misma idea para funciones: función corta, un nombre, una acción.

O: abierto para extensión, cerrado para modificación

Agregar un medio de pago no debería tocar la clase que procesa pagos. El código de abajo exige editar el método cada vez que entra un tipo nuevo:

// viola: cada tipo nuevo obliga a editar acá
class PaymentProcessor {
  process(type: string, amount: number) {
    if (type === 'credit_card') { /* ... */ }
    else if (type === 'pix') { /* ... */ }
    // 'boleto' obliga a tocar este método
  }
}

// corregido: un tipo nuevo se vuelve una clase nueva
interface PaymentMethod {
  process(amount: number): Promise<PaymentResult>
}

class CreditCardPayment implements PaymentMethod {
  async process(amount: number) { /* ... */ }
}

class PixPayment implements PaymentMethod {
  async process(amount: number) { /* ... */ }
}

BoletoPayment llega después sin tocar ninguna de las clases de arriba. El Strategy pattern de Refactoring.Guru es la misma estructura con otro nombre.

L: sustitución de Liskov

Un subtipo ocupa el lugar del tipo base y el programa sigue correcto. Cuando el código pide instanceof para decidir comportamiento, el contrato ya se rompió.

// viola: Square cambia la altura junto con el ancho
class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number) { this.width = w }
  setHeight(h: number) { this.height = h }
  area() { return this.width * this.height }
}

class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w }
  setHeight(h: number) { this.width = h; this.height = h }
}

function increaseWidth(rect: Rectangle) {
  rect.setWidth(rect.width + 1) // en un Square la altura se mueve también
}

// corregido: un contrato que ambos tipos cumplen
interface Shape {
  area(): number
  scale(factor: number): Shape
}

Rectangle y Square comparten geometría y no comparten nada de los setters. Jerarquías separadas, o composición, lo arreglan sin engañar al compilador.

I: segregación de interfaces

Una interfaz ancha obliga a quien la implementa a escribir métodos que no usa. El DataStore de abajo le exige a un consumidor de solo lectura firmar connect(), write() y subscribe().

// viola: cinco métodos para un llamador que necesita tres
interface DataStore {
  read(id: string): Promise<any>
  write(id: string, data: any): Promise<void>
  delete(id: string): Promise<void>
  subscribe(event: string, cb: Function): void
  connect(): Promise<void>
}

// corregido: una interfaz por capacidad
interface Reader {
  read(id: string): Promise<any>
}

interface Writer {
  write(id: string, data: any): Promise<void>
}

interface EventSource {
  subscribe(event: string, cb: Function): void
}

TypeScript ayuda acá: Pick<DataStore, 'read'> cubre el caso puntual, mientras que una interfaz nombrada le dice al siguiente lector de qué se trata la capacidad.

D: inversión de dependencia

Los servicios de alto nivel declaran de qué necesitan y reciben una implementación lista. Quien instancia queda en el borde de la aplicación.

// viola: una instancia concreta dentro del servicio
class OrderService {
  constructor() {
    this.db = new PrismaClient()
  }
}

// corregido: contrato en el dominio, implementación afuera
interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: string): Promise<Order | null>
}

class OrderService {
  constructor(private readonly repo: OrderRepository) {}
}

En producción el container inyecta PrismaOrderRepository; un test inyecta InMemoryOrderRepository y corre sin Postgres. Un framework de inyección como el wiring de reflect-metadata en NestJS automatiza el ensamblado, pero la decisión de desacoplar sigue siendo del código de dominio.

Dónde aplica y dónde no se toca

SOLID entra donde el cambio es caro: reglas de negocio, capas de servicio, puntos de extensión que el equipo toca todas las semanas. Una función utilitaria de cuatro líneas, un script de migración y código de configuración no reciben interfaz ni herencia.

Martin Fowler dibuja ese límite en Is Design Dead?: el diseño evolutivo parte de una estructura simple y refactoriza cuando aparece el dolor. La pregunta práctica antes de alcanzar un patrón: si el cambio X llega mañana, cuántos archivos se rompen? Pocos, sigue de largo. Muchos, extrae la abstracción.

Referencias

  • Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002)
  • Robert C. Martin, Clean Code (Prentice Hall, 2008)
  • Is Design Dead? (Martin Fowler, 2001)
  • Strategy Pattern (Refactoring.Guru)
  • SOLID (Wikipedia)