Trigger Frameworks – El framework de Appleman que los inició a todos (Parte 3)

Las ideas propuestas por Dan Appleman en su libro Advanced Apex para la gestión eficiente de triggers, han sido la base para varios frameworks aparecidos posteriormente.

De hecho, el título que el autor dio al capítulo de su libro, “One trigger to rule them all” es ampliamente mencionado en muchos artículos y sitios web.

Mi objetivo en esta entrada, es explicar llanamente, los conceptos e ideas de este framework, que son principios que casi todos los frameworks implementan.

El contexto de ejecución

Para la creación de un framework eficiente, hay que conocer las características del contexto de ejecución en la plataforma Salesforce.

Al contrario de lo que pueda parecer, hay diferentes tipos de Execution Context, y cada uno de ellos, tiene unos límites diferentes.

Te enumero aquí solo las características e implicaciones principales (si quieres saber más sobre el Contexto de Ejecución en Salesforce):

  • Un contexto de ejecución se inicia por un evento u operación concreta, y en nuestro caso, es importante cuando se inicia, que es, por el lanzamiento de un trigger.
  • Cuando se inicia el contexto por un trigger, todas las operaciones que se deriven de él, incluyendo el lanzamiento de otros triggers, workflows, etc., se mantienen en el mismo contexto de ejecución (esto es crucial!).
  • En un contexto de ejecución se establecen y se mantienen los mismos Governor Limits.
  • En un contexto de ejecución se establecen y se mantienen todos los miembros estáticos, SOLO durante la vida de contexto.
  • Es fácil saber cuando se inicia un contexto, pero como puedes ver en la imagen a continuación es difícil saber cuando finaliza, y las bifurcaciones que puede tener.
Ejemplo de un contexto de ejecución, con varias operaciones concatenadas – recuerda que en el mismo contexto de ejecución, se mantienen los mismos Governor Limits

Esqueleto completo del Framework

Este framework cumple las siguientes características:

  1. Solo debe existir un único trigger por objeto (“One trigger to rule them all“, más claro imposible). Esta idea, ya vimos en el patrón de Tony Scott y es una buena práctica indiscutible.
  2. Todos los triggers centralizan sus llamadas en un único Dispatcher Global.
  3. El Dispatcher realiza 3 funciones:
    • Ejecuta funciones transversales a todos los triggers, tanto al inicio de la ejecución como al finalizar.
    • Enruta las peticiones hacía el Handler del objeto adecuado.
    • Controla y gestiona las re-entradas (recursividad) de todos los objetos.
  4. Todos los Handler de objeto deben implementar una interface, que asegura el tratamiento de la llamada inicial y las re-entradas.
  5. La lógica de negocio se implementa fuera del Dispatcher, en bloques funcionales aislados.

En el diagrama a continuación, ejemplifica el comportamiento del framework:

Nota para el lector (del libro)

Para facilitar el entendimiento y explicar más claramente los conceptos, he modificado el código  que aparece en el libro, y he realizado ciertos ajustes:

  • En el  el libro, verás que no se explicita el concepto de Handler, aunque se menciona algo parecido
  • El (global) dispatcher invoca los bloques funcionales directamente sin pasar por estos Handlers

En mi opinión el uso del Handler, como mediador entre el Dispatcher Global y el Bloque funcional,  refuerza el principio de separación de responsabilidades y disminuye el acoplamiento entre clases (lo que implica menos colisiones entre desarolladores).

Pero, tu decides, qué enfoque te parece más adecuado, o aún mejor, modificarlo y adaptarlo a tus necesidades y a tu equipo.

Veamos el código de cada uno de los componentes.

Invocación del Trigger al global Dispatcher

Como vimos, en el artículo del patrón de Tony Scott, la premisa de tener trigger code-less, es una buena práctica y una premisa también para este framework.

En este caso, tenemos exactamente eso, el trigger realiza una única invocación al Global Dispatcher, para que se espavile. Las diferencias que encontramos con el patrón de Tony Scott son:

  • el trigger está desacoplado del Handler del objeto (delega ese acoplamiento al Dispatcher)
  • se transmiten todos los atributos del trigger al Dispatcher, junto con el nombre del objeto que estamos gestionando

