Tech Debt: GraphStateMachineEngine — Direct Session Entity Mutation (No Domain Boundary)
- Status: Resolved (ObjectMapper injection + SessionStatus enum only)
- Resolved: 2026-08-12
Resolution Summary
Two changes applied; remaining issues deferred per PoC trade-off acceptance:
| Change | Detail |
|---|---|
Inject Spring ObjectMapper |
GraphStateMachineEngine now accepts ObjectMapper via constructor injection instead of new ObjectMapper(). Same Spring-managed instance as WorkflowExecutionService. |
SessionStatus enum |
Created com.tuluat.engine.entity.SessionStatus with RUNNING, WAITING_APPROVAL, COMPLETED, FAILED, REJECTED. WorkflowSessionEntity.status uses @Enumerated(EnumType.STRING). All raw session.setStatus("…") strings replaced across engine, service, reconciler, controllers, and tests. |
Deferred
WorkflowExecutionStatevalue object (record) — accepted PoC trade-offcontextDataJSON blob → typed map — accepted PoC trade-off- Severity: Medium
- Module:
tuluat-engine→GraphStateMachineEngine,WorkflowExecutionService
Current State
GraphStateMachineEngine.executeNextStep() receives a WorkflowSessionEntity (a JPA entity) and
mutates it directly in-place:
session.setCurrentNodeId(nextNodeId);
session.setStatus("FAILED");
session.setContextData(writeContext(contextData));
session.setLoopCount(session.getLoopCount() + 1);
The caller WorkflowExecutionService then @Transactional-saves this entity. The engine also
does its own ObjectMapper JSON parsing via a private mapper = new ObjectMapper():
Problems
| Problem | Detail |
|---|---|
| JPA entity as domain model | WorkflowSessionEntity mixes persistence concerns (JPA annotations, @Version, @CreationTimestamp) with workflow execution state. The engine should operate on a plain domain object, not a JPA entity. |
Private ObjectMapper |
GraphStateMachineEngine creates its own new ObjectMapper() instead of injecting the Spring-managed Jackson2ObjectMapperBuilder-configured instance (which has modules like JavaTimeModule, custom serializers, etc.). This can cause silent deserialization differences between session context data and the rest of the app. |
contextData as a stringly-typed JSON blob |
Session context is String contextData = "{}" on the entity, parsed on every node step. No type safety. A typo in currentNode.outputKey() or contextData.put(...) results in a missing key that is silently propagated to the next node. |
| Status strings, not enum | session.setStatus("FAILED") / "WAITING_APPROVAL" / "COMPLETED" — raw strings. One typo produces an inconsistent state that no compiler catches. |
SpEL ExpressionParser is shared and not thread-safe for contexts |
StandardEvaluationContext is created fresh but SpelExpressionParser is shared. Per Spring docs SpelExpressionParser is thread-safe, but StandardEvaluationContext with variable sets is not. Currently safe because context is created per-call, but this pattern is a refactor-trap. |
Code path
WorkflowExecutionService.startSession() // @Transactional
→ GraphStateMachineEngine.executeNextStep() // mutates JPA entity directly
→ parseContext() // ObjectMapper (unmanaged)
→ session.setStatus("FAILED") // raw string
→ session.setContextData(...) // JSON blob back
→ sessionRepository.save(session) // persist mutated entity
Impact
contextDataJSON mismatches are invisible at runtime until a downstream node tries to read a key that was written with a different name.- The private
ObjectMapperskipsJavaTimeModule— anyOffsetDateTimeorZonedDateTimein context data will serialize differently from the main application context. - Refactoring the entity (e.g. adding a column) requires touching the engine logic — violates the separation of persistence and execution concerns.
Proposed Fix
-
Introduce
WorkflowExecutionStatevalue object — a plain record carryingcurrentNodeId,status(enum),contextData(Map<String, Object>), andloopCount. The engine works on this;WorkflowExecutionServicemaps to/from entity. -
Inject
ObjectMapper— removeprivate final ObjectMapper mapper = new ObjectMapper()and accept it as a constructor parameter (already done inWorkflowExecutionService; apply the same to the engine). -
WorkflowSessionStatusenum — already exists intuluat-crd-domain; add a correspondingSessionStatusenum intuluat-engineto eliminate raw strings.
Risk if not addressed
- Silent context-data bugs in complex multi-node workflows will be very hard to trace.
- The
ObjectMapperdivergence will causeOffsetDateTimeserialization bugs when context carries timestamp fields.