Skip to main content

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

  1. Apply the Single Capability Principle to decide whether a piece of logic deserves to be an operator or should stay in the orchestration layer.
  2. Classify an operator into the three-layer reuse pyramid: Infrastructure → Capability → Domain.
  3. Declare input/output schemas so the compiler, tooling, and downstream nodes can validate data more safely.
  4. Set the idempotency and side-effect contracts that help the framework make safer retry and scheduling decisions.
  5. Recognise — and eliminate — trivial data-shaping operators that should be transforms or input-binding expressions.

Prerequisites

Source Examples

FileWhat it shows
Operator.javaThe core @FunctionalInterfaceexecute, idempotency, sideEffectType
OperatorContext.javaRead-only record the engine passes to every operator
SchemaAware.javaOptional interface for explicit input/output schema declarations
Idempotency.javaIDEMPOTENT, NOT_IDEMPOTENT, UNKNOWN
SideEffectType.javaREAD_ONLY, WRITE, EXTERNAL_CALL, MIXED
OperatorMeta.javaAnnotation for layer, tags, version, owner
OperatorLayer.javaINFRASTRUCTURE, CAPABILITY, DOMAIN
Operator Design SpecificationFull 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:

Diagram: 07-designing-good-operators figure 1

The four layers of data transformation

Not every piece of logic belongs in an operator. BLOGE offers four layers, each with different cost:

LayerToolCostWhen to use
Routinginput { userId = fetchUser.output.id }ZeroPassing or renaming a field
Light transforminput { name = concat(a.output.first, " ", a.output.last) }ZeroConcatenation, type conversion, null filling
Structural adapttransform orderSummary { … }Zero (virtual node)Multi-field reshaping reused by several downstream nodes
Business logicnode … : 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

Diagram: 07-designing-good-operators figure 2

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:

  1. @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.

  2. Typed genericsOperator<FetchUserInput, FetchUserOutput> gives the compiler and the engine a concrete type pair. The framework auto-introspects the generics to derive a StructuredSchema even if you don't implement SchemaAware.

  3. Behavioural defaultsidempotency() returns IDEMPOTENT, so the framework can treat retry as safe. sideEffectType() returns EXTERNAL_CALL, signalling that the operator crosses a service boundary and should be paired with explicit resilience settings in the graph.

  4. OperatorContext is read-only metadata — it carries nodeId, graphName, retryAttempt, executionId, and timeSource. All business data must arrive through input.


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.

Diagram: four-step Operator refactor

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:

LayerOwnsTest seam
Pure domain logicValidation and price decisionsInputs → outputs
Effect portPayment or persistence protocolFake/controlled port plus invocation evidence
Operator adapterGraph input/output and capability callGraphTestRunner node result
GraphOrdering, branch, retry, timeoutScenario 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; }
}
MemberPurpose
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 execute method contains no if, 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:

BeforeAfterWhy it matters
FormatAddressOperator (full node)transform formattedAddressZero scheduling cost; no need for timeout/retry on a pure reshape
ProcessAndNotifyOperator (one fat node)processPayment + sendNotificationIndependent SLAs, independent retry policies, and they can run in parallel

Brain Check

  1. Name three of the five checks in the Single Capability Principle. (Independently testable, independently observable, independently replaceable, independently reusable, has clear domain semantics.)

  2. An operator's execute method reads ctx.graphContext().get("items") to get its business data. What's wrong? (Business data must arrive through the typed input I, not through the graph context. The graph context is for request-level metadata like trace IDs.)

  3. 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.)

  4. What does SideEffectType.EXTERNAL_CALL tell 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.)

  5. If an operator does not implement SchemaAware, how does the framework derive its schema? (By introspecting the generic type parameters I and O via SchemaIntrospector.)


Lab

  1. 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.

  2. Design a new operator from scratch. Imagine a CreditCheckOperator that calls an external credit-scoring API.

    • Define CreditCheckInput and CreditCheckOutput as Java records.
    • Implement Operator<CreditCheckInput, CreditCheckOutput>.
    • Implement SchemaAware with explicit schemas.
    • Override idempotency()IDEMPOTENT (read-only query).
    • Override sideEffectType()EXTERNAL_CALL.
    • Annotate with @OperatorMeta(layer = CAPABILITY, ...).
  3. Schema validation check. After writing your records, use SchemaIntrospector.introspect(CreditCheckOutput.class) in a unit test and verify the returned StructuredSchema has the fields you expect.

  4. Refactor a bad operator. Write a FormatCurrencyOperator that takes a Number and returns a String. 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.

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 + SchemaAware give you compile-time validation, schema-aware tooling, and safer schema evolution.
  • Behavioural contractsidempotency() and sideEffectType() — give the framework and your team clearer guidance for retry, scheduling, and resilience choices.
  • OperatorContext is for metadata, not business data. All domain values flow through the typed input. Use ctx.timeSource().now() instead of Instant.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.


Coding Agent: Open the versioned task guide.