Esto permite, a diferencia del patrón anterior, no acoplar el Dispatcher con un Handler especifico (.class), consiguiendo así, que el código del trigger se mantenga mínimo y sin cambios, desacoplando completamente  Trigger y Handler, guai!!.

En esta imagen podemos ver la primera etapa de ejecución del framework:

En el código a continuación, dejo comentada la invocación del patrón de Tony Scott, para que puedas comparar las diferencias entre ambos frameworks (observa como el Trigger conoce el .class del Handler – un cambio de Handler implica un cambio en el Trigger).

Esto que puede parecer, una mejora estética, tiene implicaciones importantes. Si decidimos tener diferentes Handlers en función de ciertos valores de ciertas variables, en el framework de Appleman es completamente posible, dado que lo implementaremos en el Dispatcher, mientras que en el de Scott, requiere de la modificación de Trigger y de las clases dependientes posteriores.

/*
* @author Esteve Graells
* @date: junio 2018
* @description: Trigger para Account que invoca al Dispatcher
*/

trigger OnAccount on Account (
before insert, before update,
before delete, after insert,
after update, after delete, after undelete) {

// Llamada al dispatcher del patron de Tony Scott
// TriggerFactory.createAndExecuteHandler(AccountHandler2.class);

// Llamada al dispatcher del framework de Dan Appleman
GlobalDispatcher.run('Account',
trigger.isBefore, trigger.isAfter,
trigger.isInsert, trigger.isUpdate,
trigger.isDelete, trigger.isExecuting,
trigger.new, trigger.newMap,
trigger.old, trigger.oldMap);
}

Ejecución del Dispatcher

Después de estudiar el framework, creo que Appleman debió dudar entre el título “One trigger to rule them all” y “One Dispatcher to manage them all“, yo me hubiera decantado por el segundo :-).

Digo esto, porque el Dispatcher en este framework, es un punto de entrada único para todos los triggers de nuestro proyecto. En él, se realizan las siguientes tareas:

  1. (Opcionalmente) Ejecución de funciones transversales como Logging, Control de cuotas, etc.
  2. (Opcionalmente) Definición de miembros estáticos comunes al contexto, por ejemplo, para albergar objetos con los que realizar DMLs únicas.
  3. Detección de la re-entrada y enrutamiento al método del Handler en curso.
  4. Invocación del Handler que controlará el flujo de negocio asociado.
  5. (opcional) Operaciones de DML únicas, para evitar DML intermedias innecesarias.
  6. (opcional) Otra vez, ejecución de funciones transversales al salir como Logging, Control de cuotas, notificaciones, etc.

Veamos cada una de las actividades.

Funciones transversales al inicio y final

Permiten realizar funciones de control, logging, notificaciones, etc. Cada uno implementamos las que necesitamos, pero atención, teniendo en mente, el rendimiento, y los límites asociados al contexto (CPU y Heap especialmente).

Si leíste el artículo anterior, ¿recuerdas el método andFinally() de la clase Handler? Claro, Scott ya vió la necesidad de tener funciones transversales de logging, housekeeping, etc., en su patrón.

Declaración de miembros estáticos

Esta sección permite definir aquellas variables que estarán disponibles durante todo el contexto de ejecución. En el framework hay 2 usos fundamentales de los miembros estáticos:

  1. Declaración de la variable que nos indicará si hay una re-entrada (contiene la instancia del Handler en curso)
  2. Declaración de la variable que contiene los elementos del objeto, que se van creando/modificando (según queramos) y poder ejecutar así una única DML al final, eficientando las operaciones sobre BD

Recordar, que el tamaño del Heap del Contexto de Ejecución es limitado, y hay que diseñar con cuidado que variables serán incluidas en el contexto.

Gestión de la re-entrada

La problemática es la siguiente: ¿qué sucede si la lógica de negocio implementada en el Trigger,  provoca invocaciones a otros triggers o ejecución de Workflows, que a su vez, acaban provocando la invocación del mismo trigger con qué se inició?

Esto se denomina una re-entrada en el trigger, y entramos en recursividad de llamadas (en la imagen del apartado inicial del Contexto de Ejecución, ya lo viste). Estas re-entradas pueden ser necesarias, y por tanto, no es objetivo eliminarlas para que no se produzcan o ignorarlas, sino gestionarlas según sea necesario.

El framework realiza un control de las re-entradas de la siguiente manera:

  1. Detecta la re-entrada mediante una variables estática (recuerda que están disponibles durante toda la vida del contexto).
  2. Incluye código de gestión de las re-entradas, mediante la implementación de una interface.

(Nota para desarrolladores Java/.net: recuerda que en Apex, una variable estática se mantiene durante la vida del Execution Context exclusivamente, no durante toda la ejecución de la aplicación como sucede en .net ó  Java).

El enfoque es muy sencillo:

  • Una variable contiene la instancia del Handler, se denomina  activeFunction.
  • Al invocar el trigger, el Dispatcher, detecta si la variable ha sido inicializada previamente, lo que implica que ya hubo una invocación anterior del Handler, con lo que esto supone una re-entrada.

Por tanto, hemos pasado de desconocer y no controlar lo que sucedía en las re-entradas, a controlarlas y aprovecharlas según necesitemos. Veremos un poco más adelante como el framework nos obliga a su gestión!!

Gestión de la entrada inicial

La entrada inicial, es la lógica de negocio. Su ejecución se lleva a cabo dentro del Handler.

Invocación del Handler

El Dispatcher invoca a los Handler, para ejecutar la lógica de negocio. Lo hace, sabiendo que implementan la interface iTriggerEntry. Esto es fundamental, porque así invoca 2 de sus métodos:

  1. Una para la entrada inicial.
  2. El otro para la re-entrada.

El Dispatcher invoca el Handler, con todos los atributos del trigger y además con el nombre del objeto, no con una clase (.class).

Este último parámetro, no es estrictamente necesario, ya que el Dispatcher ya invoca al Handler del objeto concreto (Opportunity, Account, etc.) pero sirve para realizar una comprobación de seguridad, y validar que estamos instanciando el Handler correcto.

Operación DML única

Esta idea, es parecida a la que vimos en el patrón de Tony Scott, pero aquí Appleman la explicita de manera muy clara.
Estas variables, son variable de tipo Map, que contienen los objetos que la lógica de negocio va generando y/o modificando.

Por tanto, dado que las variables son estáticas y  serán visibles durante la vida del contexto, definimos un Map (por ejemplo de Account), donde se insertan/los registros (las Accounts), y en lugar de ejecutar operaciones DML parciales en los bloques funcionales, antes de finalizar el trigger, se realiza una única operación DML (insert, update ó upsert, delete, undelete) con el Map.

Este patrón es realmente interesante por lo simple y útil que es, y lo puedes mejorar con estructuras de datos más complejas (como por ejemplo hace Scott Wells en el framework de su empresa).


/*
* @author Esteve Graells
* @date: junio 2018
* @description: Dispatcher que centraliza todas las llamadas de triggers,
* gestiona las re-entradas y realiza enrutamiento hacia el Handler
*/

public with sharing class GlobalDispatcher {

public static Map<ID, Account> acsToUpdate = new Map<ID, Account>();
public static ITriggerEntry activeFunction = null;

public static void run(
String triggerObject,
Boolean isBefore,
Boolean isAfter,
Boolean isInsert,
Boolean isUpdate,
Boolean isDelete,
Boolean isExecuting,
List<SObject> newList, Map<ID, SObject> newMap,
List<SObject> oldList, Map<ID, SObject> oldMap) {

// Ejecución de Funciones Transversales como logging,
// control de excepciones etc.
transversalFunctionsAtBeginning();

//Detección de la re-entrada en la clase en curso
if (activeFunction != null) {
activeFunction.inProgressEntry(triggerObject,
isBefore, isAfter, isInsert, isUpdate, isDelete,
isExecuting, newList, newMap, oldList, oldMap);

System.debug('-ege- Re-entrada para objeto ' + triggerObject);

return;
}

////Routing para el objeto ACCOUNT a su Handler

if (triggerObject == 'Account') {
activeFunction = new AccountEntryHandler();

activeFunction.mainEntry(triggerObject,
isBefore, isAfter, isInsert, isUpdate, isDelete,
isExecuting, newList, newMap, oldList, oldMap);

if (acsToUpdate.size() > 0) update acsToUpdate.values();

activeFunction = null;
}

//Routing para el objeto OPPORTUNITY a su Handler

if (triggerObject == 'Opportunity') {
activeFunction = new OpportunityEntryHandler();

//Invoación a la función del Handler de primera entrada
activeFunction.mainEntry(triggerObject,
isBefore, isDelete, isAfter, isInsert,
isUpdate, isExecuting,
newList, newMap, oldList, oldMap);

activeFunction = null;
}

// Ejecución de Funciones Transversales como logging,
//control de excepciones etc.

transversalFunctionsAtEnding();
}

//Código de la funcional transversal de inicio
private static void transversalFunctionsAtBeginning(){}

//Código de la funcional transversal de cierre
private static void transversalFunctionsAtEnding(){}

}

Handler y los bloques funcionales

El Handler implementa la interface iTriggerHander lo que le obliga a proporcionar código para la entrada inicial y las re-entradas.

El código de la entrada inicial, se segrega a nivel conceptual en Bloques Funcionales, que es la lógica de negocio que queremos ejecutar.

Veamos el código de la interface y un par de ejemplos sencillos de Handler (Account y Opportunity):

/*
* @author Esteve Graells
* @date: junio 2018
* @description:
*
*/

public interface iTriggerEntry {
void mainEntry(String triggerObject, Boolean isBefore,
Boolean isDelete, Boolean isAfter,
Boolean isInsert, Boolean isUpdate,
Boolean isExecuting,
List&amp;lt;SObject&amp;gt; newList, Map&amp;lt;ID, SObject&amp;gt; newMap,
List&amp;lt;SObject&amp;gt; oldList, Map&amp;lt;ID, SObject&amp;gt; oldMap);

void inProgressEntry(String triggerObject, Boolean isBefore,
Boolean isDelete, Boolean isAfter,
Boolean isInsert, Boolean isUpdate,
Boolean isExecuting, List&amp;lt;SObject&amp;gt; newList,
Map&amp;lt;ID, SObject&amp;gt; newMap,
List&amp;lt;SObject&amp;gt; oldList, Map&amp;lt;ID, SObject&amp;gt; oldMap);
}

A continuación, el Handler para el objeto Account completo, en este caso, los bloques funcionales evitan que se eliminen Accounts que tengan Oportunidades

/*
* @author Esteve Graells
* @date: junio 2018
* @description: Implantación de la lógica de negocio para la gestión de los
* triggers de Account
*
*/

