Regla de acero apoyada sobre el techo de un coche pintado en púrpura, con luz violeta colándose por debajo donde la superficie no está plana, en penumbra de taller

SOLID en C#: los principios que reviso en cada PR

Llevo veinte años revisando código ajeno y mentorizando a otros desarrolladores, y SOLID en C# es, con diferencia, lo que más veces explico en un code review sin usar esas palabras. No voy a repetir qué significa cada letra — eso está en todas partes, bien explicado, y repetirlo no te ayuda a nada. Este post va de lo que encuentro de verdad cuando reviso código de producción, y qué hago al respecto.

Qué es SOLID en C# más allá de las cinco letras

SOLID es un acrónimo de cinco principios de diseño orientado a objetos: responsabilidad única, abierto/cerrado, sustitución de Liskov, segregación de interfaces e inversión de dependencias. La definición de manual cabe en un párrafo. Lo que no cabe en un párrafo es reconocerlos en código real, que rara vez los viola de forma obvia — los viola de forma sutil, en una clase que «solo hace un poco más de lo que debería» o en una interfaz que «de momento» tiene un método que no todos sus implementadores necesitan.

Responsabilidad única: lo que de verdad encuentro

La violación real casi nunca es una clase gigante con cien métodos — eso lo ve cualquiera. La violación real es una clase de 200 líneas, razonable a primera vista, que mezcla lógica de negocio con acceso a datos y con formato de salida, porque cada una de esas tres cosas se le fue añadiendo «ya que estamos aquí». Lo que hago en el code review no es exigir una clase por método: pregunto por qué razones distintas tendría que cambiar esta clase, y si la respuesta incluye «porque cambia la base de datos» y también «porque cambia el formato del informe», ahí hay dos responsabilidades que merece la pena separar.

// Antes: una clase que sabe demasiado
public class ProcesadorPedido
{
    public void Procesar(Pedido pedido)
    {
        ValidarStock(pedido);
        GuardarEnBaseDeDatos(pedido);
        EnviarEmailConfirmacion(pedido);
        GenerarFacturaPdf(pedido);
    }
    // ... cuatro responsabilidades, cuatro razones distintas para cambiar
}

// Después: cada responsabilidad, su propia clase
public class ProcesadorPedido
{
    private readonly IValidadorStock _validador;
    private readonly IRepositorioPedidos _repositorio;
    private readonly INotificadorPedido _notificador;
    private readonly IGeneradorFactura _generadorFactura;

    public ProcesadorPedido(
        IValidadorStock validador,
        IRepositorioPedidos repositorio,
        INotificadorPedido notificador,
        IGeneradorFactura generadorFactura)
    {
        _validador = validador;
        _repositorio = repositorio;
        _notificador = notificador;
        _generadorFactura = generadorFactura;
    }

    public void Procesar(Pedido pedido)
    {
        _validador.Validar(pedido);
        _repositorio.Guardar(pedido);
        _notificador.EnviarConfirmacion(pedido);
        _generadorFactura.Generar(pedido);
    }
}

Abierto/cerrado: la señal es el switch que crece cada mes

El principio dice que el código debe estar abierto a extensión pero cerrado a modificación. En la práctica, la señal que busco es un switch o una cadena de if sobre un tipo o una categoría, que cada pocos meses alguien tiene que ampliar con un caso nuevo. Cada vez que se toca, hay riesgo de romper los casos que ya funcionaban.

// La señal: cada tipo nuevo obliga a tocar este método
public decimal CalcularDescuento(Cliente cliente)
{
    switch (cliente.Tipo)
    {
        case TipoCliente.Nuevo: return 0m;
        case TipoCliente.Frecuente: return 0.1m;
        case TipoCliente.Premium: return 0.2m;
        default: throw new ArgumentException("Tipo no soportado");
    }
}

// La alternativa: cada tipo de cliente sabe calcular su propio descuento
public interface IPoliticaDescuento
{
    decimal Calcular();
}

public class DescuentoClientePremium : IPoliticaDescuento
{
    public decimal Calcular() => 0.2m;
}

No siempre merece la pena esta reestructuración — si ese switch lleva tres años sin cambiar, tocarlo «por si acaso» es resolver un problema que no existe. La señal que de verdad me hace intervenir es la frecuencia real de cambio, no la posibilidad teórica de que cambie.

Sustitución de Liskov: el principio que menos se entiende y más duele cuando se rompe

Este es el que menos se explica bien, y el que más quebraderos de cabeza causa cuando se ignora: una subclase debe poder sustituir a su clase base sin que el código que la usa se rompa o se comporte distinto. Lo que encuentro en la práctica es una subclase que lanza una excepción en un método que la clase base sí soportaba, porque «en este caso concreto no tiene sentido». Ese «no tiene sentido» es la señal de que la jerarquía está mal planteada, no de que haga falta un caso especial.

