Un software robusto es capaz de funcionar correctamente y sin errores incluso en condiciones adversas o impredecibles. Si el código de una aplicación es difícil de entender, también será difícil entender si su funcionamiento es correcto y no podremos arreglarlo si no funciona según los requisitos. Por desgracia, como resultado tendremos una aplicación frágil.

Lo más importante para crear un software robusto es tener un diseño simple. Durante años estuve aprendiendo diferentes técnicas para desarrollar código simple y elegante, pero para mí las esenciales son las siguientes 5 reglas:

  1. KISS: Keep It Super Simple.
  2. DRY: Don’t repeat yourself.
  3. YAGNI: You aren’t gonna need it.
  4. LoD: Law of Demeter.
  5. SoC: Separation of Concerns.

Estoy seguro de que estáis usando muchas de estas reglas, pero con otros nombres. Son las reglas que me ayudan en mi día a día para seguir entregando software de alta calidad que satisfaga a los stakeholders (los clientes o el Product Owner).

Vamos a verlos en detalle.

1 KISS: Keep It Super Simple

¿Alguna vez os habéis encontrado con un código tan bien formateado? Yo sí, pero no era muy manejable… Era muy difícil introducir cambios en este código.

Un código bien formateado es fundamental para que sea resistente.

KISS es el acrónimo de Keep It Super Simple (me gusta más que "Keep It Simple, Stupid!") y establece que la mayoría de las aplicaciones funcionan mejor si se mantienen simples en vez de complicadas; por lo tanto, la simplicidad debe ser un objetivo clave en el diseño y se debe evitar la complejidad innecesaria. Dicho de otra manera: “Menos es más”.

Pero para conseguir la simplicidad, debemos mejorar en nuestras habilidades de desarrollo, porque estamos acostumbrados de tratar de solucionar la complejidad y esto provoca que nuestro código sea muy complejo de leer y modificar.

Una muy buena solución para resolver problemas complejos es divide y vencerás, una estrategia utilizada por Julio César. Es una solución muy elegante, porque si dividimos un problema complejo en partes más pequeñas, podemos limitar el alcance del problema y hacerlo más manejable, que sea más fácil de entender y encontrar la mejor solución.

Vamos a ver el código que está en la clase Checkout.java de la imagen de arriba. Existen muchas técnicas para simplificar el código. Para este caso en concreto, la mejor es reemplazar condicional anidada (escalera if-else-if) con cláusulas de guardia.

En IntelliJ, las combinaciones de teclas Alt+Enter (en macOS ⌥⏎) nos muestran un menú contextual que proporciona sugerencias y correcciones para el código que se está editando. Al presionar Alt + Enter, IntelliJ analiza el código y muestra una lista de sugerencias y correcciones que se pueden aplicar.

Menú contextual que proporciona sugerencias y correcciones.

En este caso, hacemos clic en el primer if y utilizamos el menú contextual para que invierta la condición.

Menú contextual para que invierta la condición.

Después podemos utilizar de nuevo el menú contextual para eliminar el else que ya no es necesario.

Menú contextual para eliminar el else.

De esta manera tendremos un código más simple y fácil de leer.

Ejemplo de código simple y fácil de leer.

2 DRY: Don’t Repeat Yourself

DRY es acrónimo de "Don't Repeat Yourself" y viene del libro The Pragmatic Programmer. Significa "No te repitas a ti mismo" y quiere decir que cada pieza de conocimiento debe tener una representación, única e inequívoca dentro de una aplicación. Dicho de otra manera, no se debe repetir la lógica de negocio en diferentes partes. Esto no quiere decir que no puede haber código repetido, puede haberlo, siempre y cuando no se trate de la misma lógica de negocio.

La alternativa de DRY es tener la misma lógica de negocio expresada en dos o más sitios. Si cambias uno, tienes que acordarte de cambiar los otros... Esto no es una cuestión de si recuerdas hacerlo, sino de cuándo olvidarás hacerlo.

SPOT es el acrónimo de Single Point Of Truth que está definido en el libro The Art of Unix Programming y se refiere a tener una única fuente de información confiable y actualizada, lo que puede mejorar la calidad de los datos, reducir la redundancia y mejorar la eficiencia y la toma de decisiones.

Echemos un vistazo al código de Account.java. Tenemos dos métodos que son casi iguales, pero no del todo.