public class AccountEntryHandler implements iTriggerEntry {

static Set<Id> accIdsWithOpps = new Set<Id>();

public void mainEntry(
String triggerObject,
Boolean isBefore,
Boolean isAfter,
Boolean isInsert,
Boolean isUpdate,
Boolean isDelete,
Boolean isExecuting,
List<SObject> newList, Map<ID, SObject> newMap,
List<SObject> oldList, Map<ID, SObject> oldMap) {

List<Account> accNewList = (List<Account>) newList;
List<Account> accOldList = (List<Account>) oldList;
Map<ID, Account> accNewMap = (Map<ID, Account>) newMap;
Map<ID, Account> accOldMap = (Map<ID, Account>) oldMap;

if (triggerObject == 'Account') {

if (isBefore){

if (isInsert){
//// Ejecución de Bloque funcional correspondiente
System.debug('-ege- BeforeInsert ' + triggerObject);}
if (isUpdate){
//FunctionalBlock2()
System.debug('-ege- BeforeUpdate ' + triggerObject);}
if (isDelete) {
//// Ejecución de Bloque funcional correspondiente
System.debug('-ege- BeforeDelete ' + triggerObject);
beforeDeleteFunctionalBlock(isBefore, isAfter, accNewList, accNewMap, accOldList, accOldMap);
}

}else if (isAfter){
if (isInsert){
//// Ejecución de Bloque funcional correspondiente
System.debug('-ege- AfterInsert ' + triggerObject);}
if (isUpdate){
//// Ejecución de Bloque funcional correspondiente
System.debug('-ege- AfterUpdate ' + triggerObject);}
if (isDelete) {
//// Ejecución de Bloque funcional correspondiente
System.debug('-ege- AfterDelete ' + triggerObject);
afterDeleteFunctionalBlock(isBefore, isAfter, accNewList, accNewMap, accOldList, accOldMap);
}
}
}
}

//Gestión de la re-entrada para Account
public void inProgressEntry(
String triggerObject,
Boolean isBefore,
Boolean isAfter,
Boolean isInsert,
Boolean isUpdate,
Boolean isDelete,
Boolean isExecuting,
List<SObject> newList, Map<ID, SObject> newMap,
List<SObject> oldList, Map<ID, SObject> oldMap) {

// Be sure to detect for the objects you actually want to handle.

if (TriggerObject == 'Account' &amp;amp;&amp;amp; IsAfter) {
// Do some processing here
// Can dispatch to other classes is necessary
}
}

// No permitir el borrado de Accounts con Oportunidades
// Es un ejemplo de Bloque funcional

private void beforeDeleteFunctionalBlock(
Boolean isBefore,
Boolean isAfter,
List<Account> newList, Map<ID, Account> newMap,
List<Account> oldList, Map<ID, Account> oldMap){

// Dependiendo de la Naturaleza del trigger los valores
// de las Accounts vienen en un Map u otro

Set<Id> accIds = oldMap.keySet();

//Obtener las Accounts con sus Oportunidades
for (Account[] accounts : [
SELECT a.Id, (SELECT Id FROM Opportunities LIMIT 1)
FROM Account a
WHERE a.Id in :accIds]) {

for (Account acc : accounts) {

if (acc.Opportunities.size() > 0) {
accIdsWithOpps.add(acc.Id);
acc.addError('Esta account no se puede eliminar, tiene oportunidades asociadas');
}
}
}
}

// No permitir el borrado de Accounts con Oportunidades
// Es otro ejemplo de Bloque funcional
private void afterDeleteFunctionalBlock(
Boolean isBefore,
Boolean isAfter,
List<Account> newList, Map<ID, Account> newMap,
List<Account> oldList, Map<ID, Account> oldMap){

// No permitir el borrado de los Accounts que tienen Opportunities

for ( Id accId : oldMap.keySet() ){

if (accIdsWithOpps.contains(accId)){
oldMap.get(accId).addError('Esta account no se puede eliminar, tiene oportunidades asociadas');
}
}
}
}

El Handler para el objeto Opportunity completo, con bloques funcionales vacíos:

/*
* @author Esteve Graells
* @date: junio 2018
* @description: Implantación de la lógica de negocio para
* la gestión de los triggers de Opportuntiy
*
*/

public class OpportunityEntryHandler implements iTriggerEntry{

//Función de gestión del trigger en primera entrada
public void mainEntry(
String triggerObject,
Boolean isBefore,
Boolean isAfter,
Boolean isInsert,
Boolean isUpdate,
Boolean isDelete,
Boolean isExecuting,
List<SObject> newList, Map<ID, SObject> newMap,
List<SObject> oldList, Map<ID, SObject> oldMap) {

List<Opportunity> opNewList = (List<Opportunity>) newList;
List<Opportunity> opOldList = (List<Opportunity>) oldList;
Map<ID, Opportunity> opNewMap = (Map<ID, Opportunity>) newMap;
Map<ID, Opportunity> opOldMap = (Map<ID, Opportunity>) oldMap;

//Implementación de la logica de negocio que se desea
// para cada evento
if (triggerObject == 'Opportunity'){

if (isBefore){

if (isInsert){
//FunctionalBlock1()
System.debug('-ege- BeforeInsert ' + triggerObject);}
if (isUpdate){
//FunctionalBlock2()
System.debug('-ege- BeforeUpdate ' + triggerObject);}
if (isDelete){
//FunctionalBlockn()
System.debug('-ege- BeforeDelete ' + triggerObject);}
}else if (isAfter){
if (isInsert){
//FunctionalBlockn()
System.debug('-ege- AfterInsert ' + triggerObject);}
if (isUpdate){
//FunctionalBlockn()
System.debug('-ege- AfterUpdate ' + triggerObject);}
if (isDelete){
//FunctionalBlockn()
System.debug('-ege- AfterDelete ' + triggerObject);}
}
}
}

//Método que gestiona las re-entradas de Oportunidades
public void inProgressEntry(
String triggerObject,
Boolean isBefore,
Boolean isAfter,
Boolean isInsert,
Boolean isUpdate,
Boolean isDelete,
Boolean isExecuting,
List<SObject> newList, Map<ID, SObject> newMap,
List<SObject> oldList, Map<ID, SObject> oldMap) {

if (TriggerObject == 'Opportunity') {

//Logica de negocio
System.debug('-ege- Re-entrada del trigger para ' + triggerObject);
//FunctionalBlockn()

}
}
}

