Skip to content

7. Build a request with optional fields

A search request has a query, a page, a page size, an optional sort field and direction, an “include archived” switch, a timeout, and an optional tenant. Most callers set two or three of them.

  1. WorseOne new with eight parameters, two of them bool. Every caller passes every value, in the right order.

    pub struct SearchRequest {
    pub query: String,
    pub page: u32,
    pub page_size: u32,
    pub sort_by: Option<String>,
    pub descending: bool,
    pub include_archived: bool,
    pub timeout_ms: u64,
    pub tenant: Option<String>,
    }
    impl SearchRequest {
    pub fn new(
    query: &str,
    page: u32,
    page_size: u32,
    sort_by: Option<&str>,
    descending: bool,
    include_archived: bool,
    timeout_ms: u64,
    tenant: Option<&str>,
    ) -> Self {
    Self {
    query: query.to_string(),
    page,
    page_size,
    sort_by: sort_by.map(str::to_string),
    descending,
    include_archived,
    timeout_ms,
    tenant: tenant.map(str::to_string),
    }
    }
    }
  2. OKA builder with private fields. Callers name what they set, and the builder can check values.

    #[derive(Debug, Clone, PartialEq)]
    pub struct SearchRequest {
    query: String,
    page: u32,
    page_size: u32,
    sort_by: Option<String>,
    descending: bool,
    include_archived: bool,
    timeout_ms: u64,
    tenant: Option<String>,
    }
    pub struct SearchRequestBuilder {
    request: SearchRequest,
    }
    impl SearchRequest {
    /// The query is required, so it is the builder's only parameter.
    pub fn builder(query: &str) -> SearchRequestBuilder {
    SearchRequestBuilder {
    request: SearchRequest {
    query: query.to_string(),
    page: 1,
    page_size: 20,
    sort_by: None,
    descending: false,
    include_archived: false,
    timeout_ms: 5_000,
    tenant: None,
    },
    }
    }
    }
    impl SearchRequestBuilder {
    pub fn page_size(mut self, size: u32) -> Self {
    self.request.page_size = size.clamp(1, 100); // the builder can check values
    self
    }
    pub fn sort_by(mut self, field: &str, descending: bool) -> Self {
    self.request.sort_by = Some(field.to_string());
    self.request.descending = descending;
    self
    }
    pub fn timeout_ms(mut self, ms: u64) -> Self {
    self.request.timeout_ms = ms;
    self
    }
    pub fn build(self) -> SearchRequest {
    self.request
    }
    }
  3. BetterPublic fields, a Default impl, and struct update syntax at the call site. The sort direction is an enum.

    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct SearchRequest {
    pub query: String,
    pub page: u32,
    pub page_size: u32,
    pub sort: Option<Sort>,
    pub include_archived: bool,
    pub timeout_ms: u64,
    pub tenant: Option<String>,
    }
    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct Sort {
    pub field: String,
    pub order: Order,
    }
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum Order {
    Ascending,
    Descending,
    }
    impl Default for SearchRequest {
    fn default() -> Self {
    Self {
    query: String::new(),
    page: 1,
    page_size: 20,
    sort: None,
    include_archived: false,
    timeout_ms: 5_000,
    tenant: None,
    }
    }
    }

The difference is clearest at the call sites. All three build the same request:

Worse: call site
SearchRequest::new("rust books", 1, 50, Some("price"), true, false, 3_000, None)
OK: call site
SearchRequest::builder("rust books")
.page_size(50)
.sort_by("price", true)
.timeout_ms(3_000)
.build()
Better: call site
SearchRequest {
query: "rust books".to_string(),
page_size: 50,
sort: Some(Sort {
field: "price".to_string(),
order: Order::Descending,
}),
timeout_ms: 3_000,
..SearchRequest::default() // every other field from `Default`
}

In the “Worse” call, what does true, false mean? Which u32 is the page and which is the page size? A reviewer must open the definition to check each position. If two values of the same type are swapped, the code compiles and the search is wrong. Rust has no named arguments and no default parameter values, so long parameter lists are worse than in languages that do. See S31: Function with too many parameters (no builder or config struct).

The “OK” builder names every setting at the call site. It is the pattern from Effective Java, and you will see it in many crates. It has two real advantages: the fields stay private, and a setter can check its value (page_size clamps to 1 to 100). Its cost is code: a second type, and one method per setting.

The “Better” version uses the language feature for this case. ..SearchRequest::default() fills every field that the caller does not name. The call site reads like named arguments, and there is no builder to write or maintain. Two small changes also help:

Each version compared with the Worse version
CriterionOKBetter
Correctness Better Better
"Worse" accepts swapped arguments of the same type. "OK" and "Better" name every value.
Failure behavior Better Same
"OK" can check and correct values in its setters. "Better" accepts any value in a public field, including a page_size of 0.
Performance Same Same
All three compile to the same field writes.
Readability Better Better
Both name each setting. "Better" has less code to read: no builder type.
API ergonomics Better Better
"OK" can add settings without breaking callers. "Better" is the shortest call site, but every public field is part of the API.
  • Choose the builder when values need checks, or when fields must stay private: a library type with rules, or a value that is expensive to make invalid. The builder’s build() can also return a Result.
  • Choose Default and public fields for plain settings: configuration, options, test fixtures. Most application code is this case.
  • A required field with a default is a risk. “Better” has query: String::new() in Default, so SearchRequest::default() is a request with no query. If that is wrong, make the required value a parameter: SearchRequest::new("rust books") that sets defaults for the rest, and struct update for the others.
  • Adding a public field is a breaking change for code that builds the struct without ... A library can mark the struct #[non_exhaustive] to force callers to use a constructor or a builder.
  • “Worse” is acceptable for two or three parameters of different types. Point::new(x, y) needs no builder.