Skip to content

Why App is not a container

The shape it deliberately is not

The obvious design for an application context is a bag: a map from type to service, with get::<T>() to fetch one and register::<T>() to put one in. The Go side of this toolkit does something close to that with its Props struct, and plenty of Rust frameworks reach for HashMap<TypeId, Box<dyn Any>> for the same reason.

rtb_app::App is not that. It is a plain struct with named, typed fields:

pub struct App {
    pub metadata: Arc<ToolMetadata>,
    pub version: Arc<VersionInfo>,
    pub assets: Arc<Assets>,
    pub shutdown: CancellationToken,
    pub credentials_provider: Option<Arc<dyn CredentialProvider>>,
    // plus three crate-private fields
}

There is no get, no register, no Box<dyn Any> bag and no runtime registration step. A downstream tool does not add services to an App — it constructs one with the services the framework defines and hands that to its handlers.

What the typed version buys

A missing service is a compile error. app.assets either exists or the code does not build. In a container, a missing registration is a runtime None or a panic on the unlucky code path, discovered by whichever user runs the command nobody tested.

No downcast at the point of use. app.metadata is already an Arc<ToolMetadata>. There is no TypeId lookup, no Any round-trip, no .expect("metadata registered") at the top of every handler.

The context is readable. The set of things a command can rely on is the field list, visible in one place. With a container, the answer to "what is available here" is "whatever was registered by whatever ran first", which is only answerable by reading the startup path.

Clone is honestly cheap. Every field is reference-counted, so App::clone() is a handful of refcount increments. Handlers take App by value and fan-out code clones freely into spawned tasks without anyone having to think about it. That is a consequence of the fields being known: a heterogeneous bag can make the same promise, but you have to trust that nothing bulky got registered.

What it costs

Adding a field to App is a breaking change for anyone constructing one by struct literal, and adding a parameter to App::new is a breaking change for everyone. The crate has taken that hit repeatedly — credentials_provider in 0.4.0, type-erased config and typed_config_ops in 0.4.1, trailing_args in 0.7.0. A container would have absorbed all of those silently.

That is the trade, stated plainly: the framework accepts a breaking-change cost on its own release cadence in exchange for downstream code that cannot fail to find a service at runtime. Pre-1.0, with releases driven by Conventional Commits and a cargo-semver-checks gate on the release MR, that cost is bearable. It would be a harder argument to make after 1.0.

A downstream tool cannot add its own service to the App. There is nowhere to put it. Tools that need to thread their own state through commands put it in their typed configuration, or in the command struct itself — the Command implementation is your type, and it can hold whatever you like.

Why the last three fields are private

config, typed_config_ops and trailing_args are pub(crate) while the rest are pub. That is not inconsistency for its own sake — those three are the ones where direct field access would give you something useless or misleading:

  • config is an Arc<dyn Any + Send + Sync>. Reading it directly tells you nothing; you need typed_config::<C>() to get anything back.
  • typed_config_ops is only meaningful paired with the matching config. Exposing it invites mismatched pairs, which fail silently.
  • trailing_args is stored as Arc<[OsString]> and read as &[OsString]; the accessor is the useful shape.

The public fields stay public for call-site ergonomics — app.metadata.name reads better than app.metadata().name() and nothing is lost by allowing it.