Refactorización orientada a objetos: del código rígido al código extensible

Capítulo 9

Tiempo estimado de lectura: 11 minutos

+ Ejercicio

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: REGULAR 0%, VIP 10%, EMPLOYEE 20%.
  • Envío por método: STANDARD 5, EXPRESS 15, PICKUP 0.
  • Impuesto por país: ES 21%, MX 16%, US 0%.
  • 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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 de DiscountPolicy) 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()

Ahora responde el ejercicio sobre el contenido:

¿Cuál es el beneficio principal de mover los switch/if del cálculo del checkout a fábricas y usar políticas (polimorfismo) en el núcleo?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Al encapsular cada variación en una política y dejar la selección en el borde (fábricas/composición), el cálculo queda estable. Así, nuevas reglas se implementan como nuevas clases o combinaciones, reduciendo cambios en un método central y el riesgo de romper casos existentes.

Siguiente capítulo

Traducción de conceptos POO a Java y C#: equivalencias y diferencias relevantes

Arrow Right Icon
Portada de libro electrónico gratuitaPOO práctica y clara: clases, encapsulación, herencia y polimorfismo (con ejemplos en Java y C#)
90%

POO práctica y clara: clases, encapsulación, herencia y polimorfismo (con ejemplos en Java y C#)

Nuevo curso

10 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.