another test state machine

This commit is contained in:
2026-06-28 11:42:18 +02:00
parent 20b4ed780b
commit 16dccbf81b
39 changed files with 889 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package click.kamil.enterprise;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class EnterpriseStateMachineApplication {
public static void main(String[] args) {
SpringApplication.run(EnterpriseStateMachineApplication.class, args);
}
}

View File

@@ -0,0 +1,50 @@
package click.kamil.enterprise.core;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public abstract class AbstractStateMachine<S extends Enum<S> & EnterpriseState, E extends Enum<E> & EnterpriseEvent> {
private S currentState;
private final Map<E, S> transitions = new ConcurrentHashMap<>();
protected AbstractStateMachine(S initialState) {
this.currentState = initialState;
configureTransitions();
}
public abstract String getMachineType();
protected abstract void configureTransitions();
protected void addTransition(E event, S targetState) {
transitions.put(event, targetState);
}
public synchronized void trigger(E event, TransitionContext<?> context) {
S target = transitions.get(event);
if (target == null) {
throw new IllegalStateException("Invalid event " + event + " for state " + currentState);
}
executePreChecks(currentState, target, context);
S oldState = currentState;
currentState = target;
onTransition(oldState, currentState, event, context);
}
protected void executePreChecks(S from, S to, TransitionContext<?> context) {
// Parent checking logic
System.out.println("[Abstract] Executing pre-checks from " + from.name() + " to " + to.name() + " by user " + context.getUserId());
}
protected abstract void onTransition(S from, S to, E event, TransitionContext<?> context);
public S getCurrentState() {
return currentState;
}
public Class<E> getEventType() {
return (Class<E>) transitions.keySet().iterator().next().getClass();
}
}

View File

@@ -0,0 +1,25 @@
package click.kamil.enterprise.core;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
public abstract class AbstractStateMachineConfiguration<S extends Enum<S> & EnterpriseState, E extends Enum<E> & EnterpriseEvent> extends EnumStateMachineConfigurerAdapter<S, E> {
@Override
public void configure(StateMachineStateConfigurer<S, E> states) throws Exception {
configureStates(states);
}
@Override
public void configure(StateMachineTransitionConfigurer<S, E> transitions) throws Exception {
configureTransitions(transitions);
}
protected abstract void configureStates(StateMachineStateConfigurer<S, E> states) throws Exception;
protected abstract void configureTransitions(StateMachineTransitionConfigurer<S, E> transitions) throws Exception;
protected void commonProtectedMethod() {
System.out.println("Common protected method called in AbstractStateMachineConfiguration.");
}
}

View File

@@ -0,0 +1,5 @@
package click.kamil.enterprise.core;
public interface EnterpriseEvent {
String name();
}

View File

@@ -0,0 +1,5 @@
package click.kamil.enterprise.core;
public interface EnterpriseState {
String name();
}

View File

@@ -0,0 +1,17 @@
package click.kamil.enterprise.core;
import lombok.Data;
import java.time.Instant;
@Data
public class TransitionContext<T> {
private final String userId;
private final Instant timestamp;
private final T payload;
public TransitionContext(String userId, T payload) {
this.userId = userId;
this.timestamp = Instant.now();
this.payload = payload;
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.enterprise.machines.document;
import click.kamil.enterprise.core.EnterpriseEvent;
public enum DocumentEvent implements EnterpriseEvent {
SUBMIT, APPROVE, REJECT;
}

View File

@@ -0,0 +1,7 @@
package click.kamil.enterprise.machines.document;
import click.kamil.enterprise.core.EnterpriseState;
public enum DocumentState implements EnterpriseState {
DRAFT, IN_REVIEW, APPROVED;
}

View File

@@ -0,0 +1,30 @@
package click.kamil.enterprise.machines.document;
import click.kamil.enterprise.core.AbstractStateMachine;
import click.kamil.enterprise.core.TransitionContext;
import org.springframework.stereotype.Component;
@Component
public class DocumentStateMachine extends AbstractStateMachine<DocumentState, DocumentEvent> {
public DocumentStateMachine() {
super(DocumentState.DRAFT);
}
@Override
public String getMachineType() {
return "DOCUMENT";
}
@Override
protected void configureTransitions() {
addTransition(DocumentEvent.SUBMIT, DocumentState.IN_REVIEW);
addTransition(DocumentEvent.APPROVE, DocumentState.APPROVED);
addTransition(DocumentEvent.REJECT, DocumentState.DRAFT);
}
@Override
protected void onTransition(DocumentState from, DocumentState to, DocumentEvent event, TransitionContext<?> context) {
System.out.println("[Document] Document modified by " + context.getUserId() + ". Now " + to);
}
}

View File

@@ -0,0 +1,35 @@
package click.kamil.enterprise.machines.document;
import click.kamil.enterprise.core.AbstractStateMachineConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachineFactory;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachineFactory(name = "documentStateMachineFactory")
public class DocumentStateMachineConfiguration extends AbstractStateMachineConfiguration<DocumentState, DocumentEvent> {
@Override
protected void configureStates(StateMachineStateConfigurer<DocumentState, DocumentEvent> states) throws Exception {
states
.withStates()
.initial(DocumentState.DRAFT)
.state(DocumentState.IN_REVIEW)
.end(DocumentState.APPROVED);
}
@Override
protected void configureTransitions(StateMachineTransitionConfigurer<DocumentState, DocumentEvent> transitions) throws Exception {
commonProtectedMethod();
transitions
.withExternal()
.source(DocumentState.DRAFT).target(DocumentState.IN_REVIEW).event(DocumentEvent.SUBMIT)
.and()
.withExternal()
.source(DocumentState.IN_REVIEW).target(DocumentState.APPROVED).event(DocumentEvent.APPROVE)
.and()
.withExternal()
.source(DocumentState.IN_REVIEW).target(DocumentState.DRAFT).event(DocumentEvent.REJECT);
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.enterprise.machines.order;
import click.kamil.enterprise.core.EnterpriseEvent;
public enum OrderEvent implements EnterpriseEvent {
PAY, SHIP;
}

View File

@@ -0,0 +1,7 @@
package click.kamil.enterprise.machines.order;
import click.kamil.enterprise.core.EnterpriseState;
public enum OrderState implements EnterpriseState {
NEW, PENDING, SHIPPED;
}

View File

@@ -0,0 +1,38 @@
package click.kamil.enterprise.machines.order;
import click.kamil.enterprise.core.AbstractStateMachine;
import click.kamil.enterprise.core.TransitionContext;
import org.springframework.stereotype.Component;
@Component
public class OrderStateMachine extends AbstractStateMachine<OrderState, OrderEvent> {
public OrderStateMachine() {
super(OrderState.NEW);
}
@Override
public String getMachineType() {
return "ORDER";
}
@Override
protected void configureTransitions() {
addTransition(OrderEvent.PAY, OrderState.PENDING);
addTransition(OrderEvent.SHIP, OrderState.SHIPPED);
}
@Override
protected void executePreChecks(OrderState from, OrderState to, TransitionContext<?> context) {
super.executePreChecks(from, to, context);
if (to == OrderState.SHIPPED && from != OrderState.PENDING) {
throw new IllegalStateException("Must be PENDING to ship!");
}
System.out.println("[Order] Specific pre-checks passed for order.");
}
@Override
protected void onTransition(OrderState from, OrderState to, OrderEvent event, TransitionContext<?> context) {
System.out.println("[Order] Transitioned from " + from + " to " + to + " via " + event);
}
}

View File

@@ -0,0 +1,33 @@
package click.kamil.enterprise.machines.order;
import click.kamil.enterprise.core.AbstractStateMachineConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachineFactory;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachineFactory(name = "orderStateMachineFactory")
public class OrderStateMachineConfiguration extends AbstractStateMachineConfiguration<OrderState, OrderEvent> {
@Override
protected void configureStates(StateMachineStateConfigurer<OrderState, OrderEvent> states) throws Exception {
states
.withStates()
.initial(OrderState.NEW)
.state(OrderState.PENDING)
.end(OrderState.SHIPPED);
}
@Override
protected void configureTransitions(StateMachineTransitionConfigurer<OrderState, OrderEvent> transitions) throws Exception {
commonProtectedMethod();
transitions
.withExternal()
.source(OrderState.NEW).target(OrderState.PENDING).event(OrderEvent.PAY)
.action(context -> System.out.println("Payment processed."))
.and()
.withExternal()
.source(OrderState.PENDING).target(OrderState.SHIPPED).event(OrderEvent.SHIP);
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.enterprise.machines.user;
import click.kamil.enterprise.core.EnterpriseEvent;
public enum UserEvent implements EnterpriseEvent {
REGISTER, VERIFY;
}

View File

@@ -0,0 +1,7 @@
package click.kamil.enterprise.machines.user;
import click.kamil.enterprise.core.EnterpriseState;
public enum UserState implements EnterpriseState {
GUEST, REGISTERED, VERIFIED;
}

View File

@@ -0,0 +1,29 @@
package click.kamil.enterprise.machines.user;
import click.kamil.enterprise.core.AbstractStateMachine;
import click.kamil.enterprise.core.TransitionContext;
import org.springframework.stereotype.Component;
@Component
public class UserStateMachine extends AbstractStateMachine<UserState, UserEvent> {
public UserStateMachine() {
super(UserState.GUEST);
}
@Override
public String getMachineType() {
return "USER";
}
@Override
protected void configureTransitions() {
addTransition(UserEvent.REGISTER, UserState.REGISTERED);
addTransition(UserEvent.VERIFY, UserState.VERIFIED);
}
@Override
protected void onTransition(UserState from, UserState to, UserEvent event, TransitionContext<?> context) {
System.out.println("[User] " + context.getUserId() + " is now " + to);
}
}

View File

@@ -0,0 +1,32 @@
package click.kamil.enterprise.machines.user;
import click.kamil.enterprise.core.AbstractStateMachineConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachineFactory;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachineFactory(name = "userStateMachineFactory")
public class UserStateMachineConfiguration extends AbstractStateMachineConfiguration<UserState, UserEvent> {
@Override
protected void configureStates(StateMachineStateConfigurer<UserState, UserEvent> states) throws Exception {
states
.withStates()
.initial(UserState.GUEST)
.state(UserState.REGISTERED)
.end(UserState.VERIFIED);
}
@Override
protected void configureTransitions(StateMachineTransitionConfigurer<UserState, UserEvent> transitions) throws Exception {
commonProtectedMethod();
transitions
.withExternal()
.source(UserState.GUEST).target(UserState.REGISTERED).event(UserEvent.REGISTER)
.and()
.withExternal()
.source(UserState.REGISTERED).target(UserState.VERIFIED).event(UserEvent.VERIFY);
}
}

View File

@@ -0,0 +1,31 @@
package click.kamil.enterprise.web;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/machine")
public class StateMachineController {
private final StateMachineDispatcher dispatcher;
@Autowired
public StateMachineController(StateMachineDispatcher dispatcher) {
this.dispatcher = dispatcher;
}
@PostMapping("/{machineType}/transition/{event}")
public ResponseEntity<String> transition(
@PathVariable String machineType,
@PathVariable String event,
@RequestParam(defaultValue = "system-user") String userId) {
try {
String result = dispatcher.dispatch(machineType, event, userId, null);
return ResponseEntity.ok(result);
} catch (Exception e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}

View File

@@ -0,0 +1,45 @@
package click.kamil.enterprise.web;
import click.kamil.enterprise.core.AbstractStateMachine;
import click.kamil.enterprise.core.EnterpriseEvent;
import click.kamil.enterprise.core.TransitionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class StateMachineDispatcher {
private final Map<String, AbstractStateMachine<?, ?>> registry = new HashMap<>();
@Autowired
public StateMachineDispatcher(List<AbstractStateMachine<?, ?>> machines) {
for (AbstractStateMachine<?, ?> machine : machines) {
registry.put(machine.getMachineType().toUpperCase(), machine);
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
public String dispatch(String machineType, String eventString, String userId, Object payload) {
AbstractStateMachine machine = registry.get(machineType.toUpperCase());
if (machine == null) {
throw new IllegalArgumentException("Unknown machine type: " + machineType);
}
Class<? extends Enum> eventType = (Class<? extends Enum>) machine.getEventType();
Enum eventEnum;
try {
eventEnum = Enum.valueOf(eventType, eventString.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid event " + eventString + " for machine " + machineType);
}
TransitionContext<Object> context = new TransitionContext<>(userId, payload);
machine.trigger((EnterpriseEvent) eventEnum, context);
return "Triggered " + eventEnum.name() + " on " + machineType + ". New State: " + machine.getCurrentState().name();
}
}