public void credit(double amount) {
   balance -= amount;
   Calendar calendar = Calendar.getInstance();
   Date date = calendar.getTime();
   transactionList.add(new Transaction(date, -amount));
   lastTransactionDate = date;
}

public void debit(double amount) {
   balance += amount;
   Calendar calendar = Calendar.getInstance();
   Date date = calendar.getTime();
   transactionList.add(new Transaction(date, amount));
   lastTransactionDate = date;
}

Los dos métodos realizan las mismas operaciones, pero con diferentes signos: una con + y la otra con -, y actualizan el balance.

Una cosa que podemos hacer es extraer el código del método credit() a un nuevo método transaction() usando la técnica Extract Method y llamarlo con amount negativo desde credit() y positivo desde debit(). El resultado sería el siguiente:

public void credit(double amount) {
   transaction(-amount);
}

public void debit(double amount) {
   transaction(amount);
}

private void transaction(double amount) {
   balance += amount;
   Calendar calendar = Calendar.getInstance();
   Date date = calendar.getTime();
   transactionList.add(new Transaction(date, amount));
   lastTransactionDate = date;
}

Con esto hemos quitado la duplicación de la lógica de una transacción.

3 YAGNI: You aren’t gonna need it

Si vais al desierto qué te llevarías, ¿un salvavidas o agua? Creo que la respuesta es obvia. De esto se trata YAGNI que es un acrónimo que significa "You Aren't Gonna Need It", no lo vas a necesitar.

Muchas veces hacemos sobreingeniería, entregando un cohete cuando nos han pedido simplemente una bicicleta. Lo he visto muchas veces. También lo he hecho muchas veces . Esta complejidad que se añade a la aplicación, también involucra una formación para poder manejar el software, y cuanto más complejo es el sistema, más difícil será la formación. No hablemos de mantenimiento… El precio de mantener un cohete es varias veces más que el mantenimiento de una bicicleta.

¿Cuál es el precio del código YAGNI?

Desarrollar un característica presuntiva añade los siguientes costos al proyecto:

¿Cómo evitar hacer YAGNI?

Una buena solución para reducir el código YAGNi es el refactor.

4 LoD: Law of Demeter

Cuando era pequeño, cuando íbamos de viaje, mis padres me decían: “No hables con extraños”. La Ley de Demeter es el principio de mínimo conocimiento. También se puede definir como “No hables con extraños” y define las siguientes reglas de comunicación: un método M de un objeto O solo puede llamar a métodos de:

Veamos un ejemplo:

@Test
void testPersonZipCode() {
   Address address = new AddressImpl();
   address.setName("My home");
   address.setZipCode("08005");

   House house = new HouseImpl();
   house.setAddress(address);

   Person person = new PersonImpl();
   person.setHouse(house);

   String personZipCode = person.getHouse().getAddress().getZipCode();
   assertThat(personZipCode).isNotNull();
}

Vemos que para obtener el código postal person llama a un método que devuelve un objeto y después a otro método… Este code smell se llama Message Chains (cadenas de mensajes) y puede provocar efectos secundarios como pasa en el juego teléfono escacharrado.

Es muy difícil introducir cambios en este código. Imaginemos que recibimos un nuevo requisito del departamento de negocio y es que ya se puede tener más de una dirección, marcando una como principal. Para aplicar este simple cambio, debemos tener en cuenta muchas clases. La solución es aplicando la técnica Hide Delegate. Aplicándolo, nuestro código se simplificará mucho:

void testPersonZipCode() {
   Address address = new AddressImpl();
   address.setName("My home");
   address.setZipCode("08005");

   House house = new HouseImpl();
   house.setAddress(address);

   Person person = new PersonImpl();
   person.setHouse(house);

   String personZipCode = person.getZipCode();
   assertThat(personZipCode).isNotNull();
}

5 SoC: Separation of concerns

El origen del término separación de intereses (Separation of Concerns) se otorga a Edsger W. Dijkstra que en su artículo de 1974 "On the role of scientific thought" habla sobre la importancia de "centrar la atención en algún aspecto".

La separación de intereses se puede aplicar en dos niveles: nivel arquitectónico y nivel de programación. Veámoslos en detalle.

Separación de intereses a nivel de arquitectura

Arquitectura monolítica

Si estamos usando un monolito, la separación de intereses se aplica separando la aplicación en módulos cada uno de los cuales se enfoca en un solo aspecto o interés.

