core-workflow
Qué problema resuelve
Cuando el estado de una entidad se cambia con un setEstado(...) desperdigado por el código, pasan dos
cosas malas: se vuelven posibles transiciones que no deberían existir (aprobar algo que nunca se envió), y
no queda rastro de quién lo cambió ni por qué. El bug de "reversión silenciosa de proveedor" en Reniiu es
exactamente esa forma.
core-workflow da un único punto de paso para los cambios de estado. Declarás las transiciones válidas de
cada tipo de workflow de forma plana, y cada cambio que pasa por el servicio: se valida contra esa
declaración, escribe una fila de auditoría, y publica un ApplicationEvent para que otros módulos se
enganchen sin acoplarse.
El efecto secundario más útil: cualquier cambio que no pasó por acá no deja fila de auditoría — y esa ausencia es en sí misma la señal de que algo esquivó el motor.
Cómo se agrega
dependencies {
implementation("dev.echotechs.core:core-workflow:0.1.0-SNAPSHOT")
}
Arrastra core-auth, core-persistence y spring-statemachine-core 4.0.2.
<include file="db/changelog/core-workflow/changelog-master.xml"/>
Crea la tabla core.workflow_transition.
No hay propiedades de configuración. No hay servicio externo que configurar, así que la
autoconfiguración se activa sin condiciones apenas el módulo está en el classpath. Arrancar con cero
WorkflowDefinition registradas es válido.
API pública
WorkflowDefinition
Una máquina de estados por tipo de workflow. Registrá un bean por tipo; WorkflowTransitionService los
indexa por workflowType().
public final class WorkflowDefinition {
/** Usalo como fromState para un evento válido desde cualquier estado. */
public static final String ANY_STATE = "*";
public static Builder builder(String workflowType);
public String workflowType();
/** @return el estado resultante si el evento es válido desde fromState, vacío si no. */
public Optional<String> targetState(String fromState, String event);
public static final class Builder {
public Builder transition(String fromState, String event, String toState);
public WorkflowDefinition build();
}
}
Por debajo usa Spring StateMachine, pero el proyecto de dominio nunca toca su API de configuración: el
builder queda como una declaración plana (fromState, event, toState).
Detalles del builder que importan:
- El primer
fromStateque no seaANY_STATEse convierte en el estado inicial de la máquina. ANY_STATEno es un estado real de Spring StateMachine: el builder lo expande a una transición concreta por cada estado conocido al momento debuild(). El orden importa — poné losANY_STATEal final, cuando ya se declararon todos los estados.build()envuelve cualquier fallo enIllegalStateException.
Cada llamada a targetState obtiene una máquina transitoria y nueva, la resetea directo a fromState
(salteando acciones de entrada/salida — la entidad ya venía en ese estado desde hace rato), manda el evento
y lee el estado resultante. No se mantiene ninguna instancia viva entre llamadas: la columna de estado de tu
propia entidad es el único registro durable de dónde está cada cosa.
WorkflowTransitionService
public class WorkflowTransitionService {
/**
* @param reason opcional, texto libre — queda en la fila de auditoría.
* @return el estado resultante, para que el llamador lo persista en su propia entidad.
* @throws IllegalArgumentException si no hay WorkflowDefinition registrada para workflowType.
* @throws ConflictException si el evento no es una transición válida desde fromState.
*/
@Transactional
public String transition(String workflowType, UUID entityId, String fromState, String event, String reason);
public List<WorkflowTransition> history(String workflowType, UUID entityId);
}
:::caution No escribe el estado en tu entidad
core-workflow no es dueño de tu entidad, así que no le asigna el estado nuevo. Devuelve el estado
resultante y vos lo persistís, en la misma transacción.
:::
ConflictException viene de core-web → HTTP 409 con code: "INVALID_TRANSITION".
WorkflowTransition — la fila de auditoría
@Entity
@Table(name = "workflow_transition", schema = "core")
public class WorkflowTransition extends CoreEntity {
// workflowType, entityId, fromState, toState, event,
// principalType, principalId, reason, occurredAt
}
principalType/principalId salen de TenantContext.getCurrentPrincipal(). Fuera de una request
autenticada, principalType queda como "SYSTEM" y principalId como null — o sea que un job programado
sí puede transicionar, y queda registrado como tal.
public interface WorkflowTransitionRepository extends JpaRepository<WorkflowTransition, UUID> {
List<WorkflowTransition> findByWorkflowTypeAndEntityIdOrderByOccurredAtAsc(String workflowType, UUID entityId);
}
WorkflowTransitionedEvent
public record WorkflowTransitionedEvent(String workflowType, UUID entityId, String fromState, String toState,
String event, String reason) {}
ApplicationEvent estándar de Spring, publicado en cada transición exitosa. Es el punto de enganche para
notificaciones, facturación, o cualquier cosa que deba reaccionar sin acoplarse al módulo que originó el
cambio.
Ejemplo de uso
Declarar el workflow
De PurchaseRequestWorkflowConfig en los tests:
@Configuration
public class PurchaseRequestWorkflowConfig {
@Bean
public WorkflowDefinition purchaseRequestWorkflow() {
return WorkflowDefinition.builder("purchase_request")
.transition("DRAFT", "submit", "SUBMITTED")
.transition("SUBMITTED", "approve", "APPROVED")
.transition("SUBMITTED", "reject", "REJECTED")
.transition(WorkflowDefinition.ANY_STATE, "cancel", "CANCELLED")
.build();
}
}
Usarlo desde un servicio de dominio
El patrón correcto: transicionar, y persistir el estado devuelto en la misma transacción.
@Service
public class PurchaseRequestService {
private final PurchaseRequestRepository repository;
private final WorkflowTransitionService workflowService;
@Transactional
@RequiresPermission(relation = "can_edit", objectType = "purchase_request", objectId = "#id")
public PurchaseRequest submit(UUID id, String reason) {
PurchaseRequest entity = repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Purchase request " + id + " not found"));
String newState = workflowService.transition(
"purchase_request", id, entity.getState(), "submit", reason);
entity.setState(newState); // core-workflow NO hace esto por vos
return repository.save(entity);
}
}
Reaccionar a una transición
@Component
public class PurchaseRequestNotifier {
private final NotificationService notificationService;
@EventListener
public void onTransition(WorkflowTransitionedEvent event) {
if (!"purchase_request".equals(event.workflowType())) {
return;
}
if ("APPROVED".equals(event.toState())) {
notificationService.send(new NotificationMessage(
NotificationChannel.EMAIL,
resolveRequesterEmail(event.entityId()),
"Tu solicitud fue aprobada",
"purchase-request-approved",
Map.of("requestId", event.entityId())));
}
}
}
Mostrar el historial
@GetMapping("/{id}/history")
@RequiresPermission(relation = "can_view", objectType = "purchase_request", objectId = "#id")
public List<WorkflowTransition> history(@PathVariable UUID id) {
return workflowService.history("purchase_request", id);
}
Viene ordenado por occurredAt ascendente, verificado en
WorkflowTransitionServiceIntegrationTest.historyIsOrderedByWhenEachTransitionHappened.
Qué garantizan los tests
De WorkflowTransitionServiceIntegrationTest:
@Test
void validTransitionReturnsNewStateAndRecordsAnAuditRow() {
UUID entityId = UUID.randomUUID();
String newState = workflowTransitionService.transition(
"purchase_request", entityId, "DRAFT", "submit", "ready for review");
assertThat(newState).isEqualTo("SUBMITTED");
List<WorkflowTransition> history = workflowTransitionService.history("purchase_request", entityId);
assertThat(history).hasSize(1);
WorkflowTransition recorded = history.get(0);
assertThat(recorded.getFromState()).isEqualTo("DRAFT");
assertThat(recorded.getToState()).isEqualTo("SUBMITTED");
assertThat(recorded.getReason()).isEqualTo("ready for review");
assertThat(recorded.getPrincipalType()).isEqualTo("SYSTEM");
}
@Test
void invalidTransitionThrowsAndRecordsNoAuditRow() {
UUID entityId = UUID.randomUUID();
assertThatThrownBy(() -> workflowTransitionService.transition(
"purchase_request", entityId, "DRAFT", "approve", null))
.isInstanceOf(ConflictException.class);
assertThat(workflowTransitionService.history("purchase_request", entityId)).isEmpty();
}
Una transición inválida no deja fila de auditoría. Y de WorkflowDefinitionTest, la garantía de que la
máquina transitoria no filtra estado entre llamadas:
@Test
void eachCallIsIndependentOfPriorCalls() {
assertThat(purchaseRequestWorkflow.targetState("SUBMITTED", "approve")).contains("APPROVED");
assertThat(purchaseRequestWorkflow.targetState("SUBMITTED", "reject")).contains("REJECTED");
assertThat(purchaseRequestWorkflow.targetState("SUBMITTED", "approve")).contains("APPROVED");
}
Errores comunes
IllegalArgumentException: No WorkflowDefinition registered for workflow type 'x'.
El string de workflowType en la llamada tiene que coincidir exacto con el del builder. Como es un
String, no hay chequeo en compilación — conviene declarar constantes.
ConflictException: INVALID_TRANSITION cuando la transición parece válida.
Casi siempre el fromState que pasaste no es el estado real de la entidad, porque una escritura anterior
esquivó el servicio. Es justamente lo que el módulo está para detectar.
ANY_STATE no aplica al estado que esperaba.
Se expande contra los estados conocidos al momento de build(). Si declarás ANY_STATE primero y
después agregás más transiciones, los estados nuevos no quedan cubiertos. Declaralos al final.
El estado quedó desincronizado entre la auditoría y la entidad.
transition() es @Transactional, pero sólo cubre su propia escritura. Si tu método llamador no es
transaccional, podés terminar con la fila de auditoría escrita y la entidad sin actualizar. Marcá el método
de dominio como @Transactional.
El primer estado del builder terminó siendo el inicial sin querer.
El primer fromState que no sea ANY_STATE se toma como estado inicial. En la práctica casi no importa
(cada targetState resetea la máquina explícitamente), pero afecta el build() si el orden deja un
inicial inesperado.
La fila de auditoría dice SYSTEM.
No hay principal en TenantContext — corriendo desde un @Scheduled, un test, o un hilo distinto al de la
request. Es válido y esperado, pero si aparece en producción para una acción de usuario, revisá que la
llamada esté pasando por TenantContextFilter.
Notas de implementación
Diferencias entre platform-core-spec.md y lo que hace el código:
El servicio no persiste el estado en la entidad de dominio. El spec 1.5 describe el módulo como
"máquina de estados genérica" con auditoría y eventos, sin aclarar quién escribe el estado. La
implementación es explícita: no lo hace, devuelve el estado y el llamador lo persiste. Es la misma frontera
que core-auth no siendo dueño de la tabla de usuarios.
Spring StateMachine 4.0.2 corre forzado sobre Spring Framework 7. El POM de
spring-statemachine-core fija spring-context/spring-tx/spring-messaging en 6.2.19 (era Boot
3.4/3.5), mientras el BOM de este proyecto los fuerza a 7.0.8 en todo el build. Es una combinación no
probada río arriba. WorkflowDefinitionTest existe específicamente para verificar empíricamente que el
comportamiento de reset-then-send-event sigue funcionando bajo Spring 7.
No hay guardas ni acciones en las transiciones. El spec no las pide explícitamente, pero Spring
StateMachine las soporta y el builder no las expone: una transición es puramente (fromState, event, toState). Cualquier condición ("sólo se puede aprobar si el monto es menor a X") va en el código de dominio
antes de llamar a transition().