←All posts

The Options Pattern: Building Configuration with Anonymous Functions

How the Options Pattern used in ASP.NET Core and Go works: the problem it solves, ASP.NET Core internals, implementations in Go, C#, TypeScript, Rust, and Python, and when to choose it over Builder and similar patterns.

Design PatternsC#GoTypeScriptSoftware Architecture

Introduction

Library and framework APIs often take configuration as an anonymous function. In ASP.NET Core:

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "Bearer";
    options.DefaultChallengeScheme = "Bearer";
});

In Go:

server := NewServer(
    WithPort(8080),
    WithTimeout(30 * time.Second),
    WithLogger(myLogger),
)

Both implement the Options Pattern, a design pattern that uses anonymous functions (lambdas or closures) to assemble an object's configuration. Because configuration is passed as functions rather than values, callers can keep the defaults and override only the settings they need, and adding a setting leaves existing calls unchanged.

The Problem: Telescoping Constructor

Constructor Argument Explosion

Consider a function that creates an HTTP server. It starts with few arguments:

func NewServer(port int) *Server {
    return &Server{port: port}
}

As features grow, so does the argument list:

func NewServer(
    port int,
    host string,
    timeout time.Duration,
    maxConnections int,
    logger Logger,
    tlsConfig *tls.Config,
    middleware []Middleware,
    readTimeout time.Duration,
    writeTimeout time.Duration,
    idleTimeout time.Duration,
) *Server {
    // ...
}

In Java and similar languages, a common response is a series of constructor overloads, each adding one more parameter; this is known as the Telescoping Constructor. Go has no overloading, so a single function's parameter list grows as shown above. Either way, with this many arguments callers easily confuse their order. And although callers want the defaults for most arguments, Go does not let them omit any.

The "Kitchen Sink Config Struct" Limitation

A common workaround is passing a single config struct:

type ServerConfig struct {
    Port           int
    Host           string
    Timeout        time.Duration
    MaxConnections int
    Logger         Logger
    // ...
}
 
func NewServer(cfg ServerConfig) *Server { ... }

This shortens the call, but problems remain:

  • Port: 0 could mean "use port 0" or "use the default," and the struct can't tell which (the zero-value problem)
  • All fields sit at the same level, so required and optional settings look alike
  • Adding new fields can affect existing constructor calls
  • Constraints on combinations, such as "if A is set, B is unnecessary," are hard to express

Passing Configuration as Functions

The Options Pattern passes configuration as functions, not values. Instead of the value "Port is 8080," the caller passes the operation "set Port to 8080." The constructor initializes the defaults and then applies the operations in order.

Passing configuration as functions provides these benefits:

BenefitDescription
Safe defaultsIf an Option function isn't called, the default value is preserved
Flexible orderOptions that set different fields can be passed in any order; for the same field, the later Option wins
Open for extensionAdding new Option functions doesn't affect existing code (Open-Closed Principle)
Type-safeEach Option function operates on the correct field type
ComposableOption functions can be grouped into "presets" via slices/arrays

Go Implementation — Functional Options

We start with Go, whose community advocated this pattern most systematically and spread it widely. Proposed by Rob Pike and Dave Cheney, it is known in Go as Functional Options. Go lacks constructor overloading and default arguments, so Functional Options became the de facto standard configuration pattern.

Basic Implementation

Functional Options have three components: the Server struct that holds the configuration, the Option function type that modifies it, and the NewServer constructor that initializes defaults and then applies Options in order.

package server
 
import (
    "log"
    "time"
)
 
// Server is the HTTP server
type Server struct {
    port         int
    host         string
    timeout      time.Duration
    maxConns     int
    logger       *log.Logger
}
 
// Option is a function type that modifies Server configuration
type Option func(*Server)
 
// WithPort sets the port number
func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}
 
// WithHost sets the hostname
func WithHost(host string) Option {
    return func(s *Server) {
        s.host = host
    }
}
 
// WithTimeout sets the timeout duration
func WithTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.timeout = d
    }
}
 
// WithMaxConnections sets the max connection count
func WithMaxConnections(n int) Option {
    return func(s *Server) {
        s.maxConns = n
    }
}
 
