Skip to content

Design Patterns in Use

Rust has no classes, so the object-oriented pattern catalogue does not map one-to-one. Some patterns survive unchanged (strategy, composite, chain of responsibility). Some are absorbed by the language and stop being patterns (iterator, singleton). And a few Rust idioms have no GoF name at all but do the same structural work — the extension trait, the newtype, #[non_exhaustive], the consuming builder.

This page is the index. Each entry says what the pattern buys here, not what it means in general, and points at the code.


If you are trying to answer “how is this codebase organized”, four patterns carry most of the weight:

PatternWhat it structures
StrategyEvery swappable backend — caches, stores, providers, samplers, pruners
Adapter / bridgeEvery language and process boundary — Python, subprocess, socket
Template methodEvery trait where a minimal backend should be cheap to write
Chain of responsibilityThe effect system, which is how an agent does anything

The rest are local decisions.


Behaviour selected at runtime through a dyn trait object. The dominant pattern in the workspace, and the reason none of the 29 public traits declares an associated type or a generic parameter — every one of them stays object-safe.

SiteTraitImplementations
CachingCacheStore (soma-core/src/cache.rs:204)MemoryCache, LocalCache, TieredCache, FsActionStore
Data movementDataStore (soma-core/src/store/mod.rs:208)LocalDataStore, S3DataStore, ZarrStore
Model accessLlmProvider (soma-llm/src/lib.rs:72)OpenAiCompatible + a Router over Arc<dyn LlmProvider>
Forward executionForwardStrategy (soma-runtime/src/forward.rs:40)Standard, Stream, Batched
SearchSampler (soma-runtime/src/sampler/mod.rs:22)GridSampler, RandomSampler, BayesianSampler
PruningPruner (soma-runtime/src/pruner.rs:10)MedianPruner, PercentilePruner
Distributed trainingStrategyExecutor (soma-runtime/src/strategy.rs:120)TrainingStrategy
Compilation inputNodeRegistry (soma-compiler/src/compiler.rs:65)SimpleNodeRegistry, NodeCatalog
Knowledge storageKnowledgeBase (soma-memory/src/knowledge_base.rs:50)MemoryKnowledgeBase, FileKnowledgeBase, ChronosKnowledgeBase

The dyn census in soma-runtime alone: CacheStore 33 sites, Transport 13, DataStore 7, EffectHandler 6, Step 5, EventSink 9, Filter 5.

(!) One trait is defined for this and never used polymorphically: Runner (soma-runtime/src/runner/mod.rs:124) — see D-34.

Making a foreign thing satisfy a local contract. This is how every boundary in the system is crossed, and there are more instances than of any other pattern.

Adapterfile:lineAdapts
NodeMetasoma-core/src/node.rs:72FilterMeta and StepMeta into one shape — the workspace’s central adapter
PyFilterBridgesoma-python/src/bridge.rs:224A Python object → Filter
PyStepBridgesoma-python/src/agentic.rs:883Anything with poll(ctx)Step
PyToolAdaptersoma-python/src/agentic.rs:198A Python callable → Tool
PyPbtExecutorsoma-python/src/pbt.rs:40Python callables → PbtExecutor
SubprocessFiltersoma-worker/src/python_process.rs:1025A pipe to another interpreter → Filter
WsTransportsoma-worker/src/ws_transport.rs:404A WebSocket → Transport
McpToolsoma-llm/src/tools.rs:212An MCP server → Tool
FnTool<F>soma-llm/src/tools.rs:62A closure → Tool
FnTrialExecutor<F> / FnPbtExecutor<T,E>soma-runtime/src/executors/study.rs:144, pbt.rs:63Closures → trait objects

NodeMeta deserves the emphasis. Filters and steps used to live in two registries joined by an adapter that a caller had to remember to build — which is how .compile() came to skip every step’s schema validation while .run() checked them. Collapsing both into one metadata type with an effectful flag means the executor’s existing cacheability guard reads “a step is not output-cacheable” as data. There is no if is_step anywhere in the executor.

