SWDev Impl Patterns

SWDev Impl Patterns: Objects and State

value object, null object, collecting parameter, method object

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
LanguageIdiomatic 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
ResourceDescription
Refactoring Guru - Null Object Null Object transformation with before/after examples.
Refactoring Guru - Method Object Replace Method with Method Object refactoring steps.
Fowler - Value Object Fowler's definition of Value Object and how it differs from Entity.
Rust - Newtype Pattern Rust's newtype idiom - the idiomatic Value Object in Rust.