// WithLogger sets the logger
func WithLogger(l *log.Logger) Option {
    return func(s *Server) {
        s.logger = l
    }
}
 
// NewServer creates a Server by applying Options
func NewServer(opts ...Option) *Server {
    // 1. Initialize with defaults
    s := &Server{
        port:     8080,
        host:     "localhost",
        timeout:  30 * time.Second,
        maxConns: 100,
        logger:   log.Default(),
    }
 
    // 2. Apply Option functions in order
    for _, opt := range opts {
        opt(s)
    }
 
    return s
}

Usage

With no arguments, the server uses the defaults; arguments override only the fields they set. Bundling Options into a slice gives a reusable set of settings, such as production defaults.

// Default configuration
s1 := server.NewServer()
 
// Custom configuration
s2 := server.NewServer(
    server.WithPort(9090),
    server.WithTimeout(60 * time.Second),
    server.WithLogger(customLogger),
)
 
// Preset: bundle production defaults
var ProductionDefaults = []server.Option{
    server.WithPort(443),
    server.WithTimeout(10 * time.Second),
    server.WithMaxConnections(10000),
}
 
s3 := server.NewServer(ProductionDefaults...)

What Happens Internally

Calling NewServer(WithPort(9090), WithTimeout(60s)) proceeds as follows:

Closures make this work. WithPort(9090) returns a function that has captured the argument 9090, and NewServer later applies that function to the Server.

Implementation with Validation

Production code should catch invalid settings, such as an out-of-range port or a negative timeout, early. In Go, the idiomatic approach is for each Option to return an error. NewServer fails as soon as it receives one, so the server never starts with an invalid configuration.

// Option that can return errors
type Option func(*Server) error
 
func WithPort(port int) Option {
    return func(s *Server) error {
        if port < 0 || port > 65535 {
            return fmt.Errorf("invalid port: %d", port)
        }
        s.port = port
        return nil
    }
}
 
func NewServer(opts ...Option) (*Server, error) {
    s := &Server{
        port:     8080,
        host:     "localhost",
        timeout:  30 * time.Second,
        maxConns: 100,
    }
 
    for _, opt := range opts {
        if err := opt(s); err != nil {
            return nil, fmt.Errorf("applying option: %w", err)
        }
    }
 
    return s, nil
}

Real-World Usage

Functional Options are widely used in major Go libraries:

LibraryUsage
gRPC-Gogrpc.NewServer(opts...)
Zapzap.New(core, opts...)
Fxfx.New(opts...)
client-goUses Functional Options in some APIs

C# / ASP.NET Core Implementation

ASP.NET Core's Options Pattern applies the same principle as Go's Functional Options, built on a DI (Dependency Injection) container. In Go, Options are passed as constructor arguments; in ASP.NET Core, the container applies the configuration functions registered with it. Because the functions are not applied until dependencies are resolved, configuration functions registered in several places can be layered.

Basic Pattern: Action<TOptions>

// Options class definition
public class SmtpOptions
{
    public string Host { get; set; } = "localhost";
    public int Port { get; set; } = 25;
    public bool UseSsl { get; set; } = false;
    public string? Username { get; set; }
    public string? Password { get; set; }
}
 
// Configure with Action<T> at service registration
builder.Services.Configure<SmtpOptions>(options =>
{
    options.Host = "smtp.example.com";
    options.Port = 587;
    options.UseSsl = true;
});

Go's func(*Server) and C#'s Action<SmtpOptions> are different language constructs, but both are functions that receive the object holding the configuration and mutate its fields, so they serve the same design intent.

IOptions<T> Interface Family

ASP.NET Core has three interfaces for consuming options, and they differ in when the value is rebuilt. Use them for settings that don't change after startup, settings that should be current for each request, and settings that should reflect appsettings.json changes while the app runs.

InterfaceLifetimeReloadUse Case
IOptions<T>SingletonNo (created on first access, then cached)App-wide settings that don't change
IOptionsSnapshot<T>ScopedPer scope (usually per request in web apps)Recomputed values for each request scope. Don't inject it into singletons
IOptionsMonitor<T>SingletonChange notifications and refreshed current valueHot reload support, including from singleton services

