Modeling — how to think in Aperio
α — Given the primitives, how do you actually use them to model a real system?
This is the synthesis chapter. The previous seven cover what the primitives are. This one is about how to compose them into idiomatic programs — and, just as importantly, what to do when the language seems to resist your design.
The one-tower rule
The deepest commitment Aperio makes about modeling is this:
Every named quantity in your model must be assignable to exactly one locus in one locus tower.
This isn’t a style guideline. It’s structural: every other guarantee Aperio makes (wholesale-free at dissolve, vertical-only flow, the closure-violation channel, deterministic cleanup cascade) depends on each piece of state having exactly one owning locus. When state floats — when some buffer is “shared” between two loci, or there’s a global registry nobody owns, or a configuration value “lives in the environment” — the guarantees unravel at the floating point.
When the language seems to resist where you want to put a piece of state, the productive move is not to invent a workaround. It’s to ask: which locus should own this? That question almost always has a structural answer; the answer is the productive move.
(A forthcoming pond
library, memory-owner-architecture, develops this rule into
concrete patterns and helpers for declaring ownership and
verifying the assignment. This chapter will link to it when
it ships.)
The seven idiomatic patterns
Every well-shaped Aperio program is composed of seven recurring patterns. If your code doesn’t fit one of these, reconsider before inventing — the catalog is small on purpose, and most “I need an eighth pattern” instincts turn out to be one of the seven in a foreign shape.
1. App locus — outer encapsulation
Every app’s main.ap defines a top-level locus that owns the
whole run. fn main() reads argv, instantiates the locus,
exits.
locus Onboard {
params {
dir: String = "fixture";
flavor: String = "go";
}
run() {
drive(self.dir, self.flavor);
}
}
fn main() {
let mut dir = "fixture";
let mut flavor = "go";
if std::env::args_count() > 1 { dir = std::env::arg(1); }
if std::env::args_count() > 2 { flavor = std::env::arg(2); }
Onboard { dir: dir, flavor: flavor };
}
Conventions:
- Locus name is the file stem in PascalCase.
paramsholds argv-derived config with reasonable defaults (so the app self-demos with no flags).run()is the only lifecycle method needed for most apps.main()does argv parsing, then a single statement-position locus literal kicks the run.
2. Namespace lotus — empty params, methods only
When a coherent vocabulary of pure helpers forms, wrap them
in a locus with empty (or config-only) params { } and
methods only. Instantiate once, dispatch through it. The
language’s substitute for “module of functions” / “static
class” / “stateless service object.”
locus Morpheme {
params {
flavor: String = "go";
overrides: String = "";
}
fn lookup_morpheme(m: String) -> String { ... }
fn name_to_motion(name: String) -> String {
let hit = self.lookup_morpheme(name);
// ...
}
}
fn main() {
let r = std::lang::Morpheme { flavor: "go" };
let motion = r.name_to_motion("OrderProcessor");
}
The point isn’t that params is literally empty — no
lifecycle state mutated by birth/run/dissolve. Config params
are fine. Self-method calls compose within the namespace.
One alloc per instantiation; negligible.
3. Service locus — long-lived with lifecycle + bus
When the thing genuinely runs over time and participates in the bus, write the full lifecycle.
locus Listener {
params {
host: String = "127.0.0.1";
port: Int = 0;
listen_fd: Int = -1;
max_accepts: Int = 1;
on_connection: fn(std::io::tcp::Stream) = default_on_connection;
}
birth() {
self.listen_fd = std::io::tcp::listen_socket(self.host, self.port);
}
run() {
let mut accepted = 0;
while self.max_accepts < 0 || accepted < self.max_accepts {
let conn = std::io::tcp::accept_one(self.listen_fd);
handle_one_connection(conn, self.on_connection);
accepted = accepted + 1;
}
}
dissolve() {
std::io::tcp::close_fd(self.listen_fd);
}
}
Conventions:
birth()acquires resources; mutatesself.field.run()does the long-lived work; often a loop bounded by config.dissolve()releases whatbirth()acquired.- Sentinel values (
-1for “not yet bound”) letdissolve()safely no-op on partially-constructed loci.
4. Spawned child — let-bound, scope-dissolves
When a parent’s work produces children that need their own lifecycles, let-bind. The let-bound locus’s dissolve fires at the enclosing function’s scope exit; the binding stays valid for method calls in between.
fn handle_one_connection(conn_fd: Int, on_conn: fn(std::io::tcp::Stream)) {
let s = std::io::tcp::Stream { conn_fd: conn_fd };
on_conn(s);
}
The let s = ... binds the Stream locus to the fn’s scope;
when handle_one_connection returns, s.dissolve() fires
(which closes conn_fd). No explicit cleanup call needed.
Conventions:
- Use let-binding when the locus needs to live for a fn body’s full duration. Statement-position literals dissolve at end of expression — rarely what’s wanted for a usable handle.
- Per-iteration cleanup uses a free helper fn whose return is the per-iteration boundary (the example above is exactly this pattern).
5. Shape type — pure data, no flow
When a thing IS data, not flow, declare it as type.
type Request {
method: String;
path: String;
version: String;
body: String;
}
Construct via struct literal:
let req = std::http::Request {
method: "GET", path: "/", version: "HTTP/1.1", body: ""
};
Conventions: PascalCase, snake_case fields, returnable by
value, no lifecycle implications. Types may hold fn(...)
fields — dispatch via record.field(args). If methods
accumulate, the thing has flow — promote type to locus.
6. Free fn — first-class seed member
Free fns are first-class seed members. Every top-level decl in a seed is visible to every file in the seed. Use a free fn when the operation has no flow and isn’t naturally a method on an existing locus.
Common shapes:
- Return-bearing helpers called from lifecycle method
bodies (which reject
returnat v0). - Extension hooks passed via fn-pointer params (e.g.,
on_connection: fn(Stream)). The hook is named at the top level so a caller can pass it by name. - Standalone helpers that compose with the rest of the seed: format / parse / convert / classify utilities that don’t carry state.
When a coherent vocabulary of three or more free fns forms, the namespace-lotus form (pattern 2) often reads better.
7. Error-check function — bridging the channels
A locus member fn whose signature is fn(ErrType) -> SuccessType,
used as the fallback in an or self.handler(err) clause at a
fallible call site. Internally, it examines the error and
chooses: return a value (substitute, continue) or
violate NAME (escalate to the structural channel).
locus DbConnection {
params { conn_fd: Int = -1; last_error: String = ""; /* ... */ }
bus { subscribe ExecuteQuery as on_query; publish QueryResult; }
closure fatal_io { captures: last_error; epoch inline; }
fn handle_io(e: DbError) -> Row {
self.last_error = e.detail;
if e.kind == "send_failed" || e.kind == "recv_empty" {
violate fatal_io;
}
return Row { data: "" };
}
fn on_query(q: Query) {
let r = send_query(self.conn_fd, q) or self.handle_io(err);
if !self.draining { QueryResult <- r; }
}
}
This is the canonical bridge between the value channel and the structural channel — see The two failure channels §“Bridging the channels” for the full treatment.
Conventions:
- Naming. snake_case. Name for what is being handled,
not what’s being done:
handle_io,handle_parse,handle_timeout— notrecover_or_die. - Signature. The return type is the success type of the
call sites that use this handler. One handler per
(ErrType, SuccessType)pair on a given locus. - Body shape.
if-chain ormatchon the error kind; each arm eitherviolates a named closure orreturns a substitute value. The two motions are exhaustive — the typechecker ensures every path either returns the success type or diverges viaviolate. - Closure references.
violate NAMEis locus-scoped — the named closure must be declared on the same locus. This is why the handler is a member fn, not a free fn.
Anti-patterns
The shapes below are almost always “an old habit from another language smuggled past the substrate.” When you catch yourself reaching for one, reconsider.
- Bare
fn main()with helpers and no outer locus. The app’s outer encapsulation must be a locus per pattern 1. - Coherent helper vocabulary stranded as free fns when it forms a namespace. Lift into a namespace lotus once the coherence is visible (pattern 2).
typefor things that have flow. If the noun has a lifecycle implied (a Cache that’s loaded/probed/evicted, a Server that starts/serves/stops), it is a locus, not a type.- Methods on a
typerecord. Not supported at v0 — the language is telling you “this wanted to be a locus.” - “Util” namespaces of unrelated helpers. Group by vocabulary, not by “everything that didn’t fit elsewhere.” A namespace lotus should answer one question (“noun-to-motion”, “tagged-accumulator parsing”), not many.
- Floating quantities. Per the one-tower rule: every named quantity should be assignable to one locus. State that “lives between” loci is modeling error.
- Tagged-locus dispatch. A single locus with a
kind: Stringparam branching on every method, instead of an interface and multiple loci. The structural-interface primitive (F.20) is the right tool. - Fluent-builder chains that mutate self. If you’re
writing
obj.with(x).with(y).build(), the thing wanted to be a locus with properparamsand lifecycle.
A worked example: choosing the model
To make the modeling rules concrete, here’s a small system walked through pattern-by-pattern:
“I need a rate-limiter that bounds a downstream service’s request rate. Requests come in over the bus. When the downstream is overloaded, the limiter should emit a backpressure signal upstream.”
Step 1: identify the loci.
The rate-limiter is a service locus (pattern 3): it has state (the recent-request window), lifecycle (birth → run → dissolve), and bus participation. One locus.
What about the downstream service? Probably a separate locus, also pattern 3. The two coordinate through the bus, not through direct reference.
The backpressure signal: not a locus, it’s an event. A topic
(Backpressure { payload: ... }).
The “request”: same — a topic (Request { payload: ... }).
The “recent-request window”: held by the rate-limiter, in a
capacity slot. @form(ring_buffer) is the right shape — we
want a bounded window with drop-on-full.
Step 2: sketch the locus.
type Req { id: String; ts: Time; }
topic Request { payload: Req; }
topic Backpressure { payload: Req; }
@form(ring_buffer, cap = 100)
locus RateLimiter {
params { window_ms: Int = 1000; threshold: Int = 50; }
capacity { pool recent of Req; }
bus {
subscribe Request as on_request;
publish Backpressure;
}
fn on_request(r: Req) {
self.recent.push(r);
if self.over_threshold() {
Backpressure <- r;
}
}
fn over_threshold(self) -> Bool {
// ...
}
}
Step 3: check against the patterns.
- Pattern 3 (service locus): ✓
- Capacity slot for the window: ✓ (
pool recent of Reqwith@form(ring_buffer)) - Bus subscribe / publish: ✓
- One-tower:
recent,window_ms,thresholdall owned byRateLimiter. No floating quantities. - Anti-patterns: none.
Step 4: where would friction surface?
- If the rate-limiter needs to track which client was
rate-limited, we’d add per-client state — maybe a
@form(hashmap)keyed by client ID. That’s a second capacity slot, still one-tower. - If multiple rate-limiters need to coordinate (one per
service, sharing a global cap), they’d coordinate
through a parent locus that holds the global budget. Bus
topic
GlobalBudgetbetween them. - If we wanted to deploy the limiter as a separate binary
from the downstream, we’d add a
bindingsblock in main to route the Request topic through a Unix socket.
Notice how each “what if” stays inside the pattern catalog. You don’t reach for a new primitive; you compose what you have.
A reading order, going forward
You’ve finished Concepts. The two natural next steps:
- Read the Reference section for the canonical formal definitions of every construct. The spec corpus is the source of truth.
- Read working examples. The
apps/directory has 11 real programs exercising every pattern in this chapter. Pick one close to what you want to build and read it end-to-end.
If you’re building a multiplayer game, the matchmaker example from the introduction grows into a complete system — matchmaker locus, per-match game session loci, terminal client loci, all composed through the bus. The “Build a real app” tutorial walks through that build (forthcoming).