Effect-driven workflows
State changes that must do something: call an API, ask a model, wait an hour, hand a decision to a human. Declared in the spec, executed post-commit, folded back as ordinary mutations.
- The problem
- The shape of the fix
- What you can build with it
- What keeps it honest
- Worth knowing before you build on it
- Next
The problem
The moment a stateful system talks to the outside world, two properties usually die together: determinism (replaying history re-fires the emails) and clarity (the side effects are buried in imperative code far from the rule that triggered them).
The shape of the fix
Valem splits it at the functional-core / imperative-shell seam:
- The pure core never performs I/O. When an effect’s
triggerbecomes true after a committed mutation, it emits a request as data (EffectRequest). - A shell-side executor performs the actual I/O, asynchronously, post-commit.
- The result folds back into state as an ordinary mutation — logged like any other.
Because the fold-back is just a mutation, replaying history reproduces the state without
re-contacting anything. Effects are declared next to the rule that triggers them, and their progress
is visible in state via a statusPath machine: pending → in_flight → applied | failed.
What you can build with it
| Pattern | How |
|---|---|
| Enrich state from an API | A server effect: spec-provided URL, retries, SSRF egress guard, response folded back |
| Ask an LLM as part of the model | An llm effect: state-derived prompt, JSON completion folded back into a field |
| Schedule or poll | A timer effect: fold back at at/afterMs; re-arm it from a derived tick for a self-refreshing loop |
| Hand something to the caller | A caller effect: surfaced in the mutation response — the seam for human-in-the-loop steps |
| Your own kind | A ServiceLoader EffectKind + an EffectExecutor bean — a drop-in jar, no core or API edits |
The world-clock example in the gallery composes two of these into a model that
refreshes itself: a server (HTTP) effect fetches the time, a timer re-arms it every ten seconds.
What keeps it honest
- Edge-triggered, not level-triggered. A
dedupeKeyplus thestatusPathguard means a still-true trigger doesn’t re-fire the effect on every unrelated mutation. - Stale results can’t win. Fold-backs use a keyed compare-and-swap under the model lock: an
in-flight effect whose input has since changed is
SUPERSEDED(discarded and re-fired for the latest value) orCANCELLED(the trigger no longer holds — also how a timer cancels). - Egress is guarded. The built-in
serverexecutor goes through an SSRF guard (loopback, private ranges, redirects), and effect kinds are gated by an enable-list. See security model. - Replay never re-runs I/O. A folded-back response is a logged mutation, nothing more.
Worth knowing before you build on it
- Effects are asynchronous and post-commit: the mutation that triggered one returns before the
I/O finishes. Model the waiting state explicitly (that’s what
statusPathis for). - Executors live in the shell (
valem-api), so a barevalem-coreembedding getscallereffects and emits the rest as data for you to execute. - An effect URL or prompt comes from the spec. Treat spec authorship as a privileged operation, particularly when specs are LLM- or agent-generated.
Next
- Effects guide — the four built-in executors, the lifecycle, and how to add a custom kind.
- Effects reference — every effect field.
- Configuration — egress, retries, timeouts, enable-lists.