Trabajando con código legado: Sprout Method y Sprout Class

En el anterior artículo vimos una introducción al concepto de código legado.

Uno de los motivos para cambiar código legado es añadir una carácteristica nueva.

Al trabajar con código legado, lo más normal es que este se encuentre en producción, sin test y el código no sea testable debido al acoplamiento.

Esto es un handicap importante porque van a existir situaciones donde nuestras decisiones se van a ver afectadas por este contexto.

Añadiendo código con prisas

Añadir una característica nueva sobre una aplicación existente puede ser desde anadir un módulo nuevo a nuestra aplicación y no modificaríamos apenas código legado, hasta añadir una condición nueva en una lógica existente.

Cuando modificamos código legado para añadir una carácteristica nueva, en el caso ideal si esta parte del código no es testable, lo primero que deberíamos hacer es refactorizar esa parte del código con el objetivo de hacerla testable y así poder crear tests para la carácteristica nueva que tenemos que añadir.

Pero si has trabajado con código legado te habrás encontrado en la situación donde esto no siempre es posible y la cantidad de tiempo necesario para desacoplar, crear tests y añadir la carácteristica nueva no es razonable porque el código es muy complejo, o esta muy acoplado o es basatante urgente para cliente y no puede esperar tanto.

Es importante saber evaluar cuando esta situcación es cierta porque a veces mosotros mismos caemos en la pereza o el cliente tiene prisas innecesarias.

Cuando necesitas añadir código con prisas, existen varias técnicas que podemos realizar, vamos a ver Sprout Method y Sprout Class.

Sprout Method

Cuando la característica a añadir puede ser representada completamente como una porción de código nuevo, entonces lo escribimos en un método nuevo y lo invocamos desde los sitios que sea necesario. De esta forma, al menos vamos a poder escribir test para el código nuevo, veamos un ejemplo:

Imaginemos que tenemos una clase que se encarga de calcular los costes de envío de un carrito de la compra y tiene el típico aspecto de código legado como puede ser: varias condiciones encadenadas y con dependencias acopladas que impiden poder realizar test unitarios de esta clase.

class ShippingCostCalculator {

    enum class ShippingType { FAST, PRIORITY, ONE_DAY_DELIVERY }

    fun calculate(shippingType: ShippingType, cartItems: List<CartItem>, address: Address): Double {
        
        var shippingCost = 0.0 

        if (CountryVerificator.isDomestic(address)) {
            if (shippingType == ShippingType.FAST) {
                shippingCost = cartItems.map { it.quantity * 1.99 }.sum()
            } else if (shippingType == ShippingType.PRIORITY) {
                shippingCost = cartItems.map { it.quantity * 3.99 }.sum()
            } else if (shippingType == ShippingType.ONE_DAY_DELIVERY) {
                shippingCost = cartItems.map { it.quantity * 6.99 }.sum()
            }
        } else {
            if (shippingType == ShippingType.FAST) {
                shippingCost = cartItems.map { it.quantity * 1.99 }.sum()
            } else {
                throw InvalidShippingTypeException("The shipping type $shippingType is not valid for non domestic shipping")
            }
        }
        
        return shippingCost
    }
}

Ahora supongamos que el cliente quiere añadir la siguiente característica:
En caso de tipo de envio rápido en el mismo país, si el producto es grande (más de 30 cm cualquier dimensión), el coste va a ser de 2.40 por producto si es inferior se mantiene a 1.99.

El cambio podría ser realizado directamente en el if correspondiente:

fun calculate(shippingType: ShippingType, cartItems: List<CartItem>, address: Address): Double {

        var shippingCost = 0.0

        if (CountryVerificator.isDomestic(address)) {
            if (shippingType == ShippingType.FAST) {
                shippingCost = cartItems.map { item ->
                    if (item.dimensions.height > 30 || item.dimensions.width > 30) {
                        item.quantity * 2.40
                    } else {
                        item.quantity * 1.99
                    }
                }.sum()
            } else if (shippingType == ShippingType.PRIORITY) {
                shippingCost = cartItems.map { it.quantity * 3.99 }.sum()
            } else if (shippingType == ShippingType.ONE_DAY_DELIVERY) {
                shippingCost = cartItems.map { it.quantity * 6.99 }.sum()
            }
        } else {
            if (shippingType == ShippingType.FAST) {
                shippingCost = cartItems.map { it.quantity * 1.99 }.sum()
            } else {
                throw InvalidShippingTypeException("The shipping type $shippingType is not valid for non domestic shipping")
            }
        }

        return shippingCost
    }

Pero este cambio complica un poco más la función calculate.

El código antiguo como el nuevo están mezclados y por lo tanto no podemos crear un test unitario para el código nuevo.