A recursive tree walked uniformly.

  • ExecutionPlan (soma-compiler/src/plan.rs:19) — recursive in four shapes; children() (:142) is the single traversal, written per variant so a new variant breaks the build. (!) Two functions in the same crate do not use it and are wrong as a result — D-32.
  • NodeKind::SubGraph (soma-core/src/graph.rs:26) — a graph inside a node.
  • SearchDimension::Conditional (soma-core/src/search.rs:36) — a dimension gated on another.
  • TieredCache (soma-runtime/src/cache/tiered.rs:11) — a CacheStore made of CacheStores.
  • GraphSession (soma-runtime/src/graph_session.rs:38) over compile + execute + cache + events, plus the free functions graph_run / graph_fit / graph_predict (:450).
  • soma (soma/src/lib.rs) — the crate facade. (!) D-83.
  • SomaContext (soma-mcp/src/context.rs:9) over memory + filesystem + subprocess.
  • RunView (soma-python/python/soma/_runs.py:30) over the run-directory readers.
  • TieredCache — decorates by promoting on read. (!) The promotion loses provenance (D-46).
  • FileKnowledgeBase (soma-memory/src/file_kb.rs:25) — decorates MemoryKnowledgeBase with a durable JSONL log and byte-offset incremental refresh.
  • SubprocessFilter, WsTransport — remote proxies (see above).
  • NodeCatalog (soma-runtime/src/node_catalog.rs:79) — the registry, holding both node kinds and doubling as the compiler’s NodeRegistry.
  • Router (soma-llm/src/lib.rs:93), Toolbox (soma-llm/src/tools.rs:92), WorkerRegistry (soma-coordinator/src/registry.rs:73).
  • FilterMeta metaclass (soma-python/python/soma/filter.py:9) — collects SearchDescriptors into _soma_search_space at class-definition time.

EffectHandler (soma-core/src/effect.rs:262) is two methods — handles and perform — and the contract is in the doc at :256: “Handlers are tried in order; the first that claims an effect wins.”

Dispatch happens in EffectDriver::perform_one (soma-runtime/src/effects/mod.rs:531). Handlers: LlmHandler, Toolbox, GraphHandler, SleepHandler.

This is what makes Effect::Custom { kind, payload } work as an extension point with no registration step — a new handler that claims a kind is the whole feature.

A trait with a few required methods and many defaulted ones, so a minimal implementation is cheap and a sophisticated one can specialize.

TraitRequiredProvidedPayoff
KnowledgeBase (soma-memory/src/knowledge_base.rs:50)311A backend implements storage and inherits every analytic
CacheStore (soma-core/src/cache.rs:204)54A simple store ignores origin and timing; a rich one records them
DataStore (soma-core/src/store/mod.rs:208)52The defaults download everything and slice locally; ZarrStore overrides both to serve a row range remotely
Sampler (soma-runtime/src/sampler/mod.rs:22)22prepare and record_result are no-ops unless the sampler learns
StrategyContext (soma-runtime/src/strategy.rs:33)63Two of the provided methods default to refusing — an honest “not supported”
LocalRunner::walk (soma-runtime/src/runner/local.rs:26)Not a trait: one method shared by fit and forward, differing only by RunMode. Documented at :22 as the fix for two divergent loops

Defining a trait in one crate and implementing it on a foreign type from another. This is how Soma keeps “core holds contracts, runtime holds execution” without either crate depending on the other in the wrong direction.

TraitDefined inImplemented onFrom
StudyIosoma-runtime/src/study_io.rs:19Studysoma-core
StrategyExecutorsoma-runtime/src/strategy.rs:120TrainingStrategysoma-core
GradientAggregatorsoma-runtime/src/strategy.rs:132GradientAggregationsoma-core
StateAggregatorsoma-runtime/src/strategy.rs:139FederatedAggregationsoma-core

StudyIo is the clearest example of what it buys: Study gains save and load without soma-core gaining a filesystem.

Step::poll(&ctx) -> Transition (soma-core/src/step.rs:250). A step never blocks and never awaits — it returns a description of what it wants, and a driver performs the effects and calls it again with the results.

The consequence is the whole agentic design: a step holds no hidden state between turns. Everything it knows arrives through StepCtx::history (soma-core/src/step.rs:128), which is what makes journal replay exact rather than approximate, and what makes async fn in a trait unnecessary.

EventBus (soma-runtime/src/event_bus.rs:22) with two deliberately different paths:

  • lossy — a tokio broadcast channel for live subscribers who may miss events
  • lossless — a synchronous Vec<Arc<dyn EventSink>> for anything that must persist every one

(!) The sinks run on the emitting thread, so a JSONL write sits inside run_nodeD-72.

Effect (soma-core/src/effect.rs:35) describes work as data; the runtime performs it. Effect::label, is_pure and cache_key (:80:127) are what let the journal treat “what was asked” as a value.

  • Context::snapshot (soma-runtime/src/executor.rs:344) — each parallel branch gets a copy, and only the write set (the entries appended past a mark) is merged back.
  • EffectJournal — every effect result recorded so a resumed run replays rather than re-runs.

FnTrialExecutor<F> and FnPbtExecutor<T, E> — the only generic public structs in soma-runtime. In both cases the trait exists to be dyn-able, not to be implemented by users; the only implementor wraps a closure.


The caching model in one line, from soma-core/src/cache.rs:18:

state = hash(config ‖ x ‖ y)
output = hash(config ‖ state ‖ input_content_hash) + seed salt

