Principios de Diseño SOLID con Ejemplos Prácticos en C#

En este post me gustaría guardar información sobre los principios de diseño SOLID.

Pero a fin de cuentas, ¿qué es SOLID?
Bueno, básicamente es un acrónimo que representa un conjunto de cinco principios de diseño para la programación orientada a objetos, presentados por Robert C. Martin (“Uncle Bob”).

Robert Cecil Martin.png
By Tim-bezhashvyly - Own work, CC BY-SA 4.0, Link

Estos principios, cuando se aplican juntos, tienen la intención de hacer que los diseños de software sean más comprensibles, flexibles y mantenibles. Vamos a desglosar cada uno con ejemplos prácticos en C#.

S - Single Responsibility Principle (Principio de Responsabilidad Única)

Una clase debe tener una, y solo una, razón para cambiar.

Este principio, aunque simple, es uno de los más violados. Básicamente, establece que una clase debe tener una única responsabilidad o propósito. Mezclar responsabilidades hace que la clase sea frágil y difícil de mantener.

Escenario: Imaginemos que estamos construyendo un sistema de facturación. Tenemos una clase Invoice que calcula el total y también se encarga de guardar la factura en la base de datos.

Violación del principio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Invoice
{
public long Amount { get; set; }
public DateTime InvoiceDate { get; set; }

public void AddLineItem(long price, int quantity)
{
this.Amount += price * quantity;
}

// ¡Segunda responsabilidad!
public void SaveToDatabase()
{
// Lógica para conectar a la base de datos y guardar la factura.
Console.WriteLine($"Saving invoice with amount {Amount} to DB...");
}
}

El problema aquí es que la clase Invoice tiene dos razones para cambiar:

  1. Si cambia la lógica de cálculo de la factura (ej. agregar impuestos).
  2. Si cambia la forma en que se persiste (ej. cambiar de SQL Server a PostgreSQL, o guardar en un archivo).

Solución: Separamos las responsabilidades. La clase Invoice solo se preocupa por los datos y la lógica de negocio de la factura. Creamos una nueva clase, un Repository, cuya única responsabilidad es la persistencia.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 1. La clase Invoice solo se preocupa por la lógica de la factura.
public class Invoice
{
public long Amount { get; set; }
public DateTime InvoiceDate { get; set; }

public void AddLineItem(long price, int quantity)
{
this.Amount += price * quantity;
}
}

// 2. Una clase separada para la persistencia.
public class InvoiceRepository
{
public void Save(Invoice invoice)
{
// Lógica para conectar a la base de datos y guardar la factura.
Console.WriteLine($"Saving invoice with amount {invoice.Amount} to DB...");
}
}

// Uso:
var invoice = new Invoice();
invoice.AddLineItem(100, 2);
invoice.AddLineItem(50, 1);

var repository = new InvoiceRepository();
repository.Save(invoice);

Ahora, cada clase tiene una sola razón para cambiar. Si necesitamos guardar la factura en un archivo, creamos un InvoiceFileSaver sin tocar Invoice o InvoiceRepository. Esto hace que el sistema sea mucho más robusto y fácil de extender.

O - Open/Closed Principle (Principio de Abierto/Cerrado)

Las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para la extensión, pero cerradas para la modificación.

Esto significa que deberías poder agregar nueva funcionalidad sin cambiar el código existente que ya ha sido probado y está en producción. Esto se logra comúnmente a través de interfaces, clases abstractas y patrones de diseño como el Strategy o Specification.

Escenario: Tenemos un sistema de comercio electrónico y necesitamos calcular el costo de envío para un pedido. Inicialmente, solo tenemos un método de envío estándar.

Violación del principio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Order { /* ... */ }

public class ShippingCalculator
{
// Este método tendrá que ser modificado cada vez que agreguemos un nuevo tipo de envío.
public decimal Calculate(Order order, string shippingType)
{
if (shippingType == "Standard")
{
return 10m;
}
else if (shippingType == "Express")
{
return 25m;
}
else if (shippingType == "NextDayAir")
{
return 50m;
}
// ¿Qué pasa si agregamos 10 tipos más? Este método se volverá un monstruo.
return 0;
}
}

Cada vez que el negocio introduce un nuevo método de envío, tenemos que modificar la clase ShippingCalculator, lo que aumenta el riesgo de introducir errores en la lógica existente.

Solución: Usamos el patrón Strategy. Definimos una interfaz para la estrategia de cálculo y creamos una clase concreta para cada método de envío.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Order { /* ... */ }

// 1. Definimos la interfaz (la abstracción).
public interface IShippingStrategy
{
decimal Calculate(Order order);
}