Internal DI Mechanics

Configure<T>() only registers a configuration function with the DI container. OptionsFactory runs it when the options are requested:

Configure() can be called any number of times for the same type:

// Base configuration
builder.Services.Configure<SmtpOptions>(options =>
{
    options.Host = "smtp.example.com";
    options.Port = 587;
});
 
// Additional configuration (can override)
builder.Services.Configure<SmtpOptions>(options =>
{
    options.UseSsl = true;
});
 
// Also bind from appsettings.json
builder.Services.Configure<SmtpOptions>(
    builder.Configuration.GetSection("Smtp"));

All registered configurations run in registration order, and for any given property the last one wins. This is the same mechanism as Go's for _, opt := range opts { opt(s) }.

Extension Method Pattern

The AddXxx(options => ...) methods common in ASP.NET Core are the setup entry points that library authors provide. Consumers pass an Action<SmtpOptions> and get a correct configuration without knowing the internal details: options registration, validation, and service registration. An implementation looks like this:

// AddSmtp extension method implementation
public static class SmtpServiceCollectionExtensions
{
    public static IServiceCollection AddSmtp(
        this IServiceCollection services,
        Action<SmtpOptions> configure)
    {
        // 1. Register Options infrastructure
        services.AddOptions<SmtpOptions>()
            .Configure(configure)        // Apply user configuration
            .ValidateDataAnnotations()   // Validation
            .ValidateOnStart();          // Validate at startup
 
        // 2. Register the service itself
        services.AddSingleton<ISmtpClient, SmtpClient>();
 
        return services;
    }
}
 
// Usage
builder.Services.AddSmtp(options =>
{
    options.Host = "smtp.example.com";
    options.Port = 587;
    options.UseSsl = true;
});

With AddSmtp, three concerns are defined in separate places:

  1. Default values → Property initializers in the SmtpOptions class
  2. User configuration → The anonymous function received as Action<SmtpOptions>
  3. Validation → Declaratively defined via ValidateDataAnnotations()

PostConfigure and Validate

ASP.NET Core divides configuration processing into three stages, separating the responsibilities of setting, adjusting, and validating values. Configure sets the base values. PostConfigure runs after every Configure and adjusts values for consistency with one another (for example, switching the port to 465 when SSL is enabled but the port is still the default). Finally, Validate checks the resulting values.

builder.Services.AddOptions<SmtpOptions>()
    // Stage 1: Configure — inject configuration values
    .Configure(o => o.Host = "smtp.example.com")
 
    // Stage 2: PostConfigure — runs after ALL Configure calls
    .PostConfigure(o =>
    {
        // If SSL is enabled but port is still default, switch to 465
        if (o.UseSsl && o.Port == 25)
            o.Port = 465;
    })
 
    // Stage 3: Validate — final validation
    .Validate(o => !string.IsNullOrEmpty(o.Host),
              "SMTP host must not be empty")
    .ValidateDataAnnotations()
    .ValidateOnStart();

Named Options

Several configurations of the same type can coexist when each has a name:

// Two SMTP configurations: "Primary" and "Backup"
builder.Services.Configure<SmtpOptions>("Primary", options =>
{
    options.Host = "smtp-primary.example.com";
    options.Port = 587;
});
 
builder.Services.Configure<SmtpOptions>("Backup", options =>
{
    options.Host = "smtp-backup.example.com";
    options.Port = 2525;
});
 
// Usage
public class EmailService
{
    private readonly SmtpOptions _primary;
    private readonly SmtpOptions _backup;
 
    public EmailService(IOptionsSnapshot<SmtpOptions> optionsSnapshot)
    {
        _primary = optionsSnapshot.Get("Primary");
        _backup = optionsSnapshot.Get("Backup");
    }
}

OptionsFactory<T> Internals

ASP.NET Core's Options resolution is handled by OptionsFactory<TOptions>. IOptions<T> caches the created value, IOptionsSnapshot<T> caches within the current scope, and IOptionsMonitor<T> manages the current value through change notifications and IOptionsMonitorCache<T>. Simplified, the factory's Create() method looks like this:

