Algebraic Effects & Optics
Purpose
Give reducers, FSMs, and services a clear, uniform way to express:
- fine-grained updates to nested immutable state (optics), and
- “things that must happen in the real world” (algebraic effects)
…without smuggling IO or messy mutation into the domain core. Pairs directly with:
- Reducers & FSM appendix
- Business logic placement (domain vs infra)
- Rust/Laravel idioms and ports & adapters
K1. Non‑negotiables
-
No raw IO inside reducers or state machines.
Domain logic returns new state + descriptions of side effects, not side effects themselves. -
All nested state updates use optics, not hand-rolled copying:
- Lenses for product fields
- Prisms for sum/enum variants
- Traversals for collections
-
Every effect belongs to a small effect algebra:
A well‑named trait/interface/port set, not “just call the SDK/client inline.” -
Testing uses alternative interpreters:
- Fakes / in‑memory
- Record & replay
- Cost/simulation interpreters
K2. Concepts at a glance
K2.1 Optics
- Lens<S, A>
- Focus: a field
Ainside a productS - Ops:
get(s) -> A,set(s, a) -> S,over(s, f: A -> A) -> S
- Focus: a field
- Prism<S, A>
- Focus: a variant of a sum/enum
S - Ops:
preview(s) -> Option<A>,review(a) -> S
- Focus: a variant of a sum/enum
- Traversal<S, A>
- Focus: many
Avalues insideS(e.g., list items) - Ops:
over_all(s, f: A -> A) -> S
- Focus: many
Think: optics = composable paths into immutable data.
K2.2 Algebraic effects
- Effect algebra: a small interface/trait describing what the program may ask of the world:
Clock:now() -> InstantLogger:info(msg),warn(msg), …Payments:authorize(amount, card) -> Result<Authorization, Error>
- Interpreter/handler: an implementation of that algebra:
- Real infra (HTTP, DB, queues)
- In‑memory test double
- “Dry run” simulator
Effects become operations you can reason about, not scattered ad‑hoc calls.
K3. Optics + Reducers + FSMs
Reducers and FSMs are already your “mathematical heart.”
Optics make nested updates mechanical and composable.
K3.1 Minimal optics API (language‑agnostic)
Define a minimal set of building blocks:
// Conceptual; adjust syntax per languagetype Lens<S, A> = { get: (s: S) => A; set: (s: S, a: A) => S; over: (s: S, f: (a: A) => A) => S;};
type Prism<S, A> = { preview: (s: S) => A | null; review: (a: A) => S;};
type Traversal<S, A> = { overAll: (s: S, f: (a: A) => A) => S;};Then provide a small “path builder” per stack: • Rust: helper macros for struct fields and enum variants. • Laravel/PHP: Lens::field(‘order’, ‘total’), Lens::index(‘items’). • Nuxt/TS: type‑safe path<‘order.items[0].price’> builder or hand‑written lenses.
K3.2 Usage in reducers
Instead of:
// Pseudocode: manual nested updatereturn { ...state, order: { ...state.order, items: state.order.items.map((item, i) => i === idx ? { ...item, price: newPrice } : item ) }};Use a traversal:
const itemAt = itemTraversal(index); // Traversal<Order, Item>return itemAt.over(state, item => item.id === targetId ? { ...item, price: newPrice } : item);Rules: • Reducers never manually copy nested structures. • FSM transition tables talk in terms of optics: “order.items.*.status set to Cancelled on TIMEOUT.”
K3.3 Optics + FSM appendix integration
Extend the FSM checklist:  • “Where context is nested, document and use a lens/traversal path for each transition.” • Example column in transition table:
|_______________________________________________________| | From | Event | Guard | To | Action | |__||___________||__________| | Paid | REFUNDOK | amount <= ? | Refunded | items[*].status = Refunded (traversal) | |__||___________||______________________________________|
K4. Effect algebras & interpreters
K4.1 Shape of an effect algebra
Rust (core/ports)
pub trait Clock { fn now(&self) -> OffsetDateTime;}
pub trait Payments { fn authorize(&self, cmd: AuthorizeCmd) -> Result<AuthOk, PaymentErr>;}
pub trait Logger { fn info(&self, msg: &str); fn warn(&self, msg: &str);}Laravel (app/Domain/Ports)
interface Clock { public function now(): DateTimeImmutable;}
interface Payments { public function authorize(AuthorizeCmd $cmd): Either/*<PaymentError, AuthOk>*/;}
interface Logger { public function info(string $msg): void;}TypeScript / Node (fp-ts style)
interface Clock { now: () => Date;}
interface Payments { authorize: (cmd: AuthorizeCmd) => TE.TaskEither<PaymentError, AuthOk>;}
interface Logger { info: (msg: string) => T.Task<void>;}interface Clock { now: () => Date;}
interface Payments { authorize: (cmd: AuthorizeCmd) => TE.TaskEither<PaymentError, AuthOk>;}
interface Logger { info: (msg: string) => T.Task<void>;}K4.2 Program shape: capabilities in, pure-ish logic out
A service or saga receives capabilities (instances of those algebras) instead of concrete clients:
pub struct Env<'a> { pub clock: &'a dyn Clock, pub payments: &'a dyn Payments, pub logger: &'a dyn Logger,}
pub async fn capture_payment(env: &Env, cmd: CaptureCmd) -> Result<CaptureResult, CaptureErr> { env.logger.info("capture_payment.start"); let auth = env.payments.authorize(cmd.to_authorize_cmd()).await?; // pure reducer + optics for state update Ok(CaptureResult::from(auth))}Application/infra layers assemble Env with real implementations; tests assemble it with fakes.
K5. Effect programs as data (optional but powerful)
For more control (and better LLM guidance), represent workflows as data instead of “just run the effect”:
type EffectOp = | { tag: 'Log'; level: 'info' | 'warn'; msg: string } | { tag: 'Now' } | { tag: 'Authorize'; cmd: AuthorizeCmd };
type Program<A> = | { tag: 'Pure'; value: A } | { tag: 'Bind'; op: EffectOp; k: (result: any) => Program<A> };Then you write domain workflows that build a Program, and you provide interpreters: • runReal(program) – touches DB/HTTP/etc. • runTest(program) – uses in‑memory structures and captures logs. • runCost(program) – estimates cost, time, risk.
This is a generalization of your existing interpreter pattern / workflow engines, but as a reusable primitive.
LLM angle: ask models to produce programs in this DSL, never raw imperative steps.
K6. Testing & simulation with effects
Patterns: 1. Record–replay interpreter • Interpreter records EffectOps instead of executing them. • Use for: • “What would happen if we run this saga?” • Documenting expected sequences as golden test fixtures. 2. Fault injection interpreter • Same API, but randomly injects failures on effect boundaries: • Payments time out • Clock jumps ahead • Ideal for property‑based tests around sagas/retries. 3. Tracing interpreter • Wraps a real interpreter and pushes events into logs/metrics (op_count, “per‑effect latency”).
K7. Language‑specific checklists
Rust
• All non‑deterministic concerns (time, UUID, rand, network) live in traits under core/ports.• Reducers and FSMs accept plain values and never own a reference to an effect trait.• Env structs group ports/capabilities for specific workflows (not one mega‑env).• Tests define fake structs implementing the same traits; use them to assert sequence/order.Laravel / PHP
• Domain never sees Eloquent models; ports are defined as interfaces under Domain/Ports.• Controllers/jobs build “effect envs” from Laravel services/container.• Wrap external libraries (Stripe SDK, HTTP clients) behind effect ports.Nuxt / TypeScript
• For Node services, define small capability interfaces and pass them down (or use ReaderTaskEither).• For UI stores (Pinia/composables), reducers + optics handle state; effects live in composables that inject e.g. http, clock.• Where you already have RAG utilities, treat the RAG client as an effect algebra (search, pin, upsertDoc)K8. LLM guidance hooks
When wiring MCP/RAG for this repo: • Pin a short “Effects & Optics Ground Rules” doc: • “Use the Clock, Logger, Payments ports, not raw SDKs.” • “Use Lens/Traversal helpers for nested state.” • Add to review prompts: • ❓ “Does this code introduce new side effects instead of reusing ports?” • ❓ “Are nested updates expressed via optics?”
This turns the appendix into enforcement, not just philosophy.