ExamplesBy LevelBy TopicLearning Paths
524 Intermediate

Builder Pattern with Closures

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "Builder Pattern with Closures" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. The builder pattern addresses the "telescoping constructor" problem: when a type has many optional fields, constructors become unwieldy and error-prone. Key difference from OCaml: 1. **Struct generics**: Rust builders often avoid generics on the struct by boxing callbacks (`Box<dyn Fn>`); OCaml records store functions directly without boxing annotation.

Tutorial

The Problem

The builder pattern addresses the "telescoping constructor" problem: when a type has many optional fields, constructors become unwieldy and error-prone. Rust's builder idiom (fluent API) is widespread in production code — reqwest::ClientBuilder, tokio::runtime::Builder, std::thread::Builder all use it. Adding closures to builders enables behavior injection: instead of just configuring data fields, callers can inject callbacks for connection events, error handlers, or transformation pipelines. This makes APIs both configurable and extensible without requiring trait implementations.

🎯 Learning Outcomes

  • • How to combine the builder pattern with closure callbacks for behavior injection
  • • Why Box<dyn Fn(&str)> stores callbacks in builder structs without generics on the struct itself
  • • How method chaining (self -> Self) works with closure-accepting methods
  • • How Default provides sensible no-op closures for optional callbacks
  • • Where this pattern appears: HTTP client builders, async runtime builders, test harnesses
  • Code Example

    pub struct ServerBuilder {
        config: ServerConfig,
    }
    
    impl ServerBuilder {
        pub fn on_connect(mut self, f: impl Fn(&str) + 'static) -> Self {
            self.config.on_connect = Box::new(f);
            self
        }
    
        pub fn build(self) -> ServerConfig { self.config }
    }

    Key Differences

  • Struct generics: Rust builders often avoid generics on the struct by boxing callbacks (Box<dyn Fn>); OCaml records store functions directly without boxing annotation.
  • Fluent chaining: Rust self -> Self enables Builder::new().host("x").port(80).build() in idiomatic Rust; OCaml achieves this with function composition or |> pipelines.
  • No-op defaults: Rust's Default trait provides a no-op closure; OCaml uses option to represent absence, calling Option.iter on_connect addr at use time.
  • Ownership model: Rust builders consume self on each step (mut self pattern) preventing reuse after building; OCaml records are immutable by default — functional update creates a new record each step.
  • OCaml Approach

    OCaml builders are typically records with optional fields using option types for callbacks. A builder function takes a record and returns an updated copy using functional update syntax. Callbacks are plain functions stored in option fields:

    type server_config = {
      host: string; port: int;
      on_connect: (string -> unit) option;
    }
    let with_on_connect f cfg = { cfg with on_connect = Some f }
    

    Full Source

    #![allow(clippy::all)]
    //! Builder Pattern with Closures
    //!
    //! Closure-based configuration in builder APIs.
    
    /// Server configuration with closure callback.
    pub struct ServerConfig {
        pub host: String,
        pub port: u16,
        pub max_connections: usize,
        pub timeout_ms: u64,
        on_connect: Box<dyn Fn(&str)>,
    }
    
    impl std::fmt::Debug for ServerConfig {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            f.debug_struct("ServerConfig")
                .field("host", &self.host)
                .field("port", &self.port)
                .field("max_connections", &self.max_connections)
                .field("timeout_ms", &self.timeout_ms)
                .field("on_connect", &"<fn>")
                .finish()
        }
    }
    
    impl Default for ServerConfig {
        fn default() -> Self {
            ServerConfig {
                host: "localhost".to_string(),
                port: 8080,
                max_connections: 100,
                timeout_ms: 5000,
                on_connect: Box::new(|_| {}),
            }
        }
    }
    
    /// Builder for ServerConfig.
    pub struct ServerBuilder {
        config: ServerConfig,
    }
    
    impl ServerBuilder {
        pub fn new() -> Self {
            ServerBuilder {
                config: ServerConfig::default(),
            }
        }
    
        pub fn host(mut self, host: &str) -> Self {
            self.config.host = host.to_string();
            self
        }
    
        pub fn port(mut self, port: u16) -> Self {
            self.config.port = port;
            self
        }
    
        pub fn max_connections(mut self, max: usize) -> Self {
            self.config.max_connections = max;
            self
        }
    
        pub fn timeout_ms(mut self, ms: u64) -> Self {
            self.config.timeout_ms = ms;
            self
        }
    
        pub fn on_connect(mut self, f: impl Fn(&str) + 'static) -> Self {
            self.config.on_connect = Box::new(f);
            self
        }
    
        pub fn build(self) -> ServerConfig {
            self.config
        }
    }
    
    impl Default for ServerBuilder {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl ServerConfig {
        pub fn connect(&self, client: &str) {
            (self.on_connect)(client);
        }
    }
    
    /// Request handler builder.
    pub struct RequestHandler {
        validators: Vec<Box<dyn Fn(&str) -> Result<(), String>>>,
        transformer: Box<dyn Fn(String) -> String>,
    }
    
    impl RequestHandler {
        pub fn new() -> Self {
            RequestHandler {
                validators: Vec::new(),
                transformer: Box::new(|s| s),
            }
        }
    
        pub fn validate(mut self, f: impl Fn(&str) -> Result<(), String> + 'static) -> Self {
            self.validators.push(Box::new(f));
            self
        }
    
        pub fn transform(mut self, f: impl Fn(String) -> String + 'static) -> Self {
            self.transformer = Box::new(f);
            self
        }
    
        pub fn process(&self, input: &str) -> Result<String, String> {
            for validator in &self.validators {
                validator(input)?;
            }
            Ok((self.transformer)(input.to_string()))
        }
    }
    
    impl Default for RequestHandler {
        fn default() -> Self {
            Self::new()
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
        use std::cell::RefCell;
        use std::rc::Rc;
    
        #[test]
        fn test_server_builder_defaults() {
            let config = ServerBuilder::new().build();
            assert_eq!(config.host, "localhost");
            assert_eq!(config.port, 8080);
        }
    
        #[test]
        fn test_server_builder_custom() {
            let config = ServerBuilder::new()
                .host("0.0.0.0")
                .port(3000)
                .max_connections(500)
                .build();
    
            assert_eq!(config.host, "0.0.0.0");
            assert_eq!(config.port, 3000);
            assert_eq!(config.max_connections, 500);
        }
    
        #[test]
        fn test_server_on_connect() {
            let log = Rc::new(RefCell::new(Vec::new()));
            let log_clone = log.clone();
    
            let config = ServerBuilder::new()
                .on_connect(move |client| {
                    log_clone.borrow_mut().push(client.to_string());
                })
                .build();
    
            config.connect("client1");
            config.connect("client2");
    
            assert_eq!(*log.borrow(), vec!["client1", "client2"]);
        }
    
        #[test]
        fn test_request_handler_valid() {
            let handler = RequestHandler::new()
                .validate(|s| {
                    if s.is_empty() {
                        Err("empty input".into())
                    } else {
                        Ok(())
                    }
                })
                .transform(|s| s.to_uppercase());
    
            assert_eq!(handler.process("hello").unwrap(), "HELLO");
        }
    
        #[test]
        fn test_request_handler_invalid() {
            let handler = RequestHandler::new().validate(|s| {
                if s.len() < 3 {
                    Err("too short".into())
                } else {
                    Ok(())
                }
            });
    
            assert!(handler.process("ab").is_err());
            assert!(handler.process("abc").is_ok());
        }
    
        #[test]
        fn test_request_handler_chain() {
            let handler = RequestHandler::new()
                .validate(|s| {
                    if s.contains(' ') {
                        Err("no spaces".into())
                    } else {
                        Ok(())
                    }
                })
                .validate(|s| {
                    if s.is_empty() {
                        Err("empty".into())
                    } else {
                        Ok(())
                    }
                })
                .transform(|s| format!("[{}]", s));
    
            assert_eq!(handler.process("test").unwrap(), "[test]");
            assert!(handler.process("has space").is_err());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
        use std::cell::RefCell;
        use std::rc::Rc;
    
        #[test]
        fn test_server_builder_defaults() {
            let config = ServerBuilder::new().build();
            assert_eq!(config.host, "localhost");
            assert_eq!(config.port, 8080);
        }
    
        #[test]
        fn test_server_builder_custom() {
            let config = ServerBuilder::new()
                .host("0.0.0.0")
                .port(3000)
                .max_connections(500)
                .build();
    
            assert_eq!(config.host, "0.0.0.0");
            assert_eq!(config.port, 3000);
            assert_eq!(config.max_connections, 500);
        }
    
        #[test]
        fn test_server_on_connect() {
            let log = Rc::new(RefCell::new(Vec::new()));
            let log_clone = log.clone();
    
            let config = ServerBuilder::new()
                .on_connect(move |client| {
                    log_clone.borrow_mut().push(client.to_string());
                })
                .build();
    
            config.connect("client1");
            config.connect("client2");
    
            assert_eq!(*log.borrow(), vec!["client1", "client2"]);
        }
    
        #[test]
        fn test_request_handler_valid() {
            let handler = RequestHandler::new()
                .validate(|s| {
                    if s.is_empty() {
                        Err("empty input".into())
                    } else {
                        Ok(())
                    }
                })
                .transform(|s| s.to_uppercase());
    
            assert_eq!(handler.process("hello").unwrap(), "HELLO");
        }
    
        #[test]
        fn test_request_handler_invalid() {
            let handler = RequestHandler::new().validate(|s| {
                if s.len() < 3 {
                    Err("too short".into())
                } else {
                    Ok(())
                }
            });
    
            assert!(handler.process("ab").is_err());
            assert!(handler.process("abc").is_ok());
        }
    
        #[test]
        fn test_request_handler_chain() {
            let handler = RequestHandler::new()
                .validate(|s| {
                    if s.contains(' ') {
                        Err("no spaces".into())
                    } else {
                        Ok(())
                    }
                })
                .validate(|s| {
                    if s.is_empty() {
                        Err("empty".into())
                    } else {
                        Ok(())
                    }
                })
                .transform(|s| format!("[{}]", s));
    
            assert_eq!(handler.process("test").unwrap(), "[test]");
            assert!(handler.process("has space").is_err());
        }
    }

    Deep Comparison

    OCaml vs Rust: Builder with Closures

    OCaml

    type server_config = {
      host: string;
      port: int;
      on_connect: string -> unit;
    }
    
    let default_config = {
      host = "localhost";
      port = 8080;
      on_connect = fun _ -> ();
    }
    
    let with_host host cfg = { cfg with host }
    let with_on_connect f cfg = { cfg with on_connect = f }
    

    Rust

    pub struct ServerBuilder {
        config: ServerConfig,
    }
    
    impl ServerBuilder {
        pub fn on_connect(mut self, f: impl Fn(&str) + 'static) -> Self {
            self.config.on_connect = Box::new(f);
            self
        }
    
        pub fn build(self) -> ServerConfig { self.config }
    }
    

    Key Differences

  • OCaml: Record update syntax { cfg with field = value }
  • Rust: Fluent builder with method chaining
  • Both: Closures as configuration callbacks
  • Rust: Box<dyn Fn> for stored callbacks
  • Both support configurable behavior via closures
  • Exercises

  • Retry builder: Add an on_retry(f: impl Fn(u32, &str) + 'static) callback to ServerBuilder that receives the attempt number and error message, and a max_retries(n: u32) field.
  • Transform pipeline: Add add_transform(f: impl Fn(String) -> String + 'static) to the builder that accumulates multiple transforms, applied in registration order when a request arrives.
  • Validation in build: Make ServerBuilder::build() return Result<ServerConfig, String> that validates the port is in range 1..=65535 and the host is non-empty before returning the config.
  • Open Source Repos