// 2. Creamos implementaciones concretas para cada tipo de envío.
public class StandardShipping : IShippingStrategy
{
public decimal Calculate(Order order) => 10m;
}

public class ExpressShipping : IShippingStrategy
{
public decimal Calculate(Order order) => 25m;
}

// 3. La clase principal ahora depende de la abstracción, no de los detalles.
public class ShippingCalculator
{
public decimal Calculate(Order order, IShippingStrategy strategy)
{
return strategy.Calculate(order);
}
}

// Uso:
var order = new Order();
var calculator = new ShippingCalculator();

// Calcular con envío estándar
var standardCost = calculator.Calculate(order, new StandardShipping()); // 10m

// Calcular con envío express
var expressCost = calculator.Calculate(order, new ExpressShipping()); // 25m

// ¡Extensión! Para agregar un nuevo tipo de envío, solo creamos una nueva clase.
public class NextDayAirShipping : IShippingStrategy
{
public decimal Calculate(Order order) => 50m;
}

var nextDayCost = calculator.Calculate(order, new NextDayAirShipping()); // 50m

Ahora, la clase ShippingCalculator está cerrada para modificación (no la hemos tocado), pero el sistema está abierto para extensión (podemos agregar infinitas estrategias de envío creando nuevas clases que implementen IShippingStrategy).

L - Liskov Substitution Principle (Principio de Sustitución de Liskov)

Los objetos de una superclase deben poder ser reemplazados por objetos de una subclase sin afectar la corrección del programa.

En términos más simples, si tienes una clase Pajaro, y una subclase Pinguino que hereda de Pajaro, tu programa no debería romperse si le pasas un Pinguino a una función que espera un Pajaro. Esto a menudo se rompe cuando una subclase altera fundamentalmente el comportamiento de un método heredado.

Escenario: Tenemos una clase base Empleado y una subclase Manager. Un Manager es un Empleado, pero tiene una responsabilidad adicional: aprobar gastos.

Violación del principio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Empleado
{
public virtual decimal CalcularSalario() => 50000m;
}

public class Manager : Empleado
{
public override decimal CalcularSalario() => 60000m + CalcularBonus();

private decimal CalcularBonus() => 10000m;
}

public class CEO : Empleado
{
// El CEO no tiene un salario fijo, rompe el contrato de la clase base.
public override decimal CalcularSalario()
{
throw new NotImplementedException("El CEO no tiene salario, tiene participación en las ganancias.");
}
}

// Un método que procesa la nómina
public void ProcesarNomina(List<Empleado> empleados)
{
foreach (var empleado in empleados)
{
// Esto lanzará una excepción si un CEO está en la lista.
Console.WriteLine($"Pagando {empleado.CalcularSalario()} al empleado.");
}
}

El problema es que la clase CEO no puede ser sustituida por Empleado porque cambia el contrato del método CalcularSalario (lanza una excepción en lugar de devolver un decimal).

Solución: Reestructurar la jerarquía para que sea más coherente. Quizás no todos los que trabajan en la empresa son “empleados” en el sentido de tener un salario.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 1. Una interfaz común para todos los que reciben pago.
public interface IPagable
{
decimal ObtenerPago();
}

// 2. Los empleados con salario fijo implementan la interfaz.
public class Empleado : IPagable
{
public virtual decimal ObtenerPago() => 50000m;
}

public class Manager : Empleado
{
public override decimal ObtenerPago() => 60000m + 10000m; // Bonus
}

// 3. El CEO también es pagable, pero con una lógica diferente.
public class CEO : IPagable
{
public decimal ObtenerPago()
{
// Lógica para calcular el pago basado en ganancias.
return 1000000m;
}
}

// 4. El método de nómina ahora trabaja con la abstracción IPagable.
public void ProcesarNomina(List<IPagable> personal)
{
foreach (var persona in personal)
{
// Ahora es seguro llamar a ObtenerPago en cualquier objeto.
Console.WriteLine($"Pagando {persona.ObtenerPago()} a la persona.");
}
}

Ahora, cualquier objeto que implemente IPagable puede ser sustituido sin problemas, cumpliendo con el principio de Liskov.

I - Interface Segregation Principle (Principio de Segregación de Interfaces)

Ningún cliente debe ser forzado a depender de métodos que no utiliza.

Este principio aboga por crear interfaces pequeñas y específicas en lugar de interfaces grandes y monolíticas. Si una clase implementa una interfaz con métodos que no necesita, se ve forzada a proporcionar implementaciones vacías o que lanzan excepciones, lo cual es una señal de un mal diseño.

