Skip to content

Factory and Builder in Rust: Validated Construction

A factory controls how a value is created; a builder collects construction choices before producing it. In Rust, an associated function and a small builder often solve this without an inheritance hierarchy. The important boundary is that every public creation path validates the same invariants.

Rust Factory and Builder requirements

Read structs and associated functions, Result, and typestate. These fixtures use stable Rust edition 2024 and C++20 with no dependencies or hardware access.

Construct a configuration with an inclusive integer range and a sampling period in milliseconds. Both bounds are required; lower <= upper, including equal bounds. Zero and negative bounds are valid. The period must be 1–60000; omission selects 1000, while an explicit zero is an error. Repeated builder setters use the last supplied value.

Error precedence is MissingLower, MissingUpper, Bounds, then Interval. A builder is allowed to hold incomplete or invalid choices; only the finished Config claims validity. This fixture configures no real sampling device. Both languages print:

1
2
3
4
5
6
direct=0..46700, period=1000
built=-500..0, period=250
missing=MissingLower
zero period=Interval
invalid bounds=Bounds
last period=500

C++: private constructor, checked factory and builder

Save this complete program as main.cpp. Config's static factory returns a C++20 variant of a valid value or an error. Builder delegates final value validation to that factory instead of duplicating it.

#include <cassert>
#include <cstdint>
#include <iostream>
#include <limits>
#include <optional>
#include <variant>

enum class Error { MissingLower, MissingUpper, Bounds, Interval };

const char* name(Error error) {
    switch (error) {
    case Error::MissingLower: return "MissingLower";
    case Error::MissingUpper: return "MissingUpper";
    case Error::Bounds: return "Bounds";
    case Error::Interval: return "Interval";
    }
    return "Unknown";
}

class Config {
    int lower_;
    int upper_;
    std::uint32_t period_;
    Config(int lower, int upper, std::uint32_t period)
        : lower_(lower), upper_(upper), period_(period) {}
public:
    static std::variant<Config, Error> make(int lower, int upper, std::uint32_t period = 1000);
    int lower() const { return lower_; }
    int upper() const { return upper_; }
    std::uint32_t period() const { return period_; }
};

std::variant<Config, Error> Config::make(int lower, int upper, std::uint32_t period) {
    if (lower > upper) return Error::Bounds;
    if (period == 0 || period > 60000) return Error::Interval;
    return Config(lower, upper, period);
}

class Builder {
    std::optional<int> lower_;
    std::optional<int> upper_;
    std::optional<std::uint32_t> period_;
public:
    Builder& lower(int value) { lower_ = value; return *this; }
    Builder& upper(int value) { upper_ = value; return *this; }
    Builder& period(std::uint32_t value) { period_ = value; return *this; }
    std::variant<Config, Error> build() const {
        if (!lower_) return Error::MissingLower;
        if (!upper_) return Error::MissingUpper;
        return Config::make(*lower_, *upper_, period_.value_or(1000));
    }
};

int main() {
    auto direct_result = Config::make(0, 46700);
    auto direct = std::get<Config>(direct_result);
    assert(direct.lower() == 0 && direct.upper() == 46700 && direct.period() == 1000);
    std::cout << "direct=" << direct.lower() << ".." << direct.upper()
              << ", period=" << direct.period() << '\n';
    auto built_result = Builder{}.lower(-500).upper(0).period(250).build();
    auto built = std::get<Config>(built_result);
    assert(built.lower() == -500 && built.upper() == 0 && built.period() == 250);
    std::cout << "built=" << built.lower() << ".." << built.upper()
              << ", period=" << built.period() << '\n';
    auto missing = Builder{}.build();
    assert(std::get<Error>(missing) == Error::MissingLower);
    std::cout << "missing=" << name(std::get<Error>(missing)) << '\n';
    auto zero = Builder{}.lower(0).upper(46700).period(0).build();
    assert(std::get<Error>(zero) == Error::Interval);
    std::cout << "zero period=" << name(std::get<Error>(zero)) << '\n';
    auto bounds = Config::make(1, 0);
    assert(std::get<Error>(bounds) == Error::Bounds);
    std::cout << "invalid bounds=" << name(std::get<Error>(bounds)) << '\n';
    auto repeated = Builder{}.lower(0).upper(0).period(250).period(500).build();
    assert(std::get<Config>(repeated).period() == 500);
    std::cout << "last period=" << std::get<Config>(repeated).period() << '\n';

    assert(std::get<Error>(Builder{}.lower(0).build()) == Error::MissingUpper);
    assert(std::get<Error>(Builder{}.period(0).build()) == Error::MissingLower);
    assert(std::get<Error>(Config::make(1, 0, 0)) == Error::Bounds);
    assert(std::get<Error>(Config::make(0, 0, 60001)) == Error::Interval);
    for (auto period : {1u, 60000u}) {
        auto value = Config::make(0, 0, period);
        assert(std::get<Config>(value).period() == period);
    }
    auto extremes = Config::make(std::numeric_limits<int>::min(), std::numeric_limits<int>::max());
    assert(std::holds_alternative<Config>(extremes));
}

Compile with assertions enabled:

1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 main.cpp -o construction
./construction
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 main.cpp -o construction_release
./construction_release

C++ access control keeps the unchecked constructor private, while a static member function does not require an existing Config. No public default constructor or mutating setter bypasses validation. Copying this scalar configuration preserves its valid values.

The C++ builder setters return references to the builder. Chaining on a temporary is safe within the build expression above; retaining that returned reference after the temporary dies would not be safe. This fixture uses std::get only when its known inputs specify the selected alternative. A real input handler must check or visit the variant instead of assuming success.

Rust: one validator behind all public construction

Run cargo new construction --edition 2024 and replace src/main.rs with the entire source. Config has no Default implementation. The builder's default represents choices not yet supplied, not a finished valid configuration.

mod config {
    use std::num::NonZeroU32;

    #[derive(Debug, PartialEq, Eq)]
    pub enum Error {
        MissingLower,
        MissingUpper,
        Bounds,
        Interval,
    }

    #[derive(Debug, PartialEq, Eq)]
    pub struct Config {
        lower: i32,
        upper: i32,
        period: NonZeroU32,
    }

    impl Config {
        pub fn try_new(lower: i32, upper: i32, period: u32) -> Result<Self, Error> {
            if lower > upper {
                return Err(Error::Bounds);
            }
            let period = NonZeroU32::new(period).ok_or(Error::Interval)?;
            if period.get() > 60000 {
                return Err(Error::Interval);
            }
            Ok(Self {
                lower,
                upper,
                period,
            })
        }

        pub fn lower(&self) -> i32 {
            self.lower
        }

        pub fn upper(&self) -> i32 {
            self.upper
        }

        pub fn period(&self) -> u32 {
            self.period.get()
        }
    }

    #[derive(Default)]
    pub struct Builder {
        lower: Option<i32>,
        upper: Option<i32>,
        period: Option<u32>,
    }

    impl Builder {
        pub fn lower(mut self, value: i32) -> Self {
            self.lower = Some(value);
            self
        }

        pub fn upper(mut self, value: i32) -> Self {
            self.upper = Some(value);
            self
        }

        pub fn period(mut self, value: u32) -> Self {
            self.period = Some(value);
            self
        }

        pub fn build(self) -> Result<Config, Error> {
            let lower = self.lower.ok_or(Error::MissingLower)?;
            let upper = self.upper.ok_or(Error::MissingUpper)?;
            Config::try_new(lower, upper, self.period.unwrap_or(1000))
        }
    }
}

use config::{Builder, Config, Error};

fn main() {
    let direct = Config::try_new(0, 46700, 1000).unwrap();
    println!(
        "direct={}..{}, period={}",
        direct.lower(),
        direct.upper(),
        direct.period()
    );
    let built = Builder::default()
        .lower(-500)
        .upper(0)
        .period(250)
        .build()
        .unwrap();
    println!(
        "built={}..{}, period={}",
        built.lower(),
        built.upper(),
        built.period()
    );
    let missing = Builder::default().build().unwrap_err();
    println!("missing={missing:?}");
    let zero = Builder::default()
        .lower(0)
        .upper(46700)
        .period(0)
        .build()
        .unwrap_err();
    println!("zero period={zero:?}");
    let bounds = Config::try_new(1, 0, 1000).unwrap_err();
    println!("invalid bounds={bounds:?}");
    let repeated = Builder::default()
        .lower(0)
        .upper(0)
        .period(250)
        .period(500)
        .build()
        .unwrap();
    assert_eq!(repeated.period(), 500);
    println!("last period={}", repeated.period());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_bounds_are_not_zero() {
        assert_eq!(Builder::default().build(), Err(Error::MissingLower));
        assert_eq!(
            Builder::default().lower(0).build(),
            Err(Error::MissingUpper)
        );
        let value = Builder::default().lower(0).upper(0).build().unwrap();
        assert_eq!((value.lower(), value.upper(), value.period()), (0, 0, 1000));
    }

    #[test]
    fn direct_and_builder_paths_share_value_validation() {
        for (lower, upper) in [(-500, 0), (0, 46700), (i32::MIN, i32::MAX)] {
            let direct = Config::try_new(lower, upper, 250);
            let built = Builder::default()
                .lower(lower)
                .upper(upper)
                .period(250)
                .build();
            assert_eq!(direct, built);
            assert!(direct.is_ok());
        }
        assert_eq!(Config::try_new(1, 0, 250), Err(Error::Bounds));
        assert_eq!(
            Builder::default().lower(1).upper(0).build(),
            Err(Error::Bounds)
        );
    }

    #[test]
    fn period_boundaries_and_explicit_zero_are_checked() {
        for period in [1, 60000] {
            assert_eq!(Config::try_new(0, 0, period).unwrap().period(), period);
        }
        for period in [0, 60001, u32::MAX] {
            assert_eq!(Config::try_new(0, 0, period), Err(Error::Interval));
            assert_eq!(
                Builder::default().lower(0).upper(0).period(period).build(),
                Err(Error::Interval)
            );
        }
    }

    #[test]
    fn error_precedence_is_explicit() {
        assert_eq!(
            Builder::default().period(0).build(),
            Err(Error::MissingLower)
        );
        assert_eq!(
            Builder::default().lower(0).period(0).build(),
            Err(Error::MissingUpper)
        );
        assert_eq!(Config::try_new(1, 0, 0), Err(Error::Bounds));
    }

    #[test]
    fn later_setters_replace_earlier_choices() {
        let value = Builder::default()
            .lower(10)
            .lower(-500)
            .upper(0)
            .period(0)
            .period(500)
            .build()
            .unwrap();
        assert_eq!(
            (value.lower(), value.upper(), value.period()),
            (-500, 0, 500)
        );
    }
}

