Te piden añadir un descuento a una tienda online. Parece un cambio pequeño: aplicar un porcentaje al precio y seguir con tu vida. Pero el cálculo está repartido entre el carrito, el proceso de compra, las facturas y un método con un nombre tan tranquilizador como calculateFinalTotalForReal().

Tocas una cosa y se rompe otra. Arreglas la segunda y un test que parecía no tener ninguna relación empieza a fallar. El descuento ya tiene menos gracia.

La arquitectura de software tiene mucho que ver con estas situaciones: cuánto necesitamos entender para hacer un cambio, qué piezas debemos tocar y con qué confianza terminamos el trabajo.

Este artículo abre una serie sobre arquitectura. Empezaremos con unas pocas ideas que ayudan a entender cómo se organiza una aplicación, utilizando esa tienda como ejemplo. Basta con haber programado un poco y haber pensado alguna vez: «Espero que esto no se esté usando en otro sitio».

Qué es la arquitectura de software

Podemos entenderla como el conjunto de decisiones sobre la estructura de un sistema, las responsabilidades de sus partes y la forma en que colaboran. También importan las razones que explican esas decisiones.

En nuestra tienda, alguien tiene que calcular el precio, gestionar los pedidos y comunicarse con el proveedor de pagos. Decidir quién hace cada cosa y cómo se relacionan esas piezas forma parte de la arquitectura.

Estas decisiones existen aunque nadie dibuje un diagrama. Si todo acaba dentro de un único archivo, también hemos elegido una organización. Seguramente por acumulación, pero ahí está.

Una organización útil nos permite entender y modificar la aplicación con un esfuerzo razonable. Los conceptos que vienen ahora ayudan a valorar si vamos por buen camino.

Casos de uso de la tienda: el cliente consulta el catálogo, realiza una compra y consulta su pedido; el administrador gestiona promociones.

Este diagrama de casos de uso muestra qué objetivos permite cumplir la tienda y quién participa. Cada óvalo representa un objetivo; el rectángulo marca el límite del sistema. Todavía no decide qué clases necesitamos. Nos ayuda a situar el problema antes de repartir responsabilidades.

Abstracción: quedarnos con lo que necesitamos saber

Abstraer consiste en representar lo relevante para una tarea y dejar fuera los detalles que no necesitamos en ese momento.

Un mapa del metro muestra estaciones y conexiones. La profundidad del túnel importa para construirlo, pero aporta poco si queremos llegar a casa.

En la tienda, el proceso de compra necesita solicitar un cobro. En Java, una llamada podría tener esta forma:

PaymentOutcome outcome = paymentGateway.charge(amountInCents, currency, attemptKey);

Por debajo habrá peticiones de red, credenciales y formatos del proveedor. La operación nos permite trabajar con la idea de cobrar sin repetir esos detalles por toda la aplicación.

La abstracción debe conservar la información necesaria para usarla bien. Un cobro puede rechazarse o quedar pendiente de confirmación; el resultado debería permitir distinguir esas situaciones cuando importen para la compra.

Encapsulación: controlar cómo se modifica cada cosa

La encapsulación reúne datos y comportamiento tras un límite y ofrece unas vías controladas para utilizarlos.

Si cualquier parte de la tienda puede hacer esto:

order.status = OrderStatus.SHIPPED;

¿Quién comprueba que el pedido se puede enviar? Quizá estuviera cancelado. Quizá todavía no se hubiera pagado.

Una operación como order.markAsShipped() permite comprobar las condiciones antes de cambiar el estado. Así, la regla queda junto a aquello que protege.

Declarar el estado como privado ayuda, pero un método que permita asignarle cualquier valor dejaría el mismo problema abierto. Nos interesa controlar qué modificaciones tienen sentido.

Una regla dentro de su clase

Podemos expresarlo con este ejemplo reducido en Java:

enum OrderStatus {
    PAID, SHIPPED
}

final class Order {
    private OrderStatus status;

    private Order(OrderStatus status) {
        this.status = status;
    }

    public static Order paid() {
        return new Order(OrderStatus.PAID);
    }

    public void markAsShipped() {
        if (status != OrderStatus.PAID) {
            throw new IllegalStateException("Only paid orders can be shipped.");
        }

        status = OrderStatus.SHIPPED;
    }

