Factories and builders

A constructor that takes ten arguments is unreadable at the call site and easy to call in the wrong order. Two patterns replace it: a factory, which picks a type from a name and returns it, and a builder, which collects the parts in named steps and hands back the finished value once. Rust has no named arguments and no overloading, so the builder is more common here than in C++.

This lesson writes a factory that can fail, a request builder whose steps consume the value, a server builder that refuses to produce an invalid server, a configuration built from Default, and the struct literal that the module boundary stops.

A factory function

A factory takes the name of a variant and returns a boxed trait object, so the caller holds a Box<dyn Transport> and never names the concrete type. The name comes from a configuration file in a real program, which is why the factory returns a Result rather than panicking on an unknown string.

The program asks for two known transports and one that does not exist. The error is a String here and an error type of its own in a library.

trait Transport { fn name(&self) -> &'static str; } struct Http; struct Stdio; impl Transport for Http { fn name(&self) -> &'static str { "http" } } impl Transport for Stdio { fn name(&self) -> &'static str { "stdio" } } fn make(kind: &str) -> Result<Box<dyn Transport>, String> { match kind { "http" => Ok(Box::new(Http)), "stdio" => Ok(Box::new(Stdio)), other => Err(format!("unknown transport {other}")), } } fn main() { for kind in ["http", "stdio", "carrier-pigeon"] { match make(kind) { Ok(transport) => println!("transport {}", transport.name()), Err(error) => println!("error {error}"), } } }

rustc --edition 2024 factory.rs
./factory

transport http
transport stdio
error unknown transport carrier-pigeon

A factory is also where a cache lives. A program that parses a configuration file asks for the same transport by the same name many times, and the factory can keep a HashMap of the names it has already built. The returned box owns nothing shared, so the cache hands out a fresh value and the caller is free to drop it. The unknown name is a value the caller can print and log rather than a panic in a library. The lesson on Result covers the ? that a longer factory uses, and trait objects cover the box that the caller holds.

A builder with named steps

A builder is a second struct with the same fields, all optional, and one method per field. Each method takes self by value, changes the field and returns the builder, so the calls chain and the compiler rejects a typo at the call site.

The request builder starts as a GET with no body and turns into a POST with one. The finished struct is plain data with no optional parts.

struct Request { method: String, path: String, body: Option<String>, } struct RequestBuilder { method: String, path: String, body: Option<String>, } impl RequestBuilder { fn new(path: &str) -> RequestBuilder { RequestBuilder { method: "GET".to_string(), path: path.to_string(), body: None } } fn method(mut self, method: &str) -> RequestBuilder { self.method = method.to_string(); self } fn body(mut self, body: &str) -> RequestBuilder { self.body = Some(body.to_string()); self } fn build(self) -> Request { Request { method: self.method, path: self.path, body: self.body } } } fn main() { let get = RequestBuilder::new("/").build(); let post = RequestBuilder::new("/items").method("POST").body("{}").build(); println!("{} {} {:?}", get.method, get.path, get.body); println!("{} {} {:?}", post.method, post.path, post.body); }

rustc --edition 2024 builder.rs
./builder

GET / None
POST /items Some("{}")

The builder is a second type, and that duplication is the honest cost. A large configuration with twelve fields has twelve setters and twelve assignments in build, and a new field touches both structs. The alternative is a struct with public fields and a separate validate function, which is shorter to write and easier to forget to call. The chain reads in the order the fields are written, and a field that is never set keeps the value from new. The cost is one copy of the builder at every step, which the optimizer removes for a chain of a few calls. The lesson on methods covers self against &mut self as a receiver.

An object that is valid when it is built

The builder can refuse. build is the only place that makes a Server, and the fields of Server are private, so every server that exists has a host and a port that passed the check.

The three calls show a default port, a reserved port and a missing host. The first succeeds and the other two return the message that explains why.

struct Server { host: String, port: u16, } struct ServerBuilder { host: Option<String>, port: Option<u16>, } impl ServerBuilder { fn new() -> ServerBuilder { ServerBuilder { host: None, port: None } } fn host(mut self, host: &str) -> ServerBuilder { self.host = Some(host.to_string()); self } fn port(mut self, port: u16) -> ServerBuilder { self.port = Some(port); self } fn build(self) -> Result<Server, String> { let host = self.host.ok_or("host is required")?; let port = self.port.unwrap_or(8080); if port < 1024 { return Err(format!("port {port} is reserved")); } Ok(Server { host, port }) } } fn report(result: Result<Server, String>) { match result { Ok(server) => println!("{} {}", server.host, server.port), Err(error) => println!("error {error}"), } } fn main() { report(ServerBuilder::new().host("localhost").build()); report(ServerBuilder::new().host("localhost").port(80).build()); report(ServerBuilder::new().port(9000).build()); }

rustc --edition 2024 valid.rs
./valid

localhost 8080
error port 80 is reserved
error host is required

Note which errors are reachable and which are not. A caller cannot build a server with a port of zero and cannot forget the host, because there is no other constructor, and the three messages are the whole documentation of the rule. A test of build covers the three cases, and the rest of the program can treat every Server as valid without a check of its own. The lesson on hiding the implementation is the privacy half of this section, and Option explains ok_or and unwrap_or.

Default and the update syntax

A struct with many fields and sensible defaults does not need a builder at all. Implementing Default gives every field a value, and the update syntax overrides the few that a caller cares about. The compiler checks that the type has every field, which a builder with optional methods cannot.

#[derive(Debug)] struct Options { width: u32, height: u32, title: String, fullscreen: bool, } impl Default for Options { fn default() -> Options { Options { width: 80, height: 24, title: String::from("untitled"), fullscreen: false, } } } fn main() { let small = Options { width: 40, ..Default::default() }; let full = Options { fullscreen: true, ..Default::default() }; println!("{} {} {} {}", small.width, small.height, full.title, full.fullscreen); println!("{}", Options::default().title); }

rustc --edition 2024 defaults.rs
./defaults

40 24 untitled true
untitled

A type that implements Default also works with unwrap_or_default and Option::unwrap_or_default, which is the shortest way to say "use this if the caller gave nothing". The update syntax takes the remaining fields from the default value and moves the String out of it, so the default is consumed and not copied. That is why the last line builds a fresh one. A builder and Default are often combined: the builder starts as Options::default() and its build returns it.

The lesson on structs covers the field init shorthand and the update syntax in full, including the case where the type does not implement Default and the caller writes every field.

The struct literal outside the module

A private field cannot be set in a literal from another module, even when the caller can name the type. The error is E0451, and the constructor is the only way in.

mod config { pub struct Config { host: String, port: u16, } impl Config { pub fn new(host: &str, port: u16) -> Config { Config { host: host.to_string(), port } } pub fn host(&self) -> &str { &self.host } pub fn port(&self) -> u16 { self.port } } } fn main() { let good = config::Config::new("localhost", 8080); let bad = config::Config { host: "localhost".to_string(), port: 8080 }; println!("{}", good.host().len() + bad.port() as usize); }

rustc --edition 2024 literal_err.rs

error[E0451]: fields `host` and `port` of struct `Config` are private
--> literal_err.rs:24:32
|
24 | let bad = config::Config { host: "localhost".to_string(), port: 8080 };
| ^^^^ private field ^^^^ private field

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0451`.

A struct with one private field and one public field produces the same error for the literal as for a fully private one, so a type either takes the rule seriously or does not. The constructor new is the same value with the same fields and it compiles, which is the whole difference between a type that keeps a rule and one that publishes its fields. The next lesson collects the decisions of this kind into a short list of rules for a public interface.

Exercises

  1. Add a timeout step to the request builder and a default of thirty seconds.
  2. Make ServerBuilder start from Default and add a with_port alias.
  3. Make the factory read its name from an environment variable and report the unknown name with a non-zero exit code.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does a factory function return when the name is unknown?

    Answer

    A Result with an error, so the caller decides what to do

    The name usually comes from configuration, so the failure is a value the caller reports.

  2. Why does each builder method take self by value?

    Answer

    So the calls chain and the builder is consumed once

    Taking self and returning it makes the chain work and stops the old binding from being reused.

  3. Where does a validating builder check its fields?

    Answer

    In build, which is the only function that creates the value

    With private fields, build is the one gate, so every value that exists has passed it.

  4. What does the struct update syntax do with the default value?

    Answer

    It takes the remaining fields from it, moving values out of it

    The fields that are not named come from the second value, and non-Copy fields move.

  5. Why is a builder more common in Rust than in C++?

    Answer

    Rust has no named arguments and no overloading

    A builder gives each argument a name, which the language does not provide at the call site.

  6. What does this program print? trait Transport { fn name(&self) -> &'static str; } struct Http; impl Transport for Http { fn name(&self) -> &'static str { "http" } } fn make(kind: &str) -> Result<Box<dyn Transport>, String> { if kind == "http" { Ok(Box::new(Http)) } else { Err(format!("unknown {kind}")) } } fn main() { match make("http") { Ok(transport) => println!("{}", transport.name()), Err(error) => println!("{error}"), } }

    Answer

    http

    The known name builds the box and the transport prints its own name.

  7. What does this program print? struct Request { method: String, path: String, } struct Builder { method: String, path: String, } impl Builder { fn new(path: &str) -> Builder { Builder { method: "GET".to_string(), path: path.to_string() } } fn method(mut self, method: &str) -> Builder { self.method = method.to_string(); self } fn build(self) -> Request { Request { method: self.method, path: self.path } } } fn main() { let request = Builder::new("/a").method("POST").build(); println!("{} {}", request.method, request.path); }

    Answer

    POST /a

    The method step replaces GET with POST and the path is unchanged.

  8. What does this program print? struct Server { host: String, port: u16, } struct Builder { host: Option<String>, port: Option<u16>, } impl Builder { fn build(self) -> Result<Server, String> { let host = self.host.ok_or("host is required")?; Ok(Server { host, port: self.port.unwrap_or(80) }) } } fn main() { let good = Builder { host: Some("h".to_string()), port: None }.build(); let bad = Builder { host: None, port: Some(1) }.build(); println!("{} {}", good.map(|s| s.port).unwrap_or(0), bad.is_err()); }

    Answer

    80 true

    The default port is eighty and the builder without a host fails.

  9. What does this program print? #[derive(Debug)] struct Options { width: u32, height: u32, } impl Default for Options { fn default() -> Options { Options { width: 80, height: 24 } } } fn main() { let small = Options { width: 40, ..Default::default() }; let tall = Options { height: 50, ..Default::default() }; println!("{} {} {} {}", small.width, small.height, tall.width, tall.height); }

    Answer

    40 24 80 50

    Each literal overrides one field and takes the other from the default value.

  10. What is the first line of the compiler output for this program? mod config { pub struct Config { host: String, port: u16, } impl Config { pub fn new(host: &str, port: u16) -> Config { Config { host: host.to_string(), port } } pub fn port(&self) -> u16 { self.port } } } fn main() { let config = config::Config { host: "h".to_string(), port: 80 }; println!("{}", config.port()); }

    Answer

    error[E0451]: fields host and port of struct Config are private

    The struct is public and its fields are not, and a literal may not set them.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev