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
- Los VOs nos ayudan a implementar reglas de negocio para un valor dentro de nuestro programa.
- Los VOs deben ser inmutables.
- Debe ser imposible instanciar un VO en un estado ilegal.
- Una vez tenemos una instancia de VO podemos estar seguros que contiene un estado válido dentro de nuestro programa.