Skip to main content

Pattern: Builder

The builder pattern is used to construct complex objects with many parameters in a flexible and readable way. Instead of requiring all parameters upfront, a builder accumulates configuration through method calls and produces the final object when build() is called. This pattern is especially useful in testing, where you often need to create objects with slight variations while keeping most fields at sensible defaults.

In published code, builder pattern may introduce additional gas costs due to intermediate structs and multiple function calls. This pattern is best suited for tests where gas considerations are not a concern, and readability and maintainability are required.

Defining a Builder

A builder struct mirrors the target object's fields but wraps them in Option types. This allows each field to remain unset until explicitly configured. A typical builder provides:

  • A new() function that creates an empty builder
  • Setter methods that configure individual fields and return the builder for chaining
  • A build() function that constructs the final object using defaults for unset fields
module book::user;

use std::string::String;

/// A user account with multiple properties.
public struct User has drop {
name: String,
age: u8,
email: String,
balance: u64,
is_active: bool,
}

/// Creates a new user - requires all fields.
public fun new(
name: String,
age: u8,
email: String,
balance: u64,
is_active: bool,
): User {
User { name, age, email, balance, is_active }
}

public fun balance(self: &User): u64 { self.balance }
public fun is_active(self: &User): bool { self.is_active }
public fun age(self: &User): u8 { self.age }

The corresponding builder:

#[test_only]
module book::user_builder;

use book::user::{Self, User};
use std::string::String;

/// Builder for creating `User` instances in tests.
public struct UserBuilder has drop {
name: Option<String>,
age: Option<u8>,
email: Option<String>,
balance: Option<u64>,
is_active: Option<bool>,
}

/// Creates an empty builder with all fields unset.
public fun new(): UserBuilder {
UserBuilder {
name: option::none(),
age: option::none(),
email: option::none(),
balance: option::none(),
is_active: option::none(),
}
}

// === Setter methods - each returns the builder for chaining ===

public fun name(mut self: UserBuilder, name: String): UserBuilder {
self.name = option::some(name);
self
}

public fun age(mut self: UserBuilder, age: u8): UserBuilder {
self.age = option::some(age);
self
}

public fun email(mut self: UserBuilder, email: String): UserBuilder {
self.email = option::some(email);
self
}

public fun balance(mut self: UserBuilder, balance: u64): UserBuilder {
self.balance = option::some(balance);
self
}

public fun is_active(mut self: UserBuilder, is_active: bool): UserBuilder {
self.is_active = option::some(is_active);
self
}

/// Builds the `User`, using defaults for any unset fields.
public fun build(self: UserBuilder): User {
let UserBuilder { name, age, email, balance, is_active } = self;
user::new(
name.destroy_or!(b"Default User".to_string()),
age.destroy_or!(18),
email.destroy_or!(b"user@example.com".to_string()),
balance.destroy_or!(0),
is_active.destroy_or!(true),
)
}

Here, the new() function initializes all fields to option::none(), representing an "unconfigured" state. Each setter method wraps the provided value in option::some() and stores it in the corresponding field. The key to the pattern is the build() function, which uses the destroy_or! macro to unwrap each Option: if a field was configured, its value is used; otherwise, the macro returns the default value provided as the second argument. This approach lets tests specify only the fields they care about while ensuring the final object is always fully initialized.

Example Usage

Without a builder, every test must specify all fields, even when only one field is relevant to the test:

#[test]
fun test_balance_check_without_builder() {
// We only care about `balance`, but must specify everything
let user = user::new(
b"Alice".to_string(),
25,
b"alice@example.com".to_string(),
1000, // <-- the only field we care about
true,
);
assert!(user.balance() == 1000);
}

#[test]
fun test_inactive_user_without_builder() {
// We only care about `is_active`, but must specify everything
let user = user::new(
b"Bob".to_string(),
30,
b"bob@example.com".to_string(),
500,
false, // <-- the only field we care about
);
assert!(user.is_active() == false);
}

With a builder, tests become focused and self-documenting:

#[test]
fun test_balance_check() {
// Only specify what matters for this test
let user = new()
.balance(1000)
.build();

assert!(user.balance() == 1000);
}

#[test]
fun test_inactive_user() {
// Only specify what matters for this test
let user = new()
.is_active(false)
.build();

assert!(user.is_active() == false);
}

#[test]
fun test_underage_user() {
// Testing age-related logic
let user = new()
.age(16)
.build();

assert!(user.age() < 18);
}

Each test clearly shows which field matters. Adding new fields to User only requires updating the builder's build() function with a default - existing tests remain unchanged.

Method Chaining

The key to fluent builder syntax is method chaining. Each setter method takes mut self by value, modifies it, and returns the modified builder. Here's a very common example:

public fun is_active(mut self: UserBuilder, is_active: bool): UserBuilder {
self.is_active = option::some(is_active);
self
}

Because the method takes ownership of self and returns UserBuilder, you can chain multiple calls together:

let user = user_builder::new()
.name("Alice")
.balance(1000)
.is_active(true)
.build();

Each method in the chain consumes the previous builder and returns a new one. The final build() call consumes the builder and produces the target object.

Usage in system packages

The Sui Framework and Sui System packages use builders extensively for testing. The most notable examples are:

ValidatorBuilder in Sui System

The ValidatorBuilder in the sui-system package demonstrates a comprehensive builder for a complex type with many fields - cryptographic keys, network addresses, and economic parameters:

use sui_system::validator_builder;

#[test]
fun test_validator_operations() {
let validator = validator_builder::preset()
.name("My Validator")
.gas_price(1000)
.commission_rate(500) // 5%
.initial_stake(100_000_000)
.build(ctx);

// test validator operations...
}

The preset() function returns a builder pre-filled with valid test defaults, so tests only override the fields they care about.

TxContextBuilder in Sui Framework

The TxContextBuilder allows customizing transaction context for specific test scenarios:

use sui::test_scenario as ts;

#[test]
fun test_epoch_dependent_logic() {
let mut test = ts::begin(@0x1);
let ctx = test
.ctx_builder()
.set_epoch(100)
.set_epoch_timestamp(1000000)
.build();

// test logic that depends on epoch...

test.end();
}

Summary

  • A builder accumulates configuration through setter methods and produces the final object via build().
  • Use Option fields to make configuration optional, with sensible defaults in build().
  • Method chaining (fun method(mut self, ...): Self) creates a fluent API.
  • Builders reduce test boilerplate and isolate tests from changes to the target struct.
  • Reserve this pattern for test utilities where readability matters more than gas costs.