How to Write Fluent APIs (Method Chaining) in Rust

You write fluent APIs in Rust by defining methods that return `Self` or `&mut Self` to enable chaining. Define a struct, implement methods that take `&mut self` and return `&mut Self`, then call them sequentially on the same instance.

How to Write Fluent APIs (Method Chaining) in Rust

You're porting a Python configuration builder to Rust. You write db.host("localhost").port(5432).connect(). You hit run. The compiler yells about a moved value. You stare at the code. It looks identical to the Python version. The difference isn't syntax. It's ownership. Rust moves the value into the first method, leaving nothing for the second.

The compiler isn't blocking your chain. It's asking you to specify how ownership moves.

The baton pass

Method chaining works by returning the object so the next method has something to call. In Rust, "returning" has two distinct flavors. You can return the value itself (Self), or you can return a mutable reference to it (&mut Self). The choice changes how the compiler tracks ownership, how much copying happens, and whether you can reuse the variable after the chain.

Think of a relay race. If the runner hands the baton to the next person, the first runner can't use it anymore. That's self. The method consumes the value. If you want to chain, the method must hand the baton back. That's -> Self. The method takes ownership, does its work, and returns the value so the next method can grab it.

If the runner hands a photo of the baton, that's not useful. But if the runner hands a temporary key that lets the next person modify the baton without taking it, that's &mut self. The method borrows the value, modifies it, and returns the mutable reference. The original owner still holds the value. The chain borrows along the way.

Chaining is just passing the baton. Make sure the baton comes back.

Minimal builder

The most common fluent pattern in Rust is the builder. You create an object, configure it through a chain of methods, and consume it at the end. Each method takes self, modifies the state, and returns self.

struct Query {
    table: String,
    limit: Option<u32>,
}

impl Query {
    /// Start a new query builder with a table name.
    fn new(table: &str) -> Self {
        Query {
            table: table.to_string(),
            limit: None,
        }
    }

    /// Set the row limit. Consumes self and returns it.
    fn limit(mut self, n: u32) -> Self {
        self.limit = Some(n);
        // Return self so the chain continues.
        self
    }

    /// Execute the query. Consumes self and returns the result.
    fn execute(self) -> String {
        // Terminal step. Consumes the builder.
        format!("SELECT * FROM {} LIMIT {:?}", self.table, self.limit)
    }
}

fn main() {
    // Chain methods. Each step takes ownership and passes it forward.
    let sql = Query::new("users").limit(10).execute();
    println!("{}", sql);
}

Notice mut self in the limit method. When you take self, the parameter is immutable by default. You can't change self.limit. You must write mut self to allow modification inside the function. This is a common stumbling block. self moves the value, but mut self moves the value and makes it mutable.

The value flows like water. No copies. No allocations. Just movement.

Walkthrough

When you call Query::new("users"), Rust creates a Query struct on the stack. You immediately call .limit(10). The method signature fn limit(mut self, ...) tells Rust to move the Query into the function. Inside, self is the owner. You update limit. You return self. Rust moves the struct back out. Now .execute() grabs it. The value moves along the chain. It never copies. It never allocates extra memory for the chain itself.

Performance note: self chains can be slightly more efficient than &mut self chains because the compiler can elide the reference indirection. The value moves directly into the next call. For most code, the difference is invisible. For tight loops, self wins.

Realistic client builder

Real-world builders often have a build() method. This signals that construction is complete. Sometimes build() returns a different type than the builder. Sometimes it returns the same type. Both are valid.

struct HttpClient {
    base_url: String,
    timeout_secs: u32,
    retries: u32,
}

impl HttpClient {
    /// Create a client with defaults.
    fn new(base: &str) -> Self {
        HttpClient {
            base_url: base.to_string(),
            timeout_secs: 30,
            retries: 0,
        }
    }

    /// Set timeout. Returns Self for chaining.
    fn timeout(mut self, secs: u32) -> Self {
        self.timeout_secs = secs;
        self
    }

    /// Set retries. Returns Self for chaining.
    fn retries(mut self, count: u32) -> Self {
        self.retries = count;
        self
    }

    /// Finalize the client. Validates configuration.
    fn build(self) -> Self {
        // In a real app, you might validate here.
        // If validation fails, panic or return Result.
        // Returning self keeps the chain fluent.
        self
    }
}

fn main() {
    let client = HttpClient::new("https://api.example.com")
        .timeout(5)
        .retries(3)
        .build();

    println!("URL: {}, Timeout: {}s, Retries: {}",
        client.base_url, client.timeout_secs, client.retries);
}

Convention: build() often returns a different type. If your builder returns the same type, you can skip build() and just use the value. But keeping build() signals "construction is done" to the reader. It separates configuration from usage.

Keep build() even if it returns the same type. It tells the reader construction is finished.

When errors break the chain

Fluent APIs clash with error handling. If a method can fail, it should return Result<Self, Error>. But Result doesn't have the builder methods. The chain breaks.

// This breaks the chain.
fn host(self, h: &str) -> Result<Self, Error> {
    // Validate URL...
    Ok(self)
}

// Chain fails here. Result has no method port.
// let c = Config::new().host("x").port(5);

You can fix this with combinators like and_then, but the code becomes verbose. Or you can use the ? operator, but that requires being inside a function that returns Result. Both approaches destroy the fluent feel.

Convention: Keep builder methods infallible. Validate in build(). If you return Result from every step, the chain becomes a mess of ? operators or combinator calls. Save the error handling for the end.

Validate at the end. Keep the chain smooth.

Common pitfalls

If you forget to return self, the method returns (). Chaining fails with E0308 (mismatched types). The compiler expects a struct, gets nothing. Add self to the end of the method body.

If you define methods with self but try to reuse the variable after the chain, you hit E0382 (use of moved value). The chain consumed the value. Bind the result to a new variable, or use &mut self if you need to keep the original.

If you try to call a &mut self method on an immutable binding, you get E0596 (cannot borrow as mutable). Declare the variable with let mut.

Check the return type. If it's not Self or &mut Self, the chain stops.

Decision matrix

Use self -> Self when building an object in a single pass. The value moves through the chain and ends up in the final binding. This is the standard builder pattern. Use &mut self -> &mut Self when mutating an existing variable. The object stays in place, and the chain borrows it mutably. Use &self -> &Self for immutable queries that return a reference, but prefer returning owned values for simplicity unless performance demands otherwise. Use distinct methods instead of chaining when the operation performs I/O, panics, or returns a Result that must be handled immediately.

Pick the signature that matches the lifecycle. Builders move. Mutators borrow.

Where to go next