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:
@@ -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);
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+16
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user