La implementación de un Handler por Objeto, permite separar y ordenar nuestro código.

Además, cabe la posibilidad de implementar los bloques funcionales en clases separadas (separation of concerns), para conseguir así aún más desacople y re-utilización de esas clases.

Qué hemos conseguido

Casi hemos acabado, pero además desde el punto de vista de Arquitectura de desarrollo, con este enfoque hemos conseguido:

  • Tenemos Trigger con código mínimo.
  • Tenemos un Dispatcher central, que permite ejecuciones tranversales, DML eficientes con miembros estáticos, detección y gestión de la re-entradas.
  • Tenemos bloques funcionales en el Handler o en clases separadas para su re-utilización posterior y de esta manera disminuir las colisiones entre los desarrolladores.

¿Consigue los objetivos?

Veamos si esta propuesta consigue los objetivos que nos habíamos propuesto:

  1. Crear un único trigger por objeto y evitar duplicidades: si, tenemos centralizada la ejecución del trigger, y además podemos optar por centralizar todas las ejecuciones de todos los triggers.
  2. Crear el trigger sin código, externalizando todo su código en clases externas: hemos externalizado toda la funcionalidad en clases.
  3. Controlar el orden de ejecución: tenemos un Dispatcher que gestiona el orden de ejecución de los bloques funcionales y por tanto, el flujo está claramente bajo nuestro control.
  4. Detección de la recursividad / Control de re-entradas: cada uno de los Handlers, está obligado a implementar un método para la re-entrada, y por tanto, al menos plantearse que debe hacer.
  5. Activar/Desactivar un trigger en caliente en cualquier entorno: no propone nada al respecto, pero es muy fácil introducirlo.
  6. Crear una estructura de código que facilite un entorno multi-programador: con todos los puntos anteriores, y con una curva de aprendizaje muy pequeña, se proporciona un entorno para que todo el equipo de desarrollo colabore, minimizando las colisiones de código.

Conclusiones

Este framework, propone patrones e ideas que basándose en las buenas prácticas de APEX mejoran enormemente la gestión de triggers en proyectos mediano-grandes.

El control de la recursividad, fue de los primeros en tratarlo con detalle y proponer la idea de incluirlo dentro del framework. Como comenta el autor, este es un esqueleto básico de gestión de triggers, para implementar nuestro propio framework y veremos en la siguiente entrada, como partiendo de las ideas de Appleman y Scott, otr framework proporciona nuevas ideas e intenta mejorar ambos.

En el siguiente artículo veremos cómo otro framework añadió nuevas ideas y proporcionó un framework que podríamos considerar muy completo, el framework de Triggers de Hari Krishnan.

Por supuesto, recomiendo la lectura del libro de Appleman, que aborda diversos temas muy interesantes.
Nota:: posterior a la escritura de este artículo, Dan Appleman ha publicado la 4a edición de su libro, con grandes cambios en su contenido. Este articulo hace referencia a la 3a Edición, no a la 4a.

Espero que te sea de ayuda.

Enlaces Interesantes

  1. Enlace al repositorio Bitbucket con las clases del proyecto completo