Patron Decorator (Envoltorio) en c#


patrón decorator c# .net


El patrón de diseño Decorator es uno de los veintitrés patrones descritos en el libro "Design Patterns: Elements of Reusable Object-Oriented Software" de los autores conocidos como Gang of Four (GoF). 

En este post voy a explicar todo lo que hay que saber sobre el patrón desde un punto de vista c#.net.

La traducción de Decorator al español sería Envoltorio, pero prefiero no traducirlo porque es el nombre con el que se conoce.

¿QUé es el patrón de diseño decorator?

La definición oficial en el catálogo de patrones de diseño es la siguiente:

El patrón de diseño Decorator asigna responsabilidades adicionales a un objeto dinámicamente, proporcionando una alternativa flexible a la herencia para extender la funcionalidad.

En términos simples, este patrón nos permite añadir funcionalidades a un objeto sin modificar la estructura de las clases existentes. Si bien la herencia es una técnica clásica para extender funcionalidades, el Decorator actúa como un "envoltorio" para agregar responsabilidades sin crear nuevas subclases, lo que permite una mayor modularidad y flexibilidad.

Un consejo bastante extendido en diseño de software es limitar el uso de la herencia cuando sea posible, ya que puede generar estructuras rígidas y difíciles de modificar. El patrón Decorator es, por tanto, una de las alternativas a la herencia, aunque no es la única opción.

Ejemplo visual:
Para recordar el concepto, imagina un regalo: primero lo metes en una caja, luego envuelves esa caja con papel de regalo. Aunque has añadido capas de presentación, el contenido esencial del regalo sigue siendo el mismo. Esta es una metáfora sencilla de cómo el Decorator permite añadir "capas" de funcionalidad alrededor de un objeto sin alterarlo directamente.

Dentro de la clasificación de patrones de diseño, el Decorator pertenece a los patrones estructurales, ya que su objetivo es organizar y simplificar la estructura del sistema.

Motivación · ¿Cuándo usar el PAtrón decorator?

En el desarrollo de software, es común que surjan nuevos requisitos o funcionalidades adicionales que deben ser incorporadas a objetos existentes. Un enfoque tradicional para añadir estas nuevas capacidades es mediante la herencia, creando subclases que extienden el comportamiento de las clases base. Sin embargo, la herencia puede llevar a estructuras complejas y rígidas, especialmente cuando necesitamos combinar múltiples características de manera flexible.

El patrón Decorator aborda estos problemas al permitirnos añadir funcionalidades a objetos de manera dinámica, sin necesidad de modificar las clases existentes o crear múltiples subclases. Esto es especialmente útil en situaciones como las siguientes:

  • Añadir funcionalidades a objetos existentes sin modificar su código fuente:
    Supongamos que estás trabajando con una API externa de la que recuperas un conjunto de datos. Has implementado un servicio que utiliza dicha API para obtener una lista de usuarios:
     
    public class ServicioUsuarios : IServicioUsuarios
    {
        private readonly ApiExterna _apiExterna;

        public ServicioUsuarios(ApiExterna apiExterna)
        {
            _apiExterna = apiExterna;
        }

        public async Task<Usuario> BuscarUsuarioPorId(int id)
        {
            return await _apiExterna.Usuarios.UsuarioConId(id);
        }

        public  async Task<IEnumerable<Usuario>> BuscarUsuarios()
        {
            return await _apiExterna.Usuarios.Todos();
        }
    }

Hasta aquí, todo normal. Pero ahora resulta que el servidor externo está sobrecargado y, de vez en cuando, devuelve un timeout. Quieres manejar estos casos añadiendo lógica de reintento o manejo de excepciones específicas.

Utilizando el patrón Decorator, podemos añadir esta funcionalidad sin tocar un solo método de la clase ya creada. Creamos un decorador que envuelve al servicio original y agrega el manejo de timeout:

     
public class ServicioUsuariosConReintento : IServicioUsuarios
{
    private readonly IServicioUsuarios _servicioOriginal;

    public ServicioUsuariosConReintento(IServicioUsuarios servicioOriginal)
    {
        _servicioOriginal = servicioOriginal;
    }

    public async Task BuscarUsuarioPorId(int id)
    {
        try
        {
            return await _servicioOriginal.BuscarUsuarioPorId(id);
        }
        catch (TimeoutException)
        {
            // Lógica de reintento o manejo de excepción
            // ...
            throw;
        }
    }

    public async Task> BuscarUsuarios()
    {
        try
        {
            return await _servicioOriginal.BuscarUsuarios();
        }
        catch (TimeoutException)
        {
            // Lógica de reintento o manejo de excepción
            // ...
            throw;
        }
    }
}

