2.0 Objects and State
The four patterns in this chapter address how objects hold state and how computations
accumulate results. They answer recurring questions: when should a simple value become
a type, how do you eliminate null checks at call sites, how do you pass partial results
through a computation, and when should a complex method become a class?
2.1 Value Object
Problem: primitive types - integers, strings, floats - carry no
domain meaning. float could be a price, a weight, a temperature, or
a probability. Passing raw primitives lets callers confuse them; adding domain
constraints requires guards duplicated across every call site.
Solution: wrap the primitive in a type that carries domain meaning.
The type enforces constraints at construction and makes the intent visible at every
call site. Equality is by value content, not by identity. Instances are usually
immutable - once constructed, a value object does not change. Beck:
use a class to represent a value with no identity, only a magnitude or meaning.
// Raw primitives - caller has no guidance
fn apply_discount(price: f64, rate: f64) -> f64
// Value objects - domain constraints enforced, meaning visible
struct Price(f64); // always >= 0
struct Rate(f64); // 0.0 ..= 1.0
struct Money { amount: Price, currency: Currency }
impl Price {
fn new(v: f64) -> Result<Price, String> {
if v < 0.0 { Err("price cannot be negative") }
else { Ok(Price(v)) }
}
}
fn apply_discount(price: Price, rate: Rate) -> Price
The signature now documents what each argument represents and prevents transposing
the arguments. The constructor is the single place where the invariant is enforced.
Value Object - Language Idioms
| Language | Idiomatic form |
| Rust |
Newtype pattern: struct Price(f64). Implement PartialEq,
PartialOrd, and Display explicitly; derive Copy
for small numeric values. The newtype prevents accidental use of the inner
value without explicit unwrapping.
|
| C++ |
A struct or class wrapping the primitive; overload
comparison operators; [[nodiscard]] on factory functions to prevent
discarding construction errors. constexpr constructors allow use
in compile-time contexts.
|
| C# |
A record struct (C# 10+) provides value equality and immutability
with minimal boilerplate. Earlier versions use a struct with
overridden Equals and GetHashCode.
|
| Python |
A @dataclass(frozen=True) with __post_init__
validation; or a NamedTuple subclass. Both provide value
equality and immutability without manual __eq__.
|
2.2 Null Object
Problem: a method that may or may not return a result forces every
caller to check for null/nil/None before using the result. The checks are repetitive,
easy to forget, and obscure the main logic at each call site.
Solution: return a Null Object - an instance of the same type that
implements all methods with neutral (do-nothing or identity) behavior. The caller
uses the result without checking; the Null Object simply does nothing or returns
empty/zero values. Fowler: use a Null Object when you need to distinguish the
absence of an object from its presence in a way that callers should not have to
check for.
trait Logger {
fn log(&self, msg: &str);
fn warn(&self, msg: &str);
}
struct FileLogger { file: File }
struct NullLogger; // does nothing
impl Logger for FileLogger { fn log(..) { writeln!(..) } fn warn(..) { .. } }
impl Logger for NullLogger { fn log(..) {} fn warn(..) {} }
// callers always hold Box<dyn Logger> - no null checks
struct Server { logger: Box<dyn Logger> }
// In tests or when logging is disabled:
Server::new(Box::new(NullLogger))
Null Object - Notes
| Notes |
| vs. Option |
Option / Maybe / nullable forces the
caller to handle absence explicitly - good when absence is meaningful and
the caller must react differently. Null Object is better when absence should
be transparent - the caller does not care and should not need to check.
|
| Testing |
Null Object doubles as a test double. Injecting NullLogger
in a unit test silences logging output without mocking the logger interface.
|
| Risk |
When absence should cause a failure, a Null Object silently
swallows the error. Only apply where doing nothing is genuinely correct.
|
2.3 Collecting Parameter
Problem: a recursive traversal or multi-step computation produces
results that must be accumulated. Returning partial results from each recursive call
and merging them is awkward; using a global accumulator introduces hidden state.
Solution: pass the result container as a parameter into the
computation. Each call contributes to the shared container; the caller retains
ownership and reads the final result after the computation completes. Beck:
add a parameter to a method so that it can collect results across several
method calls.
// Without collecting parameter: awkward merge of partial results
fn find_large_files(dir: &Path, min_bytes: u64) -> Vec<PathBuf> {
let mut results = vec![];
for entry in read_dir(dir) {
if entry.is_dir() {
results.extend(find_large_files(&entry.path(), min_bytes)); // allocation per level
} else if entry.metadata().len() >= min_bytes {
results.push(entry.path());
}
}
results
}
// With collecting parameter: no per-level allocation
fn find_large_files(dir: &Path, min_bytes: u64, results: &mut Vec<PathBuf>) {
for entry in read_dir(dir) {
if entry.is_dir() {
find_large_files(&entry.path(), min_bytes, results);
} else if entry.metadata().len() >= min_bytes {
results.push(entry.path());
}
}
}
// caller:
let mut large = Vec::new();
find_large_files(root, 1_000_000, &mut large);
Collecting Parameter - Notes
| Notes |
| When to use |
Recursive traversal, multi-phase accumulation, or any case where the result
is built incrementally and returning partial results per call is wasteful or
awkward.
|
| Alternatives |
Iterators and generators push collected results lazily without a collecting
parameter. In Rust, returning an impl Iterator or using channels
often replaces the collecting parameter pattern.
|
| Visibility |
If the collecting parameter version is more complex to call, provide a
public wrapper that creates the container and calls the private recursive
function.
|
2.4 Method Object
Problem: a method is too complex to decompose cleanly because the
sub-steps share many local variables. Passing all those variables as parameters to
helper methods produces unreadable signatures.
Solution: promote the method into its own class. The original
parameters become constructor arguments; the shared local variables become fields;
the method body becomes a compute() or run() method that
delegates to well-named private helpers that share state through self.
Beck: create a class for a method that is too complex to compose cleanly.
// Before: complex method with many shared locals
fn generate_report(data: &Dataset, opts: &Options) -> Report {
let filtered = filter_records(data, &opts.predicates);
let grouped = group_by_key(filtered, &opts.group_key);
let aggregated = aggregate(grouped, &opts.agg_fns);
let sorted = sort_by(aggregated, &opts.sort_key);
let formatted = format_rows(sorted, &opts.template);
Report::new(formatted, opts.title.clone())
}
// After: Method Object
struct ReportGenerator<'a> {
data: &'a Dataset,
opts: &'a Options,
filtered: Vec<Record>,
grouped: HashMap<Key, Vec<Record>>,
aggregated: Vec<Row>,
}
impl<'a> ReportGenerator<'a> {
fn new(data: &'a Dataset, opts: &'a Options) -> Self { ... }
fn run(mut self) -> Report {
self.filter();
self.group();
self.aggregate();
self.sort();
self.format()
}
fn filter(&mut self) { ... }
fn group(&mut self) { ... }
fn aggregate(&mut self) { ... }
fn sort(&mut self) { ... }
fn format(self) -> Report { ... }
}
Method Object - Notes
| Notes |
| When to use |
When a method has six or more local variables that are needed by more than
one sub-step, making parameter passing impractical. Multi-pass computations
(filter, group, aggregate, format) are a common case.
|
| Testability |
A method object is easier to unit-test than a private method: each step can
be tested in isolation by constructing the object, populating fields, and
calling the helper directly.
|
| Naming |
Name the class for what it computes, not how: ReportGenerator,
not GenerateReportHelper. The method object is a first-class
participant in the design.
|
2.5 References
Objects and State References