Una forma diferente de resolverlo es crear un nuevo método que se encargue de calcular el tipo de envio rápido dentro del mismo país, ya que es dónde hay una característica nueva.

     fun calculateDomesticFastShippingCost(cartItems: List<CartItem>): Double {
        return cartItems.map { item ->
            if (item.dimensions.height > 30 || item.dimensions.width > 30) {
                item.quantity * 2.40
            } else {
                item.quantity * 1.99
            }
        }.sum()
    }

Con este enfoque es posible crear tests para el método nuevo o incluso haberlo diseñado siguiente TDD si lo prefieres.

Ahora simplemente hay que modificar el if correspondiente pero no añadimos más complejidad al método calculate de la que ya tiene.

class ShippingCostCalculator {

    enum class ShippingType { FAST, PRIORITY, ONE_DAY_DELIVERY }

    fun calculate(shippingType: ShippingType, cartItems: List<CartItem>, address: Address): Double {

        var shippingCost = 0.0

        if (CountryVerificator.isDomestic(address)) {
            if (shippingType == ShippingType.FAST) {
                shippingCost = calculateDomesticFastShippingCost(cartItems)
            } else if (shippingType == ShippingType.PRIORITY) {
                shippingCost = cartItems.map { it.quantity * 3.99 }.sum()
            } else if (shippingType == ShippingType.ONE_DAY_DELIVERY) {
                shippingCost = cartItems.map { it.quantity * 6.99 }.sum()
            }
        } else {
            if (shippingType == ShippingType.FAST) {
                shippingCost = cartItems.map { it.quantity * 1.99 }.sum()
            } else {
                throw InvalidShippingTypeException("The shipping type $shippingType is not valid for non domestic shipping")
            }
        }

        return shippingCost
    }

   fun calculateDomesticFastShippingCost(cartItems: List<CartItem>): Double {
        return cartItems.map { item ->
            if (item.dimensions.height > 30 || item.dimensions.width > 30) {
                item.quantity * 2.40
            } else {
                item.quantity * 1.99
            }
        }.sum()
    }
}

Cuando realizamos esta técnica, el método nuevo deberá ser público con el objetivo de poder escribir los tests necesarios.

Si la creación de la clase no es viable para poder crear test unitarios porque en la clase esta acoplada a unas dependencias que no estamos en disposición de crear desde un test, tendríamos que hacer que el método sea estático y pasar las referencias necesarios para el método por parámetro.

Estan son algunas de las concesiones que hay que hacer cuando trabajamos con código legado.

Es importante destacar que el método sprout no es la situación ideal, ya que el código viejo sigue sin validarse mediante tests y hacemos una serie de concesiones que no haríamos si fuera todo código nuevo.

Sin embargo nos permite al menos validar el código nuevo que vamos añadiendo.

Sprout Class

Para estos escenarios donde aplicar sprout method puede resultar un poco complejo y sobre todo si la característica nueva a añadir puede asociar a una responsabilidad nueva que no tiene nada que ver con el código existente, podemos aplicar Sprout class.

Imaginemos que ahora el cliente quiere añadir un nuevo tipo de envío ultra rápido.

En esta circunstancia, podemos crearnos una nueva clase:

class UltraFastShippingCostCalculator{
    override fun calculate(cartItems: List<CartItem>, address: Address): Double {
        return cartItems.map { item ->
            if (item.dimensions.height > 30 || item.dimensions.width > 30) {
                item.quantity * 4.40
            } else {
                item.quantity * 2.99
            }
        }.sum()
    }
}

Esta nueva clase no se mezclaría con el código antíguo y podríamos tener test unitários sin realizar ninguna concesión al ser código completamente nuevo.

Incluso si creamos un interface ShippingCostCalculator con una función calculate, poco a poco podríamos ir extrayendo cada logíca correspondiente a un tipo de envío a su propia clase y utilizar un método de factoría que nos devuelva la clase a utilizar en función del tipo de envío.

Referencias

Las técnicas que aparecen en este artículo las puedes encontrar en el libro Working Effectively with Legacy Code de Michael Feathers,el cual pertece a la serie de Robert C. Martin.

Conclusiones

Al igual que existen patrones y técnicas de código limpio, a médida que vamos adquiriendo experiencia trabajando con código legado, empezamos a descubir code smell que se van repitiendo normalmente en código legado y que suelen tener las mismas soluciones casi siempre.

En este artículo hemos visto dos técnicas que podemos utilizar cuando necesitamos añadir código con prisas.

También hemos visto cuando puede ser conveniente utilizar una u otra, que como siempre dependerá del contexto.

Trabajar con código legado no es sentillo.