Escenario: Tenemos un sistema de gestión de documentos con una interfaz IMachine para una máquina multifuncional.

Violación del principio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Interfaz monolítica
public interface IMachine
{
void Print(Document d);
void Scan(Document d);
void Fax(Document d);
}

// Una impresora moderna puede hacer todo.
public class MultiFunctionPrinter : IMachine
{
public void Print(Document d) { /* ... */ }
public void Scan(Document d) { /* ... */ }
public void Fax(Document d) { /* ... */ }
}

// Pero una impresora antigua y barata solo puede imprimir.
public class OldFashionedPrinter : IMachine
{
public void Print(Document d) { /* ... */ }

// Se ve forzada a implementar métodos que no necesita.
public void Scan(Document d)
{
throw new NotImplementedException("This printer cannot scan.");
}

public void Fax(Document d)
{
throw new NotImplementedException("This printer cannot fax.");
}
}

El problema es que OldFashionedPrinter es forzada a depender de los métodos Scan y Fax, que no utiliza.

Solución: Segregar la interfaz grande en interfaces más pequeñas y específicas por rol.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 1. Interfaces pequeñas y específicas.
public interface IPrinter
{
void Print(Document d);
}

public interface IScanner
{
void Scan(Document d);
}

public interface IFax
{
void Fax(Document d);
}

// 2. Las clases implementan solo las interfaces que necesitan.
public class OldFashionedPrinter : IPrinter
{
public void Print(Document d) { /* ... */ }
}

public class Photocopier : IPrinter, IScanner
{
public void Print(Document d) { /* ... */ }
public void Scan(Document d) { /* ... */ }
}

// La máquina multifuncional puede implementar varias.
public class MultiFunctionDevice : IPrinter, IScanner, IFax
{
// ... implementaciones ...
}

Ahora, las clases solo dependen de las funcionalidades que realmente proveen. El código es más limpio, más claro y más fácil de mantener.

D - Dependency Inversion Principle (Principio de Inversión de Dependencias)

  1. Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones (ej. interfaces).
  2. Las abstracciones no deben depender de los detalles. Los detalles (clases concretas) deben depender de las abstracciones.

Este principio es clave para crear sistemas desacoplados. En lugar de que la lógica de negocio (alto nivel) dependa directamente de la capa de acceso a datos (bajo nivel), ambas dependen de una interfaz definida por la capa de alto nivel.

Escenario: Tenemos un servicio de notificaciones (NotificationService) que envía un correo electrónico cuando ocurre un evento.

Violación del principio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Módulo de bajo nivel
public class EmailSender
{
public void SendEmail(string message)
{
Console.WriteLine($"Sending email: {message}");
}
}

// Módulo de alto nivel
public class NotificationService
{
// ¡Dependencia directa de una clase concreta!
private readonly EmailSender _emailSender;

public NotificationService()
{
_emailSender = new EmailSender();
}

public void Notify(string message)
{
_emailSender.SendEmail(message);
}
}

El problema es que NotificationService está fuertemente acoplado a EmailSender. ¿Qué pasa si mañana queremos enviar notificaciones por SMS o Slack? Tendríamos que modificar NotificationService.

Solución: Invertir la dependencia. NotificationService define una interfaz que necesita, y la implementación concreta se le “inyecta” desde el exterior (Inyección de Dependencias).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 1. La capa de alto nivel define la abstracción que necesita.
public interface IMessageSender
{
void SendMessage(string message);
}

// 2. El módulo de alto nivel depende de la abstracción.
public class NotificationService
{
private readonly IMessageSender _sender;

// La dependencia se inyecta a través del constructor.
public NotificationService(IMessageSender sender)
{
_sender = sender;
}

public void Notify(string message)
{
_sender.SendMessage(message);
}
}

// 3. Los módulos de bajo nivel implementan la abstracción.
public class EmailSender : IMessageSender
{
public void SendMessage(string message)
{
Console.WriteLine($"Sending email: {message}");
}
}

public class SmsSender : IMessageSender
{
public void SendMessage(string message)
{
Console.WriteLine($"Sending SMS: {message}");
}
}

// Uso:
// El "compositor" de la aplicación decide qué implementación usar.
var emailService = new NotificationService(new EmailSender());
emailService.Notify("Hello via Email!");

var smsService = new NotificationService(new SmsSender());
smsService.Notify("Hello via SMS!");

Ahora, NotificationService no sabe nada sobre EmailSender o SmsSender. Solo conoce la interfaz IMessageSender. Podemos agregar nuevos métodos de notificación sin tocar NotificationService, logrando un sistema flexible y desacoplado.