Ir al contenido
wannacode

La tienda: qué patrón dónde

En resumen: Un ejercicio de patrones de diseño: una tienda con pago, avisos y almacenamiento en un solo método se reordena con Strategy, Observer y Repository, y su salida sigue igual.

Problema

Una tienda en un solo método

Aquí tienes una tienda en línea pequeña. El método del pedido calcula el importe según la forma de pago, guarda el pedido, envía un correo y escribe en el almacén:

class Shop
{
private readonly List<string> orders = new();
public void PlaceOrder(string id, string customer, decimal amount, string method)
{
decimal total;
if (method == "tarjeta")
{
total = amount;
}
else if (method == "contra reembolso")
{
total = amount + 2.50m;
}
else
{
throw new ArgumentException("Forma de pago desconocida: " + method);
}
orders.Add($"{id};{customer};{total}");
Console.WriteLine($"Correo a {customer}: pedido {id}, {total:F2} €");
Console.WriteLine("Almacén: preparar " + id);
}
public int Count => orders.Count;
}
var shop = new Shop();
shop.PlaceOrder("A-1", "ana@example.com", 80, "tarjeta");
shop.PlaceOrder("A-2", "ivan@example.com", 45, "contra reembolso");
Console.WriteLine("Pedidos: " + shop.Count);
Salida
Correo a ana@example.com: pedido A-1, 80.00 €
Almacén: preparar A-1
Correo a ivan@example.com: pedido A-2, 47.50 €
Almacén: preparar A-2
Pedidos: 2

Funciona. Pero el mes que viene llegan tres cambios: transferencia bancaria con un 2 % de descuento, un SMS tras cada pedido y una base de datos en lugar de una lista. Los tres están en el mismo método. Cada uno lo toca, y no se puede escribir una prueba del importe sin que salga un correo.

En esta lección no hay un patrón nuevo. Es un ejercicio: reconoces cuál de los patrones del módulo ordena cada una de estas cosas, y cuáles no hacen falta aquí.

La idea

El síntoma da nombre al patrón

No eliges un patrón de una lista. Lo eliges por lo que duele:

Síntoma en el código Patrón
una cadena de if sobre un tipo que crece con cada variante nueva Strategy, o Factory si la cadena elige qué crear
tras un evento, el propio código llama a todos los que deben enterarse Observer
el almacenamiento está dentro de las reglas y una prueba sin base de datos es imposible Repository y pasarlo desde fuera
un switch sobre el texto del estado en cada método State
un constructor con muchos parámetros opcionales Builder
una biblioteca ajena con una interfaz incómoda Adapter
cinco subsistemas que hay que llamar en un orden exacto Facade
comportamiento «alrededor» de un método: registro, caché, añadidos al precio Decorator
deshacer la última acción Command
un árbol donde una parte y el todo se usan igual Composite

Si no encuentras el síntoma, lo más probable es que no necesites un patrón.

Pruébalo

Reconócelo

Mira otra vez la tienda de la primera tarjeta.

1. Una cadena de if sobre el texto de la forma de pago que crece con cada forma nueva. ¿Qué patrón la ordena?
2. Tras un pedido, el propio método envía un correo y escribe en el almacén. Mañana llegará también el SMS. ¿Qué patrón?
3. Los pedidos se guardan en una lista dentro de la tienda. Mañana necesitas una base de datos, y para la prueba quieres algo en memoria. ¿Qué patrón?
4. El pedido tiene tres campos. ¿Necesita un Builder?

Míralo

Una forma posible de ordenarla

Tres de los síntomas están en la tienda, así que también son tres los patrones.

Las formas de pago son estrategias. Cada una es una función, así que la tabla es Dictionary<string, Func<decimal, decimal>>, sin interfaz (mira la lección de Strategy):El pedido es un record. El almacenamiento está detrás de una interfaz, con una implementación para la memoria:
record Order(string Id, string Customer, decimal Total);
interface IOrderRepository
{
void Save(Order order);
int Count { get; }
}
class InMemoryOrders : IOrderRepository
{
private readonly List<Order> orders = new();
public void Save(Order order) => orders.Add(order);
public int Count => orders.Count;
}
La tienda recibe el almacenamiento y la tabla de formas desde fuera. Los suscriptores son el evento Placed, que dispara cuando se hace un pedido:
class Shop
{
private readonly IOrderRepository orders;
private readonly Dictionary<string, Func<decimal, decimal>> methods;
public event Action<Order>? Placed;
public Shop(IOrderRepository orders, Dictionary<string, Func<decimal, decimal>> methods)
{
this.orders = orders;
this.methods = methods;
}
public void PlaceOrder(string id, string customer, decimal amount, string method)
{
if (!methods.TryGetValue(method, out var payment))
{
throw new ArgumentException("Forma de pago desconocida: " + method);
}
var order = new Order(id, customer, payment(amount));
orders.Save(order);
Placed?.Invoke(order);
}
public int Count => orders.Count;
}