De esta manera, añadimos nueva funcionalidad sin modificar el código existente, respetando el principio de abierto/cerrado (Open/Closed Principle) de SOLID.

  • Evitar la "explosión de clases" al combinar múltiples comportamientos:

Otra situación conocida en la que el patrón funciona de maravilla es cuando se produce una explosión de clases. Imagina que estás modelando las bebidas que se sirven en un café. La interfaz ICafe es sencilla, tiene una propiedad Descripcion y un método que calcula el precio:

     
    public interface ICafe
    {
       string Descripcion { get; }

       decimal Precio();
    }

Puedes pedir un café solo, un descafeinado, un café con leche, un café con azúcar, un café descafeinado con leche natural, un café con nata, etc. Si intentas modelar cada una de estas bebidas en su propia clase......¡booom! explosión de clases.

  • CafeSolo
  • CafeConLeche
  • CafeConAzucar
  • CafeConLecheYAzucar
  • CafeDescafeinadoConLecheYAzucar
  • ...

Esto se vuelve insostenible. Sin embargo, si para cada "componente" creas una clase individual y las combinas para obtener la bebida final, conseguirás un código mucho más simple y reducido. Aquí es donde entra en juego el patrón Decorator:

// Componente concreto
public class CafeSolo : ICafe
{
    public string Descripcion => "Café solo";
    public decimal Precio() => 1.00m;
}

// Decorador abstracto
public abstract class CafeDecorator : ICafe
{
    protected ICafe _cafe;

    protected CafeDecorator(ICafe cafe)
    {
        _cafe = cafe;
    }

    public virtual string Descripcion => _cafe.Descripcion;
    public virtual decimal Precio() => _cafe.Precio();
}

// Decoradores concretos
public class ConLeche : CafeDecorator
{
    public ConLeche(ICafe cafe) : base(cafe) { }

    public override string Descripcion => _cafe.Descripcion + ", con leche";
    public override decimal Precio() => _cafe.Precio() + 0.20m;
}

public class ConAzucar : CafeDecorator
{
    public ConAzucar(ICafe cafe) : base(cafe) { }

    public override string Descripcion => _cafe.Descripcion + ", con azúcar";
    public override decimal Precio() => _cafe.Precio() + 0.10m;
}

// Uso del patrón Decorator
ICafe miCafe = new CafeSolo();
miCafe = new ConLeche(miCafe);
miCafe = new ConAzucar(miCafe);

Console.WriteLine(miCafe.Descripcion); // Salida: "Café solo, con leche, con azúcar"
Console.WriteLine($"Precio: {miCafe.Precio()}€"); // Salida: "Precio: 1.30€"

Con este enfoque, puedes combinar las adiciones de forma flexible sin crear una clase para cada combinación posible. Esto simplifica enormemente el diseño y facilita el mantenimiento y la expansión del sistema.
 

  • Modificar o extender funcionalidades en tiempo de ejecución:

En aplicaciones donde la personalización es clave, como editores gráficos o sistemas de notificaciones, el patrón Decorator permite añadir o quitar funcionalidades en tiempo de ejecución de manera dinámica.

Por ejemplo, en un sistema de notificaciones, podrías tener un componente base que envía notificaciones por correo electrónico. Si en algún momento necesitas añadir notificaciones por SMS o push, puedes crear decoradores que añadan estos comportamientos sin modificar el componente original.

Estructura · Diagrama de Clases

ComponenteConcreto
Operacion()
Componente
Operacion()
Decorador
Operacion()
1
DecoradorConcretoAOperacion()estadoAñadidoDecoradorConcretoBOperacion()ComportamientoAñadido()
  _componente.Operacion();
 _componente.Operacion();
 ComportamientoAñadido();

Participantes

  • La estructura típica del patrón Decorator incluye los siguientes componentes:

  • Component (Componente):
    Es una interfaz o clase abstracta que define las operaciones que pueden ser modificadas por los decoradores.

  • ConcreteComponent (Componente Concreto):
    Es una implementación concreta del Component, que proporciona la funcionalidad básica sobre la cual se pueden agregar responsabilidades adicionales.

  • Decorator (Decorador):
    Clase abstracta que implementa la interfaz Component y contiene una referencia a un objeto Component. El Decorator delega las operaciones al objeto que decora y puede agregar su propio comportamiento antes o después de la delegación.

  • ConcreteDecorator (Decorador Concreto):
    Son clases concretas que extienden el Decorator y agregan responsabilidades específicas al Component.

