Trabajando con código legado: Parallel Change

Este es el cuarto artículo que escribo relacionado con código legado o legacy code, como te guste más llamarlo.

En esta ocasión voy a hablar de una técnica que se llama parallel change o cambio paralelo en español.

El cambio paralelo, también conocido como expandir y contraer (expand and contract), es una técnica para implementar cambios incompatibles hacia atrás en una interfaz de manera segura.

Contexto

Para entender esta técnica vamos a ver un ejemplo sencillo.

Imaginad que tenemos un caso de uso SearchProductsUseCase donde se puede buscar por texto libre.

class SearchProductsUseCase(private val productRepository: ProductRepository) {
    fun execute(keywords: String): List<Product> {
        return productRepository.search(keywords)
    }
}

Ahora imaginad que el cliente nos solicita poder buscar por texto libre y además por una categoría específica. De forma que se pueda buscar por ambos criterios a la vez o por cada uno de forma separada.

Una opción podría ser añadir una método execute para combinación.

class SearchProductsUseCase(private val productRepository: ProductRepository) {
    fun execute(keywords: String): List<Product> {
        return productRepository.search(keywords)
    }
    
    fun execute(categoryId: String): List<Product> {
        return productRepository.search(categoryId)
    }
    
    fun execute(keywords: String, categoryId: String): List<Product> {
        return productRepository.search(keywords, categoryId)
    }
}

Pero no parece ser la opción más acertada, sobre todo si existe la posibilidad de añadir más criterios en un futuro.

La mejor opción sería modificar el método execute para que acepte cualquier criterio y dejarlo preparado para que cause un menor impacto añadir en el futuro nuevos criterios.

Una posibilidad es crearnos un objeto query que contenga ambos criterios y permita que tengan valor o no.

En kotlin la forma más sencilla de hacer esto es utilizando tipos nulables pero se podrían utilizar conceptos funcionales como option o maybe.

data class Query (
   val keywords: String? = null, 
   val categoryId: String? = null)

class SearchProductsUseCase(private val productRepository: ProductRepository) {
    fun execute(query: ProductQuery): List<Product> {
        return productRepository.search(query)
    }
}

El problema surge si existen clientes que estaban utilizando la firma anterior.

Hay que saber cuando es momento de meterse en un refactor y cuando no, ya que aplicar la regla del boy scout a veces puede ser un arma de doble filo.

En ocasiones por diferentes circunstancias puede que no sea el mejor momento de ponerse a cambiar de golpe todos los clientes modificando las llamadas a la antigua firma del método por la nueva.

Este tipo de cambios implican dos acciones:

  • Implementar el cambio
  • Actualizar los usos de la firma antigua

Ten en cuenta que este ejemplo es muy sencillo, en algunos contextos y según que código, realizar estas dos acciones a la vez puede ser muy duro y poco apropiado.

Vamos a ver como abordar este cambio de forma progresiva siguiendo las tres fases de parallel change: expandir, migrar y contraer.

Expandir

En la fase de expandir se añade un nuevo método de manera que el interface de la clase contiene la antigua y la nueva versión.

En esta fase los clientes antiguos siguen utilizando la versión antigua y los nuevos empezarán a utilizar la versión nueva.

data class Query (
   val keywords: String? = null, 
   val categoryId: String? = null)

class SearchProductsUseCase(private val productRepository: ProductRepository) {
    // Old version
    fun execute(keywords: String): List<Product> {
        return productRepository.search(keywords)
    }
    
    // New version
    fun execute(query: ProductQuery): List<Product> {
        return productRepository.search(query)
    }
}

Migrar

Durante la fase de migración se actualizarían los clientes que realizan llamadas a la versión antígua a la nueva versión.

Esto puede ser realizado de forma incremental para casos complejos.

Si estamos en el escenario de una librería donde clientes externos deben actualizarse puede ser una fase larga.

En esta fase es recomendable marcar la versión antigua como deprecada. Así evitamos que nuevos clientes comiencen a llamar a la versión antigua.

data class Query (
   val keywords: String? = null, 
   val categoryId: String? = null)

class SearchProductsUseCase(private val productRepository: ProductRepository) {
    @Deprecated(message = "`SearchProductsUseCase.execute (keywords)` is deprecated. Use `SearchProductsUseCase.execute (query)` instead ")
    fun execute(keywords: String): List<Product> {
        return productRepository.search(keywords)
    }
    
    // New version
    fun execute(query: ProductQuery): List<Product> {
        return productRepository.search(query)
    }
}

Contraer

La fase de contraer se produce cuando no queda ninguna llamada a la versión antigua y solo se utiliza la versión nueva, incluyendo posibles tests, ya que no dejan de ser clientes también.

data class Query (
   val keywords: String? = null, 
   val categoryId: String? = null)

class SearchProductsUseCase(private val productRepository: ProductRepository) {
    fun execute(query: ProductQuery): List<Product> {
        return productRepository.search(query)
    }
}

Diagrama

Os dejo una versión gráfica del proceso:

Parallel-Change-Diagram

Curso y artículos relacionados

Conclusiones

En este artículo hemos visto una técnica que nos permite modificar la firma de un método público que es consumido por varios clientes de forma progresiva y segura.

Puede ser últil tanto si lo que estamos modificando es una librería que luego es consumida por otras aplicaciones como si la interfaz a modificar y los clientes pertecen a la misma aplicación.