Principio Abierto/Cerrado
Muestra cómo diseñar componentes y módulos que están abiertos a la extensión pero cerrados a la modificación usando patrones prácticos de TypeScript.
Introducción
El Principio de Abierto/Cerrado (OCP) establece: una clase debe estar abierta para extensión pero cerrada para modificación. A primera vista, esto parece contradictorio. ¿Cómo puede algo estar abierto y cerrado a la vez?
La respuesta está en la abstracción. OCP significa que debes agregar nuevo comportamiento sin reescribir código existente. Cuando llega un nuevo requerimiento, extiendes el sistema a través de nuevas implementaciones, no editando clases que ya funcionan.
Este principio es poderoso porque cada vez que modificas código existente, arriesgas introducir bugs y romper pruebas. Al diseñar para extensión, mantienes la lógica probada sellada contra cambios.
En este artículo, examinamos OCP a través de escenarios reales: sistemas de almacenamiento de imágenes y formatos de exportación de sesiones. Mostramos cómo tomar una violación y refactorizarla usando el patrón Strategy.
El Problema: Diseño Orientado a la Modificación
Un Ejemplo Simple: Procesamiento de Pagos
Antes de sumergirnos en una aplicación real, veamos un ejemplo simple y clásico: procesar pagos con diferentes métodos. Esto ilustra OCP sin necesitar contexto complejo.
Sin OCP: Sentencias Switch para Cada Método de Pago
1public class PaymentProcessor
2{
3 public decimal ProcessPayment(string paymentMethod, decimal amount)
4 {
5 switch (paymentMethod.ToLower())
6 {
7 case "creditcard":
8 return ProcessCreditCard(amount);
9
10 case "paypal":
11 return ProcessPayPal(amount);
12
13 case "cryptocurrency":
14 // ¿Nuevo método de pago? Modificar esta clase
15 throw new NotSupportedException("Crypto aún no soportado");
16
17 default:
18 throw new NotSupportedException(
19 $"Método de pago '{paymentMethod}' no soportado");
20 }
21 }
22
23 private decimal ProcessCreditCard(decimal amount)
24 {
25 // Validar tarjeta, cobrar, retornar comisión
26 return amount * 0.029m; // 2.9% comisión
27 }
28
29 private decimal ProcessPayPal(decimal amount)
30 {
31 // Llamar API de PayPal, procesar, retornar comisión
32 return amount * 0.034m; // 3.4% comisión
33 }
34}El problema: Cada nuevo método de pago requiere modificar PaymentProcessor. Cada modificación arriesga romper métodos existentes. Las pruebas son complejas porque pruebas todos los métodos juntos.
Con OCP: Patrón Strategy
1// Definir el contrato
2public interface IPaymentMethod
3{
4 decimal ProcessPayment(decimal amount);
5}
6
7// Cada método de pago es su propia clase
8public class CreditCardPaymentMethod : IPaymentMethod
9{
10 public decimal ProcessPayment(decimal amount)
11 {
12 // Lógica de tarjeta de crédito aislada aquí
13 return amount * 0.029m;
14 }
15}
16
17public class PayPalPaymentMethod : IPaymentMethod
18{
19 public decimal ProcessPayment(decimal amount)
20 {
21 // Lógica de PayPal aislada aquí
22 return amount * 0.034m;
23 }
24}
25
26// Nuevo método de pago - SIN modificación del código existente
27public class CryptocurrencyPaymentMethod : IPaymentMethod
28{
29 public decimal ProcessPayment(decimal amount)
30 {
31 // Lógica de crypto aislada aquí
32 return amount * 0.001m;
33 }
34}
35
36// El procesador está cerrado para modificación
37public class PaymentProcessor
38{
39 private readonly IPaymentMethodFactory _paymentMethodFactory;
40
41 public decimal ProcessPayment(string paymentMethod, decimal amount)
42 {
43 var method = _paymentMethodFactory.GetPaymentMethod(paymentMethod);
44
45 if (method == null)
46 throw new NotSupportedException(
47 $"Método de pago '{paymentMethod}' no soportado");
48
49 return method.ProcessPayment(amount);
50 }
51}Ahora agregar un nuevo método de pago (Bitcoin, Apple Pay, Google Pay) es solo una nueva clase. PaymentProcessor nunca cambia.
Caso de Estudio: Almacenamiento de Imágenes en Gathering
La aplicación Gathering (un sistema de gestión de Comunidades de Práctica) necesita almacenar imágenes (banners de sesiones, logos de comunidades). Inicialmente, Azure Blob Storage es la opción. Pero escalar a múltiples regiones y proveedores de nube requiere AWS S3, Google Cloud Storage u opciones de almacenamiento local.
Sin OCP, terminas modificando el servicio de almacenamiento repetidamente. Veamos cómo Gathering resuelve esto con el patrón Strategy.
La Violación: Lógica Específica de Almacenamiento Mezclada
1public class ImageStorageService
2{
3 private readonly ILogger _logger;
4
5 // Este método debe modificarse para cada nuevo proveedor
6 public async Task<Result<string>> UploadImageAsync(
7 string storageProvider, Stream imageStream, string fileName)
8 {
9 switch (storageProvider.ToLower())
10 {
11 case "azure":
12 return await UploadToAzureAsync(imageStream, fileName);
13
14 case "aws":
15 // ¿Nuevo proveedor? Modificar esta clase
16 _logger.LogError("AWS S3 aún no implementado");
17 return Result.Failure<string>("No soportado");
18
19 case "gcs":
20 // ¿Otro proveedor? Modificar de nuevo
21 _logger.LogError("GCS aún no implementado");
22 return Result.Failure<string>("No soportado");
23
24 default:
25 throw new NotSupportedException(
26 $"Proveedor de almacenamiento '{storageProvider}' no soportado");
27 }
28 }
29
30 private async Task<Result<string>> UploadToAzureAsync(
31 Stream imageStream, string fileName)
32 {
33 var blobClient = new BlobClient(
34 new Uri("https://account.blob.core.windows.net/images/file.jpg"));
35 await blobClient.UploadAsync(imageStream);
36 return Result.Success(blobClient.Uri.AbsoluteUri);
37 }
38}El problema: Cada vez que necesitas un nuevo proveedor de almacenamiento, debes:
- Abrir la clase ImageStorageService
- Agregar un nuevo caso al switch
- Implementar la lógica del proveedor inline
- Probar toda la clase de nuevo
- Arriesgar regresión en proveedores existentes
Esto viola OCP: la clase está cerrada para modificación en teoría pero abierta en práctica. Cada nueva funcionalidad te obliga a reabrirla y cambiarla.
El Enfoque Correcto: Definir una Abstracción
Gathering resuelve esto elegantemente haciendo el almacenamiento enchufable. La clave es definir una abstracción que todos los proveedores de almacenamiento implementen.
Paso 1: Definir el Contrato
1// Cada proveedor de almacenamiento implementa esta interfaz
2public interface IImageStorageService
3{
4 Task<Result<string>> UploadImageAsync(
5 Stream imageStream,
6 string fileName,
7 CancellationToken cancellationToken = default);
8
9 Task<Result> DeleteImageAsync(
10 string imageUrl,
11 CancellationToken cancellationToken = default);
12}Paso 2: Implementar para Azure
1public sealed class AzureBlobStorageService : IImageStorageService
2{
3 private const string ContainerName = "images";
4 private readonly BlobServiceClient _blobServiceClient;
5
6 public AzureBlobStorageService(BlobServiceClient blobServiceClient)
7 {
8 _blobServiceClient = blobServiceClient;
9 }
10
11 public async Task<Result<string>> UploadImageAsync(
12 Stream imageStream, string fileName, CancellationToken cancellationToken = default)
13 {
14 try
15 {
16 var containerClient = _blobServiceClient
17 .GetBlobContainerClient(ContainerName);
18 await containerClient.CreateIfNotExistsAsync(
19 cancellationToken: cancellationToken);
20
21 var extension = Path.GetExtension(fileName).ToLower();
22 var blobName = $"{Guid.NewGuid()}{extension}";
23 var blobClient = containerClient.GetBlobClient(blobName);
24
25 imageStream.Seek(0, SeekOrigin.Begin);
26
27 await blobClient.UploadAsync(imageStream, cancellationToken);
28 return Result.Success(blobClient.Uri.AbsoluteUri);
29 }
30 catch (Exception ex)
31 {
32 return Result.Failure<string>($"Error al subir: {ex.Message}");
33 }
34 }
35
36 public async Task<Result> DeleteImageAsync(
37 string imageUrl, CancellationToken cancellationToken = default)
38 {
39 try
40 {
41 var uri = new Uri(imageUrl);
42 var blobClient = new BlobClient(uri);
43 await blobClient.DeleteIfExistsAsync(
44 cancellationToken: cancellationToken);
45 return Result.Success();
46 }
47 catch (Exception ex)
48 {
49 return Result.Failure($"Error al eliminar: {ex.Message}");
50 }
51 }
52}Paso 3: Agregar AWS S3 - ¡Sin Modificar Código Existente!
1// Nueva implementación - el código existente nunca cambia
2public sealed class AwsS3StorageService : IImageStorageService
3{
4 private readonly IAmazonS3 _s3Client;
5 private readonly string _bucketName;
6
7 public AwsS3StorageService(IAmazonS3 s3Client, string bucketName)
8 {
9 _s3Client = s3Client;
10 _bucketName = bucketName;
11 }
12
13 public async Task<Result<string>> UploadImageAsync(
14 Stream imageStream, string fileName, CancellationToken cancellationToken = default)
15 {
16 try
17 {
18 var key = $"{Guid.NewGuid()}{Path.GetExtension(fileName).ToLower()}";
19
20 var putRequest = new PutObjectRequest
21 {
22 BucketName = _bucketName,
23 Key = key,
24 InputStream = imageStream
25 };
26
27 await _s3Client.PutObjectAsync(putRequest, cancellationToken);
28 return Result.Success($"https://{_bucketName}.s3.amazonaws.com/{key}");
29 }
30 catch (Exception ex)
31 {
32 return Result.Failure<string>($"Error al subir: {ex.Message}");
33 }
34 }
35
36 public async Task<Result> DeleteImageAsync(
37 string imageUrl, CancellationToken cancellationToken = default)
38 {
39 try
40 {
41 var uri = new Uri(imageUrl);
42 var key = uri.LocalPath.TrimStart('/');
43
44 await _s3Client.DeleteObjectAsync(_bucketName, key, cancellationToken);
45 return Result.Success();
46 }
47 catch (Exception ex)
48 {
49 return Result.Failure($"Error al eliminar: {ex.Message}");
50 }
51 }
52}Paso 4: El Handler Depende de la Abstracción
Observa: el handler nunca cambia, incluso cuando se agregan nuevos proveedores de almacenamiento.
1public class CreateSessionCommandHandler : ICommandHandler<CreateSessionCommand>
2{
3 private readonly IImageStorageService _imageStorageService;
4
5 public async Task<Result> HandleAsync(CreateSessionCommand request,
6 CancellationToken cancellationToken = default)
7 {
8 string? imageUrl = null;
9 if (request.ImageStream is not null)
10 {
11 // Funciona con CUALQUIER proveedor - Azure, AWS, GCS, local
12 var uploadResult = await _imageStorageService.UploadImageAsync(
13 request.ImageStream,
14 request.ImageFileName,
15 cancellationToken);
16
17 if (uploadResult.IsFailure)
18 return Result.Failure(uploadResult.Error);
19
20 imageUrl = uploadResult.Value;
21 }
22
23 // Crea sesión con imageUrl
24 return Result.Success();
25 }
26}✓ Beneficios de OCP Aquí:
- Cerrado para modificación: CreateSessionCommandHandler nunca cambia.
- Abierto para extensión: Agrega GoogleCloudStorageService y regístralo. No se toca código existente.
- Fácil de probar: Prueba cada proveedor independientemente con mocks.
- Sin riesgo: Un bug en el código de AWS no puede afectar a Azure.
Otro Ejemplo: Formatos de Exportación de Sesiones
El mismo patrón aplica donde sea que tengas variación. Aquí hay otro escenario: exportar datos de sesiones en múltiples formatos (CSV, JSON, XML, PDF).
Sin OCP
1public class SessionExportService
2{
3 public async Task<Result<string>> ExportSessionsAsync(
4 Guid communityId, string format)
5 {
6 var sessions = await _sessionRepository.GetByCommunityAsync(communityId);
7
8 switch (format.ToLower())
9 {
10 case "csv":
11 return GenerateCsv(sessions);
12 case "json":
13 return GenerateJson(sessions);
14 case "xml":
15 // ¿Nuevo formato? Modificar esta clase
16 return Result.Failure<string>("XML no soportado");
17 case "pdf":
18 // ¿Otro formato? Modificar de nuevo
19 return Result.Failure<string>("PDF no soportado");
20 default:
21 throw new NotSupportedException();
22 }
23 }
24}Con OCP
1// Definir la abstracción
2public interface ISessionExportFormatter
3{
4 string FormatName { get; }
5 Task<Result<string>> ExportAsync(IEnumerable<Session> sessions);
6}
7
8// Cada formato es su propia clase
9public class CsvSessionExportFormatter : ISessionExportFormatter
10{
11 public string FormatName => "csv";
12
13 public Task<Result<string>> ExportAsync(IEnumerable<Session> sessions)
14 {
15 var csv = "Título,Ponente,Fecha";
16 foreach (var session in sessions)
17 {
18 csv += session.Title + "," + session.Speaker + "," +
19 session.Schedule;
20 }
21 return Task.FromResult(Result.Success(csv));
22 }
23}
24
25public class JsonSessionExportFormatter : ISessionExportFormatter
26{
27 public string FormatName => "json";
28
29 public Task<Result<string>> ExportAsync(IEnumerable<Session> sessions)
30 {
31 var json = JsonConvert.SerializeObject(sessions, Formatting.Indented);
32 return Task.FromResult(Result.Success(json));
33 }
34}
35
36public class XmlSessionExportFormatter : ISessionExportFormatter
37{
38 public string FormatName => "xml";
39
40 public Task<Result<string>> ExportAsync(IEnumerable<Session> sessions)
41 {
42 var doc = new XDocument(
43 new XElement("Sessions",
44 sessions.Select(s => new XElement("Session",
45 new XElement("Title", s.Title),
46 new XElement("Speaker", s.Speaker)
47 ))
48 )
49 );
50 return Task.FromResult(Result.Success(doc.ToString()));
51 }
52}
53
54// El servicio está cerrado para modificación
55public class SessionExportService
56{
57 private readonly ISessionRepository _sessionRepository;
58 private readonly ISessionExportFormatterFactory _formatterFactory;
59
60 public async Task<Result<string>> ExportSessionsAsync(
61 Guid communityId, string format)
62 {
63 var formatter = _formatterFactory.GetFormatter(format);
64
65 if (formatter == null)
66 return Result.Failure<string>("Formato no soportado");
67
68 var sessions = await _sessionRepository.GetByCommunityAsync(communityId);
69 return await formatter.ExportAsync(sessions);
70 }
71}Para agregar exportación PDF: crea PdfSessionExportFormatter, regístralo en la factory. SessionExportService nunca cambia.
Entendiendo el Principio de Abierto/Cerrado
OCP tiene dos dimensiones:
- Cerrado para modificación: Los cambios al comportamiento existente no deben requerir editar código existente.
- Abierto para extensión: El nuevo comportamiento se agrega creando código nuevo, no modificando el viejo.
El mecanismo es la abstracción. Al definir una interfaz, creas un contrato. Las nuevas implementaciones satisfacen el contrato sin cambiar el código original.
El Patrón: Cómo Funciona OCP
- Identificar puntos de variación: ¿Dónde difieren las diferentes implementaciones?
- Extraer una abstracción: Definir una interfaz que todas las implementaciones deben satisfacer.
- Crear implementaciones: Cada variación se convierte en su propia clase.
- Depender de la abstracción: Tu handler depende de la interfaz, no de clases concretas.
- Conectar vía DI: Registrar implementaciones al inicio.
Ejemplo en TypeScript
El mismo patrón aplica en TypeScript. Aquí está el almacenamiento de imágenes en Node.js/Express:
1// La abstracción
2interface IImageStorageService {
3 uploadImage(
4 stream: NodeJS.ReadableStream,
5 fileName: string
6 ): Promise<Result<string>>;
7
8 deleteImage(imageUrl: string): Promise<Result<void>>;
9}
10
11// Implementación Azure
12class AzureImageStorageService implements IImageStorageService {
13 constructor(private blobServiceClient: BlobServiceClient) {}
14
15 async uploadImage(
16 stream: NodeJS.ReadableStream,
17 fileName: string
18 ): Promise<Result<string>> {
19 const containerClient = this.blobServiceClient
20 .getContainerClient("images");
21 const extension = fileName.slice(fileName.lastIndexOf("."));
22 const blobName = crypto.randomUUID() + extension;
23 const blobClient = containerClient.getBlockBlobClient(blobName);
24
25 await blobClient.uploadStream(stream);
26 return success(blobClient.url);
27 }
28
29 async deleteImage(imageUrl: string): Promise<Result<void>> {
30 const blobClient = new BlobClient(imageUrl);
31 await blobClient.delete();
32 return success(undefined);
33 }
34}
35
36// Implementación AWS - nuevo archivo, sin cambios al código existente
37class AwsS3ImageStorageService implements IImageStorageService {
38 constructor(private s3Client: S3Client, private bucketName: string) {}
39
40 async uploadImage(
41 stream: NodeJS.ReadableStream,
42 fileName: string
43 ): Promise<Result<string>> {
44 const extension = fileName.slice(fileName.lastIndexOf("."));
45 const key = crypto.randomUUID() + extension;
46
47 const command = new PutObjectCommand({
48 Bucket: this.bucketName,
49 Key: key,
50 Body: stream,
51 });
52
53 await this.s3Client.send(command);
54 const url = "https://" + this.bucketName + ".s3.amazonaws.com/" + key;
55 return success(url);
56 }
57
58 async deleteImage(imageUrl: string): Promise<Result<void>> {
59 const url = new URL(imageUrl);
60 const key = url.pathname.slice(1);
61
62 const command = new DeleteObjectCommand({
63 Bucket: this.bucketName,
64 Key: key,
65 });
66
67 await this.s3Client.send(command);
68 return success(undefined);
69 }
70}
71
72// El handler depende de la abstracción
73class CreateSessionHandler {
74 constructor(private imageStorageService: IImageStorageService) {}
75
76 async handle(request: CreateSessionRequest): Promise<Result<Session>> {
77 let imageUrl: string | undefined;
78
79 if (request.imageStream) {
80 const result = await this.imageStorageService.uploadImage(
81 request.imageStream,
82 request.imageFileName
83 );
84
85 if (!result.isSuccess) {
86 return failure("Error al subir imagen");
87 }
88
89 imageUrl = result.value;
90 }
91
92 return success(session);
93 }
94}Beneficios de Aplicar OCP
1. Menor Riesgo de Regresiones
Al agregar nuevas clases en vez de modificar las existentes, mantienes la lógica probada sellada. El almacenamiento Azure sigue funcionando porque nunca tocaste ese código.
2. Pruebas Más Fáciles
Prueba cada implementación independientemente. Una prueba de AwsS3StorageService no necesita saber sobre Azure. Las pruebas son más pequeñas y enfocadas.
3. Escalabilidad
A medida que el sistema crece, el servicio principal se mantiene pequeño. Solo crece el número de implementaciones, cada una en su propio archivo con sus propias pruebas.
4. Desarrollo en Paralelo
Los desarrolladores pueden trabajar en diferentes implementaciones simultáneamente. La interfaz define claramente qué debe implementarse.
5. Flexibilidad en Tiempo de Ejecución
Con inyección de dependencias, puedes intercambiar implementaciones sin cambios de código. Usa Azure en producción, almacenamiento local en pruebas.
Trampas del Mundo Real
Trampa 1: Sobre-Abstracción
No todo necesita una interfaz. Pregúntate: “¿Esto tendrá múltiples implementaciones?” Si no, mantenlo simple. Solo abstrae los puntos de variación.
Trampa 2: Abstracciones con Fugas
Si tu interfaz expone detalles de implementación, las subclases tendrán problemas. Mantén la interfaz enfocada y clara.
Trampa 3: Explosión de Factories
Mantén la factory simple: mapea strings a implementaciones. Usa inyección de dependencias para inyectar implementaciones, no para instanciarlas dentro de la factory.
Checklist: Detectando Violaciones de OCP
Si respondes “sí” a alguna de estas, OCP probablemente está siendo violado.
Conclusión
El Principio de Abierto/Cerrado transforma cómo piensas sobre agregar funcionalidades. En lugar de modificar código existente (riesgoso, frágil), extiendes a través de nuevas implementaciones (seguro, aislado).
El mecanismo es la abstracción: define una interfaz clara, luego crea nuevas implementaciones. El código existente nunca cambia. Las pruebas nunca necesitan re-ejecutarse. El riesgo se minimiza.
OCP habilita desarrollo en paralelo, pruebas más fáciles y menores regresiones. Escala naturalmente: agregar el décimo proveedor de almacenamiento es tan seguro como agregar el segundo.
La idea clave: diseña para extensión a través de abstracción, no para modificación a través de inspección.
En el próximo artículo, exploramos el Principio de Sustitución de Liskov, que asegura que los tipos derivados son seguros para usarse en lugar de sus tipos base, una propiedad crucial para que OCP funcione de manera confiable.