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

En el anterior artículo vimos las técnicas de sprout method y sprout Class.

Son unas técnicas que pueden resultar de utilidad en el contexto de tener que implementar una característica nueva con prisas, sin tiempo para preparar el terreno del código antiguo, refactorizandolo y cubrirlo con test previamente.

Vimos que sobre todo sprout method y sprout class son útiles cuando la nueva característica se puede representar en un método completamente nuevo bien porque modifica el comportamiento antiguo o bien porque es un comportamiento completamente alternativo al antiguo según una serie de circunstancias.

En este artículo vamos a ver las técnicas wrap method y wrap class que encajan dentro de este mismo contexto de añadir código con prisas sobre código legado.

Sin embargo en esta ocasión wrap method y wrap class encajan cuando la caractéristica nueva es una acción añadida sobre el código antiguo, es decir, se deben ejecutar consecutivamente ambos comportamientos, el antiguo y el nuevo.

Acoplamiento temporal

Añadir código a un método o clase existente es la forma más simple de añadir comportamiento sobre legacy code pero a veces no es lo más conveniente.

Cuando un método se crea tiene un proposito o responsabilidad. Si el comportamiento a añadir es un proposito nuevo que se debe ejecutar simultáneamente, mezclar estos dos códigos o responsabilidades simplemente porque deben ejecutarse al mismo tiempo no es buena idea.

Aquí se esta produciendo un acoplamiento temporal pero en un futuro puedes tener verdaderos dolores de cabeza si estos dos códigos crecen juntos de forma acoplada y posteriormente es necesario que se ejecuten por separado.

Wrap Method

Un porcentaje muy alto de las aplicaciones móviles necesitan realizar peticiones de red. El caso más habitual es realizar una petición a un servicio remoto para posteriormente mostrar esa información por pantalla.

Este podría ser un ejemplo de una clase que utiliza Retrofit para realizar una petición a una API remota:

class ProductApiClient(private val retrofit: Retrofit) {

    fun getAll(): List<Product> {
        val competitors: List<Competitor>

        val productAPI = retrofit.create(ProductAPI::class.java)

        val call = productAPI.competitors
        var competitorListResponse = call.execute()
        
        competitors = competitorListResponse!!.body()

        return competitors
    }
}

Ahora imaginemos que cliente nos solicita reducir los datos que se descarga la aplicación y para ello debemos cachear la información en una base de datos.

También debemos establecer algún mecanismo de invalidación de la cache para volver a realizar la petición de red cuando corresponda.

class ProductApiClient(private val retrofit: Retrofit) {
    ...

    fun getAll(): List<Product> {
        val products = getAllFromCache()
        
        if (products.size == 0 || !isValidCache()){
            products = getAllFromRemote()
        }
        
        return products
    }

    private fun getAllFromRemote(): List<Product> {
        val competitors: List<Competitor>

        val productAPI = retrofit.create(ProductAPI::class.java)

        val call = productAPI.competitors
        var competitorListResponse = call.execute()
        
        competitors = competitorListResponse!!.body()

        return competitors
    }
    
    private fun getAllFromCache(): List<Product> {
        Realm realm = Realm.getDefaultInstance();
        
        List<ProductDB> productsDB = realm.copyFromRealm(
                realm.where(ProductDB.class).findAll());
        realm.close();

        return productsDB;
    }
    ...
}

Hemos renombrado getAll como getAllFromRemote y lo hemos convertido en privado. También hemos creado un nuevo método getAll que envuelve la llamada antigua realizando más cosas.

De esta forma, el cliente de esta clase que utiliza getAll no se ve afectado por este cambio.

Utilizar wrap method es mejor opción que mezclar el código antiguo y el nuevo en el mismo método pero tiene inconvenientes:

La nueva carácteristica y la antígua están acopladas.

En nuestro caso si quisieramos desde algún punto de la aplicación obtener los productos directamente de la base de datos no podemos, salvo que dupliquemos el contenido del método getAllFromCache en otra clase.

Puede existir algun escenario donde tenga sentido que esten acopladas como por ejemplo al escribir en un log.

Wrap Class

También existe la posibilidad de envolver la clase entera utilizando la técnica wrap class.

Tenemos varias opciones.

Patrón decorator

Wrap class, al igual que sprout class tiene sentido cuando el código nuevo representa una responsabilidad totalmente distinta al código viejo.

Una de las técnicas de wrap class es utilizar el patrón decorador.

Por un lado tenemos el código legado inicial.

class ProductApiClient(private val retrofit: Retrofit) {

    fun getAll(): List<Product> {
        val competitors: List<Competitor>

        val productAPI = retrofit.create(ProductAPI::class.java)

        val call = productAPI.competitors
        var competitorListResponse = call.execute()
        
        competitors = competitorListResponse!!.body()

        return competitors
    }
}

