Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 7 — Designing Good Operators
Promise: By the end of this chapter you will know how to decide what belongs inside an operator, how to declare its schema and behavioural contracts, and how to avoid the most common design mistake — turning every piece of logic into a node.
Learning Goals
- Apply the Single Capability Principle to decide whether a piece of logic deserves to be an operator or should stay in the orchestration layer.
- Classify an operator into the three-layer reuse pyramid: Infrastructure → Capability → Domain.
- Declare input/output schemas so the compiler, tooling, and downstream nodes can validate data more safely.
- Set the idempotency and side-effect contracts that help the framework make safer retry and scheduling decisions.
- Recognise — and eliminate — trivial data-shaping operators that should be transforms or input-binding expressions.
Prerequisites
- Chapter 4 — Data That Flows (especially transforms and the data-transformation layer model)
- Chapter 6 — Resilience by Design for how node contracts influence retry and timeout choices
Source Examples
| File | What it shows |
|---|---|
Operator.java | The core @FunctionalInterface — execute, idempotency, sideEffectType |
OperatorContext.java | Read-only record the engine passes to every operator |
SchemaAware.java | Optional interface for explicit input/output schema declarations |
Idempotency.java | IDEMPOTENT, NOT_IDEMPOTENT, UNKNOWN |
SideEffectType.java | READ_ONLY, WRITE, EXTERNAL_CALL, MIXED |
OperatorMeta.java | Annotation for layer, tags, version, owner |
OperatorLayer.java | INFRASTRUCTURE, CAPABILITY, DOMAIN |
Operator Design Specification | Full normative spec (eight chapters) |
Why This Matters
The previous chapters taught you how to build graphs and move data. But the quality of a BLOGE system ultimately depends on the quality of its operators — the building blocks every graph is made of.
Get operator boundaries wrong and you'll see:
- Bloated graphs where half the nodes are one-line field renames that could be a transform.
- Monolith operators that bundle three independent I/O calls and are impossible to retry or observe individually.
- Opaque contracts where nobody knows what an operator's output looks like until they read its source.
Get them right and you unlock:
- Reuse — a well-scoped operator can serve dozens of graphs.
- Observability — each operator has its own latency, error-rate, and retry metrics.
- Safe evolution — typed schemas make breaking changes visible at compile time, not at 2 AM.
Mental Model
The Single Capability Principle
One operator should encapsulate one independently measurable business capability. Ask five questions:
The four layers of data transformation
Not every piece of logic belongs in an operator. BLOGE offers four layers, each with different cost:
| Layer | Tool | Cost | When to use |
|---|---|---|---|
| Routing | input { userId = fetchUser.output.id } | Zero | Passing or renaming a field |
| Light transform | input { name = concat(a.output.first, " ", a.output.last) } | Zero | Concatenation, type conversion, null filling |
| Structural adapt | transform orderSummary { … } | Zero (virtual node) | Multi-field reshaping reused by several downstream nodes |
| Business logic | node … : Operator { … } | Full (scheduled, timed, retriable) | Domain rules, I/O, side effects |
Rule of thumb: If removing the logic only breaks data format compatibility — use a transform. If removing it changes business meaning — it must be an operator.
The three-layer reuse pyramid
Each layer is tagged via
@OperatorMeta(layer = …)
using the
OperatorLayer
enum (INFRASTRUCTURE, CAPABILITY, DOMAIN). Use it to make the
operator's reuse scope explicit in code and documentation.
First Working Example
Consider an operator that fetches a user from an external service. Declaring typed I/O, schema, and behavioural contracts makes its framework boundary explicit.
Step 1 — Define typed input and output records
public record FetchUserInput(String userId) {
public FetchUserInput {
if (userId == null || userId.isBlank())
throw new IllegalArgumentException("userId must not be blank");
}
}
public record FetchUserOutput(String id, String name, String email, int vipLevel) {}
Records give you immutability, validation in the compact constructor, and
automatic schema introspection via
SchemaIntrospector.
Step 2 — Implement the operator
The 43-line implementation is kept whole so metadata, the effect-bearing call, idempotency, side-effect classification, and both schemas can be reviewed as one Operator contract.
@OperatorMeta(
layer = OperatorLayer.INFRASTRUCTURE,
tags = {"user", "query"},
description = "Fetch a user by ID from the user service",
owner = "user-platform",
since = "1.0.0"
)
public class FetchUserOperator
implements Operator<FetchUserInput, FetchUserOutput>, SchemaAware {
private final UserServiceClient client;
public FetchUserOperator(UserServiceClient client) {
this.client = client;
}
@Override
public FetchUserOutput execute(FetchUserInput input, OperatorContext ctx)
throws Exception {
// Business data comes through `input`, not through `ctx`.
return client.fetchUser(input.userId());
}
@Override
public Idempotency idempotency() {
return Idempotency.IDEMPOTENT; // safe to retry
}
@Override
public SideEffectType sideEffectType() {
return SideEffectType.EXTERNAL_CALL; // crosses a service boundary
}
@Override
public SchemaDescriptor inputSchema() {
return SchemaIntrospector.introspect(FetchUserInput.class);
}
@Override
public SchemaDescriptor outputSchema() {
return SchemaIntrospector.introspect(FetchUserOutput.class);
}
}
Notice four things:
-
@FunctionalInterface— the core contract is a single method:O execute(I input, OperatorContext ctx). No abstract base class to extend, no lifecycle hooks to learn first. -
Typed generics —
Operator<FetchUserInput, FetchUserOutput>gives the compiler and the engine a concrete type pair. The framework auto-introspects the generics to derive aStructuredSchemaeven if you don't implementSchemaAware. -
Behavioural defaults —
idempotency()returnsIDEMPOTENT, so the framework can treat retry as safe.sideEffectType()returnsEXTERNAL_CALL, signalling that the operator crosses a service boundary and should be paired with explicit resilience settings in the graph. -
OperatorContextis read-only metadata — it carriesnodeId,graphName,retryAttempt,executionId, andtimeSource. All business data must arrive throughinput.
Refactor One Bad Operator in Four Cuts
The fastest way to recognise a good Operator is to repair a bad one. Start with
PlaceOrderOperator: it validates fields, calculates discounts, calls payment,
writes the order, sends mail, and translates every exception. Its name describes
a workflow, not one capability.
Cut 1 and 2 — make pure decisions ordinary code
Extract validation and pricing into functions or domain objects with explicit
inputs and outputs. They do not need OperatorContext, retry, or a node. A
table-driven unit test can now cover discount boundaries without starting a
graph.
Cut 3 and 4 — expose effects, then keep the adapter thin
Introduce narrow ports such as PaymentPort and OrderRepository. The final
Operator assembles typed input, invokes one business capability, and returns an
inspectable result:
| Layer | Owns | Test seam |
|---|---|---|
| Pure domain logic | Validation and price decisions | Inputs → outputs |
| Effect port | Payment or persistence protocol | Fake/controlled port plus invocation evidence |
| Operator adapter | Graph input/output and capability call | GraphTestRunner node result |
| Graph | Ordering, branch, retry, timeout | Scenario and status assertions |
The finish line is not a small class. It is a clean failure boundary: a pricing
bug fails a pure test; a payment protocol bug fails the port contract; a wrong
dependency fails the graph scenario. SchemaAware, metadata, and context APIs
then describe this capability—they do not rescue a capability whose
responsibilities are still mixed.
Break It Apart
The Operator interface
From
Operator.java:
@FunctionalInterface
public interface Operator<I, O> {
O execute(I input, OperatorContext ctx) throws Exception;
default Idempotency idempotency() { return Idempotency.UNKNOWN; }
default SideEffectType sideEffectType() { return SideEffectType.MIXED; }
}
| Member | Purpose |
|---|---|
execute(I, OperatorContext) | The one method you must implement — receives assembled input and returns output |
idempotency() | Tells the engine whether retry is safe (IDEMPOTENT), forbidden (NOT_IDEMPOTENT), or unspecified (UNKNOWN) |
sideEffectType() | Tells the framework what kind of effects to expect so scheduling and resilience decisions stay explicit |
OperatorContext — what the engine tells you
From
OperatorContext.java:
public record OperatorContext(
String nodeId,
String graphName,
GraphContext graphContext, // request-scoped; writes isolated per node
int retryAttempt, // 0 = first execution
String executionId,
TimeSource timeSource // never null; defaults to SystemTimeSource.INSTANCE
) {}
The graphContext is available for request-level metadata (trace ID,
tenant ID). It is not the place for business data — that must flow through
the typed I input.