Este es uno de los artículos que dedicaré a expresar modelos de datos típicos para abordar ciertos problemas. Así como existen los patrones de diseño en ingeniería de software, lo que planteo acá son patrones de modelos de datos.
En el primero de estos artículos, pretendo explicar un modelo de datos que permita expresar entidades «transicionables», es decir entidades que durante todo su ciclo de vida parten en cierto estado y van cambiando de un estado a otro según lo descrito mediante un autómata finito.
Supongamos estamos desarrollando un sistema de venta online, para este ejemplo nos referiremos a una «venta» como nuestra entidad transicionable. Cuando un cliente realiza una compra online la venta comienza en el estado «Creada», luego cuando se procesa el pago por parte del cliente se pasa al estado «Pago aceptado» o también podría pasar al estado «Pago rechazado»; esta solicitud la recibe alguna persona encargada de la tienda, verificará el stock en bodega y preparará el pedido posiblemente armando una caja con todos los productos que fueron incluidos en la venta, cuando esta persona encargada entrega el pedido a la empresa de transportes la venta pasa al estado «Despachada» y finalmente cuando la empresa de transportes informa que los productos fueron entregados al cliente la venta pasa al estado «Entregada».
El ejemplo descrito anteriormente notarán se puede dar en sistemas de todo tipo: venta de pólizas, pedidos online, fabricación de X objeto, curse de créditos… en casi todas nuestras aplicaciones que nos permitan modelar un negocio nos encontraremos con estas entidades transicionables. Lo primero aquí es expresar estos cambios de estado de nuestra entidad transicionable como un autómata finito, pero antes definiré ciertos conceptos de un «autómata finito» llevado a este contexto que se debe tener claro:
- Estados: Corresponde a un conjunto con todos los (valga la redundancia) estados en los que podría estar nuestra entidad transicionable.
- Transiciones: Corresponde a un conjunto de relaciones de pares de estados, una transición relaciona un estado de origen con un estado de destino, nos indican desde qué estados a qué estados pueden cambiar nuestra entidad transicionable.
- Estado inicial: Corresponde a un y solo un estado en el cual comienza nuestra entidad transicionable.
- Estados finales: Corresponde a un conjunto de estado en los cuales nuestra entidad transicionable «ha finalizado» y se asume que ya no tendrá más cambios de estado.
Un autómata lo podemos visualizar gráficamente como un grafo dirigido y para el ejemplo de ventas online sería el siguiente diseño:
Solución N°1: Estados y transiciones
Por lo tanto, a partir de la imagen anterior, podemos ver que nuestro problema de llevar un autómata a un modelo de datos se reduce a ¿como expresar un grafo en una base de datos?; acá la primera aproximación de solución:
Acá se puede observar los 4 conceptos descritos anteriormente de un autómata finito: Estados y transiciones; mientras que un estado inicial y/o final corresponden a características de los estados expresados a través de unas columnas de tipo bool.
Solución N°2: Estados y transiciones y autómata
Lo descrito anteriormente tiene demasiadas limitaciones, por ejemplo cuando se tienen varias entidades transicionables en nuestro dominio del problema se deben replicar las tablas Estado (STATE) y Transición (TRANSITION) por cada entidad transicionable. Volviendo al ejemplo del sistema de ventas online tendríamos la tabla «STATE_SALE» y «TRANSITION_SALE», si además detectamos en nuestro dominio del problema que los clientes pasan por cambios de estado, entonces debemos agregar las tablas «STATE_CLIENT» y «TRANSITION_CLIENT».
La segunda solución propuesta agrega la tabla de autómatas «AUTOMATON_ENTITY», con lo cual es posible reutilizar la misma tabla «STATE_ENTITY» y «TRANSITION_ENTITY» para todas las entidades transicionables de nuestro dominio del problema.
Generalmente, con la solución N°2 es suficiente para poder controlar los cambios de estado, lo típico es que cuando una entidad transicionable que actualmente se encuentra en un estado «A» y se intente transicionar hacia el estado «B» se valide que el cambio de estado sea posible debido a la existencia en la base de datos de una transición que relacione «A» con «B». Esta validación quedaría expresada mediante la siguiente consulta SQL:
SELECT tr.* FROM STATE_ENTITY st1 JOIN TRANSITION_ENTITY tr ON (tr.STATE_ID_FROM = st1.STATE_ID) JOIN STATE_ENTITY st2 ON (st2.STATE_ID = tr.STATE_ID_TO) JOIN AUTOMATON_ENTITY au ON (st1.AUTOMATON_ID = au.AUTOMATON_ID AND st2.AUTOMATON_ID = au.AUTOMATON_ID) WHERE au.name = :automatonName AND st1.name = :fromStateName AND st2.name = :toStateName;
Si esta consulta devuelve algún resultado es porque existe una transición entre los estados designados mediante los parámetros «fromStateName» y «toStateName», además considerando que estos estados deben pertenecer al autómata designado mediante el parámetro «automatonName», si no devuelve resultado es porque se está intentando realizar un cambio de estado inválido según el autómata definido en la base de datos y debemos lanzar el correspondiente error en la aplicación. (Estimado lector, considere traducir esta consulta SQL a lo que sea usted utilice como capa de abstracción para acceso a sus datos, me refiero a un ORM, ODM o lo que sea).
A continuación el poblado de datos del autómata de ventas online para así continuar con el ejemplo presentado al principio:
/* Inserts de autómata */ INSERT INTO AUTOMATON_ENTITY ( AUTOMATON_ID, NAME, DESCRIPTION, TARGET_ENTITY_ID) (SELECT NEXTVAL('automaton_id_seq'), 'AUTOMATA_VENTA_ONLINE', 'Autómata para modelar las ventas online', 'cl.wigtor.model.OnlineSale' WHERE NOT EXISTS( SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE' ) ); /* Inserts de estados */ INSERT INTO STATE_ENTITY ( STATE_ID, NAME, DESCRIPTION, TS_CREATION, TS_MODIFY, AUTOMATON_ID, IS_INITIAL, IS_FINAL) (SELECT NEXTVAL('state_id_seq'), 'Creada', 'Estado inicial, venta online recién creada', now(), now(), (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE'), true, false WHERE NOT EXISTS( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Creada' and AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) ); INSERT INTO STATE_ENTITY ( STATE_ID, NAME, DESCRIPTION, TS_CREATION, TS_MODIFY, AUTOMATON_ID, IS_INITIAL, IS_FINAL) (SELECT NEXTVAL('state_id_seq'), 'Pago rechazado', 'El pago de webpay ha sido rechazado por cualquier motivo, la venta ya no es válida', now(), now(), (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE'), false, true WHERE NOT EXISTS( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Pago rechazado' and AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) ); INSERT INTO STATE_ENTITY ( STATE_ID, NAME, DESCRIPTION, TS_CREATION, TS_MODIFY, AUTOMATON_ID, IS_INITIAL, IS_FINAL) (SELECT NEXTVAL('state_id_seq'), 'Pago aceptado', 'El pago de webpay ha sido aprobado, la venta sigue su curso', now(), now(), (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE'), false, false WHERE NOT EXISTS( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Pago aceptado' and AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) ); INSERT INTO STATE_ENTITY ( STATE_ID, NAME, DESCRIPTION, TS_CREATION, TS_MODIFY, AUTOMATON_ID, IS_INITIAL, IS_FINAL) (SELECT NEXTVAL('state_id_seq'), 'Despachada', 'Los productos solicitados han sido despachados por parte de la persona encargada', now(), now(), (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE'), false, false WHERE NOT EXISTS( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Despachada' and AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) ); INSERT INTO STATE_ENTITY ( STATE_ID, NAME, DESCRIPTION, TS_CREATION, TS_MODIFY, AUTOMATON_ID, IS_INITIAL, IS_FINAL) (SELECT NEXTVAL('state_id_seq'), 'Entregada', 'Los productos solicitados han sido entregados por la empresa de despacho al cliente', now(), now(), (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE'), false, true WHERE NOT EXISTS( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Entregada' and AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) ); /* Inserts de transiciones */ INSERT INTO TRANSITION_STATE_ENTITY ( TRANSITION_ID, NAME, DESCRIPTION, TS_CREATION, TS_MODIFY, STATE_ID_FROM, STATE_ID_TO ) SELECT NEXTVAL('transition_id_seq'), 'Creada a Pago rechazado', 'Creada a Pago rechazado', now(), now(), ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Creada' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ), ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Pago rechazado' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) WHERE NOT EXISTS ( SELECT TRANSITION_ID FROM TRANSITION_STATE_ENTITY WHERE STATE_ID_FROM = ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Creada' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) AND STATE_ID_TO = ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Pago rechazado' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) ) ; INSERT INTO TRANSITION_STATE_ENTITY ( TRANSITION_ID, NAME, DESCRIPTION, TS_CREATION, TS_MODIFY, STATE_ID_FROM, STATE_ID_TO ) SELECT NEXTVAL('transition_id_seq'), 'Creada a Pago aceptado', 'Creada a Pago aceptado', now(), now(), ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Creada' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ), ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Pago aceptado' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) WHERE NOT EXISTS ( SELECT TRANSITION_ID FROM TRANSITION_STATE_ENTITY WHERE STATE_ID_FROM = ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Creada' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) AND STATE_ID_TO = ( SELECT STATE_ID FROM STATE_ENTITY WHERE NAME = 'Pago aceptado' AND AUTOMATON_ID = (SELECT AUTOMATON_ID FROM AUTOMATON_ENTITY WHERE NAME = 'AUTOMATA_VENTA_ONLINE') ) ) ; /* Por espacio no se pusieron las otras transiciones ... */
Notar que en las operaciones de INSERT anteriores he utilizado la expresión WHERE NOT EXISTS, la cual permite evitar que se realice el INSERT en caso que la fila ya exista en la base de datos, esto asumiendo que la llave alternativa de la tabla «AUTOMATON_ENTITY» corresponde a la columna «NAME», la llave alternativa de la tabla «STATE_ENTITY» corresponde a las columnas «NAME» junto a «AUTOMATON_ID» y la llave alternativa de la tabla «TRANSITION_ENTITY» corresponde a las columnas «STATE_ID_FROM» junto a «STATE_ID_TO».
Solución N°3: Estados, transiciones, autómatas y tags.
Cuando se tienen que aplicar reglas de negocio según el estado en que se encuentre nuestra entidad transicionable o según la transición usada para cambiarle el estado a esta entidad. Debemos recurrir al conocimiento del nombre de los estados en la parte del código de nuestra aplicación. Siguiendo el ejemplo del autómata de ventas, supongamos estamos construyendo una vista en que se muestra la información de una venta y debemos aplicar la siguiente regla: «Si la venta se encuentra en estado ‘Creada’, entonces se debe mostrar una sección para el pago mediante tarjeta de crédito», lo cual en pseudocódigo sería algo así:
if ("Creada".equals(sale.getState().name())) { showPaymentSection(sale); }
También es posible aplicar reglas de negocio cuando ocurren transiciones, por ejemplo «Si la venta transiciona al estado ‘Pago aceptado’ o al estado ‘Despachada’, entonces se debe enviar un mail al cliente indicando este cambio de estado», nuevamente en pseudocódigo esto quedaría:
if ("Pago aceptado".equals(transitionUsed.getStateTo().getName()) || "Despachada".equals(transitionUsed.getStateTo().getName())) { sendMailToClient(sale); }
Hasta aquí no hay problema, hasta que aparece en tu bandeja de mail un mensaje del product owner de tu proyecto de ventas online diciendo:
«Favor agregar nuevo estado llamado ‘Pago parcial aceptado’, debe comportarse igual a ‘Pago aceptado’, pero….»
Aquí comienzan los problemas si sólo se hace uso de la solución N°2, el negocio cambia y a la misma velocidad debe cambiar el software que soporta al negocio, la solución rápida es buscar en el código todos los if
que involucren el estado ‘Pago aceptado’ y agregar el estado ‘Pago parcial aceptado’, en el ejemplo anterior quedaría:
if ("Pago aceptado".equals(transitionUsed.getStateTo().getName()) || "Despachada".equals(transitionUsed.getStateTo().getName()) || "Pago parcial aceptado".equals(transitionUsed.getStateTo().getName())) { sendMailToClient(sale); }
Se hace inmanejable cuando el autómata dispone de muchos estados y muchas reglas de negocio que pregunten por el estado y/o la transición utilizada. A lo cual se agregará el concepto de «tag de estado» y «tag de transición» al modelo de datos:
- Tag de estado: Corresponde a una característica que se le da a un estado, pudiendo ser estas características compartidas entre varios estados. Por ejemplo si se define el tag ‘showPaymentSection’, este se asocia por base de datos a todos los estados en que debe mostrarse la sección de pago.
- Tag de transición: Corresponde a una característica que se le da a una transición, pudiendo ser estas características compartidas entre varias transiciones. Por ejemplo si se define el tag ‘sendMailToClient’, este se asocia por base de datos a todas las transiciones en que debe realizarse la regla de negocio que envía mail al cliente.
Notar que un estado puede tener varios tags, como también un tag estar presente en varios estados, lo mismo ocurre con las transiciones por lo tanto en el nuevo modelo de datos existe una relación n-n entre «STATE_ENTITY» y «TAG_ENTITY» representado por la tabla «STATE__TAG» como también la relación n-n entre «TRANSITION_ENTITY» y «TAG_ENTITY» representado por la tabla «TRANSITION__TAG».
El otro cambio que se debe realizar es en la validación de las reglas de negocio según el estado y la transición, ahora no se debe preguntar por el nombre del estado, sino que se debe preguntar por ¿El estado tiene cierto tag?, quedando expresado de la siguiente forma para verificar tags de estado:
if (sale.getState().containsTag("showPaymentSection")) { showPaymentSection(sale); }
Y de esta formar para verificar por tags de transición:
if (transitionUsed.containsTag("sendMailToClient")) { sendMailToClient(sale); }
Si se da el caso que aparece un nuevo estado o se nos solicita modificar el comportamiento de estados ya existentes, entonces sólo se debe modificar el poblado de datos con la asociación entre estados con tags o entre transiciones con tags.
Solución N°4: Estados, transiciones, autómatas, tags y tags especiales.
Otra situación típica en las empresas es tener excepciones a la regla, nuestra aplicación realiza validaciones que nos indicaron en el proceso de toma de requerimientos que son estrictas, ahí aparecen conversaciones del estilo:
Usuario: «Siempre que se cambia al estado ‘Pago aceptado’ o ‘Despachada’ debe enviarse un mail.»
Equipo de desarrollo: «¿Seguro que siempre?, alguien quizá no quiere que se le envíe un mail…»
Usuario: «Nooo, imposible, siempre debe enviarse un mail.»
Equipo de desarrollo: «Ok (El cliente siempre tiene la razón).»
Al usuario hay que creerle, pero no tanto. A los días de que el sistema de ventas online está en funcionamiento llega un mail del product owner:
«Favor no enviar mails a cliente ‘X’, le vendemos cientos de veces todos los días, le enviamos un reporte diario de ventas y solicitó que no copáramos su bandeja de mails.»
La solución rápida es agregar esta excepción directamente en el código quedando de esta forma:
if (transitionUsed.containsTag("sendMailToClient") && !ignoreSendToMail(sale.getClient())) { sendMailToClient(sale); }
boolean ignoreSendToMail(ClientEntity client) { if (client.getName().equals("X")) { return true; } return false; }
Esto claramente tiene como problema que el código se hace inmantenible en caso que esta no sea la única excepción a la regla (Y les aseguro no lo será).
La solución acá es relacionar la entidad transicionable directamente con los tags, generando una tabla de «SPECIAL_TAGS».
Ahora las reglas de negocio que verifican los tags de estado, además deben verificar si existen tags especiales en nuestra entidad transicionable. Quedando el ejemplo anterior:
if (sale.getState().containsTag("showPaymentSection") || sale.containsSpecialTag("showPaymentSection")) { showPaymentSection(sale); }
if (transitionUsed.containsTag("sendMailToClient") && !sale.containsSpecialTag("ignoreSendMail")) { sendMailToClient(sale); }
Por lo tanto en este ejemplo la excepción a la regla se configura mediante un insert a la base de datos que relacione el tag «ignoreSendMail» con la entidad transicionable que se desea se trate de excepcionalmente.
Conclusiones:
En este artículo se ha presentado un modelo de datos lo más genérico posible para expresar cambios de estado de alguna entidad transicionable de nuestro dominio del problema, cada lector deberá adaptarlo un poco más a sus propias necesidades, posiblemente agregando columnas adicionales.
En futuros artículos se presentarán extensiones a este modelo de datos, por ejemplo mediante la inclusión de formularios a los cambios de estado, almacenamiento de logs de auditoría y reglas de negocio almacenadas en nuestro modelo de datos (Algo así como triggers).