Ahora de lo que se trata es de extraer un interfaz para ProductApiClient.

interface ProductApiClient {
   fun getAll(): List<Product>
}

La clase vieja debe implementar esta interfaz y creamos una clase nueva que la implementa también y recibe por parámetro la clase vieja:

class CachedProductApiClient(private val decorated: ProductApiClient): ProductApiClient {

    ...

    override fun getAll(): List<Product> {
        val products = getAllFromCache()
        
        if (products.size == 0 || !isValidCache()){
            products = decorated.getAll()
        }
        
        return products
    }
    
    private fun getAllFromCache(): List<Product> {
        Realm realm = Realm.getDefaultInstance();
        
        List<ProductDB> productsDB = realm.copyFromRealm(
                realm.where(ProductDB.class).findAll());
        realm.close();

        return productsDB;
    }
    
    ...
}

De esta forma el código antiguo queda desacoplado del nuevo.

Recuerda que esta técnica tiene sentido cuando son responsabilidades distintas.

Tiene mucho sentido utilizar el patrón decorador si la clase a extender tiene mucho código existente que lo invoca, porque las llamadas al código antíguo se ven mínimamente afectadas.

Sin usar el patrón decorator

Existe otra técnica de envolver la clase antigua sin utilizar el patrón decorador.

Consiste en crear una clase nueva, que no implementa ningún interface en común con el código antiguo y recibe la clase del código antiguo en el constructor.

class CachedProductApiClient(private val productApiClient: ProductApiClient) {

    ...

    fun getAllCacheFirst(): List<Product> {
        val products = getAllFromCache()
        
        if (products.size == 0 || !isValidCache()){
            products = productApiClient.getAll()
        }
        
        return products
    }
    
    private fun getAllFromCache(): List<Product> {
        Realm realm = Realm.getDefaultInstance();
        
        List<ProductDB> productsDB = realm.copyFromRealm(
                realm.where(ProductDB.class).findAll());
        realm.close();

        return productsDB;
    }
    
    ...
}

Como concepto independiente

Utilizando las técnicas de patrón decorador y sin él, la lectura de red queda desacoplada de la lectura de base de datos pero no ocurre lo mismo en el sentido contrario.

Tenemos la lectura de base de datos acoplada a la lectura de red.

Existe otra técnica donde podríamos extraer la parte del código que se encarga de leer de la base de datos en su propia clase.

Para ello se necesita de otra clase nueva añadida que envuelve a las dos clases de lectura de datos.

De esta forma dejamos solo en la clase que envuelve, la lógica de decidir si se lee de red o de la base de datos.

class ProductRepository(
    private val productLocalDataSource: RealmDataSource
    private val productRemoteDataSource: RetrofitDataSource) {

    ...

    override fun getAll(): List<Product> {
        val products = productLocalDataSource.getAll()
        
        if (products.size == 0 || !productLocalDataSource.isValidCache()){
            products = productRemoteDataSource.getAll()
        }
        
        return products
    }
    
    ...
}

En este último ejemplo hemos renombrado la clase que contiene la lógica de datos a repositorio.

El patrón repositorio como este, con sus posibles variaciones de interpretación, es bastante habitual en el desarrollo móvil.

Cada uno de los ejemplos que hemos visto desde wrap method, wrap class decorando, wrap class sin decorar y finalmente repository bien podrían ser diferentes etapas en la evolución de un código legado.

Según el contexto, es posible que sea una opción más que aceptable quedarnos en alguna de las etapas intermedias y no tenga sentido llegar hasta la última etapa que hemos visto.

Finalmente han quedado desacoplados totalmente la lectura de red y la lectura de base de datos.

El repositorio esta haciendo a su vez de wrapper de ambas clases.

Con este último ejemplo también obtenemos el código más testable de todos donde podríamos crearnos distintos escenarios de prueba utilizando dobles de test para comprobar si nos esta devolviendo correctamente los datos de red o de cache.

A veces tanto wrap method como wrap class se pueden acabar convirtiendo en un concepto de alto nivel nuevo dentro de nuestra arquitectura como hemos visto utilizando el concepto de repositorio.

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.

Artículos relacionados

Conclusiones

En este artículo hemos viemos dos nuevas técnicas que podemos utilizar cuando tenemos que añadir código con prisas, wrap method y wrap class.

También hemos visto diferentes técnicas de wrap class.

Son útiles cuando necesitamos añadir un nuevo comportamiento que de debe ser ejecutado a la vez que otro antiguo, produciendose un acoplamiento temporal.

Con estas técnicas reducimos el acoplamiento entre el código antiguo y el nuevo permitiendo poder ejecutarse más facilmente en un futuro por separado.