Objetivo del caso práctico
Vamos a refactorizar un módulo típico “rígido”: cálculo de precio final de un pedido con muchas reglas (descuentos, impuestos, envío) implementadas con if/switch. La meta es evolucionarlo hacia un diseño extensible usando polimorfismo y composición, manteniendo el comportamiento mediante verificaciones (asserts) durante el proceso.
Escenario: checkout con reglas cambiantes
Reglas actuales (simplificadas):
- Descuento por tipo de cliente:
REGULAR0%,VIP10%,EMPLOYEE20%. - Envío por método:
STANDARD5,EXPRESS15,PICKUP0. - Impuesto por país:
ES21%,MX16%,US0%. - Cupones opcionales:
NONE,WELCOME5(5 fijo),PERCENT10(10% adicional).
El problema: cada nueva regla implica tocar un método central, añadir más condicionales y aumentar el riesgo de romper casos existentes.
Versión inicial rígida (procedural / condicionales)
Java: implementación inicial
enum CustomerType { REGULAR, VIP, EMPLOYEE } enum ShippingMethod { STANDARD, EXPRESS, PICKUP } enum Coupon { NONE, WELCOME5, PERCENT10 } class Order { public final double subtotal; public final CustomerType customerType; public final ShippingMethod shippingMethod; public final String countryCode; public final Coupon coupon; public Order(double subtotal, CustomerType customerType, ShippingMethod shippingMethod, String countryCode, Coupon coupon) { this.subtotal = subtotal; this.customerType = customerType; this.shippingMethod = shippingMethod; this.countryCode = countryCode; this.coupon = coupon; } } class CheckoutServiceRigid { public double total(Order o) { double discountRate = 0.0; switch (o.customerType) { case VIP: discountRate = 0.10; break; case EMPLOYEE: discountRate = 0.20; break; default: discountRate = 0.0; } double discounted = o.subtotal * (1.0 - discountRate); double afterCoupon = discounted; switch (o.coupon) { case WELCOME5: afterCoupon = Math.max(0, afterCoupon - 5.0); break; case PERCENT10: afterCoupon = afterCoupon * 0.90; break; default: break; } double shipping = 0.0; switch (o.shippingMethod) { case STANDARD: shipping = 5.0; break; case EXPRESS: shipping = 15.0; break; case PICKUP: shipping = 0.0; break; } double taxRate; switch (o.countryCode) { case "ES": taxRate = 0.21; break; case "MX": taxRate = 0.16; break; case "US": taxRate = 0.0; break; default: taxRate = 0.0; } double taxed = afterCoupon * (1.0 + taxRate); return taxed + shipping; } }C#: implementación inicial equivalente
enum CustomerType { Regular, Vip, Employee } enum ShippingMethod { Standard, Express, Pickup } enum Coupon { None, Welcome5, Percent10 } class Order { public double Subtotal { get; } public CustomerType CustomerType { get; } public ShippingMethod ShippingMethod { get; } public string CountryCode { get; } public Coupon Coupon { get; } public Order(double subtotal, CustomerType customerType, ShippingMethod shippingMethod, string countryCode, Coupon coupon) { Subtotal = subtotal; CustomerType = customerType; ShippingMethod = shippingMethod; CountryCode = countryCode; Coupon = coupon; } } class CheckoutServiceRigid { public double Total(Order o) { double discountRate = o.CustomerType switch { CustomerType.Vip => 0.10, CustomerType.Employee => 0.20, _ => 0.0 }; double discounted = o.Subtotal * (1.0 - discountRate); double afterCoupon = o.Coupon switch { Coupon.Welcome5 => Math.Max(0, discounted - 5.0), Coupon.Percent10 => discounted * 0.90, _ => discounted }; double shipping = o.ShippingMethod switch { ShippingMethod.Standard => 5.0, ShippingMethod.Express => 15.0, ShippingMethod.Pickup => 0.0, _ => 0.0 }; double taxRate = o.CountryCode switch { "ES" => 0.21, "MX" => 0.16, "US" => 0.0, _ => 0.0 }; double taxed = afterCoupon * (1.0 + taxRate); return taxed + shipping; } }Verificación mínima antes de tocar nada (tests/ asserts)
Antes de refactorizar, fijamos el comportamiento con verificaciones simples. No buscamos cobertura perfecta; buscamos “red de seguridad” para cambios estructurales.
Java: asserts
class CheckoutRigidAssertions { static void assertClose(double expected, double actual) { if (Math.abs(expected - actual) > 0.0001) throw new AssertionError("Expected " + expected + " got " + actual); } public static void main(String[] args) { CheckoutServiceRigid svc = new CheckoutServiceRigid(); Order o1 = new Order(100, CustomerType.VIP, ShippingMethod.STANDARD, "ES", Coupon.NONE); // 100 -10% = 90; tax 21% => 108.9; +5 shipping => 113.9 assertClose(113.9, svc.total(o1)); Order o2 = new Order(50, CustomerType.REGULAR, ShippingMethod.PICKUP, "US", Coupon.WELCOME5); // 50 -5 = 45; tax 0 => 45; +0 => 45 assertClose(45.0, svc.total(o2)); Order o3 = new Order(200, CustomerType.EMPLOYEE, ShippingMethod.EXPRESS, "MX", Coupon.PERCENT10); // 200 -20% = 160; -10% => 144; tax 16% => 167.04; +15 => 182.04 assertClose(182.04, svc.total(o3)); } }C#: asserts simples
static class CheckoutRigidAssertions { static void AssertClose(double expected, double actual) { if (Math.Abs(expected - actual) > 0.0001) throw new Exception($"Expected {expected} got {actual}"); } public static void Run() { var svc = new CheckoutServiceRigid(); var o1 = new Order(100, CustomerType.Vip, ShippingMethod.Standard, "ES", Coupon.None); AssertClose(113.9, svc.Total(o1)); var o2 = new Order(50, CustomerType.Regular, ShippingMethod.Pickup, "US", Coupon.Welcome5); AssertClose(45.0, svc.Total(o2)); var o3 = new Order(200, CustomerType.Employee, ShippingMethod.Express, "MX", Coupon.Percent10); AssertClose(182.04, svc.Total(o3)); } }Refactor paso a paso hacia polimorfismo y composición
Paso 1: extraer el concepto de “cálculo” como pipeline
En vez de un método monolítico, modelamos el checkout como una secuencia: aplicar descuento, aplicar cupón, calcular impuestos, sumar envío. Esto prepara el terreno para reemplazar condicionales por objetos.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Paso 2: encapsular datos de entrada en un contexto de precio
Sin repetir teoría: creamos un objeto de contexto que viaja por el pipeline y evita parámetros sueltos. Mantiene subtotal, país, etc., y el “monto actual” en cada etapa.
Paso 3: introducir interfaces para cada variación
Identificamos variaciones independientes: descuento, cupón, impuestos, envío. Cada una se vuelve un contrato (interfaz) con una implementación por regla. Esto reduce el “centro” de cambios.
Paso 4: reemplazar switch/if por polimorfismo
En vez de switch(customerType), elegimos una estrategia DiscountPolicy adecuada. Lo mismo para impuestos, envío y cupón. La selección puede hacerse con fábricas simples (todavía con un switch pequeño) o con un registro/diccionario. El switch se mueve a un borde (composición), no al núcleo del cálculo.
Paso 5: aislar dependencias externas (por ejemplo, impuestos desde un servicio)
Simulamos que la tasa de impuesto viene de un servicio externo (API/DB). Introducimos un puerto (TaxRateProvider) y lo inyectamos. En tests usamos un stub.
Diseño refactorizado (equivalente en Java y C#)
Java: contratos y composición
interface DiscountPolicy { double apply(double subtotal); } class NoDiscount implements DiscountPolicy { public double apply(double subtotal) { return subtotal; } } class PercentageDiscount implements DiscountPolicy { private final double rate; public PercentageDiscount(double rate) { this.rate = rate; } public double apply(double subtotal) { return subtotal * (1.0 - rate); } } interface CouponPolicy { double apply(double amount); } class NoCoupon implements CouponPolicy { public double apply(double amount) { return amount; } } class FixedCoupon implements CouponPolicy { private final double value; public FixedCoupon(double value) { this.value = value; } public double apply(double amount) { return Math.max(0, amount - value); } } class PercentageCoupon implements CouponPolicy { private final double rate; public PercentageCoupon(double rate) { this.rate = rate; } public double apply(double amount) { return amount * (1.0 - rate); } } interface ShippingPolicy { double cost(); } class FlatShipping implements ShippingPolicy { private final double cost; public FlatShipping(double cost) { this.cost = cost; } public double cost() { return cost; } } interface TaxRateProvider { double rateFor(String countryCode); } class MapTaxRateProvider implements TaxRateProvider { public double rateFor(String countryCode) { switch (countryCode) { case "ES": return 0.21; case "MX": return 0.16; case "US": return 0.0; default: return 0.0; } } } class CheckoutService { private final DiscountPolicy discount; private final CouponPolicy coupon; private final ShippingPolicy shipping; private final TaxRateProvider taxRateProvider; public CheckoutService(DiscountPolicy discount, CouponPolicy coupon, ShippingPolicy shipping, TaxRateProvider taxRateProvider) { this.discount = discount; this.coupon = coupon; this.shipping = shipping; this.taxRateProvider = taxRateProvider; } public double total(double subtotal, String countryCode) { double afterDiscount = discount.apply(subtotal); double afterCoupon = coupon.apply(afterDiscount); double taxed = afterCoupon * (1.0 + taxRateProvider.rateFor(countryCode)); return taxed + shipping.cost(); } }C#: contratos y composición equivalentes
interface IDiscountPolicy { double Apply(double subtotal); } sealed class NoDiscount : IDiscountPolicy { public double Apply(double subtotal) => subtotal; } sealed class PercentageDiscount : IDiscountPolicy { private readonly double _rate; public PercentageDiscount(double rate) { _rate = rate; } public double Apply(double subtotal) => subtotal * (1.0 - _rate); } interface ICouponPolicy { double Apply(double amount); } sealed class NoCoupon : ICouponPolicy { public double Apply(double amount) => amount; } sealed class FixedCoupon : ICouponPolicy { private readonly double _value; public FixedCoupon(double value) { _value = value; } public double Apply(double amount) => Math.Max(0, amount - _value); } sealed class PercentageCoupon : ICouponPolicy { private readonly double _rate; public PercentageCoupon(double rate) { _rate = rate; } public double Apply(double amount) => amount * (1.0 - _rate); } interface IShippingPolicy { double Cost(); } sealed class FlatShipping : IShippingPolicy { private readonly double _cost; public FlatShipping(double cost) { _cost = cost; } public double Cost() => _cost; } interface ITaxRateProvider { double RateFor(string countryCode); } sealed class MapTaxRateProvider : ITaxRateProvider { public double RateFor(string countryCode) => countryCode switch { "ES" => 0.21, "MX" => 0.16, "US" => 0.0, _ => 0.0 }; } sealed class CheckoutService { private readonly IDiscountPolicy _discount; private readonly ICouponPolicy _coupon; private readonly IShippingPolicy _shipping; private readonly ITaxRateProvider _taxRateProvider; public CheckoutService(IDiscountPolicy discount, ICouponPolicy coupon, IShippingPolicy shipping, ITaxRateProvider taxRateProvider) { _discount = discount; _coupon = coupon; _shipping = shipping; _taxRateProvider = taxRateProvider; } public double Total(double subtotal, string countryCode) { var afterDiscount = _discount.Apply(subtotal); var afterCoupon = _coupon.Apply(afterDiscount); var taxed = afterCoupon * (1.0 + _taxRateProvider.RateFor(countryCode)); return taxed + _shipping.Cost(); } }Cómo reemplazar los condicionales: fábricas (selección en el borde)
El polimorfismo elimina el switch del cálculo, pero alguien debe elegir implementaciones. La idea es que esa decisión esté en un lugar “de composición” (por ejemplo, al construir el servicio), no dentro del algoritmo.
Java: fábrica simple para mantener equivalencia con enums
class PoliciesFactory { static DiscountPolicy discountFor(CustomerType t) { switch (t) { case VIP: return new PercentageDiscount(0.10); case EMPLOYEE: return new PercentageDiscount(0.20); default: return new NoDiscount(); } } static ShippingPolicy shippingFor(ShippingMethod m) { switch (m) { case STANDARD: return new FlatShipping(5.0); case EXPRESS: return new FlatShipping(15.0); case PICKUP: return new FlatShipping(0.0); default: return new FlatShipping(0.0); } } static CouponPolicy couponFor(Coupon c) { switch (c) { case WELCOME5: return new FixedCoupon(5.0); case PERCENT10: return new PercentageCoupon(0.10); default: return new NoCoupon(); } } }C#: fábrica equivalente
static class PoliciesFactory { public static IDiscountPolicy DiscountFor(CustomerType t) => t switch { CustomerType.Vip => new PercentageDiscount(0.10), CustomerType.Employee => new PercentageDiscount(0.20), _ => new NoDiscount() }; public static IShippingPolicy ShippingFor(ShippingMethod m) => m switch { ShippingMethod.Standard => new FlatShipping(5.0), ShippingMethod.Express => new FlatShipping(15.0), ShippingMethod.Pickup => new FlatShipping(0.0), _ => new FlatShipping(0.0) }; public static ICouponPolicy CouponFor(Coupon c) => c switch { Coupon.Welcome5 => new FixedCoupon(5.0), Coupon.Percent10 => new PercentageCoupon(0.10), _ => new NoCoupon() }; }Verificación de que el comportamiento se mantiene
Reutilizamos los mismos casos y comparamos resultados. Si esto falla, el refactor cambió lógica (o reveló un bug previo).
Java: asserts contra la versión refactorizada
class CheckoutRefactoredAssertions { static void assertClose(double expected, double actual) { if (Math.abs(expected - actual) > 0.0001) throw new AssertionError("Expected " + expected + " got " + actual); } static double totalRefactored(Order o) { CheckoutService svc = new CheckoutService( PoliciesFactory.discountFor(o.customerType), PoliciesFactory.couponFor(o.coupon), PoliciesFactory.shippingFor(o.shippingMethod), new MapTaxRateProvider() ); return svc.total(o.subtotal, o.countryCode); } public static void main(String[] args) { Order o1 = new Order(100, CustomerType.VIP, ShippingMethod.STANDARD, "ES", Coupon.NONE); assertClose(113.9, totalRefactored(o1)); Order o2 = new Order(50, CustomerType.REGULAR, ShippingMethod.PICKUP, "US", Coupon.WELCOME5); assertClose(45.0, totalRefactored(o2)); Order o3 = new Order(200, CustomerType.EMPLOYEE, ShippingMethod.EXPRESS, "MX", Coupon.PERCENT10); assertClose(182.04, totalRefactored(o3)); } }C#: asserts contra la versión refactorizada
static class CheckoutRefactoredAssertions { static void AssertClose(double expected, double actual) { if (Math.Abs(expected - actual) > 0.0001) throw new Exception($"Expected {expected} got {actual}"); } static double TotalRefactored(Order o) { var svc = new CheckoutService( PoliciesFactory.DiscountFor(o.CustomerType), PoliciesFactory.CouponFor(o.Coupon), PoliciesFactory.ShippingFor(o.ShippingMethod), new MapTaxRateProvider() ); return svc.Total(o.Subtotal, o.CountryCode); } public static void Run() { var o1 = new Order(100, CustomerType.Vip, ShippingMethod.Standard, "ES", Coupon.None); AssertClose(113.9, TotalRefactored(o1)); var o2 = new Order(50, CustomerType.Regular, ShippingMethod.Pickup, "US", Coupon.Welcome5); AssertClose(45.0, TotalRefactored(o2)); var o3 = new Order(200, CustomerType.Employee, ShippingMethod.Express, "MX", Coupon.Percent10); AssertClose(182.04, TotalRefactored(o3)); } }Aislar dependencias externas: impuestos como servicio inyectable
Si mañana el impuesto se obtiene de un endpoint, no queremos que el checkout dependa de HTTP. Ya tenemos el puerto TaxRateProvider/ITaxRateProvider. En producción se implementa con un cliente real; en pruebas se usa un stub.
Java: stub de impuestos para pruebas
class StubTaxRateProvider implements TaxRateProvider { private final double rate; public StubTaxRateProvider(double rate) { this.rate = rate; } public double rateFor(String countryCode) { return rate; } } class CheckoutWithStubTaxTest { static void assertClose(double expected, double actual) { if (Math.abs(expected - actual) > 0.0001) throw new AssertionError(); } public static void main(String[] args) { CheckoutService svc = new CheckoutService(new NoDiscount(), new NoCoupon(), new FlatShipping(0), new StubTaxRateProvider(0.21)); assertClose(121.0, svc.total(100, "ANY")); } }C#: stub de impuestos para pruebas
sealed class StubTaxRateProvider : ITaxRateProvider { private readonly double _rate; public StubTaxRateProvider(double rate) { _rate = rate; } public double RateFor(string countryCode) => _rate; } static class CheckoutWithStubTaxTest { static void AssertClose(double expected, double actual) { if (Math.Abs(expected - actual) > 0.0001) throw new Exception(); } public static void Run() { var svc = new CheckoutService(new NoDiscount(), new NoCoupon(), new FlatShipping(0), new StubTaxRateProvider(0.21)); AssertClose(121.0, svc.Total(100, "ANY")); } }Decisiones de diseño (por qué así y no de otra forma)
- Separar variaciones independientes: descuento, cupón, envío e impuestos cambian por motivos distintos; al separarlos, cada cambio afecta una pieza pequeña.
- Polimorfismo en el núcleo, condicionales en el borde: el cálculo (
CheckoutService) no sabe de enums ni códigos; solo compone políticas. Si aparece un nuevo cupón, se añade una clase y se registra en la fábrica. - Composición para combinar reglas: si mañana hay “descuento VIP + descuento por temporada”, se puede crear un
CompositeDiscount(lista deDiscountPolicy) sin tocar el checkout. - Dependencias externas invertidas: impuestos queda detrás de
TaxRateProvider, permitiendo stubs y evitando que el dominio dependa de infraestructura.
Extensión guiada: añadir una nueva regla sin tocar el algoritmo
Ejemplo: nuevo cupón FREESHIP que hace envío 0. En la versión rígida habría que modificar el método central. En la versión refactorizada, puedes modelarlo como una política adicional o como un decorador del envío.
Java: decorador de envío
class FreeShipping implements ShippingPolicy { public double cost() { return 0.0; } } // En composición: si cupón == FREESHIP, usar new FreeShipping() en vez de shippingFor(...)C#: decorador equivalente
sealed class FreeShipping : IShippingPolicy { public double Cost() => 0.0; } // En composición: si cupón == FreeShip, usar new FreeShipping()