// OptionsFactory<TOptions>.Create() internals (simplified)
public TOptions Create(string name)
{
    // 1. Create new instance (defaults)
    TOptions options = new TOptions();
 
    // 2. Execute all IConfigureOptions<T>
    foreach (var setup in _setups)
    {
        if (setup is IConfigureNamedOptions<TOptions> namedSetup)
            namedSetup.Configure(name, options);
        else if (name == Options.DefaultName)
            setup.Configure(options);
    }
 
    // 3. Execute all IPostConfigureOptions<T>
    foreach (var post in _postConfigures)
        post.PostConfigure(name, options);
 
    // 4. Execute all IValidateOptions<T> (aggregate errors)
    var failures = new List<string>();
    foreach (var validate in _validations)
    {
        var result = validate.Validate(name, options);
        if (result.Failed)
            failures.AddRange(result.Failures);
    }
    if (failures.Count > 0)
        throw new OptionsValidationException(name, typeof(TOptions), failures);
 
    return options;
}

The core is the same as the for _, opt := range opts { opt(s) } loop in Go's NewServer. ASP.NET Core splits it into Configure → PostConfigure → Validate stages and adds support for named options.

TypeScript Implementation

With object spread and Partial<T>, TypeScript can merge partial settings into defaults with type checking. For configuration through functions, it can express both Go-style Functional Options and ASP.NET Core-style callbacks.

Basic: Partial<T> Pattern

The simplest approach uses Partial<T>:

interface ServerOptions {
  port: number;
  host: string;
  timeout: number;
  maxConnections: number;
}
 
const defaults: ServerOptions = {
  port: 8080,
  host: "localhost",
  timeout: 30_000,
  maxConnections: 100,
};
 
function createServer(overrides?: Partial<ServerOptions>): Server {
  const options = { ...defaults, ...overrides };
  return new Server(options);
}
 
// Usage
const server = createServer({ port: 9090, timeout: 60_000 });

However, this configuration is value merging, not function application, so it isn't really the Options Pattern. Partial<T> merging is simple and intuitive, but it makes validation and dependencies between values (for example, changing the port when SSL is enabled) hard to express. Functional Options, shown next, put that logic inside the Option functions.

Functional Options

Go-style Functional Options in TypeScript look like this:

interface ServerConfig {
  port: number;
  host: string;
  timeout: number;
  maxConnections: number;
  logger: Logger;
}
 
type ServerOption = (config: ServerConfig) => void;
 
function withPort(port: number): ServerOption {
  return (config) => {
    if (port < 0 || port > 65535) throw new RangeError(`Invalid port: ${port}`);
    config.port = port;
  };
}
 
function withHost(host: string): ServerOption {
  return (config) => { config.host = host; };
}
 
function withTimeout(ms: number): ServerOption {
  return (config) => { config.timeout = ms; };
}
 
function withMaxConnections(n: number): ServerOption {
  return (config) => { config.maxConnections = n; };
}
 
function createServer(...opts: ServerOption[]): Server {
  const config: ServerConfig = {
    port: 8080,
    host: "localhost",
    timeout: 30_000,
    maxConnections: 100,
    logger: console,
  };
 
  for (const opt of opts) {
    opt(config);
  }
 
  return new Server(config);
}
 
// Usage
const server = createServer(
  withPort(9090),
  withTimeout(60_000),
  withMaxConnections(5000),
);
 
// Presets
const productionDefaults: ServerOption[] = [
  withPort(443),
  withTimeout(10_000),
  withMaxConnections(10000),
];
 
const prodServer = createServer(...productionDefaults);

Callback Pattern (ASP.NET Core Style)

As in ASP.NET Core, a single anonymous function can configure everything:

function createServer(configure: (options: ServerConfig) => void): Server {
  const config: ServerConfig = {
    port: 8080,
    host: "localhost",
    timeout: 30_000,
    maxConnections: 100,
    logger: console,
  };
 
  configure(config);
 
  return new Server(config);
}
 
