feat: initial approval workflow engine with in-memory and JDBC storage

Lightweight linear approval engine (v0.1):

- ordo-api: domain model, OrdoEngine port, repository SPI with
  conditional updates (insertIfAbsent, completeIfPending) and a
  TransactionExecutor port for atomic multi-step writes
- ordo-core: DefaultOrdoEngine running register/start/approve/reject
  inside a transaction boundary; in-memory engine and repositories
- ordo-storage-jdbc: thread-bound JDBC transactions, normalized V1
  schema migration, Jackson-based ProcessContext JSON codec
- tests: unit tests plus H2 integration tests; PostgreSQL integration
  tests run against a local instance via ordo.test.pg.* properties and
  skip when unreachable

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
0264408
2026-09-08 16:56:04 +08:00
co-authored by Claude Code
commit 397a9c229b
58 changed files with 2779 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.jetlumen</groupId>
<artifactId>ordo</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>ordo-api</artifactId>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,16 @@
package com.jetlumen.ordo.api;
/** A single, named approval step in a linear process definition. */
public record ApprovalStep(String id, String name, String assignee) {
public ApprovalStep {
requireText(id, "step id");
requireText(name, "step name");
requireText(assignee, "step assignee");
}
static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
}
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api;
import java.time.Instant;
public record ApprovalTask(String id, String instanceId, String stepId, String name, String assignee,
TaskStatus status, Instant createdAt, Instant completedAt, TaskAction action) {
}
@@ -0,0 +1,11 @@
package com.jetlumen.ordo.api;
/** Resolves the current assignee for an approval step when a task is created. */
@FunctionalInterface
public interface AssigneeResolver {
String resolve(ApprovalStep step, ProcessContext context);
static AssigneeResolver direct() {
return (step, context) -> step.assignee();
}
}
@@ -0,0 +1,26 @@
package com.jetlumen.ordo.api;
import java.util.List;
import java.util.Optional;
/** Public entry point for definition registration and approval operations. */
public interface OrdoEngine {
void register(ProcessDefinition definition);
default ProcessInstance start(String definitionId, String initiator) {
return start(definitionId, initiator, ProcessContext.empty());
}
ProcessInstance start(String definitionId, String initiator, ProcessContext context);
default ApprovalTask approve(String taskId, String actor) {
return approve(taskId, actor, null);
}
ApprovalTask approve(String taskId, String actor, String comment);
default ApprovalTask reject(String taskId, String actor) {
return reject(taskId, actor, null);
}
ApprovalTask reject(String taskId, String actor, String comment);
Optional<ProcessInstance> findInstance(String instanceId);
Optional<ApprovalTask> findTask(String taskId);
List<ApprovalTask> findTasks(String instanceId);
List<ApprovalTask> findPendingTasksByAssignee(String assignee);
List<ApprovalTask> findPendingTasksByInstanceId(String instanceId);
}
@@ -0,0 +1,20 @@
package com.jetlumen.ordo.api;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Immutable business data available while a process instance is running. */
public record ProcessContext(Map<String, Object> variables) {
public ProcessContext {
variables = Map.copyOf(Objects.requireNonNull(variables, "variables must not be null"));
}
public static ProcessContext empty() {
return new ProcessContext(Map.of());
}
public Optional<Object> value(String name) {
return Optional.ofNullable(variables.get(name));
}
}
@@ -0,0 +1,23 @@
package com.jetlumen.ordo.api;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Immutable blueprint for a linear approval process. */
public record ProcessDefinition(String id, String name, List<ApprovalStep> steps) {
public ProcessDefinition {
ApprovalStep.requireText(id, "definition id");
ApprovalStep.requireText(name, "definition name");
steps = List.copyOf(steps);
if (steps.isEmpty()) {
throw new IllegalArgumentException("a definition must contain at least one approval step");
}
Set<String> ids = new HashSet<>();
for (ApprovalStep step : steps) {
if (!ids.add(step.id())) {
throw new IllegalArgumentException("duplicate step id: " + step.id());
}
}
}
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api;
import java.time.Instant;
public record ProcessInstance(String id, String definitionId, String initiator, ProcessStatus status,
Instant startedAt, Instant finishedAt, ProcessContext context) {
}
@@ -0,0 +1,5 @@
package com.jetlumen.ordo.api;
public enum ProcessStatus {
RUNNING, APPROVED, REJECTED
}
@@ -0,0 +1,15 @@
package com.jetlumen.ordo.api;
import java.time.Instant;
import java.util.Objects;
/** Immutable audit record created when an approval task is completed. */
public record TaskAction(String actor, String comment, Instant operatedAt) {
public TaskAction {
if (actor == null || actor.isBlank()) {
throw new IllegalArgumentException("actor must not be blank");
}
comment = comment == null || comment.isBlank() ? null : comment.strip();
Objects.requireNonNull(operatedAt, "operatedAt must not be null");
}
}
@@ -0,0 +1,5 @@
package com.jetlumen.ordo.api;
public enum TaskStatus {
PENDING, APPROVED, REJECTED
}
@@ -0,0 +1,13 @@
package com.jetlumen.ordo.api;
import java.util.function.Supplier;
/**
* Port for the transaction boundary around multistep write operations.
* Implementations either run the action as-is (in-memory storage) or commit
* and roll back atomically (JDBC storage).
*/
@FunctionalInterface
public interface TransactionExecutor {
<T> T execute(Supplier<T> action);
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api.exception;
public final class DefinitionAlreadyExistsException extends OrdoException {
public DefinitionAlreadyExistsException(String definitionId) {
super("definition already exists: " + definitionId);
}
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api.exception;
public final class DefinitionNotFoundException extends OrdoException {
public DefinitionNotFoundException(String definitionId) {
super("definition not found: " + definitionId);
}
}
@@ -0,0 +1,12 @@
package com.jetlumen.ordo.api.exception;
/** Base type for business-rule violations raised by the Ordo runtime. */
public class OrdoException extends RuntimeException {
public OrdoException(String message) {
super(message);
}
public OrdoException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api.exception;
public final class TaskAlreadyCompletedException extends OrdoException {
public TaskAlreadyCompletedException(String taskId) {
super("task is already completed: " + taskId);
}
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api.exception;
public final class TaskNotFoundException extends OrdoException {
public TaskNotFoundException(String taskId) {
super("task not found: " + taskId);
}
}
@@ -0,0 +1,7 @@
package com.jetlumen.ordo.api.exception;
public final class UnauthorizedTaskOperationException extends OrdoException {
public UnauthorizedTaskOperationException(String taskId, String actor) {
super("actor '" + actor + "' is not the assignee for task: " + taskId);
}
}
@@ -0,0 +1,27 @@
package com.jetlumen.ordo.api.repository;
import com.jetlumen.ordo.api.ApprovalTask;
import java.util.List;
import java.util.Optional;
/** Storage port for approval tasks and their pending-task indexes. */
public interface ApprovalTaskRepository {
/** Inserts a new task. */
void save(ApprovalTask task);
Optional<ApprovalTask> findById(String taskId);
List<ApprovalTask> findByInstanceId(String instanceId);
List<ApprovalTask> findPendingByAssignee(String assignee);
List<ApprovalTask> findPendingByInstanceId(String instanceId);
/**
* Atomically completes the task only if it is still pending.
*
* @return true if the update was applied, false if the task had already been completed by a concurrent operation
*/
boolean completeIfPending(ApprovalTask completedTask);
}
@@ -0,0 +1,17 @@
package com.jetlumen.ordo.api.repository;
import com.jetlumen.ordo.api.ProcessDefinition;
import java.util.Optional;
/** Storage port for immutable process definitions. */
public interface ProcessDefinitionRepository {
/**
* Inserts the definition if no definition with the same id exists.
*
* @return true if the definition was inserted, false if a definition with the same id already exists
*/
boolean insertIfAbsent(ProcessDefinition definition);
Optional<ProcessDefinition> findById(String definitionId);
}
@@ -0,0 +1,16 @@
package com.jetlumen.ordo.api.repository;
import com.jetlumen.ordo.api.ProcessInstance;
import java.util.Optional;
/** Storage port for process instances. */
public interface ProcessInstanceRepository {
/** Inserts a new instance; fails if the id already exists. */
void insert(ProcessInstance instance);
/** Replaces the stored instance with the same id, e.g. when the process reaches a terminal status. */
void update(ProcessInstance instance);
Optional<ProcessInstance> findById(String instanceId);
}
@@ -0,0 +1,50 @@
package com.jetlumen.ordo.api;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ProcessDefinitionTest {
@Test
void rejectsBlankStepFields() {
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep(" ", "Manager", "maria"));
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", " ", "maria"));
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", "Manager", " "));
}
@Test
void rejectsDefinitionsWithoutStepsOrWithDuplicateStepIds() {
assertThrows(IllegalArgumentException.class,
() -> new ProcessDefinition("leave", "Leave request", List.of()));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", "maria"),
new ApprovalStep("manager", "HR approval", "henry")
)));
}
@Test
void rejectsBlankDefinitionFields() {
List<ApprovalStep> steps = List.of(new ApprovalStep("manager", "Manager approval", "maria"));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition(" ", "Leave request", steps));
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", " ", steps));
}
@Test
void copiesTheSuppliedStepList() {
List<ApprovalStep> suppliedSteps = new ArrayList<>();
suppliedSteps.add(new ApprovalStep("manager", "Manager approval", "maria"));
ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", suppliedSteps);
suppliedSteps.add(new ApprovalStep("hr", "HR approval", "henry"));
assertEquals(1, definition.steps().size());
assertThrows(UnsupportedOperationException.class,
() -> definition.steps().add(new ApprovalStep("lead", "Lead approval", "lee")));
}
}