The Rust Engineering Handbook / Chapter 48
Generic, Concrete, and Dynamic Boundaries
Place parametric flexibility, concrete stability, closed variation, callbacks, and runtime polymorphism at deliberate API seams.
An API review begins with a signature that is technically flexible and practically exhausting:
pub fn run<S, D, F, R, E>(
source: S,
decoder: D,
filter: F,
retry: R,
) -> Result<Pipeline<S, D, F, R>, E>
where
S: Source<Error = E> + Send + Sync + 'static,
D: Decoder<S::Item, Error = E> + Clone + Send + Sync + 'static,
F: Fn(&D::Output) -> bool + Clone + Send + Sync + 'static,
R: RetryPolicy<E> + Clone + Send + Sync + 'static,
E: Error + Send + Sync + 'static;
Nothing here is automatically wrong. Every parameter may support a real implementation. Yet the caller must choose five type arguments, satisfy coupled associated types, interpret a long diagnostic, compile a distinct pipeline shape, and name or infer a return type that exposes the library’s assembly. The signature exports the implementation’s variability budget to every caller.
The repair is not “replace all generics with Box<dyn Trait>.” Dynamic dispatch would move some costs and constraints rather than remove them. The governing decision is: put variation at the narrowest boundary that needs it, and choose its representation from who selects the implementation, when selection occurs, whether the set is closed, and which costs the API may impose.
This chapter traces that decision across generic inputs, concrete outputs, opaque returns, enums, callbacks, trait objects, plugins, test seams, and ABI boundaries. Its central example keeps one domain operation—delivering an event—constant while changing only the abstraction boundary. That comparison exposes trade-offs without pretending one mechanism wins everywhere.
Review in two passes. First delete variability that callers do not own. Then classify every remaining choice by selection time, whether its set is open, and whether callers must store its concrete type. Only after those decisions should allocation, dispatch, compilation, diagnostics, and ABI costs select a representation. This order prevents a mechanism such as generics or dyn from manufacturing its own requirement.
First remove variability that is not part of the caller’s job
Before choosing a dispatch mechanism, ask whether the parameter should exist. If every supported source yields the same Event, every decoder ultimately constructs the same validated event, and retry policy is an operational property of Relay, the public entry point can absorb those details behind concrete types:
pub struct RelayOptions { /* validated policy */ }
pub struct Relay { /* private pipeline */ }
impl Relay {
pub fn start(options: RelayOptions) -> Result<Self, StartError>;
pub fn submit(&self, event: Event) -> Result<Receipt, SubmitError>;
}
Concrete does not mean inflexible internally. Relay can contain generics in private modules, enums, trait objects, or platform-specific implementations. It means callers depend on a stable domain surface rather than the assembly strategy. This is often the best default for an application-facing library whose job is to provide one coherent service.
Keep a public parameter only when callers need to supply meaningfully different behavior. Then identify the selection axis:
- Compile-time, open set: callers implement a trait and each use has a statically known type. Consider a generic parameter.
- Runtime, open set: callers choose among independently implemented values at runtime. Consider a trait object.
- Runtime, closed set: the library owns all variants and wants exhaustive handling. Consider an enum.
- One operation, often local: callers inject behavior without a full named abstraction. Consider a callback.
- No caller choice: expose a concrete type or hide the concrete implementation behind
impl Trait.
The matrix is a starting model, not an automatic answer. Object safety, ownership, allocation, code size, inference, compatibility, and error quality can change the decision.
Generic inputs give the callee a statically known implementation
The fixture’s generic function is direct:
pub trait Transport {
fn deliver(&self, event: &Event) -> Delivery;
}
pub fn deliver_generic<T: Transport>(transport: &T, event: &Event) -> Delivery {
transport.deliver(event)
}
Each call has a concrete T. The compiler can type-check and optimize against that type, and may monomorphize machine code for the instantiations used. Static dispatch does not guarantee inlining, zero overhead, or smaller code. Those are implementation and optimization outcomes to measure. The semantic benefit is stronger: the implementation type participates in the function’s type and no runtime type erasure is required.
Generic input is valuable for foundational adapters, collection algorithms, serialization strategies, storage backends used per instance, and performance-sensitive inner boundaries where callers naturally know the type. It also permits traits with associated items or methods that are not dyn-compatible.
Its costs appear in public use:
- bounds and associated-type relationships enter diagnostics;
- each distinct type can add code generation and compile work;
- the concrete type propagates into structs such as
Relay<T>; - changing bounds may break downstream code;
- callers returning or storing differently parameterized values need another abstraction;
- inference becomes fragile when parameters do not appear in obvious input positions.
Require only semantic capabilities. A bound like T: Transport + Clone + Send + Sync + 'static is justified only if cloning, cross-thread movement, sharing, and retention are intrinsic to the operation. If those bounds exist because today’s implementation spawns a worker, a concrete facade may preserve more architectural freedom.
Generic inputs can coexist with concrete storage. A constructor may accept impl Into<Endpoint> and immediately normalize to Endpoint, or accept a generic iterator and collect into a private structure. The generic boundary then improves input interoperability without infecting the object’s public type.
Concrete outputs stabilize what callers store and compose
Returning a named concrete type gives callers a stable name, methods, trait implementations, documentation target, and storage story. It also commits the library to that public type’s behavior and capabilities. This is appropriate when the result is a domain object—RelayConfig, Receipt, Event—rather than an implementation artifact.
Avoid returning deeply nested adapter types merely because they already exist in the implementation. A type such as Map<Filter<Decode<S>, F>, G> exports ordering and composition choices. A named Events iterator wrapper can hide the nest while documenting item, error, ordering, cancellation, and size behavior. It still becomes a public type whose generic parameters and trait implementations need compatibility care.
Concrete outputs also improve downstream inference. The caller can write let relay = Relay::start(options)?; without supplying a type annotation that exists only to resolve a hidden strategy. Error messages name domain concepts rather than a chain of type constructors. This readability is an API property, not cosmetic documentation work.
impl Trait hides a single concrete return type
Return-position impl Trait exposes bounds while hiding the concrete type:
pub fn pending(&self) -> impl Iterator<Item = &Event> + '_ {
self.queue.iter().filter(|event| event.is_pending())
}
The function still returns one concrete type for a given implementation; callers cannot assume its name. The library may change the iterator composition while preserving promised bounds and observable behavior. This is useful for iterators, futures, and adapters whose identity adds no domain value.
It does not mean “any implementing type at runtime.” Two branches returning unrelated iterator types do not become compatible merely because both implement Iterator. Unify them with one adapter shape, an enum, or a trait object. Also remember that every explicit bound is a promise. Adding or removing Send, ExactSizeIterator, DoubleEndedIterator, or a lifetime relation changes what downstream code can prove. Even unlisted auto-trait behavior can affect users and deserves compatibility testing according to the library’s policy.
Argument-position impl Trait is generic syntax from the caller’s perspective. It can reduce visual noise for an independent parameter, but named type parameters remain clearer when types relate to each other, appear in return positions, or need explicit turbofish selection. Do not use several unrelated impl Trait parameters to conceal that the API still demands a large generic assembly.
An enum owns a closed variation set
The fixture provides two built-in transports:
pub enum BuiltInTransport {
Http(HttpTransport),
Memory(MemoryTransport),
}
impl Transport for BuiltInTransport {
fn deliver(&self, event: &Event) -> Delivery {
match self {
Self::Http(value) => value.deliver(event),
Self::Memory(value) => value.deliver(event),
}
}
}
An enum is ideal when the library owns the complete set, callers benefit from explicit variants, and closed-world matching is useful. It has no trait-object vtable and need not allocate, though its size and alignment accommodate its largest variant plus a discriminant subject to representation rules. Dispatch is a branch whose performance depends on workload and optimization. Again, measure rather than infer a universal result.
The compatibility question is central. An exhaustive public enum grants callers knowledge of all variants, so adding one can break matches. #[non_exhaustive] reserves growth but requires downstream wildcard handling and gives up some exhaustive reasoning. If callers must add their own transports, an enum owned by the library is not an open plugin surface. A “custom” variant containing Box<dyn Transport> can combine built-ins with extension, but then one variant carries dynamic-object constraints and the enum’s semantics must explain them.
Enums can simplify generic sprawl by centralizing a small set of supported strategies. If an application supports exactly two wire formats for policy reasons, WireFormat may be clearer and more testable than a public generic decoder. Closed variation is a feature when the product intentionally controls the set.
Callbacks model one capability, not an object hierarchy
A callback is appropriate when the caller supplies one operation:
pub fn map_delivery(
event: &Event,
map: impl FnOnce(&Event) -> Delivery,
) -> Delivery {
map(event)
}
Choose FnOnce, FnMut, or Fn from invocation semantics, as Chapter 22 established. Document whether the callback runs synchronously, how many times, on which thread or task, whether it may reenter the component, what it may borrow, and what happens if it panics. Adding Send + 'static because the implementation might spawn later imposes ownership and thread requirements on every capture. Add them only when asynchronous retention is part of the contract.
Callbacks become awkward when behavior has multiple related operations, identity, lifecycle, configuration, or inspectable state. A named trait can express those relationships and document laws. Conversely, a trait with one method implemented only by closure adapters may add ceremony without meaning. The boundary-selection question is about capability shape, not a preference for nominal or structural style.
Trait objects make implementation a runtime value
The dynamic function erases the concrete transport behind a reference:
pub fn deliver_dynamic(transport: &dyn Transport, event: &Event) -> Delivery {
transport.deliver(event)
}
The pointer contains enough metadata to dispatch through the selected implementation. Dynamic dispatch usually prevents call-site specialization through that boundary, but whole-program optimization details are not a language guarantee. A borrowed &dyn Transport does not allocate. Box<dyn Transport> normally owns a heap allocation for the erased value; Arc<dyn Transport + Send + Sync> adds shared ownership and atomic reference-count behavior. Keep dispatch, ownership, and allocation as separate decisions.
Trait objects are strong when runtime configuration selects an implementation, heterogeneous values share a collection, compile-time type propagation would overwhelm the public surface, or a plugin must cross a stable internal registration seam. The trait must be dyn-compatible. Methods returning Self, generic methods, some associated constructs, and other restrictions can prevent object use; consult the Reference and reproduce the exact compiler behavior rather than relying on an old “object safety” checklist.
Object bounds express operational requirements:
pub struct PluginRelay {
transport: Box<dyn Transport + Send + Sync>,
}
Send + Sync says the erased implementation supports the service’s cross-thread ownership and shared-reference contract. It is not decorative concurrency seasoning. A thread-affine plugin needs a different host architecture, perhaps one task owning it behind channels. A 'static bound on an owned trait object means the erased value contains no non-'static borrowed data; it does not mean the object lives forever.
Dynamic dispatch can improve compile times and code size by reducing monomorphized instantiations, but wrappers, shims, inlining loss, allocation, and dependency structure affect the actual result. Establish a budget and measure clean/incremental compile time, final binary sections, and representative throughput or latency.
See the choice as a cost spectrum, not a speed ranking
The memory aid positions boundary forms by selection time and openness, then annotates costs rather than declaring a winner.