// Usage
const server = createServer((options) => {
  options.port = 9090;
  options.timeout = 60_000;
  options.maxConnections = 5000;
});

The two can also be combined: a callback configures everything, and Go-style Options then override individual settings.

function createServer(
  configure?: (options: ServerConfig) => void,
  ...opts: ServerOption[]
): Server {
  const config: ServerConfig = {
    port: 8080, host: "localhost",
    timeout: 30_000, maxConnections: 100, logger: console,
  };
  configure?.(config);
  for (const opt of opts) opt(config);
  return new Server(config);
}

Rust Implementation

Each Rust closure has its own unique type. To return different closures as the same ServerOption type, or to collect them into an array or Vec as a preset, they must be heap-allocated as Box<dyn FnOnce>. Unlike Go's func(*Server), they can't go into a slice as is, and each Option costs a heap allocation.

Representing Options with Box<dyn FnOnce>

The Rust community favors the Builder Pattern, but the Functional Options approach from Go also works. Some Options, such as with_host, move a captured String into the settings, so this example uses FnOnce, which can be called only once:

pub struct ServerOptions {
    pub port: u16,
    pub host: String,
    pub timeout: std::time::Duration,
    pub max_connections: usize,
}
 
impl Default for ServerOptions {
    fn default() -> Self {
        Self {
            port: 8080,
            host: "localhost".into(),
            timeout: std::time::Duration::from_secs(30),
            max_connections: 100,
        }
    }
}
 
// Option expressed as FnOnce
type ServerOption = Box<dyn FnOnce(&mut ServerOptions)>;
 
pub fn with_port(port: u16) -> ServerOption {
    Box::new(move |opts| { opts.port = port; })
}
 
pub fn with_host(host: impl Into<String>) -> ServerOption {
    let host = host.into();
    Box::new(move |opts| { opts.host = host; })
}
 
pub fn with_timeout(duration: std::time::Duration) -> ServerOption {
    Box::new(move |opts| { opts.timeout = duration; })
}
 
pub struct Server { /* ... */ }
 
impl Server {
    pub fn new(opts: impl IntoIterator<Item = ServerOption>) -> Self {
        let mut options = ServerOptions::default();
        for opt in opts {
            opt(&mut options);
        }
        Server::from_options(options)
    }
 
    fn from_options(_options: ServerOptions) -> Self {
        Server { /* ... */ }
    }
}
 
// Usage
let server = Server::new([
    with_port(9090),
    with_timeout(std::time::Duration::from_secs(60)),
]);

Because of the Box<dyn FnOnce> allocations, the traditional Builder Pattern is often preferred for heavy use in Rust. As a rule of thumb, use Builder when options are few and statically known, and Functional Options when option combinations change at runtime.

Python Implementation

In Python, settings are commonly passed as keyword arguments (**kwargs), but the Options Pattern can also be implemented explicitly. Python functions are first-class objects and closures can be returned directly, so there is no need to wrap them in a Box as in Rust. With @dataclass, the defaults go in the field declarations.

from dataclasses import dataclass
from typing import Callable
 
@dataclass
class ServerOptions:
    port: int = 8080
    host: str = "localhost"
    timeout: float = 30.0
    max_connections: int = 100
 
# Option type
Option = Callable[[ServerOptions], None]
 
def with_port(port: int) -> Option:
    def apply(opts: ServerOptions) -> None:
        if not 0 <= port <= 65535:
            raise ValueError(f"Invalid port: {port}")
        opts.port = port
    return apply
 
def with_host(host: str) -> Option:
    def apply(opts: ServerOptions) -> None:
        opts.host = host
    return apply
 
def with_timeout(seconds: float) -> Option:
    def apply(opts: ServerOptions) -> None:
        opts.timeout = seconds
    return apply
 
class Server:
    def __init__(self, *opts: Option):
        self.options = ServerOptions()
        for opt in opts:
            opt(self.options)
 
# Usage
server = Server(
    with_port(9090),
    with_timeout(60.0),
)
 
# Presets
production_defaults: list[Option] = [
    with_port(443),
    with_timeout(10.0),
]
 