In the project:

1
2
3
4
5
6
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --release --quiet

NonZero excludes zero but does not enforce our upper limit or ordered bounds. Private fields prevent callers outside the module from bypassing the public validator. Code inside the module still bears responsibility for preserving invariants; privacy is not a proof that every internal constructor is correct.

The consuming Rust builder returns its updated value, not a reference to a temporary. This API discards the builder after build, including a failed build. If callers must edit and retry the same choices, a borrowed build operation or an error that retains the builder is a different contract. Choosing a builder receiver is an ownership decision, not a syntax preference.

Factory names are not a required hierarchy

This is a checked factory function, not a complete Abstract Factory or subclass-based Factory Method framework. Such patterns solve additional requirements, such as choosing an open product family or coordinating related products. If those requirements arise, a trait, generic factory or runtime interface can supply creation behaviour; see traits and dispatch.

When converting an existing raw configuration into a validated one, TryFrom is another suitable Rust boundary for a fallible conversion. It does not replace the validation logic. Avoid maintaining a second validator merely because conversion and direct construction have different names.

For three simple mandatory arguments, the direct factory may be clearer than a builder. Named setters help when options grow, call-site ambiguity matters or construction is assembled across steps. A builder is not automatically necessary because one exists in the C++ design.

Changed requirement: new input paths and cross-field rules

If a file, CLI or deserialiser becomes a new input source, treat its raw values as unvalidated choices and call the same constructor. Successfully parsing an integer does not establish its domain validity. A deserialisation shortcut or new public setter must not silently bypass ordered bounds and period limits.

If the configuration becomes a real device operation, separate pure value validation from acquiring resources. Valid configuration does not guarantee access permissions, connectivity or successful startup. Constructing the value here performs no I/O and is not a readiness test.

If required fields must be supplied at compile time, a typestate builder can expose build only when its type records those fields as present. It still needs runtime validation of period limits and relationships between runtime bounds. More types do not remove the domain checks.

Intentional failures: bypassing construction boundaries

Compile this independent Rust program; the private field is not externally constructible:

1
2
3
4
5
6
7
8
9
mod config {
    pub struct Config {
        period: u32,
    }
}

fn main() {
    let _invalid = config::Config { period: 0 };
}

Use the full program's Config::try_new or Builder::build and handle Result instead of exposing fields to silence the compiler. The miniature example isolates privacy; the complete value type above also enforces the cross-field rules.

This C++ program independently fails because its constructor is private:

1
2
3
4
5
6
7
class Config {
    explicit Config(unsigned period) : period_(period) {}
    unsigned period_;
};
int main() {
    Config invalid(0);
}

Repair through the checked factory in the complete C++ program. Neither making the constructor public nor assigning a default zero interval meets the requirement.

Selection criteria and exercises

Construction requirement Candidate Remaining obligation
Few required inputs Checked associated/static factory Validate every input and relationship
Incrementally assembled choices Builder Distinguish omission, explicit zero and defaults
Existing raw value becomes validated TryFrom / conversion factory Delegate to the same validator
Compile-time presence of required choices Typestate builder Still check runtime values and cross-field rules
Open family of created products Trait/generic/runtime factory interface Specify ownership, creation failure and family compatibility
  1. Add a raw-input conversion and test that it rejects the same zero period and reversed bounds as the direct factory.
  2. Remove the upper-period check and retain the 60001 test. The resulting valid-looking value must fail the contract test.
  3. Supply a lower bound of zero, omit the upper bound, and set an invalid period. Confirm the documented MissingUpper precedence.
  4. Explain why a Default implementation that manufactures a zero-period Config would undermine this API, even though Builder::default is useful.
  5. Decide whether a failed build consumes or retains choices before adding a retry API. Test the state callers can observe after that failure.

Verification and next step

The extracted programs were checked on Raspberry Pi 4B with rustc/cargo 1.99.0, GCC 14.2.0 and kernel 6.18.50+rpt-rpi-v8. Rust passed five tests in debug and release, exact-source formatting and both output checks. The raw-input conversion also passed both profiles; reuse after consuming build was rejected with E0382, and external private-field construction with E0451. Removing the upper-period check compiled but failed the retained boundary test. C++20 passed output and assertions at -O0 and -O2, with assertions enabled; its private-constructor bypass failed compilation. No device-readiness, allocation or performance claim follows from these fixtures.

Next: Visitor, enums and open behaviour, the trade-off between adding data variants and adding operations.

Previous: State and typestate · Course overview

Donate