    public OrderStatus status() {
        return status;
    }
}

El método Order.paid() hace explícito que el ejemplo parte de un pedido ya pagado; omitimos la gestión del cobro. El enum limita los estados que podemos representar. markAsShipped() admite el cambio desde pagado y rechaza repetirlo una vez enviado. En el modelo completo tendríamos que definir también los demás estados y sus transiciones.

Clase Order con estado privado y operaciones públicas para consultar el estado y marcar el pedido como enviado.

En este diagrama de clases, - indica un elemento privado y + uno público. La clase permite consultar el estado y solicitar una transición, manteniendo la decisión sobre su validez dentro de Order.

Una idea relacionada es la ocultación de información: mantener los detalles internos dentro de su módulo para que otros componentes puedan utilizarlo sin depender de ellos.

Cohesión: agrupar lo que tiene sentido junto

La cohesión indica hasta qué punto los elementos de una pieza contribuyen a un propósito común.

Un componente que reúne las reglas de promociones tiene una idea reconocible. Uno que valida cupones, redimensiona imágenes y hace copias de seguridad cuesta más de explicar. Aunque todo quepa dentro de Utilities.

Para valorar una agrupación podemos preguntar: ¿estas operaciones comparten conocimiento?, ¿suelen cambiar por los mismos motivos?

Aquí encaja la separación de responsabilidades. Calcular un descuento y mostrarlo en una página son asuntos que conviene poder entender y modificar por separado. Si cambiar el aspecto del carrito obliga a editar la fórmula del descuento, merece la pena revisar cómo los hemos mezclado.

Acoplamiento: qué necesita saber una pieza sobre otra

El acoplamiento describe las dependencias entre las partes de un sistema y ayuda a entender cómo pueden propagarse los cambios.

Imagina que el correo de confirmación consulta las tablas internas del pedido, interpreta sus códigos de estado y vuelve a calcular el total. Un cambio en cualquiera de esos detalles puede obligarnos a revisar también el correo.

Si recibe el destinatario, la referencia y el resumen que debe mostrar, necesita conocer menos detalles del pedido. Sigue existiendo una relación, pero su alcance resulta más fácil de controlar.

Las piezas necesitan colaborar. Nos interesa que sus dependencias sean claras y que cada una conozca lo necesario para hacer su trabajo. Cohesión y acoplamiento se complementan: agrupaciones coherentes por dentro y relaciones comprensibles entre ellas.

La diferencia se aprecia al cambiar una regla: una promoción nueva debería llevarnos a revisar el cálculo correspondiente. Si también tenemos que corregir el correo porque había copiado la fórmula, habíamos repartido el mismo conocimiento entre piezas con propósitos diferentes.

Modularidad: dar una responsabilidad a cada parte

La modularidad organiza el sistema en partes con responsabilidades y formas de colaboración definidas. En nuestra tienda podríamos distinguir catálogo, pedidos y promociones.

Un módulo puede contener varios archivos y clases, y convivir con los demás dentro de la misma aplicación. Para que su límite sea útil, necesitamos saber de qué se ocupa, qué operaciones ofrece y qué detalles mantiene dentro.

Crear carpetas llamadas Catalog y Orders ayuda a orientarse. Si ambas modifican libremente los datos internos de la otra, esa separación será difícil de mantener.

Una buena señal es la localidad del cambio: al modificar la política de cupones, el trabajo se concentra en una zona reconocible. Puede afectar a varios archivos y a sus pruebas; lo importante es que podamos explicar por qué cambian juntos.

Volvamos al descuento

Con estas ideas ya podemos mirar el problema inicial de otra forma. Buscamos dónde vive la regla, la agrupamos con las promociones relacionadas y ofrecemos una operación que expresa lo que hace. El carrito y la factura utilizan el resultado que les corresponde sin inventarse cada uno su versión del cálculo.

Después comprobamos las condiciones de la promoción y los casos importantes. Y miramos qué hemos conseguido: ¿es más fácil encontrar la regla?, ¿hay menos detalles repartidos?, ¿se entiende qué hace cada pieza?

Estas preguntas sirven para empezar a pensar en arquitectura desde el código que tenemos delante. La próxima vez que pidan cambiar el descuento, deberíamos poder localizarlo sin una expedición por toda la tienda.