Debe ser imposible representar estados ilegales

2021-05-16

Este es el tercer post en esta serie sobre arquitectura de software. En los posts anteriores he hablado de mi experiencia aprendiendo sobre este tema y sobre algunos de los problemas que he enfrentado en situaciones donde no hay una arquitectura clara.

En este post espero empezar a introducir algunas ideas y técnicas concretas que he usado para comenzar a resolver mis problemas.

Estados ilegales

Cuando digo “que sea imposible representar estados ilegales”, lo que quiero decir es que nuestra meta debería ser que nuestro programa nunca esté en un estado inválido. O sea que sea imposible hacer algo que llevará a nuestro programa a estar en un estado inesperado o no definido.

Eso significa que si nuestro programa es un carrito de compras y tenemos una regla de negocio que permite un máximo de tres artículos en carrito, debería ser imposible añadir un cuarto artículo al carrito.

En el pasado he atentido este tipo de reglas de negocio, poniendo validaciones en el perímetro de mi programa. Usualmentemeste trabajo en aplicaciones web, así que esto signfica, validación en un controller o algo directamente relacionado al endpoint que recibe el request del usuario para añadir un artículo al carrito. No hay nada malo con esto, solo que si la petición de añadir un cuarto artículo al carrito llega de otra parte, nada evitará que suceda, poniendo nuestro programa en un estado inválido o ilegal. Este podría ser un efecto deseado en algunos casos pero en este ejemplo estamos supiniendo que no queremos que esto pase nunca.

Este ejemplo me ayuda a ilustrar lo que queremos alcanzar, pero este post no cubrirá todo lo necesario para lograrlo. Me voy a enfocar en la pieza mas pequeña de un sistema que no permite representar estados ilegales.

Value Objects

No voy a ponerme a definir términos así que puedes mirar aquí y aquí para buenas definiciones. De lo que si voy a hablar es sobre como uso VOs dentro de mis programas para aplicar reglas de negocio.

Lo primero es decidir cuando usar un VO. Mi respuesta es que todo valor dentro de tu programa por defecto, debería ser un VO. La ventaja de este acercamiento es que te da la oportunidad de establecer reglas para cada valor individualmente. El ejemplo obvio de un VO es una cantidad de dinero (Amount).

Si tenemos un endpoint en un web app que recibe una cantidad de dinero probablemente tendremos un payload JSON como este.

{
 "amount": 100
}

Este objeto muestra el amount con un valor de 100 centavos o $1.00. Cómo nuestro programa puede manejar varios tipos de moneda, rápidamente nos damos cuenta que necesitamos saber la moneda de esa cantidad así que añadimos ese campo.

{
 "amount": 100,
 "currency": "USD"
}

Ok, ahora si tenemos todo lo que necesitamos. Lo próximo que hacemos es validar el input en nuestra aplicación así que podemos escribir el siguiente código.

app.post(async (req, res) => {
    let {amount, currency} = req.body;

    if (typeof amount !== "number") {
        return res.send(422);
    }

    if (amount < 0 || amount > 1000000) {
        return res.send(422);
    }

    if (typeof currency !== "string") {
        return res.send(422);
    }

    if (!["USD", "EUR"].includes(currency)) {
        return res.send(422);
    }

    try {
        let response = await controller(amount, currency);
        return res.json(response);
    } catch (error) {
        // do some error handling
        return res.send(400);
    }

});
async function controller(amount, currency) {
    // do stuff with amount and currency
}

Obviamente esto se puede hacer de forma mucho mas declarativa usando algo como JSON Schema o cualquier otra librería de validación de objetos.

Pero hacer ese tipo de validación no nos ayuda a recordar que ya hicimos esa validación. Las funciones que reciban esto valores no tienen forma de saber que ya fueron validados y por lo menos en mi caso esto crea un poco de duda. ¿Que tal si no valido el input en esta funcion y alguien pasa valores sin verificar? Esto puede producir bugs e incluso problemas de seguridad. Este problema se puede resolver con VOs.

De este punto en adelante voy a usar TypeScript para los ejemplos. El sistema de types de TS nos va a ayudar a garantizar el contenido de nuestro VOs. Primero vamos a definir este VO Amount.

class Amount {
    readonly value: number;
    readonly currency: string;

    constructor(value: number, currency: string) {
        this.value = value;
        this.currency = currency;
    }
}

Este es el primer paso. Si te das cuenta definí una clase con ambos valores. Esto es una decisión de diseño importante. Como sabemos estos dos valores están completamente conectados. No se puede cambiar uno, sin que el otro cambie. $1 USD y 1€ EUR no son lo mismo. Así que estos dos valores en realidad son uno solo. Esto es lo primero que añade este VO a nuestro programa. Nos está dejando representar dos datos como un solo objeto conectándolos para siempre y ayudándonos a recordar que no podemos cambiar uno sin pensar como se afecta el otro. Sigamos.

Ahora el problema que veo es que si creo una instancia de esta clase no hay nada impidiendo que ponga valores que no puedo aceptar en mi programa. Por ejemplo si estamos haciendo un carrito de compras, ningún producto puede tener como precio un número negativo pero este VO nos permite pasar un número negativo. Como esta, hay otras reglas de negocio, que podemos implementar en este objeto para asegurarnos de que siempre que lo usemos, estas reglas sean aplicadas y si alguien o algo trata de romper estas reglas nuestro programa no lo permita.