Because a downstream key uses the content hash of its input rather than the identity of its producer, an unchanged intermediate cuts off the rest of the graph early — the whole point of the design.

Filter identity is derived, not written: Rust from canonical CBOR of the field list plus #[soma(cache_version)] (soma-macros/src/lib.rs:30), Python from qualname + canonical config + a source-hash ladder (soma-python/python/soma/_identity.py:124). An unhashable config raises CacheConfigError — never a silent key.

Three independent instances, which is how you know it is the codebase’s actual philosophy rather than one clever file:

  • EffectJournal (soma-runtime/src/effects/journal.rs:51) — pure effects keyed by content, impure ones by (run, node, turn, index). Record once, replay forever. Suspension is modelled as an effect, so resume needs no separate checkpoint format.
  • ResearchStep::completed (soma-agent/src/research.rs:87) — reconstructs its record list from ctx.history rather than holding it in a field.
  • The experiment pool (soma-memory/src/record.rs:26) — append-only, with RecordKind::Amendment and an amends field. Nothing is ever rewritten.

Temp file → write_allsync_allrename, at soma-runtime/src/cache/local.rs:69, cache/fs_store.rs:232, tracking/local_tracker.rs:214 and tracking/head.rs:46. FsActionStore also commits blob-first, record-last (soma-runtime/src/cache/fs_store.rs:194), so a crash can leave an unreferenced blob but never a record pointing at nothing.

(!) Four implementations, two of them weaker — D-12.

EnvManager::env_id_for (soma-worker/src/env_manager.rs:362) keys a Python environment by the hash of its requirements rather than by plan id. Same idea as the cache, applied to venvs.

PROTOCOL_VERSION + check_version (soma-worker/src/protocol.rs:33, :311), RECORD_SCHEMA_VERSION (soma-memory/src/record.rs:20), RUN_SCHEMA_VERSION (soma-core/src/tracking.rs), FORMAT_VERSION (soma-runtime/src/cache/fs_store.rs). Every persisted or transmitted format carries a version and refuses a mismatch rather than guessing.


These have no GoF name, and they are where most of the design lives.

Not applied uniformly, and the non-uniformity is the design:

Applied toReason
Data enums — Value, Effect, Event, SomaError, NodeKind, ExecutionPlan, DataRefA consumer need not have an opinion about a new variant, and an old worker must tolerate a new one on the wire
Not applied to — NodeOutcome (soma-core/src/node.rs:37), Transition (soma-core/src/step.rs:38), StreamMode (soma-core/src/filter.rs:32)Every consumer must decide over them. A wildcard arm there is a silent wrong answer, and adding a variant should break every match

The reason is written into each doc comment either way. This is the single most transferable convention in the codebase.

CacheKey([u8; 32]) (soma-core/src/cache.rs:18), Messages(Vec<Message>) (soma-core/src/message.rs:189), ContentHash (soma-core/src/action.rs:52), ShutdownSignal (soma-worker/src/server.rs:33).

(!) The idiom is not applied to NodeId, EdgeId, RunId, StudyId or TrialId, which are all String aliases and therefore mutually assignable — D-56.

fn with_x(mut self, x: X) -> Self. The crate’s dominant construction idiom: Context has 8 (soma-runtime/src/executor.rs:195), GraphSession 7 (graph_session.rs:82), LlmRequest 5 (soma-core/src/effect.rs:201), ExperimentRecord 12 (soma-memory/src/record.rs), plus StepMeta, Node, Edge, Graph, StepCtx, Study, ArchitectureFingerprint, NodeSpec, EffectDriver, GraphHandler, Worker, ProviderConfig, RetryPolicy, SerializedPlan, RetrievalQuery, WorkerRegistry.

(!) Three wide structs skipped it: RunManifest (20 fields, 3-argument constructor), RunSummary (17), Study (15 fields, 2 builders) — D-06.

Every Value payload is Arc-wrapped (soma-core/src/value.rs:15), so Clone is a refcount bump rather than a tensor copy. Arc<dyn Filter> and Arc<dyn Step> in the catalog; Arc<Value> for trained state; and a NodeCatalog clone deliberately shares its StateStore (soma-runtime/src/node_catalog.rs:75).

AsAny (soma-core/src/any.rs:13) with impl<T: Any> AsAny for T — a supertrait of Filter and Step that costs implementors nothing and buys three downcast sites.

Three fan-out sites, all std::thread::scope, no runtime: execute_parallel (soma-runtime/src/executor.rs:1103), perform_all (soma-runtime/src/effects/mod.rs:459), spawn_all (:355). The rationale is at soma-runtime/src/effects/mod.rs:12.