Consideraciones importantes:

  • Orden de los decoradores:
    El orden en que aplicas los decoradores afecta al resultado final, ya que cada decorador puede ejecutar su comportamiento antes o después de delegar la llamada.

  • Sobrecarga de objetos pequeños:
    Usar demasiados decoradores puede aumentar la complejidad y el número de objetos en memoria, lo cual puede afectar al rendimiento.

Colaboraciones

En el patrón Decorator, los objetos colaboran entre sí de una manera estructurada para lograr la extensión dinámica de funcionalidades. La clave de esta colaboración radica en cómo los decoradores envuelven a los componentes y cómo las llamadas a los métodos se propagan a través de esta cadena de envolturas.

Interacción entre objetos:

  1. Componentes y Decoradores implementan la misma interfaz o clase base:

    Tanto el Component (componente) como el Decorator (decorador) implementan la misma interfaz o heredan de la misma clase abstracta. Esto asegura que los decoradores puedan ser utilizados en lugar de los componentes originales sin romper el principio de sustitución de Liskov.

  2. El Decorador contiene una referencia al componente que decora:

    Cada instancia de un decorador mantiene una referencia interna a un objeto Component. Esta referencia puede ser a un componente concreto o a otro decorador. Esto permite crear una cadena de decoradores que envuelven al componente original.

  3. Delegación de operaciones:

    Cuando se llama a un método en el decorador, este método, por lo general, realiza alguna operación adicional y luego delega la llamada al componente que está decorando. La delegación puede ocurrir antes, después o incluso alrededor de la operación adicional, dependiendo de la funcionalidad que el decorador quiera agregar.

  4. Composición dinámica de comportamientos:

    Al envolver un componente con múltiples decoradores, se crea una pila de llamadas donde cada decorador puede añadir su propio comportamiento. La llamada se propaga desde el decorador más externo hasta el componente base, permitiendo que cada decorador contribuya con su funcionalidad.

     

Añadiendo funcionalidad antes o después de la delegación:

  • Antes de la delegación:

    Un decorador puede ejecutar código antes de llamar al componente decorado. Por ejemplo, en un decorador de seguridad, podríamos verificar permisos antes de delegar la operación:

    public override void Operacion() 
    { 
       VerificarPermisos(); 
       _componente.Operacion(); 
    }
    
    

    Después de la delegación:

    El decorador puede ejecutar código después de que el componente decorado haya realizado su operación. Por ejemplo, en un decorador de registro (logging):

    public override void Operacion() 
    { 
       _componente.Operacion(); 
       RegistrarOperacion(); 
    } 
    
  • Alrededor de la delegación:

    El decorador puede envolver la llamada al componente decorado con código que se ejecuta tanto antes como después. Esto es útil en decoradores que manejan transacciones o excepciones:

    public override void Operacion() { 
      IniciarTransaccion(); 
       try 
        {
          _componente.Operacion(); 
          ConfirmarTransaccion(); 
         } 
        catch (Exception) 
       {
         RevertirTransaccion(); 
         throw; 
       } 
    }
    
    

VENTAJAS Y DESVENTAJAS

Ventajas

  • Mayor flexibilidad que la herencia estática:
    El patrón Decorator permite añadir o eliminar responsabilidades a los objetos en tiempo de ejecución de manera dinámica. Esto ofrece una flexibilidad que la herencia estática no proporciona, ya que con la herencia las características deben definirse en tiempo de compilación.

  • Evita clases sobrecargadas en la cima de la jerarquía:
    En lugar de crear clases con todas las funcionalidades posibles (lo que podría llevar a clases monolíticas y difíciles de mantener), el patrón Decorator permite comenzar con una clase base simple y añadir responsabilidades adicionales según se necesiten. Esto simplifica la jerarquía de clases y hace que el sistema sea más manejable.

  • Combinación selectiva de responsabilidades:
    Los decoradores permiten combinar diferentes funcionalidades de forma modular. Puedes añadir solo las responsabilidades que necesitas, sin tener que crear una subclase para cada posible combinación de comportamientos. Esto es especialmente útil en sistemas donde las combinaciones de características pueden ser numerosas.

  • Cumple con el Principio Abierto/Cerrado:
    El patrón Decorator permite extender el comportamiento de los objetos sin modificar el código existente. Esto respeta el principio de que las clases deben estar abiertas para extensión pero cerradas para modificación, facilitando la evolución del software sin riesgo de introducir errores en el código ya probado.

  • Promueve la reutilización y el mantenimiento del código:
    Al encapsular las funcionalidades adicionales en decoradores independientes, se fomenta la reutilización de código y se facilita el mantenimiento. Si una funcionalidad cambia, solo es necesario modificar el decorador correspondiente.