A mi me gusta poner estas reglas en el constructor y si alguna de estas reglas no se cumple, lo que hago es throw una excepción para impedir la creación de la instancia. Esto hace imposible que exista una instancia que no cumpla con las reglas que establecimos.

class Amount {
    readonly value: number;
    readonly currency: string;

    constructor(value: number, currency: string) {
        // Check value
        if (typeof value !== "number") {
            throw new Error("Amount value must be a number");
        }

        if (value < 1 || value > 1000000) {
            throw new Error("Amount value must be between 1 and 1,000,000");
        }

        // Check currency
        if (typeof currency !== "string") {
            throw new Error("Amount currency must be a string");
        }

        if (currency.length !== 3) {
            throw new Error("Amount currency must be a valid ISO-4217 alpha code")
        }

        if (!["USD", "EUR"].includes(currency)) {
            throw new Error("Amount currency can only be USD or EUR");
        }

        this.value = value;
        this.currency = currency;
    }

}

Ok, ahora este VO es bastante más estricto, verifica los tipos de los argumentos que recibe y también se asegura que este objeto sigue nuestras reglas de negocio. Como hace throw de una excepción si algo está mal, no es posible crear una instancia en un estado ilegal.

Por otro lado, si tenemos una instancia de este objeto, sabemos que tiene que ser válida porque logró pasar por todas las pruebas (guards) que establecimos. Así que una vez tenemos una instancia de este objeto podemos estar seguros que es válida y podemos confiar en lo que contiene. No tenemos que volver a verificar nada porque sabemos todo.

Ahora donde quiera que necesitemos este valor en vez de pasar un number y un string podemos pasar un Amount y estar confiados.

Se que esto puede parece increíblemente obvio para algunos, especialmente los que están acostumbrados a trabajar en lenguajes strongly typed pero les puedo asegurar que para los que llevamos toda la vida en lenguajes dynamically typed esto es una revelación.

Que más podemos hacer con un VO. Pues habrás notado declare los fields de Amount como readonly. Una de las caracteristicas más importantes de un VO es que sea inmutable y que una vez creado, no se pueda cambiar. Esto nos da muchas garantías sobre el estado interno de nuestro objeto.

Una acción que seguro vamos a necesitar hacer con nuestro Amount es sumarle otro Amount pero como dije antes no podemos modificar un VO una vez creado. Así que lo que podemos hacer es crear un método que recibe el Amount que queremos sumar y crea un VO nuevo que contiene el resultado.

add(amount: Amount): Amount {
    if (amount.currency !== this.currency) {
        throw new Error("Amounts of different currency cannot be added");
    }

    return new Amount(amount.value + this.value, this.currency);
}

Nuevamente aquí podemos ver que estamos validando que ambos VOs son del mismo currency antes de sumarlos. Una vez sabemos que son del mismo currency creamos una nueva instancia de Amount con el resultado de la suma.

Otra cosa importante es poder comparar dos VOs por su valor así que vamos a escribir un método para esto.

equals(amount: Amount): boolean {
    return amount.value === this.value && amount.currency === this.currency;
}

Fácil. Ahora podemos comparar dos VOs y saber si contienen el mismo valor. Es importante notar que estamos comparando tanto el value como el currency porque sabemos bien que un Euro y un Dólar no son lo mismo.

VOs en mi web app

Creo que esos son suficientes ejemplos simples. Ahora un último ejemplo de como se puede usar este VO en el contexto de nuestra aplicación web.

app.post(async (req, res) => {
    let {amount, currency} = req.body;

    try {
        let amount = new Amount(amount, currency);
        let response = await controller(amount);
        return res.json(response);
    } catch(error) {
        // do some error handling
        return res.send(400);
    }
});
async function controller(amount: Amount) {
    // do stuff with Amount
}

Ahí está. Ahora Amount nos ayuda a validar el input que estamos recibiendo y luego de que tenemos una instancia sabemos que es un valor que no necesitamos validar nuevamente. El resto de nuestro programa, siempre y cuando reciba una instancia de Amount, puede trabajar con el valor de forma segura y sin preocupación.

Además este VO nos ayuda a organizar el código y mantener las reglas de negocio cerca del los datos que las necesitan. La clase Amount tiene todo el código necesario para crear una instancia válida y las acciones (método) que podemos tomar con ese objeto.

Result

Como notaste, cada vez que queremos crear un VO, necesitamos hacer new y pasar los argumentos que hagan falta para crear la instancia. Como nuestro constructor puede throw siempre tenemos que asegurarnos de hacer try/catch para manejar cualquier error al momento de la instanciación.

Aunque esto no es el fin del mundo, bien rápido se puede hacer algo tedioso. Dicho eso, hay formas de mejorar la experiencia al usar VOs.

Todos mis VOs tienen un método estático llamado create. Este método me permite instanciar VOs sin temor a una excepción pero en vez de devolver una instancia de Amount, devulve una instancia de un objeto llamado Result que sirve para envolver el Amount que se creo o un Error si hubo problemas al crear el VO.

Esta idea de usar un objecto para envolver el resultado es algo muy común en otros lengajes como Rust y nos permite hacer cosas interesantes. Los detalles sobre Result los voy a dejar para un post futuro donde también hablare sobre como uso Maybe en mis programas.

Resumen