Tech Wiki

TOPICSSERIES

[Rust Zero to Production 12] Design a Rust Domain Model with Structs and Methods

An endpoint can be represented with nothing but u64 and String. Those types say almost nothing about the rules, though. A zero ID, a check interval passed where an ID belongs, or text with an unsupported scheme can all reach a struct literal unless every caller remembers the same conventions.

This example gives endpoint IDs, URLs, and check intervals separate types, then makes the fields of Endpoint private. Constructors admit valid values, and mutation methods preserve the same rules. The URL claim stays deliberately narrow: a few string checks do not amount to complete URL validation.

1. Meaning Collisions in Primitive Types

The following struct compiles, but it does not express the rules attached to its fields.

struct Endpoint {
    id: u64,
    url: String,
    check_every_seconds: u64,
}

The two u64 values are easy to swap at a call site. url is an arbitrary string, not text known to satisfy an endpoint policy. Units, zero-value rules, and accepted schemes survive only in names and comments.

A newtype wraps an existing representation in a one-field tuple struct. It gives different roles different static types without adding another runtime value. This example uses EndpointId(u64), EndpointUrl(String), and CheckInterval(Duration). Passing a CheckInterval where an EndpointId is required is a compile error.

2. Constructors Guard Invariants

The standalone Rust 2024 crate is at examples/article-12-structs-domain-model. Its core constructors look like this:

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EndpointId(u64);

impl EndpointId {
    pub const fn new(value: u64) -> Result<Self, ModelError> {
        if value == 0 {
            Err(ModelError::ZeroEndpointId)
        } else {
            Ok(Self(value))
        }
    }

