Construction: Resource & Scope
Build the backend — config → pool → repo — with dependent composition, build-once sharing, and LIFO teardown.
Layer<B> answers “given a backend, mount its services”
(idioms §6). architect::resource answers
the step before that: build the backend, with dependencies,
sharing, and ordered cleanup. It’s the Effect-Layer idea (dependency
wiring + scoped resources) as plain value-level combinators — no
Effect<A,E,R> monad, no typed requirement tracking, no feature flag
(the Scope/Resource surface is transport-agnostic and wasm-clean).
The two nouns#
Resource<T, E = eyre::Report>— a lazy, composable recipe that builds aT. Nothing runs until.build(&scope). Name a concreteEfor typed failures; the default is fine for binaries.Scope— collects async finalizers and runs them in reverse registration order (LIFO) onscope.close().await. The pool opened after the config is closed before it. A secondclose()is a no-op.
let scope = Scope::new(); // Arc<Scope>
let value = recipe.build(&scope).await?; // run the recipe
// … use value …
scope.close().await; // finalizers, LIFO
The scope is an Arc so the resource graph and the eventual owner —
a server’s shutdown path — share the same one.
Combinators#
| Combinator | Effect |
|---|---|
Resource::succeed(v) | already a value — no work, no failure |
Resource::from_fn(f) | build from an async closure; f receives the Scope so it can defer cleanup or build sub-resources |
Resource::acquire_release(acquire, release) | build via acquire, register release as a LIFO finalizer (needs T: Clone) |
.and_then(f) | dependent composition — f: T -> Resource<U>, the config → pool → repo chain; both steps’ finalizers stack on the same scope |
.zip(other) | independent pair — build both, return the tuple |
.map(f) / .map_err(f) | transform the value / the error |
SharedResource::from_fn(f) / .resource() | build once, share — see below |
.into_router(&scope) | terminal step on the server: build a Services backend and mount its bundle → LayerRouter |
The real thing: a server#
A server builds its sqlite backend exactly this way (the example server this snippet comes from is no longer in the tree) — the pool is acquired under the scope so Ctrl-C closes it gracefully:
use example::architect::{Resource, Scope};
fn resource() -> Resource<ExampleRepoStorage<DatabaseConnection>> {
// config → pool (acquire_release: closed on scope teardown) → repo
Resource::from_fn(|_| async {
Ok(std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite://./example.db?mode=rwc".into()))
})
.and_then(|url| {
Resource::acquire_release(
Resource::from_fn(move |_| async move {
let db = Database::connect(&url).await?;
Migrator::up(&db, None).await?;
Ok(db)
}),
|db: DatabaseConnection| async move {
tracing::info!("closing database pool");
let _ = db.close().await;
},
)
})
.map(ExampleRepoStorage::new)
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
let scope = Scope::new();
let repo = resource().build(&scope).await?;
axum::serve(listener, vox_router(repo, collab))
.with_graceful_shutdown(shutdown_signal()) // Ctrl-C
.await?;
scope.close().await; // ← the pool's finalizer runs here
Ok(())
}
The in-memory backend is the degenerate case —
Resource::succeed(ExampleRepoMemory::new()) — which is why backend
selection stays a one-line cfg swap in the bin while the rest of the
chain is identical.
Memoization: one pool, many repos#
Backends often share a resource. SharedResource::from_fn(f) takes a
recipe (f: Fn(Arc<Scope>) -> Future<Output = Result<T, E>>) and gives
a cloneable handle: the first .build(&scope) (on any clone) runs the
recipe and caches the value; every later build returns the same
instance. Failed builds are not cached — the next build runs the
recipe again, which is why it is Fn and not FnOnce. .resource()
views the shared handle as a fresh Resource so it feeds further
.map/.and_then chains (crates/architect/src/resource.rs
SharedResource):
let pool = SharedResource::from_fn(|scope| async move {
pool_resource().build(&scope).await // builds at most once
});
let users = pool.resource().map(UserRepoStorage::new);
let orders = pool.resource().map(OrderRepoStorage::new);
let (users, orders) = users.zip(orders).build(&scope).await?; // one pool
Feeding the service layer#
A construction chain on the server terminates in
Resource::into_router (vox-gated): build the backend, then mount its
Services bundle —
Resource<B> → LayerRouter:
let router = resource().into_router(&scope).await?; // build + into_router()
// serve router …
scope.close().await;
That’s the whole pipeline: Resource builds the backend, Layer
binds its services, the transport in front of the LayerRouter is
chosen last — axum WebSocket for a server, the
in-process transport for desktop/CLI/tests.
For multi-node backend graphs there’s also a pure-data planner —
LayerGraph/LayerNode::plan() returns a topological build order or a
precise diagnostic (missing provider, conflict, cycle, duplicate). See
crates/architect/src/plan.rs and its tests.
Scope rules of thumb#
- One scope per lifetime. A process gets one (closed after the
server stops); a test gets its own (closed at the end — the e2e
suite’s
local_transport_round_tripdoes exactly this). deferfor anything that must outlive an await. Finalizers are async;scope.defer(move || async move { … })is the escape hatch whenacquire_releasedoesn’t fit the shape.- LIFO is the contract. Registration order is teardown order,
reversed — verified by
resource.rs’s own unit tests (scope_closes_finalizers_lifo).
Linked from
- Build a feature, end to endDefine an entity and a service, write a backend, serve it, consume it — the whole architect flow.
- The backendImplement a service and its repo, add storage and migrations, compose layers, read the caller, and record each call as one wide event.
- ArchitectureThe patterns the architect template is showcasing.
- Idioms & enforcementThe bearcove/vox + Dioxus conventions architect follows, and what keeps them honest.