| Boundary | Selection | Variation owner | Typical strengths | Costs to budget |
|---|---|---|---|---|
| Concrete type | library design time | library | stable name, inference, focused API | less caller substitution |
impl Trait return |
library compile time | library | hides adapter identity, static type | one hidden type, exposed bounds |
| Enum | caller runtime | library/closed | explicit cases, no required allocation | largest-variant size, branch, case evolution |
| Generic parameter | caller compile time | downstream/open | precise types, possible specialization | code generation, compile time, type propagation |
| Callback | call time | downstream/open operation | local capability, captures | retention, reentrancy, closure bounds |
| Trait object | caller runtime | downstream/open | type erasure, heterogeneous storage | dyn compatibility, indirection, possible allocation |
“Runtime cost” is not one number. It includes instruction-cache pressure, branch prediction, allocation, pointer chasing, cache locality, and missed optimization. “Compile-time cost” includes front-end type checking, monomorphization, optimization, linking, and incremental invalidation. A generic API can be faster at runtime and more expensive to build; a concrete facade may improve both if it removes unnecessary abstraction; a trait object may reduce binary size yet hurt a tiny hot loop. Only workload evidence resolves the trade.
Plugin systems require more than dyn Trait
A trait object supports runtime polymorphism within compatible Rust code. It does not by itself define a durable dynamic-library ABI. Rust’s native ABI and trait-object layout are not stable cross-version plugin contracts. Loading arbitrary .so or .dll files compiled with different toolchains needs an explicit boundary: a C ABI with versioned function tables and owned-buffer rules, a stable-ABI strategy with carefully controlled dependencies, or an out-of-process protocol.
An in-process plugin host must specify:
- discovery, registration, and capability negotiation;
- API and data schema versions;
- allocation and deallocation ownership;
- panic and unwinding containment;
- thread, reentrancy, and callback rules;
- shutdown and resource release;
- security trust, signing, sandboxing, and update policy;
- failure isolation and observability.
If plugins are untrusted or independently deployed, a process boundary is often more honest. Serialization and IPC cost may buy crash isolation, access control, independent upgrades, and a language-neutral contract. Rust generics cannot cross a C ABI directly, and Box<dyn Trait> should not be exported as though its representation were a stable foreign interface.
Within one application binary, a registry of Box<dyn Transport + Send + Sync> may be entirely appropriate. Name that scope. Do not let “plugin” imply guarantees the mechanism does not provide.
ABI boundaries should be concrete and ownership-explicit
Foreign interfaces favor a small concrete vocabulary: fixed-width integers, explicitly represented structs and enums where valid, opaque handles, byte pointers plus lengths, and versioned function tables. Every allocation needs a matching owner and deallocator. Every callback needs calling convention, lifetime, thread, and reentrancy rules. Panics must not unwind across an unsupported foreign boundary.
Keep generics and trait objects behind the Rust side of the facade. A concrete FFI entry point can dispatch internally to an enum, generic implementation, or trait object. This is the same stable-shell principle from Chapter 46 applied to binary callers: expose operations and representation rules you can preserve; retain internal variation.
ABI stability and API source compatibility are distinct. #[repr(C)] defines aspects of layout for supported field types; it does not make every semantic change or Rust type FFI-safe. Version the contract and test it with a real foreign caller or an ABI inspection tool appropriate to the project.
Test doubles should follow architecture, not dictate it
“We need a trait so we can mock it” often creates an open public abstraction solely for one test suite. Start with the production seam. Pure domain functions can accept values. Stateful components can use an in-memory concrete implementation behind a private generic or enum. Network behavior can be tested through a local fake server at the protocol boundary. Clock or randomness capabilities may merit small injected traits or callbacks because nondeterminism is genuinely an architectural dependency.
When downstream libraries need substitution, a public trait can be correct. Document its laws, error behavior, concurrency, and evolution policy as Chapter 46 requires. When only the crate’s own tests need it, keep the trait private or use a test-only adapter. Public extension rights are too costly to grant accidentally.
Trait objects simplify heterogeneous test tables. Generics enable zero-cost local fakes and compile-time checking. Enums can make supported production and test modes explicit. None is universally “more testable”; testability follows controllable effects and observable outcomes.
Inference and errors are part of boundary usability
A flexible API fails if ordinary calls require annotations unrelated to the domain. Generic constructors with no input evidence are a common example:
let relay = Relay::new(Default::default()); // which transport?
Default type parameters can help some declarations, but inference rules and associated constructors do not make every omitted type resolve intuitively. Provide concrete constructors such as Relay::http(config), a concrete facade, or an explicit builder method that names the choice.
Long trait errors reveal where abstraction relationships are too distributed. Improve them by:
- reducing public type parameters;
- naming intermediate domain types;
- moving bounds to the operation that needs them;
- avoiding one error type parameter shared across unrelated components;
- converting internal errors to a stable boundary error;
- adding compile-fail examples for genuinely subtle requirements;
- testing representative downstream calls, not only implementation internals.
Do not erase every error behind dynamic dispatch merely to shorten diagnostics. A concrete public facade over private generic machinery often provides both strong internal typing and clear caller errors.
Simplify the excessive pipeline
Return to the opening signature. First classify variation:
- Source selection is runtime operational configuration.
- Decoding is fixed by the chosen source protocol in this product.
- Filtering is a local per-submission operation.
- Retry is validated service policy, not arbitrary caller code.
- Errors need stable domain categories rather than one type shared by all components.
One possible surface is:
pub enum SourceConfig {
Http(HttpSourceConfig),
File(FileSourceConfig),
}
pub struct Relay { /* private runtime-selected source */ }
impl Relay {
pub fn start(config: RelayConfig, source: SourceConfig)
-> Result<Self, StartError>;
pub fn submit_where(
&self,
event: Event,
keep: impl FnOnce(&Event) -> bool,
) -> Result<Receipt, SubmitError>;
}
The closed source enum is right only if the library owns supported sources. If downstream applications must register sources inside one binary, SourceConfig could select built-ins while a separate register_source(name, Box<dyn Source + Send + Sync>) owns the extension seam. If independently deployed plugins are required, define a process or stable ABI protocol instead.
The return is concrete because callers operate a relay, not a pipeline type expression. Decoder and retry types disappear because callers do not select them. The callback remains local and generic because it represents one per-call operation and need not be stored. Errors are concrete at the public boundary. Private modules remain free to use generics where measurement or correctness warrants them.
This simplification gives up arbitrary assembly. That is a deliberate product decision, not a technical limitation. A lower-level companion crate may expose generic primitives for advanced integrators while the main crate provides the stable facade. Separate layers prevent the common path from paying the cognitive and compatibility cost of the expert extension path.
Exercise: spend the abstraction budget deliberately
Take an API with at least four public type parameters and simplify it. Your deliverable must include:
- A variability inventory naming who chooses each implementation, at what time, and whether the set is open.
- Three proposed surfaces: generic-first, concrete facade with private variation, and runtime-extensible.
- A boundary matrix covering allocation, indirection, monomorphization, compile time, binary size, inference, error readability, test doubles, and evolution.
- A representative caller for each surface, including storage in a struct, construction from configuration, and error handling.
- Measurements or reproducible proxies: clean and incremental build time, binary section size, number of monomorphized instantiations where observable, allocations, and a workload benchmark if dispatch is plausibly material.
- A plugin and ABI statement that says whether runtime extension is same-binary registration, dynamic library loading, or out-of-process communication.
Add adversarial witnesses: two distinct implementations in one collection, a thread-affine implementation, a borrowed implementation, a downstream fake, a new enum case, an opaque return passed to a thread, and a foreign caller. Not every design must support every witness. It must reject unsupported uses clearly and for a reason consistent with its stated boundary.
The exercise succeeds when each remaining abstraction parameter corresponds to caller-owned variation, the common call is inferable without irrelevant annotations, and runtime extension does not pretend to establish an ABI it lacks.
Boundary review questions
- Is this variability actually a caller decision, or only an internal implementation choice?
- Who selects the implementation, and must selection happen at compile time or runtime?
- Is the variant set open to downstream code or intentionally closed?
- Does the caller need the concrete type in storage, returns, or associated relationships?
- Which bounds express domain semantics, and which leak today’s threading or cloning strategy?
- Can a concrete facade contain generic machinery without reducing required capability?
- Would an enum make supported cases clearer, and is its growth policy acceptable?
- Is a callback one synchronous capability or a retained, repeated, reentrant lifecycle?
- Is a borrowed trait object enough, or does ownership truly require
BoxorArc? - Are code-size, clean/incremental compile-time, allocation, and dispatch budgets measured separately?
- Can ordinary calls infer types and produce errors in domain vocabulary?
- Does a “plugin” seam have an actual versioned ABI or protocol, ownership rules, and failure isolation?
- Is a public trait serving downstream architecture rather than only internal mocking?
Expose one kind of freedom at each seam
Generics make an implementation type part of compile-time composition. Concrete types stabilize a domain surface. impl Trait hides one chosen concrete return behind promised capabilities. Enums represent a closed runtime choice. Callbacks inject one operation. Trait objects turn an open implementation into a runtime value. None is a global architecture.
Good public APIs combine them. relay-service can accept a concrete validated configuration, keep a concrete Relay facade, use private generics in its hot pipeline, offer an enum for supported built-ins, take a generic one-shot callback, and store explicitly registered extensions behind trait objects. ABI or process boundaries remain concrete and versioned.
The abstraction budget is paid by callers in compilation, diagnostics, type propagation, allocation, indirection, compatibility, and operational complexity. Spend it where variation is a real requirement. With construction and polymorphic boundaries now explicit, the next chapter can specify the behavior that crosses them: failure, cancellation, blocking, retries, and async execution context.
Sources and verification notes
- Rust Reference: generic parameters, traits, trait objects, dyn compatibility, implementation traits, and function pointer types.
- Standard library documentation:
FnOnce,FnMut,Fn,Box, andArc. - Rust Reference: type layout and behavior considered undefined, relevant to representation and foreign-boundary claims.
- Rust API Guidelines: flexibility, predictability, and future proofing.
- Executable source:
examples/rust-engineering-handbook/part-08/construction-boundaries-lab/, a Rust 2024 crate with a declared Rust 1.85 MSRV. Generic, dynamic, enum, concrete-wrapper, and callback paths share the same testedTransportoperation. Performance and ABI conclusions still require workload- and platform-specific evidence.
Continue reading
Full table of contents