Skip to content

The Rust Engineering Handbook / Chapter 47

Builders, Configuration, and Typestate APIs

Design complex construction so required inputs, layered configuration, validation, secrets, and type-level guarantees remain explicit.

The first useful output of relay-service is not an event. It is a configuration value that the runtime can trust.

That sounds like a small distinction until configuration comes from three places, operators override it during an incident, a token must never enter ordinary diagnostics, and one invalid combination can create an unbounded retry loop. The service should not carry Option<String> fields through its hot path and rediscover missing values after starting worker tasks. Nor should its public constructor require twenty positional arguments or encode every numeric relationship in a forest of types.

Complex construction is an API boundary with four separate jobs: collect inputs, establish precedence, validate the complete value, and hand runtime code an invariant-bearing type. A constructor, builder, configuration loader, and typestate transition are different tools within that boundary. Good design assigns each tool only the guarantees it can express clearly.

This chapter builds the construction surface for relay-service. It uses ordinary runtime validation for value and cross-field rules, typestate for two truly mandatory capabilities, and an explicit layer loader for file and environment policy. The result is discoverable in an IDE, testable without process-global state, and safe to log because secrets travel through a separate typed channel.

Five artifacts keep that surface understandable: a raw layer that may be incomplete, a builder that records construction progress, a validated configuration that carries runtime invariants, a redacted provenance record that explains chosen values, and a running handle that owns effects. Do not merge them merely because they mention the same fields. Each has a different validity, secrecy, and cleanup contract.

Begin with the value the runtime is allowed to receive

The runtime configuration is a closed, already-validated value:

pub struct RelayConfig {
    endpoint: Endpoint,
    token: Secret,
    batch_size: usize,
    flush_interval: Duration,
}

Its private fields matter. Code that owns a RelayConfig may assume an endpoint exists, a token exists, the batch size is within the supported range, and the flush interval is nonzero. It does not need defensive expect calls at every use site. If later mutation is required, expose operations that preserve those invariants rather than public field assignment.

Construction is successful only when it produces that trusted value. The intermediate representation may be incomplete and invalid; the runtime representation may not. This separation prevents a common failure in configuration-heavy applications: using one permissive struct both as deserialization input and as live state. Optional fields are appropriate in a patch or raw-input type because absence has meaning there. They are usually wrong in the final type when absence is not a valid runtime state.

Endpoint and Secret also carry policy:

pub struct Endpoint(Box<str>);
pub struct Secret(Box<str>);

impl Endpoint {
    pub fn parse(value: &str) -> Result<Self, ConfigError> {
        if value.starts_with("http://") || value.starts_with("https://") {
            Ok(Self(value.into()))
        } else {
            Err(ConfigError::InvalidEndpoint(value.into()))
        }
    }
}

The fixture’s endpoint validation is intentionally small enough to inspect; a production parser would define URL syntax, allowed schemes, credentials, fragments, DNS policy, and normalization more rigorously. The important structure is durable: parse external text at the boundary, return a domain type, and keep raw strings out of the core.

Secret is not magical protection. Its type prevents accidental interchange with ordinary text and creates a place to omit or redact Debug and Display. It cannot stop a caller from invoking an explicit exposure method, copying bytes, or leaking them through another channel. Secret handling is an authority and lifecycle policy, not a wrapper trick.

Constructor families should correspond to real entry paths

A single new function works when a type has a small, canonical set of inputs:

impl Record {
    pub fn new(id: RecordId, payload: Payload) -> Self;
}

Complex configuration usually has several legitimate entry paths:

  • programmatic construction in an embedding application;
  • file plus environment loading in a service binary;
  • test construction with deliberate small defaults;
  • conversion from a previous schema during migration.

Do not disguise these as overloads—Rust has no function overloading by parameter list—or as one generic constructor that accepts anything vaguely convertible. Give paths names that expose policy: RelayConfigBuilder::new, load_config, RelayConfig::for_test, or RelayConfig::try_from_v1. A named constructor tells reviewers which guarantees and compatibility obligations belong to that path.

Defaults require the same discipline. A default is not merely a convenient value; it is a statement that an unconfigured choice is safe and unsurprising. A two-second flush interval and batch size of 500 might be reasonable application defaults. A production endpoint or authentication token cannot be. Implement Default for a final public type only if the default value itself is meaningful and valid. It is often better to implement it for the initial builder state, where defaults apply only to optional tuning fields and mandatory capabilities remain missing.

Constructor proliferation has a cost. new, new_with_timeout, new_with_timeout_and_capacity, and new_with_everything produce an unstable combinatorial family. Retain a canonical short constructor for the common case and move orthogonal options into a builder. When several constructors represent different semantics rather than different option counts, keep them distinct.

A by-value builder makes snapshots and transitions explicit

The fixture uses a consuming builder:

let config = RelayConfigBuilder::new()
    .endpoint(Endpoint::parse("https://relay.example")?)
    .token(token)
    .batch_size(250)
    .flush_interval(Duration::from_secs(1))
    .build()?;

Each setter takes self and returns a builder. This style composes naturally in an expression and can change the builder’s type, which typestate needs. The caller can rebind for conditional construction:

let builder = RelayConfigBuilder::new().endpoint(endpoint).token(token);
let builder = if low_latency {
    builder.flush_interval(Duration::from_millis(100))
} else {
    builder
};
let config = builder.build()?;

A mutable-reference builder uses &mut self -> &mut Self. It is convenient when code incrementally edits one builder through branches or helper functions, and it avoids moving a large intermediate value at the source level. It cannot change Self into another typestate through an ordinary setter, and calling build(&self) must clone, borrow, or otherwise preserve its stored fields. build(&mut self) may leave a partially emptied builder after moving fields out. build(self) makes single-use transfer unambiguous.

The runtime cost difference is rarely decided from receiver syntax alone. Optimizers may eliminate moves of small builder values, while heap-backed fields move as handles. Measure unusually large or hot builders. Choose the ownership form first for lifecycle semantics:

Builder form Strongest fit Main design cost
self -> Self fluent single-use assembly, typestate transitions conditional reuse requires rebinding
&mut self -> &mut Self incremental mutation and helper functions build ownership and reset behavior need care
persistent clone-on-write branching templates reused many times cloning/allocation and shared-state semantics

Do not implement both forms under nearly identical method names unless the extra surface supports a demonstrated caller workflow. Two styles double documentation and compatibility work.

Required presence and valid values are different proofs

The construction-state diagram separates two kinds of evidence. MissingEndpoint to PresentEndpoint proves that an endpoint setter ran. It does not prove every property of the endpoint, batch size, token, or their combination.

A construction-state map shows a by-value relay builder moving from missing endpoint and token through independent typed transitions to a build-capable state, followed by runtime validation and a trusted RelayConfig.

The fixture encodes two required fields in type parameters:

pub struct RelayConfigBuilder<E, T> {
    endpoint: Option<Endpoint>,
    token: Option<Secret>,
    batch_size: usize,
    flush_interval: Duration,
    endpoint_state: PhantomData<E>,
    token_state: PhantomData<T>,
}

impl RelayConfigBuilder<PresentEndpoint, PresentToken> {
    pub fn build(self) -> Result<RelayConfig, ConfigError> {
        // validate value and cross-field constraints
    }
}

Only the state with both markers has build. Omitting a mandatory setter is therefore a compile-time error for programmatic users. The internal Option fields remain because Rust must represent earlier states; their expect calls are justified by a local invariant that the transition methods maintain. Independent editorial review should still audit that correspondence because typestate markers are only as sound as their implementation.

The batch size remains a runtime-validated usize. Encoding every possible range as a distinct type would complicate parsing and diagnostics without removing the need to check an external number. Cross-field rules are similarly runtime work: a flush interval may need to be less than a request timeout, or a durable queue may require a different acknowledgement mode. Const generics and marker types do not make arbitrary configuration relationships automatically clearer.

Use typestate when all of these are true:

  • states are few, meaningful, and stable;
  • permitted transitions are part of the public protocol;
  • invalid order or missing capability is a frequent programming error;
  • callers mostly construct values in typed Rust code;
  • the additional type names and diagnostics are worth the prevention.

Prefer runtime validation when values originate as text, rules change independently of the API, state count would multiply, or operators need one coherent list of corrections. Most serious systems use both: compile-time states for a small protocol and runtime validation for data.

Validation timing determines the quality of failure

A setter can validate its own input immediately. Endpoint::parse should reject an unsupported scheme at the point where raw text becomes an endpoint. This localizes the error and ensures later builder states contain a valid Endpoint.

The build operation validates relationships that require the full picture. It checks numeric bounds and can compare multiple fields. Configuration loading may instead collect all independent errors so an operator fixes one file once rather than restart repeatedly. That error aggregation policy is part of the loader contract; a programmatic builder may reasonably return the first failure if typed callers encounter it during development.

Avoid validation after side effects. build should not start threads, open sockets, register metrics, write files, or acquire process-global resources unless the constructed type explicitly represents those resources and documents partial failure. A useful split is:

raw inputs -> validated RelayConfig -> Relay::start(config) -> running RelayHandle

Validation errors then contain no cleanup problem. Startup errors can report resource acquisition and leave a defined rollback state. Runtime failures belong to the service’s operational contract, not its configuration parser.

Validation also needs a stability policy. Error enum variants exposed by a public library may constrain future checks. A non-exhaustive structured error, stable categories plus detailed context, or an opaque application error may be appropriate depending on whether callers branch programmatically. Never return the token or a full raw configuration object inside an error.

Layering is an ordered merge, not ambient magic

The fixture models each source as ConfigLayer and applies a documented order:

pub fn load_config(
    layers: &[ConfigLayer],
    token: Secret,
) -> Result<RelayConfig, ConfigError> {
    let mut merged = BTreeMap::new();
    for layer in layers {
        merged.extend(layer.0.clone());
    }
    // parse and build the trusted value
}

Later layers win. A service binary might pass built-in defaults, a file layer, an environment layer, and explicit command-line overrides in that order. The library does not read process environment variables itself. That choice makes precedence visible, tests deterministic, embedding possible, and security review narrower.

Real loaders need field-specific merge semantics. A scalar may replace; a list may replace or append; a map may merge by key; null may clear or mean absent. State these rules and preserve provenance long enough to diagnose “which source selected this value?” without retaining secrets. Reject unknown keys by default for production service configuration. Silently ignoring flush_intervl converts a typo into changed operational behavior.

Owned configuration is the robust default for a long-running service. Borrowed &str fields can be useful for a short-lived parser view or zero-copy embedded format, but they couple runtime lifetime to the source buffer and complicate reload. An owned configuration can be validated, moved into a worker, compared with a replacement, and retained independently. Ownership does not require each field to allocate independently: boxed strings, interned values, shared immutable data, or compact types remain implementation choices.

Reload introduces another boundary. Parse and validate a complete replacement before swapping it into live state. Decide which fields are reloadable, how workers observe the change, whether in-flight operations keep the old snapshot, and what happens when application fails halfway. A builder only creates values; it does not solve transactional rollout.

Preserve provenance without preserving raw exposure

Operators need to explain a value after construction: “Why is the batch size 900?” A final runtime value alone cannot answer. Keep a parallel, redacted provenance record when operations require it:

batch_size = 900
source = environment
key = RELAY_BATCH_SIZE
parsed_at = startup generation 14

Provenance should identify a source and schema field, not copy a secret or the entire source document. Decide whether paths, environment-variable names, tenant identifiers, or command-line text are themselves sensitive. A diagnostic endpoint can expose the effective non-secret configuration and a hash or generation identifier while access controls protect more detailed provenance.

Configuration schemas evolve like APIs. Adding an optional field with a safe default may be compatible; changing a default can alter load and latency without changing source; renaming a key can make a deployment fail at restart; changing units from seconds to milliseconds can be catastrophic if parsing still succeeds. Put units in names or typed syntax, version schemas when interpretation changes, and test old production-shaped samples against new loaders. Deprecation warnings should point to source and replacement without echoing values.

Migration must also preserve precedence. If flush_seconds becomes flush_interval, define what happens when an old file supplies the former and an environment override supplies the latter. Reject ambiguous duplicates or specify a deterministic rule and warning. Silently choosing whichever map entry happens to be visited first is not a migration policy.

For reload, retain the last accepted configuration and its generation. Build the proposed snapshot completely, compute a redacted difference, ask each affected subsystem whether it can apply the change, and commit only according to the service’s chosen atomicity model. If partial application is unavoidable, expose that as an operational state with reconciliation rather than reporting a clean reload. These mechanisms live beyond the builder, but construction must produce immutable snapshots and comparable domain values that make them possible.

Secrets need a separate route and a bounded lifetime

Environment variables and configuration files are delivery mechanisms, not automatically safe secret stores. They may be readable through process inspection, crash reports, backups, shell history, diagnostic endpoints, or permissive file modes. A service should document the assumed platform and threat model.

At the API boundary:

  • keep secret values out of generic string maps used in debug output;
  • use a type without revealing Debug or Display implementations;
  • redact structured errors and traces;
  • avoid cloning secrets merely to satisfy builder ergonomics;
  • define rotation and reload ownership;
  • minimize exposure to child processes and external commands;
  • zero memory only when the allocator, copies, optimization behavior, and threat model make that claim supportable.

The fixture passes Secret separately from ordinary ConfigLayer values. That does not guarantee secure storage, but it makes accidental inclusion in map dumps harder and gives reviewers a distinct dataflow to follow. A production integration may accept a secret-provider handle rather than secret bytes, with explicit caching, renewal, failure, and shutdown behavior.

Extensibility belongs at named seams

Builders tend to accumulate with_custom_* hooks. Each hook can leak construction order, internal types, or partially validated state. Prefer extensions that operate on stable concepts: a caller supplies a validated Endpoint, a typed credentials provider, or a complete retry policy. Avoid callbacks that receive &mut BuilderInner or arbitrary configuration maps after validation.

For forward-compatible file schemas, version the schema and define unknown-field behavior. For public Rust APIs, adding a builder setter is often source-compatible, but changing its bounds, validation timing, default, or interaction with another field may change behavior. Builder evolution still needs downstream witness tests from Chapter 44.

Generated-builder crates can reduce repetitive setter and marker code. They introduce a different contract surface: macro attributes, generated names, error types, optional-field semantics, documentation quality, compiler/MSRV requirements, and dependency maintenance. Generation is a trade-off, not a design decision. Write the construction policy first, inspect expanded public API and diagnostics, pin behavior with tests, and ensure the generated surface does not expose representation accidentally. Hand-written code is often preferable for a central public type with unusual invariants; generation can be effective for numerous regular internal data types.

Repairs that look convenient can weaken construction

Several changes make call sites shorter while moving risk elsewhere:

  • Making every final field Option<T> removes construction errors but distributes missing-state handling across runtime code.
  • Adding unwrap in the loader turns operator input into a process abort and discards actionable context.
  • Accepting impl Into<String> for every setter hides allocation policy and weakens domain validation.
  • Giving secrets Clone + Debug for derived-builder compatibility expands their leak and lifetime surface.
  • Reading environment variables inside build makes identical source calls process-dependent and hard to test.
  • Using typestate for a dozen independent optional flags can create hundreds of conceptual states and unreadable errors.
  • Validating only in setters misses cross-field rules and permits defaults to bypass checks.
  • Performing network probes during build confuses deterministic validation with fallible startup.

The corrective question is not “does construction compile?” It is “at which boundary is each invariant proved, and what evidence can a failure safely reveal?”

Treat construction failures as part of observability design. Give errors stable categories suitable for metrics, attach redacted field and source context for logs, and let the binary choose presentation. Avoid turning every invalid value into a unique high-cardinality metric label. A startup controller should distinguish operator-correctable configuration from unavailable external dependencies and internal defects because retry and alert policies differ. The library supplies structured evidence; the application decides exit codes, retry, and telemetry. This division keeps the builder independent of one logging stack while ensuring a failed configuration never collapses into an unactionable invalid input message.

Design the relay-service configuration

Produce a configuration API for these requirements:

  • endpoint and credentials are mandatory;
  • batch size defaults to 500 and must be in 1..=10_000;
  • flush interval defaults to two seconds and must be nonzero;
  • a file supplies baseline values, environment supplies deployment overrides, and command-line values have highest precedence;
  • unknown keys are errors;
  • credentials may rotate without logging their contents;
  • a running service may reload batch size and flush interval but not endpoint;
  • tests must not mutate process-global environment.

Deliver five artifacts:

  1. Raw layer, builder, validated configuration, and running-service types. Mark which may be incomplete.
  2. A precedence table with replace, merge, clear, and unknown-key behavior for every field.
  3. The choice between by-value and mutable-reference builder, including conditional construction and reuse needs.
  4. A validation matrix that assigns each rule to parsing, a setter/domain constructor, final build, or service startup.
  5. A secret dataflow from provider to rotation and drop, listing every place redaction is required.

Then compare three designs: runtime-only builder, the small hybrid typestate used here, and a fully typed configuration protocol. Compile at least one success witness and one omitted-required-field witness. Test invalid numeric values, precedence, unknown keys, secret-free diagnostics, failed reload preservation, and concurrent reader behavior during a successful reload.

The design succeeds when worker code receives only valid snapshots, operator errors identify source and field without revealing secrets, and the type system carries only the states that improve the API more than they complicate it.

Construction review card

Before accepting a construction surface, answer:

  • What exact invariants does the final type permit runtime code to assume?
  • Which inputs are required, defaulted, derived, mutually exclusive, or conditionally valid?
  • Where is local validation performed, and where are cross-field rules checked?
  • Does build have side effects? If so, what is the cleanup and retry contract?
  • Is builder ownership single-use, mutable, or persistent, and can callers tell?
  • Which states deserve types, and which values still require runtime validation?
  • Are file, environment, command-line, and programmatic precedence rules explicit and testable?
  • Are unknown keys, schema versions, provenance, and reload semantics defined?
  • Can any error, debug output, clone, or generated method expose a secret?
  • Which generated or third-party surface becomes part of the MSRV and SemVer contract?
  • Can downstream witnesses prove defaults, required fields, validation timing, and migration behavior?

Hand a complete invariant to the runtime

Builders are useful because they make a complex call readable, not because fluent syntax is inherently safe. Constructor families name distinct policies. By-value and mutable-reference builders express different lifecycles. Typestate can prevent a small stable class of protocol errors, while runtime validation remains necessary for external values and relationships. Layered configuration needs explicit precedence and provenance. Secrets need their own dataflow. Generated builders exchange boilerplate for a generated public contract that still requires review.

The durable boundary is the validated value. Once RelayConfig exists, service code should not wonder which source supplied the batch size or whether a token is absent. It should operate under documented invariants. The next design question is what kind of abstraction should consume that value: a generic implementation chosen at compilation, a stable concrete facade, a closed enum, or a runtime-selected trait object.

Sources and verification notes