Separación de intereses en módulos.

Arquitectura de microservicios

En la arquitectura de microservicios se consigue la misma separación de intereses, pero esta vez se divide en microservicios, cada uno con su responsabilidad e independencia. En este caso se suele utilizar el patrón Base de datos por servicio que ayudará que sean realmente independientes.

Base de datos por servicios.

Patrón de arquitectura MVC

Otro uso de separación de intereses se puede encontrar en el patrón de arquitectura Modelo-Vista-Controlador (MVC).

Separación de intereses se puede encontrar en el patrón de arquitectura Modelo-Vista-Controlador (MVC).

Como podemos ver, View será el único responsable de mostrar la interfaz de usuario y manejar las interacciones con los usuarios. Controller contendrá solo la lógica de negocio y Model se encargará de definir la estructura de datos y actualizar la base de datos. Las responsabilidades (intereses) se separan como tales porque cambiar la interfaz de usuario no debería afectar ni la lógica de negocio ni el almacenamiento de datos y viceversa.

Separación de Intereses a nivel de código

La separación de intereses a nivel de código se puede conseguir de la siguiente manera empezando desde nivel más alto de abstracción.

Hexagonal architecture

En la arquitectura hexagonal la separación de intereses se aplica a través de la división de la aplicación en tres capas principales:

Aspect-oriented programming (AOP)

La programación orientada a aspectos es un paradigma de programación que tiene como objetivo aumentar la modularidad al separar los intereses transversales. Lo logra agregando un comportamiento adicional al código existente sin modificar el código en sí, en lugar de especificar qué código se modifica por separado a través de una especificación de "pointcut", por ejemplo "hacer log de todas las llamadas a métodos cuyo nombre empieza con una cadena de texto…”.

Dependency injection (DI)

La inyección de dependencia es un patrón de diseño de software que separa la creación de los objetos de su uso. En lugar de que cada clase cree sus propias dependencias, se utiliza un contenedor de inyección de dependencias para gestionar la creación y configuración de los objetos. Esto permite que las clases se centren en su propia tarea y no tengan que preocuparse por la creación de dependencias.

Separación de los métodos por responsabilidad

Este tipo de separación divide a los métodos en dos tipos: Qué y Cómo.

Os lo explico con un ejemplo de la vida real: cuando tengo una lista de tareas, me centro en las tareas que debo realizar y en el orden en el cual debo realizarlas, es decir, me centro en qué debo realizar. Cuando llegue el momento de realizar la tarea, en este momento me centraré en cómo realizarla.

Vamos a verlo en el código de nuestro Checkout. Si revisamos el código, veremos que tenemos muchas instrucciones de cómo validar los diferentes módulos. Vamos a agruparlos por tipo de validación extrayendo el código en los diferentes métodos que serán del tipo cómo y tendremos un método que responderá a la pregunta: ¿qué se debe hacer para que el checkout sea válido?

public boolean validate() {
   validateCart();
   validateDelivery();
   validatePayment();

   return true;
}

private void validateCart() {
   if (cart.getNumProducts() <= 0) {
       throw new CartException("The cart is empty");
   }

   if (cart.getTotal() < Cart.MIN_PURCHASE_AMOUNT) {
       throw new CartException("Does not reach the minimum required for the cart");
   }

   if (cart.getTotal() >= Cart.MAX_PURCHASE_AMOUNT) {
       throw new CartException("You have exceeded the maximum total allowed for a purchase");
   }

   if (!cart.checkStock()) {
       throw new CartException("No hay stock");
   }
}

private void validateDelivery() {
   if (!delivery.checkAddress()) {
       throw new DeliveryException("Invalid address");
   }

   if (!delivery.hasCarrier()) {
       throw new DeliveryException("No carrier");
   }
}

private void validatePayment() {
   if (!payment.hasPaymentMethods()) {
       throw new PaymentException("No payment methods");
   }

   if (!payment.isCompulsiveBuyer()) {
       throw new PaymentException("Are you a compulsive buyer signed");
   }
}

Ahora podemos responder muy fácil a esta pregunta: para que el Checkout sea válido, se deben validar las reglas del Cart, Delivery y Payment.

Con esta simple separación de qué de cómo, hemos ganado una mayor legibilidad del código.

Ventajas de la separación de intereses

La separación de intereses nos brinda varias ventajas:

Cohesión

