Skip to main content

Online edition for BLOGE 0.9.8-RC1 · facts verified 2026-09-15 · 中文

Chapter 20 — Spring and Production Wiring

Promise: By the end of this chapter you will be able to start one BLOGE graph through Spring Boot with no manual engine bean, read the health result, and apply one deliberate GraphEngineCustomizer override without taking ownership of the whole auto-configuration graph.


Learning Goals

  1. Add the bloge-spring dependency and let BlogeAutoConfiguration wire a GraphEngine, OperatorRegistry, GraphLoader, and compiled List<Graph> with zero manual bean definitions.
  2. Annotate runtime Operator, SuspendableOperator, or StreamingOperator implementations with @BlogeOperator so Spring discovers and registers them.
  3. Inject GraphEngine and the compiled graph list into a service class, execute a graph, and return results from an HTTP endpoint.
  4. Read UP + graphCount=1 as a wiring observation, not as business proof.
  5. Apply one lightweight-engine override with GraphEngineCustomizer, and know when replacing a bean would create unnecessary ownership.

Prerequisites

  • Chapter 7 — Designing Good Operators — you need the operator design model before wiring operators into Spring.
  • Chapter 13 — Durable Execution — runtime stores, checkpoints, and suspend/resume, since the starter conditionally wires all of them.
  • Familiarity with Spring Boot auto-configuration, @ConfigurationProperties, and Spring Boot Actuator basics.

Source Examples

FileWhat it shows
BlogeAutoConfiguration.javaEntry auto-configuration that imports the core slice
BlogeProperties.javaAll spring.bloge.* configuration properties and nested groups
BlogeOperator.javaThe @Component-composed annotation for operator scanning
BlogeObservabilityAutoConfiguration.javaMetrics, tracing, MDC, health indicator wiring
BlogeEndpointAutoConfiguration.java/actuator/bloge and /actuator/blogeStats endpoints
BlogeAuditAutoConfiguration.javaAudit journal listener activation
SessionExecutorAutoConfiguration.javaSessionExecutor wiring with access guards and listeners
spring-ticket-triage.blogeMinimal DSL loaded by the starter example
ch19/loan-approval.blogeLoan approval graph wired with Spring operators
SpringBootTicketTriageApplication.javaEnd-to-end Spring Boot application using the starter
SpringTicketTriageService.javaService layer injecting GraphEngine and List<Graph>

Why This Matters

In earlier chapters you built engines by hand:

var engine = GraphEngine.builder()
.registry(registry)
.executionCheckpointStore(checkpointStore)
.listeners(listeners)
.build();

That's fine for tests and learning. In production, the wiring list grows: interceptors, listeners, context carriers, durable stores, checkpoint codecs, recovery config, shard resolvers, timer services, event deduplication — and every bean has conditions and fallbacks.

The bloge-spring module addresses this assembly work. It is a Spring Boot Starter that auto-configures components whose classpath and property conditions are satisfied. Declaring operators as Spring beans and placing .bloge files on the classpath lets the starter assemble a runnable engine for that application slice. Adding bloge-durable or bloge-metrics-otel lets the starter detect and wire their conditional beans without repeated @Bean declarations. This establishes application wiring; it does not prove that durable stores, external effects, or production capacity are ready.

Key insight: The starter doesn't hide the engine — it assembles it. Most replaceable singleton defaults use @ConditionalOnMissingBean; extension collections and endpoint slices follow their own conditions, so confirm the target bean's contract before overriding it.


The Shortest Wiring Path

Do not begin with every optional production feature. Begin with one vertical slice: one dependency, one annotated operator, one classpath DSL, one injected executor, and one health observation.

Diagram: shortest Spring wiring path

You ownThe starter ownsObservable check
@BlogeOperator bean implementing a runtime operator contractOperatorRegistryoperator name resolves during DSL load
classpath:/bloge/*.blogeGraphLoader and compiled List<Graph>expected graph is present
service input and outputlightweight GraphEngine / GraphExecutorone execution returns the expected node facts
deployment dependenciesBlogeHealthIndicator when Actuator is presentUP, graphCount=1

UP here means the application loaded at least one graph. It does not mean an order Scenario passed, an external payment system is healthy, or a release is qualified.


Mental Model

Think of bloge-spring as a layered assembly line for beans. Each layer activates only when its classpath and property prerequisites are met:

Diagram: 20-spring-and-production-wiring figure 1

Layers 2–6 are additive and independent. You never need all of them at once, and removing a classpath dependency removes the layer cleanly.


First Working Example

This is the ticket-triage example from bloge-examples/src/main/java/com/leanowtech/bloge/examples/integration/spring/.

Step 1 — Add the dependency

<dependency>
<groupId>com.leanowtech.bloge</groupId>
<artifactId>bloge-spring</artifactId>
<version>${bloge.version}</version>
</dependency>

Step 2 — Write an operator as a Spring bean

Simplified from SpringTicketClassifierOperator.java:

@BlogeOperator(
value = "SpringTicketClassifierOperator",
description = "Classifies a support ticket",
owner = "examples",
tags = {"spring", "starter", "triage"}
)
public class SpringTicketClassifierOperator implements
Operator<Map<String, Object>, ClassifiedTicket> {

public record ClassifiedTicket(String queue, int priorityScore, boolean vip) {}

@Override
public ClassifiedTicket execute(Map<String, Object> input, OperatorContext ctx) {
boolean vip = "vip".equalsIgnoreCase(String.valueOf(input.get("customerTier")));
String queue = vip ? "vip-escalation" : "general-support";
int priorityScore = vip ? 70 : 30;
return new ClassifiedTicket(queue, priorityScore, vip);
}
}

@BlogeOperator is composed from @Component, so Spring discovers the bean. The registry then accepts it only when the bean implements Operator, SuspendableOperator, or StreamingOperator. The value attribute sets the DSL operator name. The repository example deliberately keeps its domain beans plain and supplies adapters through an explicit OperatorRegistry; the compact version above chooses the zero-manual-registry path instead.

Step 3 — Place the DSL on the classpath

From spring-ticket-triage.bloge:

graph springTicketTriage {
node classifyTicket : SpringTicketClassifierOperator {
input {
ticketId = ctx.ticketId
message = ctx.message
customerTier = ctx.customerTier
}
timeout = 2s
}

node draftReply : SpringReplyDraftOperator {
depends_on = [classifyTicket]
input {
ticketId = ctx.ticketId
queue = classifyTicket.output.queue
priorityScore = classifyTicket.output.priorityScore
vip = classifyTicket.output.vip
}
timeout = 2s
}
}

The starter's default DSL location is classpath:bloge/. Place this file at src/main/resources/bloge/spring-ticket-triage.bloge and it will be loaded automatically.

Step 4 — Inject and execute

From SpringTicketTriageService.java:

@Service
public class SpringTicketTriageService {

private final GraphEngine engine;
private final Map<String, Graph> graphsByName;

public SpringTicketTriageService(GraphEngine engine, List<Graph> graphs) {
this.engine = engine;
this.graphsByName = graphs.stream()
.collect(Collectors.toUnmodifiableMap(Graph::name, Function.identity()));
}

public TicketTriageResponse triage(String ticketId, String message, String customerTier) {
Graph graph = graphsByName.get("springTicketTriage");
var result = engine.execute(graph, new GraphContext(Map.of(
"ticketId", ticketId,
"message", message,
"customerTier", customerTier
)));
return /* map result to response */;
}
}

No GraphEngine.builder(). No DefaultOperatorRegistry. No GraphLoader calls. The starter assembled all of it.

Step 5 — Expose via HTTP

From SpringTicketTriageController.java:

@RestController
@RequestMapping("/api/bloge/tickets")
public class SpringTicketTriageController {

private final SpringTicketTriageService triageService;

public SpringTicketTriageController(SpringTicketTriageService triageService) {
this.triageService = triageService;
}

@GetMapping("/triage")
public TicketTriageResponse triage(
@RequestParam("ticketId") String ticketId,
@RequestParam("message") String message,
@RequestParam(name = "customerTier", defaultValue = "standard") String customerTier) {
return triageService.triage(ticketId, message, customerTier);
}
}

One Override, One Explicit Owner

Suppose the default lightweight engine is correct except for one scheduler integration. Keep every starter-owned bean and customize only the builder seam:

@Bean
GraphEngineCustomizer schedulerOverride(MyTimerSupport timerSupport) {
return builder -> builder.schedulerTimerSupport(timerSupport);
}

Diagram: one Spring override without replacing the engine

The customizer runs after the lightweight engine-mode preset and before the core GraphEngine is built. It does not configure the durable runtime path. If you replace the whole GraphEngine bean, you also accept responsibility for its listeners, interceptors, context carriers, and future starter defaults.

The remainder of this chapter is a production reference. The first-read path is complete once the minimal slice, health boundary, and single override are clear.


Production Reference

The shortest path above is enough to understand ownership. When you need the complete bean inventory, property groups, durable-store wiring, health checks, or HTTP error contract, continue with Appendix I — Spring Production Reference.

Common Trap

❌ Registering operators manually when @BlogeOperator already exists

// DON'T do this if you also annotate the class with @BlogeOperator:
@Bean
public OperatorRegistry operatorRegistry() {
var registry = new DefaultOperatorRegistry();
registry.register("FetchUserOperator", new FetchUserOperator(userService));
return registry;
}

Because BlogeAutoConfiguration provides an OperatorRegistry bean that scans for @BlogeOperator-annotated beans, and its bean is @ConditionalOnMissingBean, declaring your own OperatorRegistry bean replaces the auto-configured one entirely. Your custom registry won't scan for @BlogeOperator beans — so all annotated operators silently disappear.

Fix: Either rely entirely on @BlogeOperator scanning (the normal path), or provide a complete OperatorRegistry bean that includes everything you need. Don't mix both approaches.


What Goes Wrong

Silent operator disappearance

You annotate a class with @BlogeOperator("MyOperator") but also define a custom OperatorRegistry bean:

@Bean
public OperatorRegistry operatorRegistry() {
return new DefaultOperatorRegistry(); // empty — no scanning
}

The auto-configured scanning registry is skipped because your bean exists. At runtime:

GraphDefinitionException: Node 'doWork' references unregistered operator 'MyOperator'

Fix: Either rely on auto-scanning (remove the custom @Bean) or populate your custom registry with all required operators.


One Production Extension at a Time

Add observability, audit, recovery, or an interceptor only when a stated operational need requires it. Appendix I provides the configuration map and a deployment checklist; keep this chapter's design rule: each override must have one reason and one owner.

Brain Check

  1. What is required for automatic operator registration? (Answer: the bean is marked with @BlogeOperator and implements Operator, SuspendableOperator, or StreamingOperator; the annotation alone only guarantees Spring component discovery.)

  2. Where does the starter look for .bloge files by default, and which property overrides this? (Answer: classpath:bloge/, configurable via spring.bloge.dsl-locations.)

  3. If you declare your own OperatorRegistry bean, what happens to the auto-configured one? (Answer: it is skipped — @ConditionalOnMissingBean means your bean wins, and automatic @BlogeOperator scanning no longer occurs.)

  4. Name two production features that activate purely by adding a classpath dependency, with no property changes. (Answer: Actuator endpoints activate when spring-boot-actuator is present; observability metrics and tracing activate when bloge-metrics-otel is present.)

  5. What does BlogeHealthIndicator report when no .bloge files are found? (Answer: DOWN with detail "reason": "No graphs loaded".)

  6. Design question: Your team uses three separate Spring Boot microservices, each with its own bloge-spring dependency and different graphs. Should each service have its own GraphEngine instance, or should they share one central engine? (Each service should have its own GraphEngine. The engine is stateless and lightweight — there is no benefit to centralising it. Sharing a database for durable stores is fine if the services need cross-service visibility, but the engines themselves are independent.)


Lab

Starting from a fresh Spring Boot application:

  1. Add bloge-spring to your POM and create one Operator implementation annotated with @BlogeOperator that returns a greeting string.

  2. Write a one-node .bloge graph in src/main/resources/bloge/ that references your operator.

  3. Create a @Service that injects GraphEngine and List<Graph>, looks up your graph by name, and executes it.

  4. Verify the health indicator: start the application and hit /actuator/health. Confirm the bloge component shows UP with your graph name.

  5. Break it intentionally: rename the .bloge file so no graphs load. Restart and verify the health indicator reports DOWN.

  6. Add a custom ExecutionListener bean that logs graph-level completion times. Execute your graph again and confirm the listener fires without any changes to the engine bean.


Experiment acceptance card

  • Expected and observed: The starter wires engine, loader, and health with one owner per customizer.
  • Failure and recovery: Remove a bean or duplicate an override; recover minimal auto-configuration.
  • Proof boundary: Proves Spring wiring and health, not production capacity or business correctness.
  • Exercise contract: Minimal app; change one bean/customizer; deliver context and health; stop with successful startup and one owner.

Recap

  • bloge-spring is a Spring Boot Starter that can auto-configure GraphEngine, OperatorRegistry, GraphLoader, and compiled graphs when their classpath, property, and bean conditions are satisfied.
  • @BlogeOperator is a @Component-composed annotation. Annotated beans are registered automatically only when they implement one of BLOGE's runtime operator contracts; plain domain beans need an adapter/customizer path. Its promptHint, usageExample, and constraintsDescription attributes support agent/LLM integration.
  • The starter uses layered, conditional auto-configuration: core beans are present inside an active starter context; durable stores activate when bloge-durable + DataSource exist; observability activates when bloge-metrics-otel exists; audit and recovery activate when their respective properties are set to true.
  • The auto-configuration is split into focused slices (BlogeCoreAutoConfiguration, BlogeGraphEngineAutoConfiguration, BlogeDurableAutoConfiguration, etc.) for cleaner separation.
  • New spring.bloge.engine-mode property selects engine presets (AUTO or REQUEST_RESPONSE).
  • New spring.bloge.tenant.* properties enable auto-configured multi-tenant support — tenant resolution, namespace resolution, concurrency caps, and rate limits — without writing a custom TenantContextResolver bean.
  • New spring.bloge.version-routing.* properties support latest, pinned, and canary graph version routing at the starter level.
  • GraphEngineCustomizer lets you fine-tune the engine builder without replacing the entire bean.
  • Most replaceable singleton defaults are guarded by @ConditionalOnMissingBean; inspect the specific slice before overriding it.
  • Custom OperatorInterceptor, ExecutionListener, and CallerContextCarrier beans are collected automatically via ObjectProvider and injected into the engine.
  • Actuator endpoints (/actuator/bloge, /actuator/blogeStats) give runtime visibility into loaded graphs and execution metrics.
  • BlogeHealthIndicator provides /actuator/health integration out of the box.

Next Step

In Chapter 21 — Scheduling and Complexity in One JVM, you will isolate the local scheduling mechanism: ready sets, completion events, and graph-complexity guards. Distributed ownership and capacity remain in Chapter 22.


Coding Agent: Open the versioned task guide.