>

SOLID: El Principio de Única Responsabilidad

Diego Güemes     Colaboraciones    28/10/2016

¿Piensas que programar es fácil? Personalmente, creo que programar es fácil, pero programar bien es muy, muy difícil. La mayoría de desarrolladores en algún momento de su carrera se han enfrentado a código difícil de mantener. Ese sentimiento de no querer modificar código existente por miedo a romperlo no se lo recomiendo a nadie.

En esta serie de artículos visitaremos los principios SOLID, un conjunto de principios que harán que nuestro código sea más fácil de mantener. Lo haremos a través de ejemplos reales con los que muchos de vosotros seguramente estéis familiarizados.

Para este principio en concreto, utilizaremos como ejemplo un servicio para añadir un producto a un carrito en una tienda online. El servicio hace lo siguiente:

  • Busca en la base de datos el producto indicado.

  • Añade el producto al carrito

  • Si el producto es caro lo registra en un log para futuros análisis.


El fragmento está escrito en Java, pero los conceptos se pueden aplicar a cualquier lenguaje:


public class ShoppingCartService {

  public void addProductToCart(int productId, int amount, ShoppingCart cart) throws SQLException {
    // Obtener el producto de la base de datos
    Connection connection = getConnection();
    Statement statement = connection.createStatement();
    ResultSet resultSet = statement.executeQuery(String.format(
        "SELECT *\n" + 
        "FROM products\n" +
        "WHERE id = %d", productId));
    if (!resultSet.next())
      throw new ProductNotFoundException(productId);

    Product product = new Product(
      resultSet.getInt("id"),
      resultSet.getString("name"),
      resultSet.getInt("stock")
    );

    // Añadir producto al carrito
    if (product.stock() < amount)
      throw new OutOfStockException(product);
    cart.getCartLines().add(new CartLine(product, amount));

    // Log productos caros
    if (product.price() > 1000) {
      try (PrintWriter log = new PrintWriter(new FileWriter("logs/expensive_products.txt", true))) {
        log.write(String.format("[%s] %s",
            new Date().toString(),
            product.toString()));
      } catch (IOException e) {
        // Ignore
      }
    }
  }
}

¿Cuál es el problema del codigo anterior? Si has pensado: “¡Hay una inyección SQL!”, estás en lo cierto, pero en este artículo no nos centraremos en seguridad. ¡El problema del código anterior es que hace demasiadas cosas! Puede cambiar por muchas razones y por lo tanto, tendremos que modificarlo con bastante frecuencia. ¿Y cuál es el problema de tocar código? ¡Que podemos introducir bugs! Sobretodo si no hemos desarrollado una buena batería de pruebas.

Cuando el código hace demasiadas cosas como en el ejemplo anterior, decimos que viola el Principio de Única Responsabilidad (a.k.a. SRP) que dice: “Una clase solamente debe tener un motivo por el que cambiar”.

Una muy buena pista para reconocer esos motivos de cambio es determinar cuáles son los actores o clientes de nuestra clase. Supongamos los siguientes escenarios para los actores en nuestro ejemplo de la tienda online:

El CTO

Una mañana a primera hora, el CTO de la compañía decide que quiere cambiar la base de datos relacional a una NoSQL por temas de escalabilidad. No vamos a discutir si esto es una buena idea o no, pero el caso es que ¡tenemos que modificar el fragmento expuesto anteriormente!

Para los lectores que consideren este cambio poco realista, pensad que si el administrador de la base de datos cambiase por alguna razón el nombre de la tabla o alguna de sus columnas, también nos forzaría a modificar esa misma clase…

Una solución aplicando SRP, desacoplaría esta dependencia:


public class ProductsRepository {

  public Product findById(int productId) {
    Connection connection = getConnection();
    Statement statement = connection.createStatement();
    ResultSet resultSet = statement.executeQuery(String.format(
        "SELECT *\n" +
            "FROM products\n" +
            "WHERE id = %d", productId));
    if (!resultSet.next())
      throw new ProductNotFoundException(productId);
    return new Product(
        resultSet.getInt("id"),
        resultSet.getString("name"),
        resultSet.getInt("stock")
    );
  }
}

public class ShoppingCartService {
  
  private ProductsRepository productsRepository;

  public void addProductToCart(int productId, int amount, ShoppingCart cart) {
    Product product = productsRepository.findById(productId);
    // ...
  }
}

De esta forma, conseguimos que los cambios relacionados con la base de datos queden ocultos en la implementación del repositorio de productos. ¡Que tiempos aquellos en los que los cambios en la base de datos no forzaban a cambiar las clases que contienen la lógica de negocio! Si por ejemplo, el dia de mañana decidimos usar un ORM (Object Relational Mapping, que proporciona una capa de abstracción para el acceso a datos) como Hibernate podríamos hacerlo sin tocar para nada la clase ShoppingCartService. Esta clase no se volverá a ver afectada por cambios relacionados con la base de datos. Sin embargo...

El dueño del producto

Unos días más tarde, el dueño del producto decide cambiar la definición de “caro” y nos dice que ahora los productos caros no son aquellos cuyo precio es mayor que 1.000€, sino los que son más de 3.000€. ¡Vaya! Tenemos que modificar esta clase también. Lo peor de todo es que es probable que esta expresión esté esparcida por toda la aplicación, por lo que tendremos que cambiar varios archivos y … ¿os acordáis del problema de modificar código? (Bugs, bugs). Dado que el producto es el que tiene el precio, quizás hubiese sido mejor encapsular esa funcionalidad en esa misma clase, ¿no?


