Online edition for BLOGE
0.9.9-RC1· prepared 2026-11-02 · 中文
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.
timeSource — testable time for operators
The timeSource field gives every operator access to the engine's clock
without hard-coding System.currentTimeMillis() or Instant.now().
Why it matters: When an operator reads the wall clock directly, tests
cannot control what time it sees. A retry-delay assertion that depends on
real time is slow and flaky. With timeSource, the test harness injects a
ManualTimeSource and the operator gets deterministic time for free.
Rule of thumb: Whenever your operator needs the current time, call
ctx.timeSource().now() instead of Instant.now().
@Override
public AuditOutput execute(AuditInput input, OperatorContext ctx) throws Exception {
Instant timestamp = ctx.timeSource().now(); // ✅ testable
// Instant timestamp = Instant.now(); // ❌ untestable
return auditService.record(input.action(), timestamp);
}
In production the engine supplies SystemTimeSource.INSTANCE, which
delegates to Instant.now() and Thread.sleep(). In tests you swap it
with ManualTimeSource:
var manualTime = new ManualTimeSource(Instant.parse("2025-01-15T10:00:00Z"));
var ctx = OperatorContext.builder()
.nodeId("audit")
.graphName("compliance")
.graphContext(new GraphContext())
.timeSource(manualTime) // inject test clock
.build();
var result = new AuditOperator(auditService).execute(input, ctx);
// assert the operator used the injected time, not the wall clock
assertEquals(Instant.parse("2025-01-15T10:00:00Z"), result.recordedAt());
This is the same ManualTimeSource used by TestGraphEngine in
Chapter 18. The difference is that here you
use it at the operator level for unit tests, while Chapter 18 uses it
at the graph level for integration tests.
SchemaAware — explicit schema declarations
From
SchemaAware.java:
public interface SchemaAware {
default SchemaDescriptor inputSchema() { return OpaqueSchema.INSTANCE; }
default SchemaDescriptor outputSchema() { return OpaqueSchema.INSTANCE; }
}
Implementing SchemaAware is optional. If you skip it, the framework falls
back to auto-introspection of the generic type parameters I and O. But
explicit declaration is preferred because:
- It survives type erasure when generics resolve to
Map<String, Object>. - It lets you annotate individual fields with descriptions and constraints.
- It enables compile-time path validation in downstream input bindings.
At the Java operator layer, DefaultOperatorRegistry first checks
SchemaAware; if you do not implement it, it falls back to
SchemaIntrospector over the operator's generic type parameters.
@OperatorMeta — catalogue metadata
From
OperatorMeta.java:
public @interface OperatorMeta {
OperatorLayer layer() default OperatorLayer.DOMAIN;
String[] tags() default {};
String version() default "";
String description() default "";
String owner() default "";
String since() default "";
// LLM tool metadata — consumed by bloge-agent-ext (Ch 17)
String promptHint() default "";
String usageExample() default "";
String constraintsDescription() default "";
}
Every operator should declare layer, description, and owner at a
minimum. Even when runtime behavior comes from the operator contract itself,
this metadata keeps intent and ownership visible to readers and tooling.
The last three fields are the LLM tool description. When an operator is
exposed as a tool to an agent node (Ch 17), bloge-agent-ext reads them to
build the function spec sent to the model:
promptHint— one-line description the LLM sees in tool listings.usageExample— a concrete invocation example to anchor the model.constraintsDescription— preconditions, side effects, or rate limits.
These also flow into operator-metadata.json (Ch 9) so Studio shows the
same hints in the operator palette.
Common Trap
❌ Creating an operator for pure data reshaping
// DON'T — this operator does nothing that a transform can't do for free
public class FormatOrderSummaryOperator
implements Operator<Map<String, Object>, Map<String, Object>> {
@Override
public Map<String, Object> execute(Map<String, Object> input,
OperatorContext ctx) {
return Map.of(
"customerName", input.get("name"),
"total", input.get("price")
);
}
}
This operator is independently testable (barely), but it has no meaningful metrics, no side effects, no domain knowledge, and no reuse potential. It fails four out of five checks from the Single Capability Principle.
Use a transform instead:
transform orderSummary {
customerName = fetchUser.output.name
total = calcPrice.output.total
}
The transform adds no Operator scheduling cost — no operator invocation, no timeout tracking, no retry overhead — and it keeps the reshape visible in the graph itself.
How to spot the trap in existing graphs:
- The operator's
executemethod contains noif, no I/O, no external call. - The operator's class has no constructor dependencies (no service client, no repository).
- The operator could be replaced by a
return input;pass-through without changing business outcomes.
If any of these are true, delete the operator and use a transform or an input-binding expression.
❌ Encoding a decision table as a plain operator
Sometimes an operator looks like business logic, but the implementation is really just a table of conditions mapping to output values — no I/O, no retry, no state. That is not an independent capability; it is a policy table hiding inside Java code.
Reach for a decision_table when:
- The logic is a pure function of its inputs (no side effects, no external calls).
- The conditions are enumerable — you can list all the cases, and the compiler or lint can warn you when the list is incomplete.
- Audit trail matters — you want a lint rule (
decision-table/missing-otherwise) and structured error codes at runtime.
Keep it in an Operator when:
- The node performs I/O (HTTP call, database query, message publish) or has side effects.
- You need retry, timeout, or fallback policies managed by the framework.
- The logic involves mutable state or coordination that cannot be expressed in a rule predicate.
See Appendix F — Decision Table for the full syntax reference and all error codes.
Guided Rewrite
Here is a graph with two design problems — one operator that is too trivial and one that is too broad:
graph invoiceProcess {
node fetchOrder : FetchOrderOperator {
input { orderId = ctx.orderId }
timeout = 3s
}
// Problem 1: trivial data reshape — should be a transform
node formatAddress : FormatAddressOperator {
input {
street = fetchOrder.output.address.street
city = fetchOrder.output.address.city
zip = fetchOrder.output.address.zip
}
}
// Problem 2: monolith — bundles payment + notification into one operator
node processAndNotify : ProcessAndNotifyOperator {
depends_on = [fetchOrder, formatAddress]
input {
order = fetchOrder.output
address = formatAddress.output
}
timeout = 10s
}
}
Rewrite:
graph invoiceProcess {
node fetchOrder : FetchOrderOperator {
input { orderId = ctx.orderId }
timeout = 3s
}
// Fix 1: replace the trivial operator with a transform
transform formattedAddress {
street = fetchOrder.output.address.street
city = fetchOrder.output.address.city
zip = fetchOrder.output.address.zip
full = concat(fetchOrder.output.address.street, ", ",
fetchOrder.output.address.city, " ",
fetchOrder.output.address.zip)
}
// Fix 2: split the monolith into two independent operators
node processPayment : ProcessPaymentOperator {
depends_on = [fetchOrder]
input {
order = fetchOrder.output
address = formattedAddress.full
}
timeout = 5s
retry = { attempts: 2, backoff: 500ms, strategy: exponential }
}
node sendNotification : SendNotificationOperator {
depends_on = [fetchOrder]
input {
email = fetchOrder.output.customerEmail
orderId = fetchOrder.output.id
}
timeout = 3s
fallback = { sent: false, reason: "notification service unavailable" }
}
}
What improved:
| Before | After | Why it matters |
|---|---|---|
FormatAddressOperator (full node) | transform formattedAddress | Zero scheduling cost; no need for timeout/retry on a pure reshape |
ProcessAndNotifyOperator (one fat node) | processPayment + sendNotification | Independent SLAs, independent retry policies, and they can run in parallel |
Brain Check
-
Name three of the five checks in the Single Capability Principle. (Independently testable, independently observable, independently replaceable, independently reusable, has clear domain semantics.)
-
An operator's
executemethod readsctx.graphContext().get("items")to get its business data. What's wrong? (Business data must arrive through the typed inputI, not through the graph context. The graph context is for request-level metadata like trace IDs.) -
You have an operator that concatenates two strings. Should it exist? (No — string concatenation is a light transform. Use
concat(a.output.first, " ", a.output.last)in an input binding or transform.) -
What does
SideEffectType.EXTERNAL_CALLtell the framework? (That the operator crosses an external-service boundary, so readers and integrations should treat it as a side-effecting call and pair it with explicit timeout/retry policy in the graph.) -
If an operator does not implement
SchemaAware, how does the framework derive its schema? (By introspecting the generic type parametersIandOviaSchemaIntrospector.)
Lab
-
Audit an existing graph. Open
order-process-v5.bloge. For each node, apply the five-question test. Are there any nodes that should be transforms? Write down your reasoning. -
Design a new operator from scratch. Imagine a
CreditCheckOperatorthat calls an external credit-scoring API.- Define
CreditCheckInputandCreditCheckOutputas Java records. - Implement
Operator<CreditCheckInput, CreditCheckOutput>. - Implement
SchemaAwarewith explicit schemas. - Override
idempotency()→IDEMPOTENT(read-only query). - Override
sideEffectType()→EXTERNAL_CALL. - Annotate with
@OperatorMeta(layer = CAPABILITY, ...).
- Define
-
Schema validation check. After writing your records, use
SchemaIntrospector.introspect(CreditCheckOutput.class)in a unit test and verify the returnedStructuredSchemahas the fields you expect. -
Refactor a bad operator. Write a
FormatCurrencyOperatorthat takes aNumberand returns aString. Then delete it and replace it with a transform or an expression at the binding site. Which approach is simpler?
Experiment acceptance card
- Expected and observed: Splitting an operator into logic, port, adapter, and graph policy creates distinct failure seams.
- Failure and recovery: Return a transport error from the adapter; replace only the adapter or fallback.
- Proof boundary: Proves testable responsibility boundaries, not external reliability.
- Exercise contract: Bad service; cut one layer at a time; deliver a responsibility map and failure test; stop when one owner remains.
Coding practice
BP-02 — Name bounded computation
Extract an ordinary helper when a calculation needs a clear name. Publish a typed function only when graph authors need it; keep business inputs explicit and test metadata against behavior.
Try one change: Use the Chapter 34 policy-code function to compare the same input under root and Turkish locales. Decide whether trim alone satisfies the contract.
Reasoning, counterexamples, and limits
BP-03 — Expose independent execution responsibility
Use an Operator when a capability needs its own retry, timeout, replacement, observations, or capacity boundary. Pure expensive computation can qualify; a short HTTP call can qualify too.
Try one change: Move a hidden credit-service query out of input evaluation. Identify Operator invocation evidence and external-effect evidence separately.
Reasoning, counterexamples, and limits
BP-05 — Separate wiring from new capabilities
Endpoint and credential changes normally belong in configuration; provider protocol changes belong behind an adapter. Changed business guarantees require a new capability or explicit version, while ordered steps belong in a graph.
Try one change: Classify changing a payment URL, replacing a provider, and replacing authorization with charging. State which changes affect callers.
Reasoning, counterexamples, and limits
BP-08 — Retain real dependencies and explicit data
Pass business facts through input/output rather than shared mutable context. Remove accidental serialization but retain required control ordering, even without a field reference.
Try one change: Let credit and income checks start independently, then join their facts. Add a charge that must follow approval and explain that control prerequisite.
Reasoning, counterexamples, and limits
Recap
- An operator should encapsulate one independently measurable business capability — the Single Capability Principle.
- There are four layers of data transformation: routing (input bindings), light transforms (expressions), structural adapts (transform blocks), and business logic (operator nodes). Use the cheapest layer that fits.
- The three-layer reuse pyramid (Infrastructure → Capability → Domain)
classifies operators by scope; tag them with
@OperatorMeta(layer = …). - Typed I/O records +
SchemaAwaregive you compile-time validation, schema-aware tooling, and safer schema evolution. - Behavioural contracts —
idempotency()andsideEffectType()— give the framework and your team clearer guidance for retry, scheduling, and resilience choices. OperatorContextis for metadata, not business data. All domain values flow through the typed input. Usectx.timeSource().now()instead ofInstant.now()for testable time.- The most common mistake is creating an operator for pure data reshaping. If there's no I/O, no side effect, and no domain rule — use a transform.
Next Step
In Chapter 8 — Turning a Personal DSL Draft into a Team Asset you will bring these
ideas back into the .bloge authoring layer — combining graph-level schemas,
doc comments, transforms, branches, and operator contracts into files that are
clear to read and ready to evolve.
Reference Links
- Operator Design Specification — full normative spec (boundary rules, schema governance, complexity limits, naming standards)
Operator.java— core operator interfaceOperatorContext.java— execution context recordSchemaAware.java— explicit schema declaration interfaceSchemaDescriptor.java— sealed schema type hierarchySchemaIntrospector.java— automatic schema derivation from Java typesOperatorMeta.java— catalogue metadata annotationOperatorLayer.java— reuse layer enumSideEffectType.java— side-effect classificationIdempotency.java— idempotency levels- Core Architecture — engine scheduling and resilience internals
Coding Agent: Open the versioned task guide.