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:
configis anArc<dyn Any + Send + Sync>. Reading it directly tells you nothing; you needtyped_config::<C>()to get anything back.typed_config_opsis only meaningful paired with the matchingconfig. Exposing it invites mismatched pairs, which fail silently.trailing_argsis stored asArc<[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.