rg async_trait over the workspace returns zero hits. The only async code is the axum servers, and both isolate the boundary properly — spawn_blocking in soma-worker/src/server.rs, and on_own_runtime (soma-worker/src/ws_transport.rs:42) which refuses to assume whether it is inside a runtime.

(!) The cost is real and acknowledged: JoinPolicy::First (soma-runtime/src/effects/mod.rs:417) returns the first success only after every sibling has joined, because “these are threads, not cancellable tasks” (:415).

An unknown variant returns an error naming the situation rather than falling through to a default:

other => Err(SomaError::Execution { … }) // executor.rs:445
// strategy.rs:274
// graph_handler.rs:184
// reader.rs:844

Applied consistently, and one of the codebase’s genuine strengths.

Where the consumer is a model rather than a program, a failure is a message rather than a Result::Err: ToolOutcome (soma-llm/src/tools.rs:182), EffectResult::Failed (soma-core/src/effect.rs:278), and every soma-mcp handler returning ToolCallResult::error(…) instead of a JSON-RPC error (soma-mcp/src/context.rs:100). An agent that can read the failure can retry; one that gets a transport error cannot.

Two anti-corruption layers, same shape: soma-worker’s DAEMON_SCRIPT (soma-worker/src/python_process.rs:19) and soma-mcp’s DRIVER (soma-mcp/src/exec.rs:26). Both swap sys.stdout away from the protocol channel so a user’s print cannot corrupt it. (!) Both live in Rust string constants — D-19.

GraphHandlerEffectDriverGraphHandler is a real cycle — a graph can be a tool for an agent that is itself a node in a graph. It terminates because MAX_GRAPH_DEPTH = 8 (soma-runtime/src/effects/graph_handler.rs:28) and child_driver (:112) refuses past it.


soma-python/python/soma/_graph.py:35 — 23 methods assigned as class attributes rather than monkey-patched at import time. The docstring at :9 is an explicit argument for why: the previous approach was invisible to help(), IDEs and mypy; the surface differed per program depending on which modules were imported; and three methods silently shadowed Rust methods of the same name.

A step is any object with poll(ctx). A transition is a plain dict. The rationale at soma-python/python/soma/agentic.py:103: “what crosses into Rust is data rather than a class hierarchy.”

(!) The cost is a stringly-typed seam kept in sync across Rust, the .pyi stub and the Python constructors by literal — D-54.

SearchDescriptor.__set_name__ / __get__ / __set__ (soma-python/python/soma/search.py:55) plus the FilterMeta metaclass (filter.py:9) turn search(...) at class level into a search space, so soma.Agent(model=search(...)) and a filter’s hyperparameters fold into the same search_space().

torch missing → DifferentiableFilter = None and 8 audit names None (soma-python/python/soma/__init__.py:31, :59). plotly and pandas lazily imported inside _go() (viz/_figures.py:18) and _pandas() (viz/_frames.py:10), so the methods always exist and only calling them needs the somatize[viz] extra.

Five _repr_html_ implementations — PyGraph (soma-python/src/graph.rs:1856), RunView, RunList, CompileInfo, DifferentiableFilter. Evaluating an object in a notebook draws it. Graph::to_svg (soma-core/src/svg.rs) exists specifically because notebooks sanitize <script>, so a mermaid block would not render.


Worth naming, because their absence is a decision rather than an oversight.

AbsentWhy
TypestateNo PhantomData anywhere. State that matters is checked at compile time (compile()) or refused at runtime, not encoded in type parameters
Generic traitsNo public trait has a type parameter or associated type — that is what keeps all 29 of them object-safe
async_traitZero uses. Every trait is synchronous; concurrency is threads
Inheritance simulationNo trait hierarchies beyond AsAny as a supertrait. Composition and dyn everywhere
A DI containerDependencies are constructor arguments and with_* builders
SingletonsNo lazy_static or OnceCell globals in the domain crates
Error-type-per-crateThree error enums workspace-wide, deliberately. The decision, and what it was chosen over, is at Architecture Decisions

Every entry here is in the Debt Register; this is the short version, organized by which pattern failed.

PatternWhere it broke
Compositeresolve_distribution / collapse_differentiable do not use children() and skip Loop/BranchD-32
Template methodTransport::execute_node’s default is wrong for every caller, and its own doc says so — D-41
StrategyRunner and RemoteRunner — a strategy interface with one live implementation and one dead one — D-34
Shared primitivesrun_node and StreamRun::run_compute share three primitives and duplicate everything around them, and have drifted — D-11
NewtypeNot applied to any id type — D-56
BuilderThree wide structs skipped it — D-06
Atomic commitFour implementations, two without fsync — D-12
DecoratorTieredCache promotion discards the provenance it decorates — D-46
Facadesoma covers 10 of 13 crates — D-83