Skip to content

The Rust Engineering Handbook / Chapter 13

Structs, Enums, and Invariant-Rich Domain Models

Encode validated facts and legal states so callers cannot construct contradictory ledger entries.

From who owns a value to what the value may mean

The ownership model can prove that one component controls a ledger row. It cannot prove that the row tells a coherent story. This one does not:

struct RawEntry {
    account: String,
    amount: i64,
    posted: bool,
    settled: bool,
    rejected: bool,
    receipt: Option<String>,
    reason: Option<String>,
}

All seven fields may be perfectly memory-safe while the account is empty, the amount is zero, every status flag is set, or a settled row lacks a receipt. If each consumer repairs those combinations independently, the record has no maintained meaning—only a convention.

Part III changes the question. Products say which facts must exist together. Sums say which alternatives exclude one another. Construction boundaries decide whether an untrusted representation qualifies as either. The aim is not to encode every business rule in a type. It is to make the facts a type claims impossible to bypass accidentally.

Account and amount still matter after an entry settles or is rejected. Put them in every legal case rather than only in the pending case:

pub enum EntryState {
    Pending {
        account: AccountId,
        amount: Amount,
    },
    Settled {
        account: AccountId,
        amount: Amount,
        receipt: ReceiptId,
    },
    Rejected {
        account: AccountId,
        amount: Amount,
        reason: RejectionReason,
    },
}

Each variant payload is a product: its facts must exist together. EntryState is a sum of those products: every value is pending, settled, or rejected, never several at once. Variant payloads put evidence beside the claim it supports. There is no settled case without an account, amount, and receipt, and no pending case carrying a meaningless rejection reason.

Repeating account and amount in the source does not duplicate them in one value; only one variant exists at runtime. It does make transitions rebuild the chosen case, which is useful when state changes create or discard evidence. An outer Entry { account, amount, state } is also credible when many operations need the common facts regardless of state. Choose the shape that makes real operations and invariants easiest to inspect.

This placement remains a domain decision, not a syntax preference. If rejection can happen before an account or amount has been validated, then Rejected may belong to a different boundary type whose payload preserves raw input. A type should describe a real phase of the system rather than absorb every nearby field.

Named-field structs work well when fields have distinct roles and may evolve internally. Tuple structs suit nominal wrappers such as AccountId(String). Unit structs can identify roles or phases without storing data. Public fields are reasonable for deliberately transparent data with no maintained invariant, but they freeze field names and representations into the downstream API. Here the fields stay private because construction is where the entry earns its meaning.

Give primitive values domain names

An account identifier and a receipt identifier may both use String. An amount and a sequence number may both use integers. Raw primitives let the compiler accept swaps that the domain rejects. Newtypes make those meanings nominally different:

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AccountId(String);

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Amount(u64);

impl AccountId {
    pub fn parse(raw: &str) -> Result<Self, DomainError> {
        let value = raw.trim();
        if value.is_empty() {
            Err(DomainError::EmptyAccount)
        } else {
            Ok(Self(value.to_owned()))
        }
    }
}

impl Amount {
    pub fn new(cents: u64) -> Result<Self, DomainError> {
        (cents > 0)
            .then_some(Self(cents))
            .ok_or(DomainError::ZeroAmount)
    }
}

The wrappers require no additional field beyond their inner values. More importantly, they provide one place for parsing, formatting, redaction, trait implementations, and representation change. They should not acquire conversions merely for convenience. From<String> for AccountId would promise that conversion cannot fail; an empty string makes that promise false. A fallible constructor or TryFrom tells the truth.

The constructor should prove only a local, durable fact. Amount::new can prove that its integer is nonzero. It should not quietly consult an exchange-rate service or decide whether a customer may transfer that amount today. Authorization, account balance, daily limits, and other time-dependent policies need runtime context and usually belong in an operation over already valid values.

std::num::NonZeroU64 can supply a machine-level nonzero invariant and enables documented layout optimizations for certain enclosing types. It still does not mean “permitted ledger amount.” A domain wrapper names that stronger interpretation and remains free to add a stable range when the domain genuinely has one.

Convert the boundary as one decision

The transport record remains useful at the edge because storage or wire formats often cannot express the domain directly. Conversion should reject contradictions before a domain value escapes:

impl TryFrom<RawEntry> for EntryState {
    type Error = DomainError;

    fn try_from(raw: RawEntry) -> Result<Self, Self::Error> {
        let account = AccountId::parse(&raw.account)?;
        let cents = u64::try_from(raw.amount)
            .map_err(|_| DomainError::NonPositiveAmount)?;
        let amount = Amount::new(cents)?;

        match (raw.posted, raw.settled, raw.rejected) {
            (true, false, false) if raw.receipt.is_none() && raw.reason.is_none() => {
                EntryState::Pending { account, amount }
            }
            (false, true, false) if raw.reason.is_none() => EntryState::Settled {
                account,
                amount,
                receipt: ReceiptId::parse(raw.receipt.as_deref())?,
            },
            (false, false, true) if raw.receipt.is_none() => EntryState::Rejected {
                account,
                amount,
                reason: RejectionReason::parse(raw.reason.as_deref())?,
            },
            _ => return Err(DomainError::ContradictoryState),
        }
    }
}

The match makes the accepted legacy combinations inspectable. Exactly one status is true, and the optional payloads must agree with it. Constructors for ReceiptId and RejectionReason can reject absent or blank evidence. After this conversion, code operating on EntryState no longer needs to ask whether three booleans contradict one another.

