The Rust Engineering Handbook / Chapter 1
A Complete Rust Program, Read as a Senior Engineer
Read an idiomatic Rust crate as a connected set of ownership, failure, abstraction, test, and cost contracts.
Start with the system you already know how to see
You may be new to Rust without being new to hard software. You already know that a service is more than its happy-path algorithm: it has ownership, failure, compatibility, resource, observability, and recovery boundaries. Rust gives many of those boundaries unusually precise expression in code. The useful starting point is therefore not a tour of keywords. It is a way to connect familiar engineering questions to the language rules that can answer them.
This handbook treats Rust as a system of enforceable contracts. For every mechanism, ask three questions:
- Semantic: What does the language or library actually guarantee?
- Design: Which ownership, type, API, or component boundary follows from that guarantee?
- Operational: Where do allocation, copying, indirection, latency, panic, cleanup, and compatibility remain visible?
The book widens those three questions in deliberate stages. Part I gives you a whole-system reading vocabulary. The next parts make ownership, data modeling, traits, memory representation, errors, and package boundaries precise before the book applies them to public APIs, concurrency, async systems, unsafe code, verification, performance, operations, and adoption. You can follow that sequence as a design course or return to individual chapters as review references; either way, the same three questions keep the details connected.
That method matters because compiler acceptance is strong but bounded evidence. It can establish that a program satisfies particular type and borrowing relationships. It cannot select the business invariant, prove that memory is bounded, make a retry idempotent, or tell a team whether an abstraction earns its maintenance cost. Throughout the book, documented guarantees stay separate from compiler implementation observations, ecosystem conventions, and editorial recommendations.
The fastest way to make this method concrete is to read one small program whole. ledger-core is a command-line importer for a financial reporting path. It accepts entries such as cash,2500, validates them, stores them, and prints a total. The algorithm is deliberately plain. The engineering interest sits in the boundaries: which layer owns parsing, whether an invalid entry can enter the ledger, where failure becomes a process exit, whether aggregation consumes data, and which behavior the tests protect.
Meet the whole artifact before taking it apart
The package has four reader-visible surfaces:
ledger-core/
├── Cargo.toml package policy and crate targets
├── src/lib.rs domain types, parsing, posting, summary
├── src/main.rs process input, output, and exit policy
└── tests/posting.rs public-API evidence from an outside crate
Its end-to-end path can be stated before any syntax is explained: the binary owns each argument, the parser borrows its text and creates a validated owned Entry, the ledger takes ownership of that entry, aggregation borrows stored entries, and the binary turns the final Result into process behavior. The library owns domain invariants and reusable behavior; the binary owns interaction with the process. Every owned transfer and recoverable exit should be visible at an API boundary.
The small package is executable source in the handbook repository. It is modest enough to inspect in one sitting, but it has the same categories of contract as a service: input, validation, state mutation, aggregation, errors, tests, and a release artifact.
The figure is a retrieval map. Solid arrows carry owned or computed values, thin arrows mark borrowing, and dashed exits mark recoverable failure. Notice especially that parse_entry borrows the CLI string while creating a new owned Entry; Ledger::post then takes ownership of that entry, while aggregation borrows the ledger through iter().
Cargo describes the build graph
The package begins with a manifest:
[package]
name = "ledger-core"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
publish = false
[lib]
name = "ledger_core"
Cargo uses the manifest to produce several crate targets:
src/lib.rsis the library crate. It exposes domain types and behavior to any consumer.src/main.rsis a binary crate. It depends on the package library asledger_coreand owns process arguments, output, and exit behavior.tests/posting.rsis a separate integration-test crate. It can use only the public library API.- files under
examples/are executable targets used to reproduce focused claims later in Part I.
Package, crate, and module are not synonyms. The package is Cargo’s manifest-governed unit. A crate is one compilation unit. A module is a namespace and privacy boundary inside a crate. That distinction affects compilation, visibility, test realism, and later workspace design.
Keeping main thin is not ceremonial architecture. A process boundary has concerns a library should not absorb: command-line parsing, environment variables, terminal output, exit codes, and perhaps signal handling. A library boundary has different concerns: stable types, invariant-preserving constructors, reusable errors, and tests that do not spawn a process. Splitting them makes both sets of obligations reviewable.
Domain types make illegal inputs fail early
The central value is an owned struct:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
account: String,
cents: i64,
}
impl Entry {
pub fn new(account: impl Into<String>, cents: i64) -> Result<Self, PostError> {
let account = account.into();
if account.trim().is_empty() {
return Err(PostError::EmptyAccount);
}
if cents == 0 {
return Err(PostError::ZeroAmount);
}
Ok(Self { account, cents })
}
}
The private fields prevent downstream code from constructing an entry that bypasses validation. PostError is an enum because callers may need to distinguish an empty account from a zero amount without parsing text. The constructor accepts any value implementing Into<String>, converts once, validates, then returns either an owned Entry or a typed error.
Several choices are already visible:
| Choice | What it buys | Cost or constraint |
|---|---|---|
Own String in Entry |
Entry can outlive the input buffer and move freely | Allocation when the caller does not already own a String |
Borrow &str in Entry |
Can avoid allocation | Couples entry validity to input lifetime |
| Keep fields private | One construction gate for invariants | Callers need accessors and cannot use struct literals |
Return Result |
Failure is explicit and composable | Callers must select a handling boundary |
The owned design fits a ledger that retains entries beyond parsing. A borrowed entry would be credible for a transient parser view, but it would move lifetime coupling into the storage API. That is not wrong; it is a different architecture.
Result and ? expose recoverable exits
Parsing borrows the input and constructs an owned entry:
pub fn parse_entry(input: &str) -> Result<Entry, Box<dyn Error>> {
let (account, cents) = input.split_once(',').ok_or(ParseError::MissingComma)?;
let cents = cents
.trim()
.parse::<i64>()
.map_err(|_| ParseError::InvalidAmount(cents.trim().to_owned()))?;
Ok(Entry::new(account.trim(), cents)?)
}
Parsing has three failure edges: missing delimiter, invalid integer, and a domain-invalid entry. Posting adds a fourth edge when the resulting aggregate would exceed the chosen i64 representation. The ? operator returns early from the function when it sees Err; it also performs the applicable error conversion. It is concise control flow, not exception handling hidden from the type signature. The return type advertises that failure is ordinary and recoverable.
The boxed error is an application-oriented simplification for this first complete program. A public library with downstream matching requirements would usually expose a stable error enum that represents both parsing and domain validation without dynamic dispatch. The trade-off is between a compact boundary and a more precise compatibility surface; later API chapters treat that choice fully.
By contrast, a malformed entry is not a reason for panic!. Panic is appropriate for an internal invariant violation or another condition the current component cannot meaningfully represent as expected failure. Input validation is expected failure. Conflating the two turns an ordinary operational event into abnormal process behavior.
Mutability is local, and moves are deliberate
The binary’s work is concentrated in one function:
fn run(arguments: impl IntoIterator<Item = String>) -> Result<i64, Box<dyn Error>> {
let mut ledger = Ledger::new();
for argument in arguments {
let entry = parse_entry(&argument)?;
ledger.post(entry)?;
}
Ok(ledger.total_cents())
}
Read it as a ledger of capabilities:
argumentsis consumed byinto_iter. Each iteration owns oneStringnamedargument.parse_entry(&argument)creates a shared reference. The parser can read the string but cannot retain it beyond what its signature permits.parse_entryreturns a new ownedEntry. Its account string does not borrow the argument.ledger.post(entry)moves the entry into the ledger and returns a recoverable overflow result. The local nameentrycannot be used afterward.ledgeris a mutable binding becausepostneeds&mut self. Mutability is limited to the accumulation scope.ledger.total_cents()receives&self; aggregation observes the ledger without consuming or mutating it.
A move is a semantic transfer of ownership. Do not infer that Rust must copy bytes to a new address, erase the source bytes, allocate, or perform a runtime ownership check. Those implementation details depend on optimization and representation. The durable statement is that the source binding is no longer available as an owner after the move.
Traits and iterators separate policy from storage
The summary operation is expressed as a trait:
pub trait Summarize {
fn total_cents(&self) -> i64;
}
impl Summarize for Ledger {
fn total_cents(&self) -> i64 {
self.entries.iter().map(Entry::cents).sum()
}
}
iter() yields shared references to entries. map(Entry::cents) converts each borrowed entry into an i64, which is a Copy value. sum() pulls values through the lazy pipeline and produces one integer. No collection is created by this pipeline.
The trait earns its existence here as a small demonstration of a behavioral contract. In a real package, a trait with only one implementation should still be challenged. An inherent Ledger::total_cents method would be simpler if no generic or dynamic boundary needs the behavior. Traits can improve substitution and composition, but they also add public surface, coherence constraints, generic instantiations, or dynamic dispatch depending on use. “Use a trait for testability” is not sufficient on its own.
Three aggregation alternatives illustrate different costs:
| Design | Ownership behavior | Operational consequence |
|---|---|---|
entries.iter() |
Borrows every entry | Ledger remains usable; no entry allocation |
entries.into_iter() |
Consumes the collection and moves entries | Useful at a terminal boundary; ledger is unavailable afterward |
| Maintain a running total | Mutates an aggregate during every post | Fast reads, but adds an invariant that every mutation path must preserve |
The iterator version favors a small invariant surface. A running total may be justified when summaries dominate and measurement shows traversal matters. That decision requires tests that compare the derived total with the stored entries.
Tests protect different boundaries
The package uses three useful test locations. A unit test beside Entry verifies the private construction rule. A binary unit test calls run without starting a process. An integration test imports only public names:
#[test]
fn public_api_posts_parsed_entries() {
let mut ledger = Ledger::new();
ledger
.post(parse_entry("receivable,900").expect("valid input"))
.expect("representable total");
ledger
.post(parse_entry("cash,-400").expect("valid input"))
.expect("representable total");
assert_eq!(ledger.total_cents(), 500);
assert_eq!(ledger.entries()[0].account(), "receivable");
}
This distinction prevents a common testing illusion. Unit tests can prove internal branches while an integration test reveals that the usable public route is missing, awkward, or improperly visible. Neither test proves process-level behavior such as exit codes or terminal encoding; that would require a binary or end-to-end test if it became part of the promised contract.
expect is acceptable in these tests because the fixture’s validity is an immediate test invariant and panic localizes a broken fixture. The library path returns errors instead of normalizing panic for invalid caller input.
Release profiles change the evidence you are reviewing
The verified command set includes both cargo check and cargo build --release. cargo check performs analysis without producing the final linked executable, which makes it the fast feedback path for most edits. A release build enables the package’s release profile and creates the artifact used for realistic size, performance, and assembly inspection. Profiles are policy as well as optimization: they can change overflow checks, debug information, link-time optimization, and panic strategy. Review the profile that will ship instead of assuming that --release changes speed alone.
The first cost review should ask concrete questions:
- Where is allocation visible?
Stringownership andVecgrowth are the obvious sites. - Where is copying visible?
i64values are copied; entries are moved unless explicitly cloned. - Where is indirection visible? The boxed error uses dynamic dispatch at the application error boundary.
- Where can the process fail? Parsing and validation return errors; allocation may abort or panic according to environment and policy; explicit indexing elsewhere could panic; profile settings may determine whether a panic unwinds or aborts.
- Which work scales with input? Posting is amortized around vector growth; aggregation is linear in stored entries.
- Which costs are claims rather than measurements? The optimizer may remove abstraction overhead, but only inspection or measurement for a defined build supports that conclusion.
Do not call the iterator “free.” The strong statement is narrower: this pipeline does not require an intermediate collection, and the compiler can often optimize iterator abstraction effectively. Runtime, code-size, and compile-time costs remain empirical questions.
Failure modes that still compile
The compiler cannot reject every weak design:
- Making
Entryfields public compiles but allows invariant bypass. - Cloning every argument before parsing compiles but adds allocation without changing the ownership need.
- Keeping parsing in
maincompiles but couples domain validation to a process adapter. - Returning only display strings compiles but makes structured recovery and metrics harder.
- Maintaining both entries and an unchecked total compiles but creates drift risk.
- Replacing input errors with panics compiles but weakens availability and caller control.
Rust makes several classes of invalid memory use difficult or impossible in safe code. It does not select the right invariant, choose the right boundary, bound memory growth, or design the incident response. Those remain engineering work.
Senior review checklist
- Can every owned resource be traced from construction to its next owner or drop point?
- Does each reference borrow for the narrowest useful scope?
- Are invalid domain states blocked at construction rather than by caller discipline?
- Does the binary own process policy while the library owns reusable behavior?
- Are recoverable conditions represented as errors at the correct abstraction level?
- Does every trait correspond to real behavioral variation or a valuable contract?
- Are iterator ownership modes—shared, mutable, consuming—chosen deliberately?
- Do unit and integration tests prove different boundaries?
- Are allocation, cloning, indirection, and linear work named rather than assumed away?
- Has the release artifact been built before making performance or size claims?
Engineering exercise: annotate and redesign
Run ledger-core with two valid entries and one malformed entry. Produce an ownership-and-failure ledger with one row per expression in run: owner before, operation, owner after, possible error, and drop boundary. Then evaluate two changes:
- Store
&strinsideEntryto avoid account allocation. - Make
Ledger::postaccept&Entryand clone internally.
For each, state the governing invariant, the lifetime or allocation consequence, the API effect, and the operational behavior under a million retained entries. A strong answer may choose either design under explicit constraints, but it must not describe the clone or borrow as costless.
Durable takeaways
- Read Rust first as ownership transfers, borrows, recoverable exits, and invariant boundaries.
- Cargo packages can produce multiple crate targets with different consumers and visibility guarantees.
Resultand?make expected failure part of the function contract; they do not decide where policy belongs.- Iterator choice communicates ownership as well as traversal.
- Compiler acceptance is necessary evidence, while tests, release builds, and cost review cover different claims.
The whole-program map now gives us vocabulary, but not the boundary of Rust’s promise. Before relying on words such as safe, race-free, deterministic, or zero-cost, those claims need to be made narrow enough to test.
Sources and version notes
- Verified with
rustc 1.97.0 (2026-07-09), Cargo 1.97.0, Rust 2024 Edition, onx86_64-unknown-linux-gnu, 2026-07-11. - The Cargo Book: package layout
- The Rust Reference: crates and source files
- Standard library:
Result - Standard library:
Iterator - Reproduction package:
examples/rust-engineering-handbook/part-01/ledger-core.
Continue reading
Full table of contents