prod_server = Server(*production_defaults)

Pattern Comparison

Options Pattern vs Builder Pattern

AspectOptions PatternBuilder Pattern
SyntaxPass functions as a listMethod chaining
State managementConstructor manages centrallyBuilder holds intermediate state
ComposabilityEasy preset creation via function slices/arraysReusing intermediate Builder state is difficult
Incremental configWorks well with DI containers (Configure from multiple places)Usually completed in one place
Type safetyGuaranteed per Option functionGuaranteed by method return types
Learning curveRequires understanding closuresNo closures needed

Options Pattern vs Config Struct

AspectOptions PatternConfig struct
Default valuesSafely set in constructorZero-value ambiguity
ValidationExecutable within each Option functionRequires separate validation layer
ExtensibilityJust add new Option functionsAdd fields to struct
ComposabilityOptions combine freely through function compositionRequires struct merging

Decision Guide

With three or fewer config items, simple arguments suffice. With four or more, choose the Options Pattern when configuration is layered from several places or presets are reused, and the Builder Pattern otherwise. When configuration comes from several places and a DI container is in use, choose the ASP.NET Core style.

Option Function Techniques

Option Function Composition

Several Options can be combined into a single Option:

// Compose multiple Options into one
func ComposeOptions(opts ...Option) Option {
    return func(s *Server) {
        for _, opt := range opts {
            opt(s)
        }
    }
}
 
// Define presets
var HighPerformance = ComposeOptions(
    WithMaxConnections(50000),
    WithTimeout(5 * time.Second),
)
 
var SecureDefaults = ComposeOptions(
    WithPort(443),
    WithTimeout(10 * time.Second),
)
 
// Compose presets
s := NewServer(
    HighPerformance,
    SecureDefaults,
    WithHost("api.example.com"), // Individual override
)

ComposeOptions is function composition: it returns one Option that applies the given Options in order. Because Options are functions, presets can be combined as functions too.

Conditional Options

// Conditionally apply an Option
func WithPortIf(port int, condition bool) Option {
    return func(s *Server) {
        if condition {
            s.port = port
        }
    }
}
 
// Environment-based configuration
func WithEnvConfig() Option {
    return func(s *Server) {
        if port := os.Getenv("PORT"); port != "" {
            s.port, _ = strconv.Atoi(port)
        }
        if host := os.Getenv("HOST"); host != "" {
            s.host = host
        }
    }
}

Self-Referential Functions (Returning Previous Values)

Rob Pike's original post describes a technique in which an Option function returns another Option that restores the previous value, enabling rollback:

type Option func(*Server) Option
 
func WithPort(port int) Option {
    return func(s *Server) Option {
        prev := s.port   // Save previous value
        s.port = port     // Set new value
        return WithPort(prev) // Return Option that restores previous
    }
}
 
// Rollback example
func temporarilyChangePort(s *Server, port int) {
    restore := WithPort(port)(s)  // Change port
    defer restore(s)               // Restore on function exit
    // ... process with temporarily changed port ...
}

This is rare in production code, but it shows that when a configuration change is a function, the operation that undoes it can be returned as a function of the same Option type.

Summary

The Options Pattern passes configuration as functions rather than values. Its names and signatures by language:

LanguageNameSignature
GoFunctional Optionsfunc(*T) / func(*T) error
C#Options Pattern / Action<T>Action<TOptions>
TypeScriptFunctional Options / Callback Pattern(options: T) => void
RustFunctional OptionsBox<dyn FnOnce(&mut T)>
PythonFunctional OptionsCallable[[T], None]

In every language, the process has three steps:

  1. Initialize with default values
  2. Apply Option functions in order
  3. Use the completed object

This structure avoids the Telescoping Constructor problem. New settings can be added without new parameters (the Open-Closed Principle), and the pattern fits well with DI containers. When configuration happens incrementally from multiple places, the Options Pattern is more suitable than the Builder Pattern.

ASP.NET Core's AddXxx(options => ...) takes an Action<T>, and Go libraries expose WithXxx functions, for the same reason: to keep the defaults and apply only the needed settings later, as functions.

References