This is also where the boundary must resist false precision. A row may pass local validation and still refer to a missing account. Two processes may race to settle it. A restored value may violate a newer business rule. Types remove representable combinations inside one process; transactions, authorization, schema migration, and revalidation still protect facts that depend on the world.

Figure 13-1 captures that narrower promise: validation turns primitive representations into meaningful facts, a product keeps the facts together, and a sum admits one legal state.

Three panels transform raw String, i64, and boolean flags into validated product types AccountId, NonZeroAmount, and PostedEntry with private fields, then into a legal sum type with Pending, Settled, and Rejected variants. Smart constructors guard both boundaries and the final panel states that invalid combinations are unrepresentable.
Product types require facts together; sum types choose one legal case. Private construction boundaries turn validation from repeated convention into a maintained invariant.

Domain values need a separate representation contract

Rust does not promise that an ordinary enum’s memory layout is a stable database, wire, or FFI format. Persist an explicit boundary representation, then validate it when converting back. A numeric discriminant used for interoperability needs a representation attribute plus a complete compatibility policy; it should not arise accidentally from the order of domain variants.

The separation also permits the two models to evolve at different speeds. A storage schema may retain old status codes for migration while the domain exposes one normalized state. Conversely, adding a domain state does not automatically assign it a safe wire encoding. The conversion is where compatibility becomes deliberate rather than incidental.

Decide whether callers may know every variant

For a closed state machine, exhaustive downstream matching can be valuable. Adding Reversed then breaks consumers until each chooses a policy, and a major-version release can make that disruption explicit.

For a public library that expects new cases, #[non_exhaustive] requires downstream code to include a fallback. That reserves evolution space, but callers lose the compiler’s exact audit of future cases. Open diagnostic categories often accept that trade; a closed financial protocol may not. Inside the defining crate, explicit matches can still make each new variant trigger review.

Marker types carry claims without carrying bytes

Unit structs and marker fields can distinguish roles or phases without runtime data. A wrapper parameterized by Validated and Unvalidated can prevent an operation from accepting the wrong phase; a private unit marker can prevent external construction. PhantomData is required when a generic parameter has semantic meaning but no stored value, with variance and drop-check consequences developed in Chapter 25.

Markers should earn the extra types they create. If every caller immediately erases the phase, a runtime enum is usually clearer. Consuming typestate protocols become useful when legal transitions, rather than merely legal values, are the central problem; Chapter 16 compares that design with runtime state.

Choose the smallest boundary that maintains the truth

Primitive fields remain appropriate when values have no domain distinction or local invariant. Private newtypes add conversion work but centralize one meaning. Structs require all their component facts together. Enums force every value into one variant and every serious consumer to choose how it handles that variant. A boundary record may retain tags and optional fields because an external schema demands them, but it should not leak those representational compromises into the domain.

The useful question is not “can this rule be encoded in a type?” Ask how long the rule stays true, who possesses the evidence, and what happens at serialization or concurrency boundaries. Encode stable local facts. Check changing policy where its dependencies are visible.

Failure modes

  • Replacing booleans with an enum while leaving contradictory payloads outside it.
  • Moving durable facts into Pending, then losing them from the settled and rejected cases.
  • Exposing fields publicly and asking callers to preserve a private convention.
  • Using From for validation that can fail.
  • Treating a serialized tag or an enum’s memory layout as the domain contract.
  • Adding a catch-all to internal matches and hiding new variants from review.
  • Encoding time-dependent policy in elaborate type parameters.
  • Wrapping primitives without deciding which invariant or mistaken substitution the wrapper prevents.

Senior review questions

  • Which facts coexist for the entire life of the value, and which payload belongs only to one case?
  • Which combinations are illegal, and can any public constructor create them?
  • Does each constructor prove only facts available at that boundary?
  • Are identical primitives carrying different meanings?
  • Is field visibility an intentional compatibility decision?
  • Is external representation converted and validated rather than mistaken for the domain?
  • Should downstream consumers be exhaustive or insulated by non_exhaustive?
  • Does each marker remove a misuse worth the generic complexity?

Start with RawEntry, but do not copy the chapter’s answer. Decide first whether account and amount exist for rejected input in your domain. Draw the legal values before writing Rust. Then implement private fields, fallible boundary conversion, and transitions that settle or reject a pending entry without discarding durable facts.

Test empty identifiers, zero and negative amounts, every contradictory flag combination, missing receipts and reasons, and attempts to construct private fields from another crate. Finally add Reversed. Record what must change in the domain API, persisted representation, downstream matches, and runtime authorization policy. A good solution makes those four consequences distinct.

Durable takeaways

  1. Structs express facts that coexist; enums express one alternative from a closed set.
  2. Keep durable facts in every legal case, and attach case-specific evidence only to the variant that requires it.
  3. Newtypes, private fields, and fallible constructors maintain local invariants after boundary conversion.
  4. Domain values and wire, storage, or FFI representations have separate evolution contracts.
  5. Types should remove meaningful invalid states while runtime systems continue to enforce facts that depend on time, authority, and concurrency.

The entry now has a legal shape. That precision becomes useful only when control flow preserves it: the next chapter follows matches, guards, moves, and borrows through every variant and asks when exhaustiveness should force a new policy decision.

Sources and version notes