Seguro te pasó: viene tu project manager, analista funcional o tester con una nueva "gran" idea de negocio.
¿Qué tal si le ofrecemos al cliente la posibilidad de exportar el resultado en distintas extensiones: csv, xlsx, txt, json, xls, pdf…?
Y vos, como buen programador que nunca dice "no es viable", aceptás el desafío. Comenzás a investigar si existe alguna librería mágica que haga la conversión automática a todos esos formatos. Pero, para tu desgracia, descubrís que no es tan sencillo y que para cada formato necesitarías una librería diferente.
Después de pensarlo un rato, se te ocurre una solución práctica: crear un if-else
, donde según el tipo de archivo instanciás la clase correspondiente para realizar la exportación. A primera vista, parece una buena idea; al fin y al cabo, solo necesitas un simple bloque de condicionales.
public class FileService
{
private readonly CsvReader _csvReader;
private readonly XlsxGenerator _xlsxGenerator;
private readonly GenerateYourAmazingXls _generateYourAmazingXls;
public async Task<List<object>?> ExportResultAsync(IFormFile file)
{
var type = file.FileName.Split('.').Last();
if (type == "csv")
{
var csvWriter = new CsvReader();
return csvWriter.Write();
}
else if (type == "xlsx")
{
var xlsxWriter = new XlsxGenerator();
return xlsxWriter.Write();
}
else if (type == "xls")
{
var xlsWriter= new GenerateYourAmazingXls();
return xlsWriter.Write();
}
else
{
return null;
}
}
}
¡Bien, funciona! Sí, pero… es poco legible ¿no? Pensás… ¿Y si mejor utilizo un switch case? Será más legible.
Por lo que comienza tu segunda etapa, el pasaje a “limpio” de lo que acabás de hacer.
public async Task<List<object>?> ExportResultAsync(IFormFile file)
{
var type = file.FileName.Split('.').Last();
switch (type)
{
case "csv":
{
var csvWriter = new CsvReader();
return csvWriter.Convert();
}
case "xlsx":
{
var xlsxWriter = new XlsxGenerator();
return xlsxWriter.WriteToXlsx();
}
case "xls":
{
var xlsWriter = new GenerateYourAmazingXls();
return xlsWriter.GenerateResult();
}
default:
return null;
}
}
¿Sigue manteniéndose igual, no? ¿Cómo va a escalar este código si quiero agregar otras 15 variantes? ¿Por qué el mismo archivo tiene que instanciar múltiples readers, writers, generators, etc.? ¿Dónde quedó la atomicidad de esto? ¿Y el principio de responsabilidad única?
Son muchas preguntas, todas válidas y alarmantes. Te quedás en blanco, sin saber cómo algo así podría mantenerse a largo plazo. Pensás que esto ya no es tu problema, que probablemente será el dolor de cabeza de algún desarrollador del futuro. Pero recordás algo importante: nosotros no somos simples programadores, somos arquitectos de software. Buscamos siempre la excelencia, ¿o no?
Es en este momento donde aparece tu salvación: el preciado Patrón Strategy.
¿Qué es el Strategy Pattern?
El Patrón Strategy se basa en separar las distintas formas en las que una tarea puede ejecutarse. En lugar de que una clase incluya múltiples opciones de algoritmos, cada opción se extrae en una clase propia, conocida como estrategia.
El contexto, que es la clase principal, no realiza la tarea directamente, sino que le pasa el trabajo a una estrategia específica. Esta estrategia es elegida por el cliente, no por el contexto. Lo interesante es que el contexto no necesita conocer los detalles de cada estrategia, ya que todas comparten una interfaz común.
Con este patrón, es muy sencillo agregar nuevas formas de resolver el mismo problema sin alterar el funcionamiento del contexto o de otras estrategias, lo que hace que el código sea más flexible y fácil de mantener.
Ahora bien, todo muy lindo lo que me contás pero… ¿cómo lo implemento?
Definir una interface para las estrategias
La interfaz es el contrato que todas las estrategias deben cumplir. Define un método común, por ejemplo, Export()
, que cada estrategia implementará de manera específica según el formato de exportación.
public interface IExportStrategy
{
FileResult Export(ICollection<object> items);
}
Crear las estrategias específicas
Cada estrategia implementa la librería necesaria para exportar en un formato particular. Esto encapsula la lógica de exportación en clases separadas, manteniendo el código limpio y modular.
public class CsvExportStrategy : IExportStrategy
{
public FileResult Export(ICollection<object> items)
{
// Lógica para exportar a CSV
}
}
public class XlsExportStrategy : IExportStrategy
{
public FileResult Export(ICollection<object> items))
{
// Lógica para exportar a XLS
}
}
public class XlsxExportStrategy : IExportStrategy
{
public FileResult Export(ICollection<object> items)
{
// Lógica para exportar a XLSX
}
}
Crear el contexto
La clase contexto es responsable de ejecutar la estrategia seleccionada. No contiene lógica específica de exportación; simplemente delega el trabajo a la estrategia que se le ha asignado.
public class ExportContext
{
public async FileResult CreateStrategy(IFormFile file)
{
// Obtenemos el tipo del archivo.
var type = file.FileName.Split('.').Last();
// Seteamos la estrategia según el tipo de archivo.
var strategy = strategies.FirstOrDefault(strategy =>
{
return strategy switch
{
CsvStrategy _ when type == "csv" => true,
XlsxStrategy _ when type == "xlsx" => true,
XlsStrategy _ when type == "xls" => true,
_ => false,
};
});
// Nos aseguramos de que, al menos, una opción haya matcheado.
ArgumentNullException.ThrowIfNull(strategy);
// En este punto, el valor de strategy será el de su correspondiente caso de uso
return strategy.Export(file);
}
}
Instanciar el contexto
Ahora, tan solo nos resta recibir un objeto de tipo IFormFile
, delegando la responsabilidad al contexto y las estrategias, para que nos entreguen lo único que necesitamos: un FileResult
.
app.MapPost("/upload", (IFormFile file) =>
{
var context = new ExportContext();
var result = context.CreateStrategy(file);
return Results.Ok(result);
})
.WithName("UploadFile")
.WithOpenApi();
Conclusión
Gracias al patrón Strategy, logramos desacoplar la lógica específica de cada exportación del contexto principal. Esto nos permite agregar o modificar estrategias sin tener que tocar el código central. Además, conseguimos mantener el principio de responsabilidad única, evitando los interminables bloques de if-else
o switch
, lo que hace que nuestro sistema sea más escalable, modular y fácil de mantener.
De este modo, cualquier cambio en una estrategia (como un nuevo formato de exportación) se implementa de forma aislada, sin riesgo de afectar el comportamiento de otras estrategias o del contexto. Sin embargo, llega la siguiente pregunta:
¿Qué pasa si cada estrategia necesita inyectar distintas dependencias para realizar su trabajo?
Por ejemplo, tal vez la estrategia de exportación a CSV requiera un logger, mientras que la estrategia para JSON necesite un servicio externo de validación. ¿Cómo podrías resolver esta nueva situación sin romper el principio de inyección de dependencias?
Acá es donde surgen otras soluciones complementarias, como el uso de factories para crear estrategias o integrar un contenedor de dependencias que gestione la instanciación dinámica. Pero... esa es una historia para otro artículo.