RFA-584 · Case file with fixtures · Case 556 of 694 · Compiler evidence
Rust Private Fields Protect the Owning Module's Invariants
Type visibility and field visibility are separate. Prefer a behaviour-focused accessor or operation unless callers truly need the representation as public data.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all Rust targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Type visibility was assumed to expose the full representation, while Rust checks every field according to the owning module's privacy boundary.
- First discriminating check
- Use or add the smallest stable accessor or domain operation, making the field public only when direct representation access is intended API.
Making a struct public lets other modules name its type. It does not automatically expose every field. The failing fixture constructs a balance through its public constructor and then reads private cents from outside the owning module. Rust reports E0616.
Privacy is checked at the representation boundary
The account module can maintain facts about Balance because external code cannot directly construct or mutate its field. It might forbid negative values, cap amounts, attach currency later, or ensure all changes produce audit records.
The official E0616 page presents two repairs: make the field public or add a getter. These choices are not equivalent API designs. Public fields let callers depend on names, types, and direct mutation where mutable access exists. Methods can expose narrower behaviour.
Rust's visibility rules are module-based. Code inside the defining module and its descendants has the relevant access; unrelated modules must use the public surface.
The repair exposes observation, not representation ownership
The repaired fixture adds cents(&self) -> i64. Callers can observe the amount but cannot assign an arbitrary new field value. The constructor remains the place where creation policy can live.
For money, a bare signed count is still incomplete because currency and overflow policy matter. The fixture stays small to isolate E0616. A production type could return a Money value, use a checked adjustment operation, and refuse mixing currencies.
I name accessors by domain meaning. Prefixes like get_ are not normally necessary for simple Rust accessors, while methods performing I/O or mutation deserve verbs that reveal that work.
Getters are not the only alternative
Sometimes a caller does not need the raw value at all. It needs to know whether a purchase is affordable, format a display amount, or apply a debit. Methods such as can_debit, display, or try_debit keep decisions next to the invariant and may be more stable than cents().
Returning a reference versus a copy is another contract. Small Copy values can be returned directly. Returning &Vec<T> exposes a concrete collection type; returning &[T] provides read-only sequence behaviour with less coupling. Returning &mut T can effectively surrender the invariant even though the field remains syntactically private.
I review accessor lifetime and mutability as carefully as visibility. A safe method can still expose too much power for the abstraction.
Public data structures can intentionally expose fields
Not every private field needs a getter. Plain configuration and data-transfer records often benefit from public fields, direct construction, destructuring, and serialization. Their representation is the intended contract.
I choose that deliberately. Adding or changing public fields can affect downstream struct literals and patterns. Marking an evolving external struct non-exhaustive or using builders changes how callers interact, each with tradeoffs.
Within one application crate, pub(crate) or pub(super) can expose a controlled collaboration boundary without making representation part of the public library API. I use the narrowest visibility that matches actual ownership, not privacy for ceremony.
Tests should defend the invariant
After fixing E0616, I test which states public operations can create. If a constructor validates non-negative amounts, tests attempt invalid construction through every public path. If mutation is transactional, tests check that failure leaves the balance unchanged.
This is more valuable than a test that only calls the getter. Privacy is useful because it makes the public operations an enumerable boundary where invariant tests can concentrate.
Generated serialization sometimes needs field access. Derive macros normally expand with appropriate access context, while external helpers may require public APIs or module placement. I do not make every field public solely to satisfy a tool before checking its supported derive or adapter pattern.
My E0616 checklist
- Which module defines the type and which module attempts access?
- Is the type public while the individual field is intentionally private?
- Does the caller need raw data, a read-only view, or a domain operation?
- Would returning mutable access bypass validation?
- Is the field's concrete type part of the intended public contract?
- Could restricted visibility serve internal collaboration without global exposure?
- Will a public-field change break construction, patterns, or serialization?
- Do tests prove the invariant across every public constructor and mutation path?
The core principle is that a public type can still own its state. E0616 marks the point where another module tries to cross that ownership boundary. I expose the smallest stable capability the caller needs and keep representation private when it protects future change or correctness.