Skip to content

The Rust Engineering Handbook / Chapter 12

Ownership-Driven Refactoring and Architecture

Turn recurring borrow friction into explicit lifecycle, identity, command, and data boundaries.

The callback that exposes the architecture

The relay service began as one convenient object. It held configuration, live connections, workers, pending messages, and callbacks. A maintenance pass needed to find a connection, update its counters, and notify a callback. The registry lookup produced &mut Connection; the callback needed the service again. Extending a lifetime moved the error into constructors and traits. Wrapping more of the graph in Arc<Mutex<_>> made the code compile, but now a callback could run while the service lock was held and the last dropped Arc had quietly become the shutdown policy.

The diagnostic points at the callback. The conflict begins much earlier, where one object was made owner, registry, scheduler, and observation surface at once.

The repair starts by naming four facts the original graph concealed: the supervisor controls process-phase lifecycles; the connection manager alone mutates the connection registry; workers own their task-local buffers; and queued work must survive the stack frame that accepted it. Configuration changes on yet another clock, so readers receive immutable revisions rather than access to a mutable service field.

This is the architectural use of borrow-checker friction. Repeated local conflicts can reveal that identity has been confused with access, or that values with different lifecycles have been stored under one owner. They do not prove that every shared reference is a design defect. The task is to find the boundary that the program already needs.

Figure 12-1 shows the resulting change. Its important feature is not fewer arrows. Each arrow has acquired one meaning: ownership, identity, transfer, snapshot access, or a short borrow.

A before-and-after architecture map. The left side shows Service, Config, Connections, Queue, and Workers connected by shared references with unclear ownership. The right side shows a supervisor owning lifecycles, immutable config snapshots, generation-checked ConnectionId handles, a bounded owned-command queue, worker-owned task state, and borrows only at leaf calls.
Borrowing becomes local when identity, lifecycle, and work transfer become explicit. Handles name resources; owners validate and lend them; bounded commands cross long-lived boundaries.

Give every long-lived resource one lifecycle owner

“The service owns everything” is too coarse to guide shutdown. In the revised relay, the supervisor starts workers, closes their command senders, and observes their joins. The connection manager creates and removes connection records. Each worker drops its own buffers. Configuration publication replaces one immutable snapshot at a time. A command owns the payload it places in a queue.

The useful test is operational. For each long-lived value, identify who creates it, who may mutate it, who decides that it is no longer useful, and who can observe failure during shutdown. Then ask whether its consumers need identity, a momentary view, a revisioned snapshot, or ownership. Values belong together when one operation must preserve an invariant across them, not merely because an early struct happened to contain them.

That qualification prevents a shallow “split the struct” refactor. Moving a connection’s balance and reservation total behind different owners may appease one borrow conflict while introducing a race. The connection manager should keep that compound invariant intact. Metrics, by contrast, can receive copied event facts; they do not need references into the live record.

Replace stored access with identity

Object graphs often use references to mean “this is connection 42.” A reference carries access and validity requirements in addition to identity. Store ConnectionId or a generational handle instead, then resolve it through the registry at the point of use.

An index alone can be reused after deletion and accidentally name a new object. The relay therefore pairs a slot with a generation:

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ConnectionId {
    slot: usize,
    generation: u32,
}

Removal increments the slot’s generation. A later connection may reuse the slot, but lookup with the old ConnectionId returns None. The executable ch12_architecture example exercises that sequence. The generation is part of the validity check, not a claim that a memory address remains stable.

Handles do not make memory addresses stable, do not eliminate lookup, and do not solve synchronization. They make the identity contract explicit. The owner still decides mutation, concurrency, and invalidation policy.

Move work to the owner

A queue that stores borrowed requests couples queued work to the caller’s storage. In this relay, enqueueing means precisely that work may begin after the request handler returns. The boundary therefore validates external input, converts it into an owned command containing a ConnectionId and compact payload, and moves the command to the connection manager.

This transfer does more than remove a lifetime. It gives overload and shutdown a place in the API. A bounded queue may reject a command while full; the sender must be able to shed, retry, or report that rejection. Closing the last sender tells the owner that no new work can arrive, but successful shutdown still requires an acknowledgement or join if callers must observe completion.

Inside the manager, handling becomes deliberately local: resolve the ID, borrow one record long enough to make the state transition, copy the small event facts needed by metrics, end the borrow, and only then invoke another subsystem. The callback no longer reenters a borrowed registry because live access never crossed that boundary.

Borrow at the leaves

Long-lived structs containing references spread lifetime parameters through constructors, traits, collections, and shutdown code. Prefer owners and IDs in long-lived structures. Resolve a handle, take &T or &mut T for the duration of a small operation, then end the borrow before calling another subsystem.

This keeps the semantic power of borrowing—no allocation, no ownership transfer—where it is easiest to prove. It also prevents a registry borrow from leaking across logging, callbacks, awaits, or unrelated mutations.

Storage does not choose the architecture

An arena can give many values a shared allocation lifetime and cheap bulk reclamation. A slab can offer indexed storage. A generational store can reject stale IDs. A pinned allocation can constrain movement. These are different contracts.

Choose an arena when values legitimately share a phase lifetime and individual reclamation is unnecessary. Choose generational handles when identity survives temporary access but deletion/reuse must be detected. Do not use an arena merely to avoid one borrow error: it can increase retained memory and make destruction timing coarser. Allocation strategies and benchmarks belong in Chapter 32; here the architectural point is that the storage owner lends temporary access.

Duplication and shared lifetime must be intentional

Cloning is correct when the system needs another independent value or snapshot and the cost is acceptable. Cloning an Arc is correct when multiple components genuinely co-own a lifetime. Copying a small immutable configuration or event fact can reduce coupling. Cloning a large payload on every retry because ownership is unclear is a design smell.

Before cloning, state which of these contracts you want:

  • duplicated state that may diverge;
  • immutable snapshot at a particular revision;
  • shared identity and shared lifetime;
  • transferred ownership with no duplication.

Measure payload and reference-count traffic when it matters. More importantly, document who triggers shutdown: the last Arc disappearing is rarely a complete service lifecycle policy.

Three designs survive the comparison

One lock around shared state remains the simplest credible design when operations are synchronous, the invariant is small, contention is low, and no callback or await occurs under the guard. Its costs are contention, lock ordering, and reentrancy risk; explicit shutdown is still needed because disappearance of the last reference is not an observable protocol.

An owner receiving bounded commands fits a component whose state transitions are naturally serialized. It pays queue memory and scheduling delay and must define overload, disconnection, acknowledgement, and join behavior. Immutable snapshots fit read-heavy configuration or indexes: readers pay almost no coordination, while publishers pay to build and retain revisions until old readers release them.

The relay uses all three ideas without treating any as an ideology. Supervisor-owned workers consume commands, every worker reads an immutable configuration revision, the connection manager serializes registry transitions, and one narrow synchronized metric accepts copied facts. Each mechanism answers a different lifecycle or invariant question.

Failure modes

  • Adding lifetime parameters to an entire object graph when references represent identity.
  • Replacing every reference with Arc without naming the shutdown owner.
  • Cloning payloads to avoid designing transfer or snapshot semantics.
  • Splitting state so an invariant spans multiple unsynchronized owners.
  • Using raw indices that silently resolve to reused slots.
  • Holding a registry borrow while invoking callbacks or other subsystems.
  • Choosing an arena for address stability without stating its retention and movement contract.
  • Introducing an unbounded command queue to escape borrowing.

Senior review checklist

  • Is each long-lived resource owned by the component that controls its lifecycle?
  • Does each relationship require identity, access, a snapshot, or ownership?
  • Are handles protected against reuse where stale identity matters?
  • Do commands own data that must outlive the call?
  • Are queues bounded and is rejection behavior explicit?
  • Are borrows short and confined to leaf operations?
  • Does every clone express intentional duplication or co-ownership?
  • Do split boundaries preserve compound invariants and shutdown order?

Architecture exercise: defend the boundary map

Begin with the relay’s Arc<Mutex<Service>>, containing configuration, connection objects, callbacks, workers, and pending messages. Before choosing a replacement, mark every relationship as identity, temporary access, snapshot, transfer, or ownership. Then write an ADR comparing one lock, a supervisor with bounded commands, and immutable snapshots with a connection registry.

The decision must name every lifecycle owner, stale-handle behavior, compound invariant, overload response, and shutdown acknowledgement. Estimate copying, allocation, lookup, contention, and implementation complexity. Keep the one-lock design as a real alternative: state the scale or behavior at which its simplicity wins, and the observation that would force the architecture to change.

Durable takeaways

  1. Repeated borrow friction often reveals an unanswered ownership or lifecycle question.
  2. Handles express identity without carrying long-lived access; owners validate and lend data.
  3. Owned requests and bounded commands fit work that outlives a caller.
  4. Borrowing remains valuable at leaf calls where validity is local and visible.
  5. Clone, Arc, arenas, messages, and snapshots encode different costs and lifecycle contracts.

This boundary map closes the ownership argument. The relay now knows who holds each value, who may act on it, and how work crosses time. It can still construct a command with an empty account, a zero amount, or contradictory statuses. The next engineering layer is algebraic: decide which facts may coexist and which states may exist at all.

Sources and version notes