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 final List<String> orders = new ArrayList<>();
void placeOrder(String id, String customer, double amount, String method) { double total; if (method.equals("tarjeta")) { total = amount; } else if (method.equals("contra reembolso")) { total = amount + 2.50; } else { throw new IllegalArgumentException("Forma de pago desconocida: " + method); } orders.add(id + ";" + customer + ";" + total); System.out.printf("Correo a %s: pedido %s, %.2f €%n", customer, id, total); System.out.println("Almacén: preparar " + id); }
int count() { return orders.size(); }}Shop shop = new Shop();shop.placeOrder("A-1", "ana@example.com", 80, "tarjeta");shop.placeOrder("A-2", "ivan@example.com", 45, "contra reembolso");System.out.println("Pedidos: " + shop.count());Correo a ana@example.com: pedido A-1, 80.00 €Almacén: preparar A-1Correo a ivan@example.com: pedido A-2, 47.50 €Almacén: preparar A-2Pedidos: 2Funciona. 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.
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. La interfaz tiene un método, así que cada forma es una lambda en la tablaMap:interface PaymentMethod { double total(double amount);}record Order(String id, String customer, double total) {}
interface OrderRepository { void save(Order order); int count();}
class InMemoryOrders implements OrderRepository { private final List<Order> orders = new ArrayList<>();
public void save(Order order) { orders.add(order); }
public int count() { return orders.size(); }}
interface OrderListener { void placed(Order order);}class Shop { private final OrderRepository orders; private final Map<String, PaymentMethod> methods; private final List<OrderListener> listeners = new ArrayList<>();
Shop(OrderRepository orders, Map<String, PaymentMethod> methods) { this.orders = orders; this.methods = methods; }
void subscribe(OrderListener listener) { listeners.add(listener); }
void placeOrder(String id, String customer, double amount, String method) { PaymentMethod payment = methods.get(method); if (payment == null) { throw new IllegalArgumentException("Forma de pago desconocida: " + method); } Order order = new Order(id, customer, payment.total(amount)); orders.save(order); for (OrderListener listener : listeners) { listener.placed(order); } }
int count() { return orders.count(); }}Qué almacenamiento, qué formas y qué suscriptores se decide en un solo lugar, donde se monta la tienda:
Map<String, PaymentMethod> methods = Map.of( "tarjeta", amount -> amount, "contra reembolso", amount -> amount + 2.50);Shop shop = new Shop(new InMemoryOrders(), methods);shop.subscribe(o -> System.out.printf("Correo a %s: pedido %s, %.2f €%n", o.customer(), o.id(), o.total()));shop.subscribe(o -> System.out.println("Almacén: preparar " + o.id()));
shop.placeOrder("A-1", "ana@example.com", 80, "tarjeta");shop.placeOrder("A-2", "ivan@example.com", 45, "contra reembolso");System.out.println("Pedidos: " + shop.count());Correo a ana@example.com: pedido A-1, 80.00 €Almacén: preparar A-1Correo a ivan@example.com: pedido A-2, 47.50 €Almacén: preparar A-2Pedidos: 2La 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 OrderRepository. Ni una sola línea de la clase de la tienda cambia:
Map<String, PaymentMethod> methods = Map.of( "tarjeta", amount -> amount, "contra reembolso", amount -> amount + 2.50, "transferencia bancaria", amount -> amount * 0.98);Shop shop = new Shop(new InMemoryOrders(), methods);shop.subscribe(o -> System.out.println("SMS a " + o.customer() + ": " + o.id()));
shop.placeOrder("A-3", "ana@example.com", 100, "transferencia bancaria");SMS a ana@example.com: A-3Ahora 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Decorator · Command · State · Repository
- Ejercicios sobre el tema: Patrones de diseño