La cohesión es la medida en que los elementos dentro de un módulo o clase están relacionados entre sí en términos de su funcionalidad. Es decir, la cohesión mide cuánto tienen en común los diferentes elementos. Una alta cohesión se refiere a un módulo que está diseñado para llevar a cabo una tarea específica y bien definida, donde los elementos dentro del módulo están altamente relacionados y trabajan juntos para lograr esa tarea. Por otro lado, una baja cohesión se refiere a un módulo en el que los elementos están poco relacionados o no tienen una tarea específica bien definida.

La cohesión se puede relacionar con el code smell Feature Envy (o envidia de características), es decir que algunos de los métodos interactúan más con los atributos o métodos de otras clases.

Volvamos a nuestro ejemplo del Checkout. Si nos fijamos en los métodos cómo, por ejemplo, validatePayment() está más relacionado con la clase Payment que con Checkout.

private void validatePayment() {
   if (!payment.hasPaymentMethods()) {
       throw new PaymentException("No payment methods");
   }

   if (!payment.isCompulsiveBuyer()) {
       throw new PaymentException("Are you a compulsive buyer signed");
   }
}

Para solucionar esto, usaremos la técnica Move Method para mover todos los métodos cómo a sus clases correspondientes. El resultado final de la clase Payment será el siguiente:

public class Payment {

   public boolean hasPaymentMethods() {
       // ...
   }

   public boolean isCompulsiveBuyer() {
       // ..
   }

   public void validate() {
       if (!hasPaymentMethods()) {
           throw new PaymentException("No payment methods");
       }

       if (!isCompulsiveBuyer()) {
           throw new PaymentException("Are you a compulsive buyer signed");
       }
   }
}

Como vemos, tenemos una cohesión muy alta en esta clase.

Acoplamiento

El acoplamiento se refiere a la medida en cómo los diferentes módulos o clases de software están relacionados entre sí. Es decir, el acoplamiento mide la interdependencia entre diferentes partes del software. Un acoplamiento bajo se refiere a una situación en la que los diferentes módulos o clases están diseñados de manera independiente y tienen poca o ninguna dependencia entre sí. Por otro lado, un acoplamiento alto se refiere a una situación en la que los diferentes módulos o clases tienen una fuerte dependencia entre sí y están diseñados de manera que un cambio en uno de los módulos puede afectar el funcionamiento de otros módulos.

Un simple ejemplo de alto acoplamiento puede ser que una clase A puede acceder y manipular a otra clase B mediante el uso de sus propiedades que son públicas, estas clases están estrechamente acopladas.

En el UserTest tenemos un tests que accede directamente a los atributos de la clase User saltándose las validaciones del atributo name.

@Test
void testSetUserName() {
   user.name = "Luke Skywalker";

   String actual = user.name;

   assertThat(actual).isEqualTo("Luke Skywalker");
}

Para reducir el acoplamiento entre las dos clases, primero es necesario cambiar el atributo name de la clase User de public a private. Esto nos obligará a usar los métodos de setear y obtener los datos del User. Este sería el resultado final:

@Test
void testSetUserName() {
   user.setName("Luke Skywalker");

   String actual = user.getName();

   assertThat(actual).isEqualTo("Luke Skywalker");
}

Ahora podemos habilitar el otro test:

@Test
void whenNameIsNullThenThrowException() {

   Throwable thrown = catchThrowable(() -> user.setName(null));

   assertThat(thrown).isInstanceOf(IllegalArgumentException.class)
       .hasMessageStartingWith("Argument for @NotNull parameter 'name'");
}

Ahora tenemos mejor control sobre las dependencias entre la clase User y las que la usan.

Conclusión

Las buenas prácticas en el desarrollo de software son muchas y discutibles, pero estas cinco reglas sumadas a los principios SOLID (sobre las cuáles hemos hablado en el artículo Los principios SOLID, ¿cuáles son y cómo pueden ayudarte?) son mis 10 mandamientos para conseguir un diseño simple, que facilite la lectura y mantenibilidad de las aplicaciones.

Espero que os ayuden a vosotros también. ¿Tienes alguna pregunta? ¡Déjanos un comentario y te contestaré tan pronto como lo lea!

El código de los ejemplos se puede ver en este repositorio de GitHub.

Podéis usar la rama main como punto de partida, en la rama solution podéis ver los pasos de la solución.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete