Skip to main content

Composition: Decompose Transitions, Compose in WSL

This is the single most important design rule when building on Kuetix, and the most common thing teams (and AI assistants) get wrong:

Do not write large, monolithic transition methods. Split a method into small, single-purpose transitions and compose them in WSL.

A transition (a Go service method called from a workflow) should do one thing. When a method starts doing several things — fetch, then validate, then transform, then respond — that is a signal to break it into several small transitions and wire them together as steps in a .wsl / .swsl workflow, rather than burying the whole flow inside one Go function.

Why this matters​

The whole point of the engine is that orchestration lives in the declarative WSL layer, not in imperative Go. Pushing logic down into a single fat transition defeats that:

  • Reusability — small transitions (Validate, Fetch, Respond) can be recombined across many workflows. A 200-line method can only be called one way.
  • Testability — each small transition has a narrow input/output contract and is trivial to unit-test in isolation.
  • Visibility — when the flow is expressed as WSL states, the control flow is readable and editable without touching Go. Hidden flow inside a method is not.
  • Composability — features and solutions orchestrate workflows; workflows orchestrate transitions. That hierarchy only works if the leaves are small.

The pattern​

Instead of one method that does everything:

// ❌ Anti-pattern: one transition doing fetch + validate + transform + respond
func (s *Orders) ProcessOrder(...) domain.FlowStepResult { /* 150 lines */ }

Write small single-purpose transitions:

func (s *Orders) FetchOrder(...) domain.FlowStepResult { /* just fetch */ }
func (s *Orders) ValidateOrder(...) domain.FlowStepResult { /* just validate */ }
func (s *Orders) ChargeOrder(...) domain.FlowStepResult { /* just charge */ }

…and compose them in a workflow, where each step's result feeds the next via $alias.field:

// orders/process.swsl
import services/common

def services/common/errors.OnAnyError(msg: "order failed", statusCode: 500) as errorHandler -> .

orders/orders.FetchOrder(id: $args.id) as order <- errorHandler ->
orders/orders.ValidateOrder(order: $order.value) as valid <- errorHandler ->
orders/orders.ChargeOrder(order: $valid.order) as charged <- errorHandler ->
services/common/response.Response(value: $charged.receipt, statusCode: 200) -> .

Each step is a one-line action that binds its result to an alias (as order), routes failures to the shared errorHandler (<- errorHandler), and chains to the next step (->). The flow ends with ..

Where to draw the line (engine@v1.0.0)​

Decompose along sequential boundaries. With the current engine build, the WSL layer cannot yet express runtime branching or looping, so that control flow must stay inside a Go transition for now:

  • when / if conditions are not evaluated at runtime — the condition is token-substituted and compared to the literal string "false", so a real comparison never reduces correctly. Multi-way routing (e.g. picking a provider) therefore stays inside a single router transition.
  • Revisited states do not re-evaluate accumulating data — a guarded cycle (on success when $turns.count < n -> Step) loops forever, so an iterative loop must stay inside one Go transition.

So the rule in practice:

  • Linear, sequential flow → express it as WSL steps. (Decompose.)
  • Branching / looping → keep it in one focused Go transition until the engine supports runtime conditions. Still decompose everything around it.

The bundled std-ai package is the reference example: ai/prompt.Prompt is a router over OpenAI/Anthropic/Gemini, and ai/agent.Run holds the think/act loop — both kept in Go for the reasons above — while every non-branching step (BuildPrompt, ParseDecision, ExecuteTool, AppendTurn) is a separate, independently-callable transition composed in WSL.

Checklist when adding a transition​

  1. Can this method be described in one sentence without "and"? If not, split it.
  2. Is each piece independently callable and unit-testable?
  3. Is the sequential glue expressed in a .wsl / .swsl workflow, not in Go?
  4. Is the only logic left in Go the genuine branching/looping the engine can't yet express?
  5. Ran kue update so the new transitions are registered before building.