Extensibility
Two ways to add features: in-tree (monorepo) and external (third-party crate).
A project built on architect can be extended along two axes, and both are first-class. Understanding the difference is mostly about understanding who depends on what.
The two axes#
┌──────────────────┐ ┌──────────────────┐
│ <feature> │ ◄──── contract ──┤ <feature> │
│ default features│ │ default features│
└──────────────────┘ └──────────────────┘
▲ ▲
│ │
┌────────────┴───────────┐ ┌───────────────┴────────────┐
│ in-tree implementations│ │ external implementations │
│ │ │ │
│ features/<feature>/ │ │ someone-elses-crate │
│ src/<backend>.rs │ │ on crates.io │
│ │ │ │
│ shipped with the app │ │ brought in à la carte by │
│ binary by default │ │ consumers who want it │
└─────────────────────────┘ └────────────────────────────┘
Both implement the same #[architect::service] trait that the
feature crate’s default feature set (its lib.rs) declares. From the trait’s perspective, in-tree and external impls are
indistinguishable.
Path 1 — In-tree (monorepo)#
The implementation is a cargo feature of the feature’s own crate
(layout): a module beside the contract,
compiled only when its flag is on. It lives in the same workspace,
ships from the same Cargo.lock and is pulled into the app binary
directly.
features/inventory/
Cargo.toml package `inventory`
src/lib.rs contract (default features)
src/memory.rs in-tree impl, flag `memory` ←
src/db.rs in-tree impl, flag `db` ←
The app binary opts in to the flags it needs:
# apps/app/server/Cargo.toml
inventory = { workspace = true, features = ["db", "vox"] }
Use this when:
- You own the implementation and it’s specific to this project
- You want zero coordination cost (PR straight into the repo)
- The impl needs to track contract changes in lockstep
- Versioning is handled by
git tagon the monorepo, not crates.io
DAW analogue: daw-reaper, daw-protools, daw-ableton —
written by the team that owns the daw repo, each a flag
(reaper, protools, …) of the feature crate. The daw binary builds
with whichever combination of cargo features that project’s release
needs.
Path 2 — External (third-party crate)#
Crate lives in someone else’s repo, publishes to crates.io, depends only on the feature crate with its default features (the contract). A consuming project pulls it in like any other dependency.
The contract crate is the only required surface. Everything else (in-tree backend flags, app binary) is convenience — third parties can replace any of it.
# Cargo.toml of a project consuming an external impl
[dependencies]
inventory = { version = "0.2", default-features = false }
inventory-redis = "0.1" # third-party — implements inventory::ItemRepo
Then in the consumer’s binary:
use inventory_redis::RedisBackend;
let backend = RedisBackend::new(/* … */);
// mount exactly like the in-tree backends (it lists its `Services` bundle)
EngineHost::new(backend.into_router(), gate, bind).serve().await?;
Use this when:
- The implementation is reusable across projects (“a Stripe backend for any architect-shaped billing feature”)
- Different teams own the contract and the impl
- The impl wants its own release cadence
- The contract is stable enough that third parties can rely on it without coordinating
DAW analogue: someone outside the daw team writes daw-logic —
a Logic Pro backend that implements the same timeline::TimelineRepo
mixing::MixingRepotraits asdaw-reaperdoes in-tree. They publish to crates.io. A user can then build their ownmy-dawbinary that pullsdaw-cli(in-tree first-party backends) ANDdaw-logic(external) — both implement the same contracts, both mount on the same vox dispatcher.
What changes between the two paths#
| Concern | In-tree | External |
|---|---|---|
| Where does the source live? | features/<feature>/src/<x>.rs | Wherever the author wants |
| What does it depend on? | the contract in the same crate | <feature> with default features (crates.io or git) |
| How is it enabled? | cargo feature on the feature crate | direct dep + manual wiring in the binary |
| Who maintains the version? | This project’s git history | The author, via crates.io semver |
| Can it be added without a fork? | No — needs a PR | Yes — cargo add and wire it in |
The contract (the feature crate’s default feature set) is the boundary that makes the
difference invisible to the running code. Both paths produce a struct
that implements the same #[architect::service] trait; the vox router
takes either one and serves it on the same WebSocket.
How to design a contract that supports both#
Two rules that have to hold for external impls to be possible at all:
1. The feature crate’s default feature set must build without backend flags.
If the trait declaration depends on SeaORM or any other server-side
machinery, external impls inherit that constraint. The default
feature set is wasm-clean (which architect’s Entity derive
already enforces) so a third party can implement the trait against
a Redis backend or a remote HTTP API without pulling in a SQL
dependency they don’t want.
2. Backend flags are optional, not required.
The in-tree backend flags are a convenience for first-party builds.
Third parties depend on the feature crate with
default-features = false and no backend flag, and bring their own.
Dependency injection: repo and service#
Both halves of the architect surface are traits. They swap independently:
┌──────────────────────────────────────────────────────────────────────┐
│ <feature> (default features) declares two traits + the wire types │
│ │
│ pub trait <T>Repo { async fn get/list/create/update/delete } │
│ pub trait <T>Service { async fn search/duplicate/... }│
└──────────────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
pick a repo: pick a service: binary assembles:
- SeaORM (architect - architect doesn't let repo = ...;
emits this when emit one — write let svc = MyService::new(repo.clone());
`server-seaorm`) it by hand mount(<T>RepoDispatcher::new(repo));
- in-memory mount(<T>ServiceDispatcher::new(svc));
- sqlx/postgres
- HTTP proxy
- mock for tests
Concrete consequences:
- A third party can replace the architect-emitted SeaORM repo.
Leave
architect/server-seaormoff the contract and the derive never compiles in the SeaORM bridge. Write your own struct implementing<T>Repoagainst sqlx/postgres/redis/whatever. - A third party can replace the service.
<T>Serviceis a hand-written#[architect::service]trait — anyone can write a different impl with different business rules, mount it on the same dispatcher. - A binary picks one of each. No fixed coupling between “architect-generated repo” and “architect-generated service” because the second doesn’t exist — services are always yours.
architect’s feature flags reflect the split:
| Feature | Pulls | What you get |
|---|---|---|
vox | vox (+ moire + facet-cbor + …) | Makes the architect-emitted <T>Repo trait a #[vox::service] — generates dispatcher + client. Without this, the trait is plain Rust 2024 async-in-trait, RPC types don’t exist. |
server-seaorm | sea-orm + async-trait | The SeaORM bridge → <T>RepoStorage<C> impl. Only enable if you want the architect-emitted SeaORM repo. |
server-axum | axum + tokio + futures + tracing + vox-core + vox-types | The architect::axum_ws adapter for mounting any dispatcher on an axum WS route. |
full | all of the above | tokio-style convenience: turns everything on. |
They’re all independent. Three useful combinations:
# In-process trait use only — drives the repo from Rust, no RPC, no
# storage opinion. Pulls only `architect` + `facet`.
architect = { ..., default-features = false }
# Client crate for a vox-served binary — wasm app, native CLI, etc.
# No storage bridge, no axum.
architect = { ..., features = ["vox"] }
# Custom server (your own repo impl) — picks transport but skips the
# SeaORM emission. No `sea-orm` in the dep tree.
architect = { ..., features = ["vox", "server-axum"] }
# Everything. Single-binary first-party app, dev experimentation.
architect = { ..., features = ["full"] }
A feature crate maps its own flags onto these: vox turns on
architect/vox, a db backend turns on architect/server-seaorm, and
the default set turns on neither.
The working example#
examples/greeter is the live demonstrator. Its backend (Memory) is a
plain struct that implements the contract traits, and
examples/greeter/tests/round_trip.rs runs one generic scenario,
async fn scenario(client: &impl Greeter), against it in process, over
vox and over HTTP. The test body never names the concrete backend or
wire, which is the proof that the contract is the only thing a consumer
depends on. An out-of-tree backend is the same shape in another
repository: depend on the contract crate and architect, implement the
trait, list the Services bundle.
(Earlier versions of this page described external-stub and
custom-server demonstrators; they left the tree with the example app.)
When you want both at once#
A real DAW build will mix paths. The same binary might use
daw-reaper (in-tree) for timeline, daw-mock (in-tree) for tests,
and daw-cloud-sync (external, on crates.io) for collaboration.
Architect doesn’t care — every backend implements the same trait,
the vox dispatcher takes anything that does, and cargo features
gate compilation.
Linked from
- Scripts, extensions and canvasesLet people and models add to the app without rebuilding it: Luau scripts over the Action List as one undo step, extensions with manifests, registries and hot reload, process extensions with remote views, and ImGui-shaped canvases over a view.
- Extensionsarchitect-extensions: packages that add actions, settings, views and document data at run time; data, Luau and process kinds; git, folder and ReaPack sources; hot reload; a project's required extensions under an org's policy.
- ArchitectureThe patterns the architect template is showcasing.
- Multi-backend featuresOne contract, swappable storage.
- Actions everywhere: bindings, ⌘K and ⌘OREAPER's Action List, taken further: everything any UI does is an action, any action binds to any number of keys, sequences, held-letter mouse tools, MIDI or OSC, per context; which-key, a ⌘K palette and a ⌘O open-anything come free.
- Extensions and scriptsAdding to an app at runtime, without rebuilding it: data-only extensions, WASM components over a versioned WIT world drawn from the Action List, Luau scripts for macros, out-of-process plugins on a desk — with capabilities, and views as frame documents.