Qué almacenamiento, qué formas y qué suscriptores se decide en un solo lugar, donde se monta la tienda:

var methods = new Dictionary<string, Func<decimal, decimal>>
{
["tarjeta"] = amount => amount,
["contra reembolso"] = amount => amount + 2.50m,
};
var shop = new Shop(new InMemoryOrders(), methods);
shop.Placed += o => Console.WriteLine($"Correo a {o.Customer}: pedido {o.Id}, {o.Total:F2} €");
shop.Placed += o => Console.WriteLine("Almacén: preparar " + o.Id);
shop.PlaceOrder("A-1", "ana@example.com", 80, "tarjeta");
shop.PlaceOrder("A-2", "ivan@example.com", 45, "contra reembolso");
Console.WriteLine("Pedidos: " + shop.Count);
Salida
Correo a ana@example.com: pedido A-1, 80.00 €
Almacén: preparar A-1
Correo a ivan@example.com: pedido A-2, 47.50 €
Almacén: preparar A-2
Pedidos: 2

La salida es la misma, línea por línea. Ordenar no ha cambiado el comportamiento, y eso es lo primero que compruebas. Aquí los dos programas se ejecutan en cada comprobación del sitio y dan la misma salida.

Míralo

Los tres cambios ahora

La transferencia bancaria es una fila nueva en la tabla. El SMS es un suscriptor nuevo. La base de datos será una implementación nueva de IOrderRepository. Ni una sola línea de la clase de la tienda cambia:

var methods = new Dictionary<string, Func<decimal, decimal>>
{
["tarjeta"] = amount => amount,
["contra reembolso"] = amount => amount + 2.50m,
["transferencia bancaria"] = amount => amount * 0.98m,
};
var shop = new Shop(new InMemoryOrders(), methods);
shop.Placed += o => Console.WriteLine("SMS a " + o.Customer + ": " + o.Id);
shop.PlaceOrder("A-3", "ana@example.com", 100, "transferencia bancaria");
Salida
SMS a ana@example.com: A-3

Ahora también es fácil una prueba del importe: pasas un almacenamiento en memoria y no suscribes el correo.

La idea

Dónde parar

El módulo tiene más de diez patrones, y aquí se usan tres. Los demás no se han olvidado, simplemente aquí no ayudan:

  • Builder: el pedido tiene tres campos obligatorios. Basta el constructor (record);
  • Singleton: nada tiene que ser el único. El almacenamiento se pasa desde fuera, así que la prueba y la tienda pueden tener cada una el suyo;
  • Factory: las formas de pago son funciones en una tabla, y la tabla ya es el lugar donde se hace la elección;
  • State: el pedido aún no tiene estados. Cuando lleguen «pagado» y «enviado», vendrá también (mira las tareas).

Paras cuando el cambio que viene se vuelve fácil. Más patrones no significa mejor código.

Ejercicio

Sigue ordenando la tienda

Resuelve las tareas en tu editor, partiendo de la tienda ordenada. Después de cada paso ejecuta el programa de la tarjeta «Una forma posible de ordenarla»: su salida debe seguir igual.

1. Los estados del pedido. El pedido pasa de nuevo → pagado → enviado y se puede cancelar, pero no después de enviarlo. Hazlo con State (o con un enum y una tabla de transiciones, si los estados no tienen comportamiento propio) y muestra el error al cancelar un pedido enviado.

2. Un descuento sobre 100 €. Un pedido de más de 100 € recibe un 5 % de descuento, sea cual sea el pago. Piensa dónde le corresponde estar: en una estrategia, en un Decorator alrededor de la estrategia o en la tienda. ¿Por qué?

3. Un almacenamiento que recuerda. Haz un segundo almacenamiento que escriba los pedidos en un archivo (uno por línea) y pásaselo a la tienda en lugar del de memoria. La clase de la tienda no cambia.

4. Un mensajero ajeno. El almacén ahora se llama a través de la biblioteca de un mensajero con un método createShipment(reference, weightGrams). El suscriptor del almacén pasa a ser un Adapter hacia ella.

Test

Ponte a prueba

1. Ordenas el código con patrones. ¿Cómo sabes que no has roto nada?
2. ¿Cuál NO es un buen motivo para añadir un patrón?
3. Tras ordenar, añades transferencia bancaria y SMS. ¿Qué tocas en la clase de la tienda?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·