// Rompe Liskov: el consumidor no puede confiar en la clase base
public class Rectangulo
{
    public virtual int Ancho { get; set; }
    public virtual int Alto { get; set; }
    public int Area() => Ancho * Alto;
}

public class Cuadrado : Rectangulo
{
    public override int Ancho { set { base.Ancho = base.Alto = value; } }
    public override int Alto { set { base.Ancho = base.Alto = value; } }
}
// Cualquier código que asuma "cambiar Ancho no toca Alto" se rompe con Cuadrado

Segregación de interfaces: la interfaz que nadie implementa entera

La señal es una interfaz con seis métodos donde cada implementación real solo usa dos o tres, y el resto los implementa lanzando NotImplementedException o devolviendo un valor vacío. Eso no es pereza del desarrollador: es una interfaz mal segregada, que debería haberse dividido en interfaces más pequeñas y específicas.

Inversión de dependencias: no es «usar inyección de dependencias»

Este es el principio que más se malinterpreta. Usar un contenedor de inyección de dependencias no garantiza cumplirlo — puedes inyectar una clase concreta en vez de una abstracción, y seguir acoplado igual. Lo que reviso es si la clase de alto nivel depende de una interfaz que ella misma (o su capa) define, en vez de depender directamente del detalle de infraestructura de turno.

// Viola inversión de dependencias: la lógica de negocio conoce el detalle concreto
public class ServicioFacturacion
{
    private readonly ClienteHttpFacturaE _clienteFacturaE; // detalle concreto de un proveedor

    public void Emitir(Factura factura) => _clienteFacturaE.Enviar(factura);
}

// Cumple el principio: la lógica de negocio depende de una abstracción propia
public interface IEmisorFacturas
{
    void Emitir(Factura factura);
}

public class ServicioFacturacion
{
    private readonly IEmisorFacturas _emisor;

    public ServicioFacturacion(IEmisorFacturas emisor) => _emisor = emisor;

    public void Procesar(Factura factura) => _emisor.Emitir(factura);
}

En la segunda versión, cambiar de proveedor de facturación es escribir una nueva clase que implemente IEmisorFacturas — la lógica de negocio no se entera del cambio.

Dónde encaja «clean code» en todo esto

SOLID en C# no cubre todo lo que hace que un código sea bueno. Nombres claros, funciones cortas, comentarios que explican el porqué y no el qué — eso es clean code, un paraguas más amplio que SOLID, y con menos reglas formales y más criterio. En un code review, muchas veces el problema no es una violación de un principio SOLID concreto: es simplemente un nombre de variable que no dice nada, o una función de 80 líneas que nadie se ha molestado en dividir. No hace falta nombrar el principio para señalarlo — a veces basta con preguntar «¿qué hace esta función, en una frase?» y ver si la respuesta necesita un «y» en medio.

Errores que veo repetirse en equipos nuevos

El más habitual es aplicar los cinco principios como una checklist obligatoria en cada clase nueva, sin criterio de cuándo pesa más la simplicidad. Crear una interfaz para una clase que solo se va a implementar una vez, «por si algún día hace falta otra implementación», es el ejemplo más claro: añade indirección hoy para un beneficio que puede que nunca llegue.

El segundo error es el contrario: ignorar estos principios por completo argumentando «esto es solo un prototipo». Los prototipos que sobreviven — y casi todos sobreviven más de lo previsto — heredan cada atajo que se tomó al principio, con intereses. El tercer error, más sutil, es discutir sobre el nombre del principio en vez de sobre el problema concreto: da igual si a una clase la llamas «violación de responsabilidad única» o simplemente «hace demasiadas cosas distintas» — lo que importa es que el equipo esté de acuerdo en que conviene separarla, no ganar una discusión terminológica.

Cuándo NO persigo SOLID al pie de la letra

Mi criterio, después de veinte años haciendo esto, es que SOLID en C# es una herramienta para gestionar el cambio, no un fin en sí mismo. Si una clase no ha cambiado en años y no hay ninguna señal de que vaya a cambiar, invertir tiempo en desacoplarla «por principio» no aporta nada real. Reservo el esfuerzo de aplicar estos principios para el código que sé, por experiencia o por el propio dominio del proyecto, que va a seguir moviéndose.

¿Tu equipo necesita a alguien que revise arquitectura y código con este criterio, no con una checklist genérica? En Refuerzo técnico y mentoring es exactamente lo que hago al entrar en un equipo.

Fuente externa citada: Architectural principles, Microsoft Learn — guía oficial de Microsoft sobre principios de diseño en aplicaciones .NET, con SOLID como referencia.