Desventajas

  • El decorador y su componente no son idénticos:
    Aunque los decoradores proporcionan una interfaz transparente, desde el punto de vista de la identidad del objeto, un componente decorado no es idéntico al componente original. Esto puede ser problemático si el código cliente depende de la identidad del objeto o utiliza comparaciones de referencia (== en lugar de .Equals()).

  • Aumento en el número de objetos pequeños:
    Los diseños que utilizan el patrón Decorator pueden generar una gran cantidad de objetos similares. Cada nueva funcionalidad requiere la creación de un decorador específico. Esto puede llevar a un sistema con muchos objetos que solo se diferencian en cómo están conectados, lo que puede ser difícil de comprender y mantener para quienes no están familiarizados con el diseño.

    Por ejemplo:
    En un sistema donde se aplican múltiples decoradores para agregar características a un objeto, puede resultar confuso rastrear qué decoradores están en uso y en qué orden se aplican. Esto puede complicar la depuración y el seguimiento del flujo del programa.

  • Complejidad en la depuración y pruebas:
    Debido a que las llamadas a los métodos pasan a través de múltiples capas de decoradores, puede ser más difícil seguir y comprender el flujo de ejecución. Esto puede complicar la depuración y las pruebas, especialmente si los decoradores se agregan o eliminan en tiempo de ejecución.

  • Sobrecarga de rendimiento:
    Cada decorador añade una capa adicional en la llamada al método, lo que puede introducir una sobrecarga en el rendimiento si se utilizan muchos decoradores. Aunque esto generalmente no es significativo, en sistemas donde el rendimiento es crítico, puede ser un factor a considerar.

  • Posible violación del Principio de Responsabilidad Única:
    Si no se diseña cuidadosamente, los decoradores pueden terminar agregando múltiples responsabilidades, lo que va en contra del principio de mantener las clases con una sola responsabilidad. Es importante que cada decorador tenga una función específica y bien definida.

Implementación

