Factory Method y Simple Factory
En resumen: Una fábrica guarda en un solo lugar la decisión de qué clase crear: Simple Factory pone la elección en un punto, y Factory Method deja que una subclase decida qué objeto fabricar.
Problema
Tres sitios conocen cada tipo de envío
El pedido dice cómo llega al cliente: "address" (a domicilio), "office" (en una oficina de mensajería) o "locker" (en un casillero). Cada tipo tiene una clase con su propio precio y su propia etiqueta:
interface IDelivery{ decimal Price { get; }
string Label(string recipient);}
class AddressDelivery : IDelivery{ public decimal Price => 5.90m;
public string Label(string recipient) => $"{recipient}, a domicilio";}
class OfficeDelivery : IDelivery{ public decimal Price => 3.90m;
public string Label(string recipient) => $"{recipient}, en una oficina de mensajería";}
class LockerDelivery : IDelivery{ public decimal Price => 2.90m;
public string Label(string recipient) => $"{recipient}, en un casillero";}Tres partes de la tienda necesitan el envío: la caja, la vista previa del precio y la impresora de etiquetas. Cada una decide por su cuenta qué clase crear:
static decimal Checkout(string type, decimal items){ IDelivery delivery = type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => throw new ArgumentException(type), }; return items + delivery.Price;}
static decimal PreviewPrice(string type){ IDelivery delivery = type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => throw new ArgumentException(type), }; return delivery.Price;}
static string PrintLabel(string type, string recipient){ IDelivery delivery = type switch { "office" => new OfficeDelivery(), _ => new AddressDelivery(), }; return delivery.Label(recipient);}Console.WriteLine($"Pedido: {Checkout("locker", 40):F2} €");Console.WriteLine($"Vista previa: envío {PreviewPrice("locker"):F2} €");Console.WriteLine("Etiqueta: " + PrintLabel("locker", "María"));Pedido: 42.90 €Vista previa: envío 2.90 €Etiqueta: María, a domicilioEl mes pasado llegaron los casilleros. Alguien añadió "locker" en la caja y en la vista previa. La impresora de etiquetas se escribió antes y tiene un _ para la dirección. María pagó el envío a un casillero, y el mensajero fue a su casa.
Un tipo nuevo son tres cambios, y uno se olvida. La elección «qué clase para qué tipo» está copiada, y las copias no saben unas de otras.
La idea
Simple Factory: new vive en un solo lugar
La elección «qué clase para qué tipo» es una sola decisión, así que vive en un solo lugar: una fábrica (factory). Recibe el tipo y devuelve un objeto nuevo. Quien llama no escribe new y no conoce la clase: recibe la interfaz.
Los roles:
- el producto: la forma común, la interfaz
IDelivery; - los productos concretos:
AddressDelivery,OfficeDelivery,LockerDelivery; - la fábrica:
Deliveries.Create(type), el único lugar que sabe qué clase corresponde a qué tipo; - los clientes: la caja, la vista previa, la etiqueta. Solo preguntan.
Esto se llama Simple Factory. No es del libro de la Banda de los Cuatro (Gang of Four), pero es la forma más común. Factory Method, el del libro, es algo un poco distinto y viene más adelante en la lección.
Una regla: un tipo desconocido es un error, no un valor por defecto silencioso. Una fábrica que no conoce el tipo lanza una excepción con un mensaje claro. Si no, el error no desaparece, se va con el mensajero a la dirección equivocada.
La tabla «nombre → estrategia» de la lección de Strategy es lo mismo: una fábrica que te da un objeto a partir de un texto.
Míralo
Una fábrica para los tres sitios
static class Deliveries{ public static IDelivery Create(string type) => type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => throw new ArgumentException($"Tipo de envío desconocido: {type}"), };}La caja, la vista previa y la etiqueta ya no eligen, preguntan:
static decimal Checkout(string type, decimal items){ return items + Deliveries.Create(type).Price;}
static decimal PreviewPrice(string type){ return Deliveries.Create(type).Price;}
static string PrintLabel(string type, string recipient){ return Deliveries.Create(type).Label(recipient);}Console.WriteLine($"Pedido: {Checkout("locker", 40):F2} €");Console.WriteLine($"Vista previa: envío {PreviewPrice("locker"):F2} €");Console.WriteLine("Etiqueta: " + PrintLabel("locker", "María"));Pedido: 42.90 €Vista previa: envío 2.90 €Etiqueta: María, en un casilleroLa etiqueta es para un casillero. La elección está en un solo lugar, así que no puede estar bien en la caja y mal en la etiqueta.
Más precisamente, para curiosos
La fábrica también puede ser una tabla: Dictionary<string, Func<IDelivery>> con valores como () => new AddressDelivery(). Una tabla es cómoda cuando los tipos se añaden desde fuera (cada módulo registra el suyo). Un switch es más claro cuando todos los tipos se conocen en un solo lugar.
Míralo
Un tipo nuevo: una clase y una línea
La tienda añade la recogida en tienda: tipo "pickup", gratis. Lo nuevo es una clase:
class PickupDelivery : IDelivery{ public decimal Price => 0.00m;
public string Label(string recipient) => $"{recipient}, en la tienda";}y una línea en la fábrica, resaltada aquí:
static class Deliveries{ public static IDelivery Create(string type) => type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), "pickup" => new PickupDelivery(), _ => throw new ArgumentException($"Tipo de envío desconocido: {type}"), };}IDelivery home = Deliveries.Create("address");Console.WriteLine($"{home.Label("María")}: {home.Price:F2} €");
IDelivery locker = Deliveries.Create("locker");Console.WriteLine($"{locker.Label("María")}: {locker.Price:F2} €");
IDelivery pickup = Deliveries.Create("pickup");Console.WriteLine($"{pickup.Label("María")}: {pickup.Price:F2} €");María, a domicilio: 5.90 €María, en un casillero: 2.90 €María, en la tienda: 0.00 €IDelivery home = Deliveries.Create("address");Console.WriteLine($"{home.Label("María")}: {home.Price:F2} €");
IDelivery locker = Deliveries.Create("locker");Console.WriteLine($"{locker.Label("María")}: {locker.Price:F2} €");
IDelivery pickup = Deliveries.Create("pickup");Console.WriteLine($"{pickup.Label("María")}: {pickup.Price:F2} €");Paso a paso, en palabras
- Deliveries.Create("address") recibe solo el texto del pedido. La fábrica busca el molde que le corresponde.
- Del molde sale un AddressDelivery nuevo, pero home tiene el tipo IDelivery: quien llama ve solo Price y Label().
- La misma fábrica, otro texto: "locker" pasa de largo los otros moldes y se detiene en LockerDelivery.
- La etiqueta ya dice casillero: la clase se elige en un solo lugar, y la caja, la vista previa y la etiqueta preguntan a ese lugar.
- Un tipo nuevo: la clase PickupDelivery y una línea más en la fábrica. Las líneas que llaman a Deliveries.Create no cambian.
- "pickup" pasa como cualquier otro tipo. El tipo nuevo se añadió en un solo lugar.
La caja, la vista previa y la etiqueta no se tocan. Se enteran del tipo nuevo cuando un pedido lo pide.
Míralo
Tipo desconocido
El tipo viene de fuera: de un formulario, de la web de un socio, de un archivo viejo. Tarde o temprano aparece un tipo que no existe:
try{ IDelivery delivery = Deliveries.Create("drone"); Console.WriteLine(delivery.Label("María"));}catch (ArgumentException e){ Console.WriteLine(e.Message);}Tipo de envío desconocido: droneEl error dice qué tipo es desconocido. Se captura donde el pedido entra en la tienda, y el cliente ve un mensaje en lugar de un paquete en el lugar equivocado.
¿Y si la fábrica no tiene_? C# solo da una advertencia. El programa compila y falla cuando llega un tipo desconocido:static class Deliveries{ public static IDelivery Create(string type) => type switch { "address" => new AddressDelivery(), "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), };}warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '""' is not covered.
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.Unmatched value was drone.Míralo
Factory Method: la subclase decide
Simple Factory elige por texto. A veces la elección no es por texto, sino por quién pregunta.
El informe de ventas se envía como CSV (para contabilidad) y como tabla HTML (en un correo). Los dos hacen lo mismo: cada producto es una fila, y al final hay una fila «Total». Solo es distinto el archivo en el que se escriben las filas:
interface IReportFile{ void Add(string product, decimal total);
string Text();}
class CsvFile : IReportFile{ private readonly List<string> lines = new() { "producto;total" };
public void Add(string product, decimal total) => lines.Add($"{product};{total:F2}");
public string Text() => string.Join("\n", lines);}
class HtmlFile : IReportFile{ private readonly List<string> rows = new();
public void Add(string product, decimal total) => rows.Add($"<tr><td>{product}</td><td>{total:F2}</td></tr>");
public string Text() => "<table>\n" + string.Join("\n", rows) + "\n</table>";}record Sale(string Product, decimal Total);
abstract class ReportExporter{ protected abstract IReportFile CreateFile();
public string Export(List<Sale> sales) { IReportFile file = CreateFile(); decimal overall = 0; foreach (var sale in sales) { file.Add(sale.Product, sale.Total); overall += sale.Total; } file.Add("Total", overall); return file.Text(); }}
class CsvExporter : ReportExporter{ protected override IReportFile CreateFile() => new CsvFile();}
class HtmlExporter : ReportExporter{ protected override IReportFile CreateFile() => new HtmlFile();}var sales = new List<Sale> { new("Café", 12.50m), new("Té", 8.00m) };Console.WriteLine(new CsvExporter().Export(sales));Console.WriteLine(new HtmlExporter().Export(sales));producto;totalCafé;12.50Té;8.00Total;20.50<table><tr><td>Café</td><td>12.50</td></tr><tr><td>Té</td><td>8.00</td></tr><tr><td>Total</td><td>20.50</td></tr></table>Esto es Factory Method: la clase base llama a un método que crea el objeto (CreateFile()), y la subclase decide qué clase exactamente.
¿Por qué no pasar un archivo ya hecho? El archivo acumula filas. Cada Export tiene que empezar con un archivo vacío, así que el informe no guarda un archivo, sabe cómo crear uno nuevo.
¿Cuándo basta Simple Factory? Factory Method compensa cuando alrededor de la creación hay un algoritmo común que las subclases no deben repetir (aquí: las filas y el total). Si solo quieres un objeto por tipo, sin algoritmo alrededor, Simple Factory es más claro. Y una subclase que existe solo para devolver un objeto nuevo es ceremonia. Entonces pasa un Func<IReportFile>: new ReportExporter(() => new CsvFile()).
Pruébalo
¿Qué saldrá?
Los tipos vienen de un formulario de la web. ¿Qué imprime el programa?
string[] types = { "office", "Locker", "locker" };foreach (var type in types){ try { Console.WriteLine(Deliveries.Create(type).Label("María")); } catch (ArgumentException e) { Console.WriteLine(e.Message); }}Pruébalo
Encuentra el error
Otro programador escribió la fábrica así:
static class Deliveries{ public static IDelivery Create(string type) => type switch { "office" => new OfficeDelivery(), "locker" => new LockerDelivery(), _ => new AddressDelivery(), };}El pedido viene de la web de un socio, que envía el tipo con mayúscula inicial:
IDelivery delivery = Deliveries.Create("Locker");Console.WriteLine("Etiqueta: " + delivery.Label("María"));Console.WriteLine($"Envío: {delivery.Price:F2} €");Etiqueta: María, a domicilioEnvío: 5.90 €María eligió un casillero. Pagó 5,90 € de envío en lugar de 2,90 €, y el paquete fue a su dirección. ¿En qué línea está el error?
Elige la línea con el error.
Esta línea convierte en silencio cualquier tipo desconocido en un envío a domicilio. La mayúscula es solo el detonante: mañana será "lockr", o un tipo nuevo que alguien olvidó añadir aquí. La fábrica debe lanzar un error claro ante un tipo desconocido. Si quieres aceptar también "Locker", pasa el tipo a minúsculas en la propia fábrica, en un solo lugar, y no en cada uno que la llama.
La idea
Cuándo no la necesitas
Una fábrica es un salto más al leer: para ver qué clase se crea, vas a otro sitio. No compensa cuando:
- ese lugar siempre necesita la misma clase.
new OfficeDelivery()donde el envío siempre va a una oficina es más claro que una fábrica; - el tipo se conoce mientras escribes el código y no llega como dato (de un pedido, un archivo, un formulario);
-
hay una fábrica de la fábrica.
DeliveryFactoryFactorycasi siempre significa que una de las dos sobra; - un Factory Method tiene una subclase «para el futuro».
Compensa cuando el tipo llega como dato y la elección de la clase, si no, se copiaría en varios sitios. O cuando hay un algoritmo común alrededor de la creación y solo cambia el paso «qué objeto».
Ejercicio
Envío exprés
Resuelve esto en tu editor.
Envío exprés. La tienda añade el envío exprés a domicilio: tipo "express", 9,90 €, etiqueta «…, exprés a domicilio». Añade una clase ExpressDelivery y hazla disponible a través de la fábrica. La caja, la vista previa y la etiqueta no se tocan. Crea un envío exprés para María e imprímelo como en la tarjeta «Un tipo nuevo»:
María, exprés a domicilio: 9.90 €Más tareas
- Mayúsculas y espacios. El formulario envía
"Locker"y" locker ". Haz que la fábrica los acepte cambiando solo la fábrica. Los tipos desconocidos tienen que seguir dando error. - Un informe JSON. Añade un formato JSON al informe de la tarjeta «Factory Method»:
[{"product": "Café", "total": 12.50}, …]. ¿Qué clases escribes y cuáles no tocas? - Precio por peso. Los casilleros admiten paquetes de hasta 20 kg, y por encima de 5 kg el envío a domicilio cuesta más. ¿Dónde va el peso: en la fábrica o en el método del precio? Piensa por qué.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Dictionary y HashSet · record, switch y patrones · Patrones de diseño · Strategy
- Continúa en: Builder · La tienda: qué patrón dónde
- Ejercicios sobre el tema: Patrones de diseño