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/ifconditions 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
- Can this method be described in one sentence without "and"? If not, split it.
- Is each piece independently callable and unit-testable?
- Is the sequential glue expressed in a
.wsl/.swslworkflow, not in Go? - Is the only logic left in Go the genuine branching/looping the engine can't yet express?
- Ran
kue updateso the new transitions are registered before building.