Skip to content

Extensibility

Two ways to add features: in-tree (monorepo) and external (third-party crate).

4 min read

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 tag on 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::MixingRepo traits as daw-reaper does in-tree. They publish to crates.io. A user can then build their own my-daw binary that pulls daw-cli (in-tree first-party backends) AND daw-logic (external) — both implement the same contracts, both mount on the same vox dispatcher.

What changes between the two paths#

ConcernIn-treeExternal
Where does the source live?features/<feature>/src/<x>.rsWherever 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 cratedirect dep + manual wiring in the binary
Who maintains the version?This project’s git historyThe author, via crates.io semver
Can it be added without a fork?No — needs a PRYes — 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-seaorm off the contract and the derive never compiles in the SeaORM bridge. Write your own struct implementing <T>Repo against sqlx/postgres/redis/whatever.
  • A third party can replace the service. <T>Service is 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:

FeaturePullsWhat you get
voxvox (+ 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-seaormsea-orm + async-traitThe SeaORM bridge → <T>RepoStorage<C> impl. Only enable if you want the architect-emitted SeaORM repo.
server-axumaxum + tokio + futures + tracing + vox-core + vox-typesThe architect::axum_ws adapter for mounting any dispatcher on an axum WS route.
fullall of the abovetokio-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

architect·Published with architect