    #[must_use]
    pub const fn get(self) -> u64 {
        self.0
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CheckInterval(Duration);

impl CheckInterval {
    pub const fn new(value: Duration) -> Result<Self, ModelError> {
        if value.is_zero() {
            Err(ModelError::ZeroCheckInterval)
        } else {
            Ok(Self(value))
        }
    }

    pub const fn from_secs(seconds: u64) -> Result<Self, ModelError> {
        Self::new(Duration::from_secs(seconds))
    }

    #[must_use]
    pub const fn duration(self) -> Duration {
        self.0
    }
}

EndpointId::new and CheckInterval::from_secs have no self parameter. They are associated functions attached to a type, not methods acting on an instance. Associated functions are commonly used as constructors and called with Type::function syntax. new is a convention, not a special language keyword.

The interval stores the standard library's Duration rather than a bare u64. Time is represented as time instead of relying on a field name to preserve whether a number means seconds or milliseconds. This domain rejects a zero interval, so both public construction paths return ZeroCheckInterval for it.

get() and duration() expose the representation only when code needs to read it. The tuple fields have no pub, which prevents external code from writing EndpointId(0) or CheckInterval(Duration::ZERO). Code inside the defining module can still access private fields. Privacy creates a module API boundary; it is not a magical proof attached to the struct itself.

3. URL Type Guarantees

EndpointUrl is another string newtype, but it does more than rename String. Its public construction path applies the minimum policy needed by this application.

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EndpointUrl(String);

impl EndpointUrl {
    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
        let value = value.into();
        let remainder = value
            .strip_prefix("https://")
            .or_else(|| value.strip_prefix("http://"))
            .ok_or(ModelError::UnsupportedUrlScheme)?;

        if remainder.split('/').next().is_none_or(str::is_empty) {
            return Err(ModelError::EmptyUrlAuthority);
        }
        if value.chars().any(char::is_whitespace) {
            return Err(ModelError::UrlContainsWhitespace);
        }

        Ok(Self(value))
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

This type guarantees exactly three things: the text starts with http:// or https://, the authority text before the next slash is nonempty, and no whitespace is present. It does not validate host syntax, port ranges, internationalized domains, percent encoding, or normalization. The code is a small application input policy, not a general URL parser. A system that needs those guarantees should choose a maintained URL parser and separately define which schemes and hosts the service accepts.

as_str() returns a borrowed view of the internal string. The as_ prefix fits this cheap borrowed-to-borrowed conversion. Callers can inspect the text but cannot mutate the String and bypass construction checks.

4. Assembling Endpoint from Validated Values

The aggregate's fields are private as well. Endpoint::new accepts the value objects above and checks the one remaining rule, a nonempty name, before it returns an instance.

#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
    id: EndpointId,
    name: String,
    url: EndpointUrl,
    check_interval: CheckInterval,
}

impl Endpoint {
    pub fn new(
        id: EndpointId,
        name: impl Into<String>,
        url: EndpointUrl,
        check_interval: CheckInterval,
    ) -> Result<Self, ModelError> {
        let name = name.into();
        validate_name(&name)?;
        Ok(Self {
            id,
            name,
            url,
            check_interval,
        })
    }

    #[must_use]
    pub const fn id(&self) -> EndpointId {
        self.id
    }

    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    #[must_use]
    pub const fn url(&self) -> &EndpointUrl {
        &self.url
    }

    #[must_use]
    pub const fn check_interval(&self) -> CheckInterval {
        self.check_interval
    }
}

With no public fields, an external caller cannot omit part of a struct literal or replace the URL with unchecked text. Reads go through id(), name(), url(), and check_interval(). Public getters allow read-only access to private fields. Ordinary getters use the field name rather than a get_ prefix.

A getter need not unwrap every value into a primitive. url() returns &EndpointUrl, preserving the URL meaning until a caller explicitly asks for as_str(). check_interval() returns its small Copy wrapper by value. This ability to select a return type for each use is more useful than exposing every field directly.

5. Mutation Methods and Invariants

Validation at construction is short-lived if callers can freely replace fields afterward. The model keeps name private and exposes rename. It assigns only after validation succeeds, so a failed rename also leaves the old name intact.

pub fn rename(&mut self, new_name: impl Into<String>) -> Result<(), ModelError> {
    let new_name = new_name.into();
    validate_name(&new_name)?;
    self.name = new_name;
    Ok(())
}

#[must_use]
pub fn is_due(&self, elapsed: Duration) -> bool {
    elapsed >= self.check_interval.duration()
}

These functions take an instance as their first parameter, so they are methods. rename uses &mut self to change state; is_due uses &self to read the interval. The receiver, not the function's name, distinguishes a method from an associated function. Creating a value reads as Endpoint::new(...); asking an existing endpoint a question reads as endpoint.is_due(...).

Put each always-true condition in the smallest useful type. EndpointId owns the nonzero ID rule, CheckInterval owns the nonzero duration rule, and EndpointUrl owns its input policy. Endpoint handles the name and the composition of those parts. Each type has a specific job instead of routing every check through one large constructor.

6. Failure-Path Tests

The example has 9 unit tests. They cover zero IDs, unsupported URL schemes, empty authority text and whitespace, zero intervals, and empty names. They also check getters, the atomic behavior of a failed rename, and the due-time boundary. A function-pointer test catches accidental changes to the main constructor and method signatures at compile time.

#[test]
fn constructors_and_methods_have_the_intended_signatures() {
    let _: fn(u64) -> Result<EndpointId, ModelError> = EndpointId::new;
    let _: fn(u64) -> Result<CheckInterval, ModelError> = CheckInterval::from_secs;
    let _: fn(&Endpoint, Duration) -> bool = Endpoint::is_due;
}

Run the complete example from the project directory:

cd examples/article-12-structs-domain-model
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

All commands should pass on stable rustc 1.98.1, Cargo 1.98.1, and Rust 2024. The first block is the library test run; the subsequent binary and documentation test runs each contain 0 tests.

running 9 tests
test tests::check_interval_rejects_zero ... ok
test tests::constructors_and_methods_have_the_intended_signatures ... ok
test tests::due_check_uses_the_interval_boundary ... ok
test tests::endpoint_id_rejects_zero ... ok
test tests::endpoint_rejects_an_empty_name ... ok
test tests::endpoint_url_rejects_an_unsupported_scheme ... ok
test tests::endpoint_url_rejects_empty_authority_and_whitespace ... ok
test tests::failed_rename_preserves_the_old_name ... ok
test tests::getters_expose_domain_values_without_exposing_fields ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The binary's exact stdout is:

endpoint 42 status-api -> https://status.example.com/health
check every 30s; due after 45s: true
renamed: public-status

A struct can do more than group related fields. Newtypes separate values that must not be exchanged, while constructors and mutation methods keep validation in one place. Private fields and narrow getters hold that boundary for outside code. The remaining obligation is precision: documentation and tests should describe exactly what a type checks, never a stronger guarantee suggested by its name.

Full source code

The complete runnable source for this article is available in the Chapter 12 project on GitHub.

Sources


One response

  1. […] Next articleDesign a Rust Domain Model with Structs and Methods […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.