Ejemplo completo para ServicioUsuariosConReintento:

     
    public class ServicioUsuariosConReintento : IServicioUsuarios
    {
        private readonly IServicioUsuarios _servicioUsuarios;
        private readonly int _maxNumeroIntentos;
        private int numeroIntentos = 0;

        public ServicioUsuariosConReintento(IServicioUsuarios componenteServicioUsuarios, int maxNumeroIntentos)
        {
            _servicioUsuarios = componenteServicioUsuarios;
            _maxNumeroIntentos = maxNumeroIntentos;
        }

        public async Task<Usuario> BuscarUsuarioPorId(int id)
        {
            numeroIntentos++;
            try
            {
                var usuario = await _servicioUsuarios.BuscarUsuarioPorId(id);
                numeroIntentos = 0;
                return usuario;
            }
            catch (Exception)
            {
                if (_maxNumeroIntentos < numeroIntentos)
                    throw;

                await Task.Delay(2000); // 2 Segundos
                return await this.BuscarUsuarioPorId(id);
            }
        }

Usos conocidos en .net

  • Flujo de E/S (System.IO.Stream y sus derivados):

    El ejemplo más clásico del patrón Decorator en .NET es la jerarquía de clases que derivan de System.IO.Stream. La clase Stream es una clase abstracta que representa una secuencia de bytes. Hay varios flujos concretos como FileStream, MemoryStream, etc. Los decoradores en este contexto son clases como BufferedStream, CryptoStream y GZipStream, que añaden funcionalidades adicionales al flujo base.

    Ejemplo:

    // Flujo base que escribe en un archivo 
    using (Stream fileStream = new FileStream("datos.txt", FileMode.Create)) 
    { 
        // Decorador que añade cifrado al flujo 
        using (Stream cryptoStream = new CryptoStream( fileStream, new AesManaged().CreateEncryptor(), CryptoStreamMode.Write)) 
        { 
            // Decorador que añade compresión al flujo 
            using (Stream compressedStream = new GZipStream(cryptoStream, CompressionMode.Compress)) 
            {
     
               using (StreamWriter writer = new StreamWriter(compressedStream)) 
               { 
                writer.WriteLine("Ejemplo de patrón Decorator en .NET"); 
               } 
           } 
        } 
    } 
    
    

    En este ejemplo, CryptoStream y GZipStream decoran al FileStream, añadiendo cifrado y compresión respectivamente.

  • System.Net.Http.HttpClient y Handlers de Mensajes:

    La clase HttpClient en combinación con HttpMessageHandler y sus derivados es otro ejemplo del patrón Decorator. Puedes crear una cadena de manejadores que procesan las solicitudes HTTP, añadiendo funcionalidades como autenticación, registro, compresión, etc.

    Ejemplo:

    // Manejador base 
    HttpMessageHandler handler = new HttpClientHandler(); 
    
    // Decorador que añade compresión 
    handler = new CompressionHandler(handler); 
    
    // Decorador que añade autenticación 
    handler = new AuthenticationHandler(handler); 
    
    // Cliente HTTP que utiliza el manejador decorado 
    HttpClient client = new HttpClient(handler); 
    
    // Uso del cliente HTTP 
    var response = await client.GetAsync("https://api.ejemplo.com/datos");
    

    Aquí, CompressionHandler y AuthenticationHandler son decoradores que añaden funcionalidades al HttpMessageHandler base.
     

  • Implementaciones en ASP.NET Core Middleware:

    En ASP.NET Core, el pipeline de middleware utiliza el patrón Decorator. Cada middleware procesa una solicitud HTTP y, opcionalmente, pasa el control al siguiente middleware en la cadena.

    Ejemplo:

    public void Configure(IApplicationBuilder app) 
    { 
       app.UseAuthentication(); 
    
       // Decorador que añade autenticación 
       app.UseCompression(); 
    
       // Decorador que añade compresión 
       app.UseStaticFiles(); 
    
       // Maneja archivos estáticos 
       app.UseMvc(); // Maneja las solicitudes MVC 
    }
    

    Cada llamada a UseX() añade un middleware que decora el pipeline de solicitud, añadiendo funcionalidad adicional.

Patrones Relacionados

  • Decorator vs. Adapter y Proxy

    • Adapter (Adaptador): Transforma la interfaz de un objeto para hacerlo compatible con otra interfaz que el cliente espera.
    • Proxy: Proporciona la misma interfaz que el objeto real, pero controla el acceso a él.
    • Decorator: Mantiene la interfaz original del objeto y añade responsabilidades adicionales.
  • Decorator y Composite

    • Ambos utilizan composición recursiva.
    • Composite: Organiza objetos en estructuras jerárquicas (árboles), permitiendo tratar objetos individuales y compuestos de manera uniforme.
    • Decorator: Añade responsabilidades a objetos individuales sin modificar su estructura.
    • Relación: Un Decorator puede verse como un Composite simplificado con solo un componente. Ambos pueden combinarse para agregar funcionalidades a partes específicas de una estructura compuesta.
  • Decorator y Strategy

    • Decorator: Añade funcionalidades externas al objeto sin cambiar su estructura interna ("cambia la piel").
    • Strategy: Modifica el algoritmo interno del objeto para alterar su comportamiento ("cambia las entrañas").
    • Diferencia clave: Decorator extiende comportamientos existentes, mientras que Strategy reemplaza completamente la lógica interna.
  • Decorator y Chain of Responsibility

    • Chain of Responsibility: Permite que una solicitud pase a través de una cadena de objetos hasta que uno la maneje.
    • Relación con Composite: Un Composite puede usar Chain of Responsibility para que los componentes accedan a propiedades globales a través de su estructura jerárquica.
  • Uso conjunto de patrones

    • Decorator y Composite pueden combinarse para crear sistemas flexibles, donde los Decorators añaden funcionalidades a componentes específicos dentro de una estructura compuesta.
    • Composite puede beneficiarse de Decorator para extender propiedades o comportamientos en partes específicas sin afectar a todo el conjunto.

Conclusión
 

El patrón Decorator es una herramienta esencial en el diseño de software orientado a objetos que ofrece una forma flexible y dinámica de extender las funcionalidades de los objetos sin recurrir a la herencia estática. Al permitir agregar responsabilidades adicionales en tiempo de ejecución, facilita la creación de sistemas más modulables y mantenibles, evitando la rigidez y complejidad que puede surgir de las jerarquías de clases extensas.

Comprender y aplicar el patrón Decorator no solo amplía nuestras habilidades como desarrolladores, sino que también nos permite diseñar soluciones más elegantes y eficientes ante problemas comunes en la programación. Al aprovechar sus ventajas y ser conscientes de sus consideraciones, podemos construir aplicaciones que sean fácilmente extensibles y adaptables a futuros cambios, mejorando la calidad y la vida útil de nuestro software.




Quizá algun día empiece a enviar una newsletter, si te gustaría recibirla subscríbete aquí

Archivo