Los principios SOLID no son reglas, son compensaciones
SOLID es el conjunto de principios más citado y menos comprendido en la ingeniería de software. La mayoría de las explicaciones se detienen en el acrónimo. Aquí te explicamos lo que cada principio significa realmente para tu base de código, y cuándo romperlos.
El problema con cómo se enseñan los principios SOLID
Cada blog de ingeniería tiene un artículo sobre SOLID. La mayoría de ellos se leen así: "La S significa Principio de Responsabilidad Única. Una clase debería tener solo una razón para cambiar." Luego, un ejemplo trivial con una clase User, y se supone que debes sentirte iluminado.
El problema no es que la definición sea incorrecta. Es que es inútil sin contexto. "Una razón para cambiar" no es una medida objetiva; depende del eje de cambio para el que estés diseñando. Y eso depende de tu sistema, tu equipo y tu dominio.
Los principios SOLID no son leyes de la física. Son heurísticas de diseño que describen compensaciones (trade-offs). Comprender la compensación es la habilidad. Recitar el acrónimo no lo es.
S — Principio de Responsabilidad Única (Single Responsibility Principle)
La definición común: Una clase debería tener solo una razón para cambiar.
La definición útil: Un módulo debería ser responsable ante un único actor.
La diferencia importa. "Una razón para cambiar" es vago. "Un actor" es concreto. Un actor es un grupo de usuarios o stakeholders que solicitarían cambios en el módulo.
// This violates SRP — not because it "does two things,"
// but because two different actors care about it
class Employee {
calculatePay(): number {
// CFO's team cares about this
return this.hoursWorked * this.hourlyRate
}
generateReport(): string {
// COO's team cares about this
return `${this.name}: ${this.hoursWorked}h`
}
save(): void {
// DBA / infrastructure team cares about this
db.employees.update(this.id, this.toJSON())
}
}
El problema no es que la clase sea "demasiado grande". Es que un cambio solicitado por el equipo de finanzas (modificar el cálculo de la nómina) podría romper la lógica de informes de la que depende el equipo de operaciones. El radio de impacto de un cambio se extiende más allá del actor que lo solicitó.
La solución es sencilla: separar según los límites de los actores:
class PayCalculator {
calculate(employee: Employee): number {
return employee.hoursWorked * employee.hourlyRate
}
}
class EmployeeReporter {
generate(employee: Employee): string {
return `${employee.name}: ${employee.hoursWorked}h`
}
}
class EmployeeRepository {
save(employee: Employee): void {
db.employees.update(employee.id, employee.toJSON())
}
}
Cuándo romperlo: Al principio de un proyecto, cuando los actores no están claros o se superponen. Dividir demasiado un módulo antes de comprender el dominio crea una indirección sin beneficio. Si no estás seguro de si dos comportamientos divergirán, mantenlos juntos y divídelos cuando la tensión se vuelva real.
O — Principio Abierto/Cerrado (Open/Closed Principle)
La definición común: Las entidades de software deben estar abiertas para la extensión, cerradas para la modificación.
La definición útil: Deberías poder añadir nuevo comportamiento a un sistema sin editar código existente y probado.
Este es el principio que más se abusa. Tomado literalmente, lleva a jerarquías de AbstractStrategyFactoryProvider que nadie puede navegar. Tomado pragmáticamente, significa diseñar tu sistema para que el eje de cambio más probable pueda manejarse añadiendo código en lugar de modificando código.
// Closed for modification: the processor doesn't change
// Open for extension: add new handlers without touching existing code
interface PaymentHandler {
type: string
process(amount: number, details: Record<string, unknown>): Promise<PaymentResult>
}
class PaymentProcessor {
private handlers: Map<string, PaymentHandler> = new Map()
register(handler: PaymentHandler): void {
this.handlers.set(handler.type, handler)
}
async process(type: string, amount: number, details: Record<string, unknown>): Promise<PaymentResult> {
const handler = this.handlers.get(type)
if (!handler) throw new Error(`No handler for payment type: ${type}`)
return handler.process(amount, details)
}
}
// Adding Stripe support = adding code, not editing PaymentProcessor
class StripeHandler implements PaymentHandler {
type = "stripe"
async process(amount: number, details: Record<string, unknown>): Promise<PaymentResult> {
// Stripe-specific logic
}
}
El patrón de registro de manejadores (handler registry) es el caballo de batalla del OCP en sistemas reales. Las arquitecturas de plugins, las cadenas de middleware, los sistemas de eventos, todos son variaciones de "registrar un nuevo comportamiento sin modificar el orquestador".
Cuándo romperlo: Cuando el límite de abstracción es incorrecto. Si cada nueva "extensión" requiere cambiar la interfaz, predijiste el eje de cambio equivocado. Es mejor refactorizar hacia el eje real que apilar abstracciones sobre un límite defectuoso. Además, para el código que realmente cambia con poca frecuencia, añadir puntos de extensión es una complejidad prematura.
L — Principio de Sustitución de Liskov (Liskov Substitution Principle)
La definición común: Los subtipos deben ser sustituibles por sus tipos base.
La definición útil: Si tu código acepta un tipo T, debería funcionar correctamente con cualquier subtipo de T sin saber con qué subtipo está tratando.
La violación clásica es el problema del cuadrado-rectángulo, que hace que todos asientan y luego lo olviden rápidamente. Aquí tienes un ejemplo más realista:
interface ReadOnlyStore<T> {
get(id: string): Promise<T | null>
list(): Promise<T[]>
}
interface Store<T> extends ReadOnlyStore<T> {
save(item: T): Promise<void>
delete(id: string): Promise<void>
}
// This violates LSP
class CachedStore<T> implements Store<T> {
async save(item: T): Promise<void> {
// Saves to cache only — doesn't persist
this.cache.set(item.id, item)
}
async delete(id: string): Promise<void> {
throw new Error("Cannot delete from cache")
}
// ... get and list work fine
}
Cualquier código que espere un Store puede llamar a delete(). Si sustituyes CachedStore, ese código falla en tiempo de ejecución. El subtipo no es sustituible: debilita el contrato al lanzar un error en una operación que el tipo base promete soportar.
La solución: CachedStore debería implementar ReadOnlyStore, no Store. La jerarquía de tipos debería reflejar el contrato de comportamiento real.
class CachedStore<T> implements ReadOnlyStore<T> {
async get(id: string): Promise<T | null> {
return this.cache.get(id) ?? null
}
async list(): Promise<T[]> {
return Array.from(this.cache.values())
}
}
Cuándo romperlo: Casi nunca. Las violaciones del LSP son errores disfrazados. Crean sistemas donde la sustitución de una implementación causa fallos en tiempo de ejecución que el sistema de tipos no puede detectar. Si te encuentras lanzando NotImplementedError en un subtipo, la jerarquía de tipos es incorrecta.
I — Principio de Segregación de Interfaces (Interface Segregation Principle)
La definición común: Ningún cliente debería ser forzado a depender de métodos que no utiliza.
La definición útil: Mantén las interfaces pequeñas y específicas de rol para que los consumidores solo vean lo que necesitan.
Esto es el SRP aplicado a las interfaces. Las interfaces "gordas" (fat interfaces) crean acoplamiento: si un consumidor solo necesita leer datos pero la interfaz también expone métodos de escritura, el consumidor está acoplado al contrato de escritura aunque nunca lo use.
// Fat interface — forces every consumer to know about everything
interface UserService {
getUser(id: string): Promise<User>
updateUser(id: string, data: Partial<User>): Promise<User>
deleteUser(id: string): Promise<void>
listUsers(filter: UserFilter): Promise<User[]>
exportUsersToCsv(): Promise<Buffer>
sendPasswordResetEmail(userId: string): Promise<void>
}
Un componente que solo muestra un perfil de usuario ahora está acoplado a las exportaciones CSV y a los restablecimientos de contraseña. Cuando la lógica del correo electrónico cambia, la dependencia del componente de perfil también cambia, aunque no le importe.
// Segregated interfaces — each consumer depends only on what it uses
interface UserReader {
getUser(id: string): Promise<User>
listUsers(filter: UserFilter): Promise<User[]>
}
interface UserWriter {
updateUser(id: string, data: Partial<User>): Promise<User>
deleteUser(id: string): Promise<void>
}
interface UserExporter {
exportToCsv(): Promise<Buffer>
}
interface PasswordResetter {
sendResetEmail(userId: string): Promise<void>
}
// The implementation can satisfy all interfaces
class UserServiceImpl implements UserReader, UserWriter, UserExporter, PasswordResetter {
// ... full implementation
}
Ahora el componente de perfil depende de UserReader. El panel de administración depende de UserReader y UserWriter. El trabajo de exportación depende de UserExporter. Cada consumidor está protegido de los cambios en preocupaciones no relacionadas.
Cuándo romperlo: Cuando la sobrecarga de muchas interfaces pequeñas excede el costo del acoplamiento. Para módulos internos con un único consumidor, una interfaz ligeramente más grande es más simple que un puñado de interfaces de un solo método. La segregación vale la pena en los límites: entre servicios, entre capas, entre equipos.
D — Principio de Inversión de Dependencias (Dependency Inversion Principle)
La definición común: Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones.
La definición útil: El módulo que contiene la lógica de negocio debe definir la interfaz que necesita. El módulo de infraestructura debe implementar esa interfaz.
Este es el principio SOLID más significativo arquitectónicamente. Sin él, tu lógica de negocio es rehén de tus elecciones de infraestructura. Cambiar la base de datos significa reescribir la capa de dominio. Cambiar el proveedor de correo electrónico significa editar el código de procesamiento de pedidos.
// WITHOUT dependency inversion:
// Business logic depends directly on infrastructure
import { PrismaClient } from '@prisma/client'
import { SendGrid } from '@sendgrid/mail'
class OrderService {
private db = new PrismaClient()
private mailer = new SendGrid()
async placeOrder(order: Order): Promise<void> {
await this.db.order.create({ data: order })
await this.mailer.send({ to: order.customerEmail, subject: 'Order confirmed' })
}
}
El OrderService está soldado a Prisma y SendGrid. Las pruebas requieren una base de datos real y una API de correo electrónico real. Cambiar a un ORM diferente significa reescribir la lógica de pedidos.
// WITH dependency inversion:
// Business logic defines what it needs; infrastructure adapts
interface OrderRepository {
save(order: Order): Promise<void>
}
interface NotificationService {
sendOrderConfirmation(email: string, order: Order): Promise<void>
}
class OrderService {
constructor(
private orders: OrderRepository,
private notifications: NotificationService,
) {}
async placeOrder(order: Order): Promise<void> {
await this.orders.save(order)
await this.notifications.sendOrderConfirmation(order.customerEmail, order)
}
}
// Infrastructure implements the business-defined interfaces
class PrismaOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await prisma.order.create({ data: order })
}
}
class SendGridNotificationService implements NotificationService {
async sendOrderConfirmation(email: string, order: Order): Promise<void> {
await sendgrid.send({ to: email, subject: 'Order confirmed' })
}
}
Ahora OrderService puede ser probado con fakes en memoria. La base de datos puede cambiarse de Postgres a MongoDB sin tocar la lógica de pedidos. El proveedor de correo electrónico puede cambiar de SendGrid a Resend con cero impacto en la capa de dominio.
Cuándo romperlo: Para módulos hoja (leaf modules) que es poco probable que cambien y no tienen costo de prueba. Si tu aplicación solo usará una base de datos y lo sabes, inyectar una interfaz de repositorio para cada tabla añade ceremonia sin beneficio. Aplica DIP en los límites que importan: aquellos donde necesitas capacidad de prueba, capacidad de intercambio o independencia de equipo.
El meta-principio
Los principios SOLID comparten un tema común: gestionar el costo del cambio. Cada principio, desde diferentes ángulos, hace la misma pregunta: "Cuando este sistema cambie (y lo hará), ¿cuánto código necesitarás tocar y cuánto se romperá?"
- SRP limita el radio de impacto de un cambio
- OCP te permite añadir comportamiento sin arriesgar el comportamiento existente
- LSP asegura que las sustituciones no creen sorpresas en tiempo de ejecución
- ISP reduce la superficie de acoplamiento entre módulos
- DIP hace que la dirección de la dependencia coincida con la dirección de la importancia
Ninguno de estos es absoluto. Todos tienen un costo: indirección, abstracción, tipos adicionales. El juicio de ingeniería consiste en saber cuándo el costo del principio es menor que el costo de violarlo. En una startup con tres ingenieros y un plazo de seis meses, la adherencia rígida a SOLID es una desventaja. En una plataforma con cincuenta ingenieros y un horizonte de cinco años, ignorar SOLID es un tipo diferente de desventaja.
La respuesta correcta, como siempre, es: depende. Pero ahora sabes de qué depende.
Este artículo forma parte de una serie sobre fundamentos de diseño de software.
Artículos Relacionados

Construyendo para la Escala: Patrones de Arquitectura que Realmente Funcionan
La mayoría de los consejos sobre escalabilidad son genéricos. Aquí presentamos los patrones que han funcionado consistentemente en sistemas reales que manejan millones de solicitudes, y aquellos que suenan bien pero fallan en la práctica.

Por qué tu búsqueda no devuelve nada — Y cómo la búsqueda vectorial de MongoDB lo soluciona
La búsqueda por palabras clave solo puede encontrar lo que está literalmente allí. Cuando los usuarios buscan 'laptop bag' y tus documentos dicen 'notebook carrying case', las expresiones regulares no ayudarán. La búsqueda vectorial entiende el significado — y MongoDB Atlas la soporta de forma nativa.

La Mentalidad de Ingeniería de IA: Qué Cambia Cuando Construyes con LLMs
La ingeniería de IA no es solo ingeniería de software con un modelo adjunto. Los bucles de retroalimentación, los modos de fallo y las señales de calidad son fundamentalmente diferentes. Así es como debes pensar al respecto.