public class Product {
  private static final int EXPENSIVE_THRESHOLD = 3000;
  
  // ...
  
  public boolean isExpensive() {
    return price() > EXPENSIVE_THRESHOLD;
  }
}

public class ShoppingCartService {

  public void addProductToCart(int productId, int amount, ShoppingCart cart){
    // ...
    if(product.isExpensive()){
      // ...
    }
  }
}

Qué fácil hubiese sido cambiar la definición de caro en un método como este ¿verdad?

Además hemos cambiado el valor “3.000” por una constante, puesto que revela mucho mejor la intención del programador.

El dueño del producto, aprovecha el paseo y en uno de esos momentos de “ya que estamos...” decide cambiar el criterio que determina si el producto se puede añadir al carrito o no. A parte de que el producto esté en el inventario, el producto tiene que estar visible en el catálogo.

¡Otra vez volvemos a modificar la misma clase! Sin embargo, si esa lógica estuviese encapsulada dentro del carrito, este servicio no se vería alterado por tal cambio. Además, tiene sentido que el carrito controle que tipo de productos admite… ¡Vamos a moverlo!


public class ShoppingCart {

  // ....

  public void addProduct(Product product, int amount) {
    if(product.stock() < amount || !product.visible())
      throw new ProductNotAvailableForShoppingException(product);
    cartLines.add(new CartLine(product, amount));
  }
}

Ahora será más difícil que la clase ShoppingCartService cambie por requisitos del dueño de producto. No sospechamos que esta clase sufra más cambios de momento, no obstante...

Los analistas

Un mes después, los analistas de negocio desean tener la opción de poder realizar análisis más avanzados en base a los carritos de compra. Han oído hablar de una tecnología llamada ElasticSearch para realizar búsquedas avanzadas y por lo visto viene con una herramienta para visualizar datos de forma gráfica. Por desgracia, en esta ocasión modificaremos la clase también. ¿Qué tal una clase nueva para encapsularlo?


public class BusinessLog {
  
  public void logProductAddedToCart(Product product) {
    if (product.isExpensive()) {
      try (PrintWriter log = new PrintWriter(new FileWriter("logs/expensive_products.txt", true))) {
        log.write(String.format("[%s] %s", new Date().toString(), product.toString()));
      } catch (IOException e) {
        // Ignore
      }
    }
  }
}


Ahora si queremos hacer el logging en ElasticSearch, podemos hacerlo sin cambiar el método principal para añadir el producto al carrito.

Nota que moviendo el criterio que decide qué productos van al log a esta nueva clase, nos protege contra este tipo de cambios también: ¿Por qué la clase que coordina las acciones para añadir un producto al carrito debería saber en qué productos están interesados los analistas? ¿Y si mañana los analistas deciden que solamente están interesados en productos denominados “especiales”? Gracias a que hemos extraído el logging a otro componente, la clase ShoppingService ni se inmuta de este cambio.

¡Paramos aquí! Seguro que si seguimos pensando se nos ocurre algún cambio más. Echemos un ojo a cómo ha quedado la clase original:


public class ShoppingCartService {
  private ProductsRepository productsRepository;
  private BusinessLog businessLog;
  
  public void addProductToCart(int productId, int amount, ShoppingCart cart){
    Product product = productsRepository.findById(productId);
    cart.addProduct(product, amount);
    businessLog.logProductAddedToCart(product);
  }
}

Ahora el código, se lee mucho mejor. Los componentes son más pequeños y más fáciles de entender. Además están desacoplados y podemos testearlos mucho más fácilmente.

Es importante darse cuenta de que no tenemos que adivinar todos estos cambios desde el principio. De hecho me atrevería a decir que es totalmente desaconsejable, pues corremos el riesgo de sobre-ingeniería. Introducir complejidad innecesaria en un proyecto, es uno de los errores más graves en el desarrollo, de hecho existe otro principio que nos lo recuerda: KISS (Keep It Simple Stupid).

Con esto quiero decir que, en el ejemplo utilizado en el artículo, haber extraído todas estas clases desde el principio hubiese sido un error.

Un cambio de base de datos como el del ejemplo, es muy improbable. Aun cuando ocurre, no he visto una sola empresa en la que migrar de una base de datos a otra, por muy desacoplados que estén los componentes, haya estado chupado. Sin embargo, extraer las clases que acceden a los datos, en mi experiencia suele ser un acierto.

Para el logging por ejemplo, hubiese sido suficiente empezar con la clase Logger que trae Java.

En general los modelos de dominio (clases como el carrito o producto), suelen ser los componentes que reciben más cambios al ser los que encapsulan las reglas que definen el negocio, por lo que haber extraído esos métodos desde el principio es razonable.

En definitiva, como conclusión: aplicad el Principio de Única Responsabilidad en aquellos puntos de cambio potenciales o en aquellas partes que creáis que hacen el desarrollo más sencillo, pero procurad no ser víctimas de la sobre-ingeniería.
















Sobre el autor

Diego Güemes   

Fullstack, estudiante de doctorado y apasionado del desarrollo de software.