How to Chain Methods in Go (Fluent Interface)

Go requires explicit pointer returns in methods to enable manual method chaining, as it lacks native fluent interface support.

The builder pattern in Go

You are configuring a database connection. You need to set the host, port, username, password, and a timeout. In JavaScript or Python, you might write .setHost("localhost").setPort(5432).setTimeout(5000). The calls flow into each other. In Go, the instinct is to write db.Host = "localhost"; db.Port = 5432. You assign fields directly.

Chaining feels natural when you are building something step by step. Go does not stop you from chaining methods. It just does not do it for you. Methods in Go do not return self by default. You have to define the return type explicitly. If you want a fluent interface, you return the receiver from every method. This is a small syntax change with big implications for how you handle state and errors.

How chaining works

Method chaining relies on every method returning the object itself so the next method can be called on the result. Go methods require a declared return type. If you write a method with no return value, the chain stops. To chain, the method must return the receiver.

The receiver is usually a pointer. If you return a value type, Go makes a copy. The chain executes, but each method operates on a different copy of the struct. The final object has none of the changes. Returning a pointer ensures all methods modify the same underlying data.

The syntax is straightforward. You add the return type to the method signature and return the receiver variable. The receiver name follows Go convention: one or two letters matching the type, like (b *Builder). You never use this or self.

Minimal example

Here is the simplest builder: a struct with fields, methods that return the pointer receiver, and a final method that produces the result.

package main

import "fmt"

type Config struct {
    host string
    port int
}

// SetHost updates the host and returns the pointer to allow chaining.
func (c *Config) SetHost(h string) *Config {
    c.host = h
    return c // return receiver so the next method can be called on the result
}

// SetPort updates the port and returns the pointer.
func (c *Config) SetPort(p int) *Config {
    c.port = p
    return c
}

// Build formats the config into a string.
func (c *Config) Build() string {
    return fmt.Sprintf("%s:%d", c.host, c.port)
}

func main() {
    // Chain calls: SetHost returns *Config, SetPort receives that and returns *Config.
    result := (&Config{}).SetHost("localhost").SetPort(8080).Build()
    fmt.Println(result)
}

What happens at runtime

The expression (&Config{}) allocates a Config struct on the heap and takes its address. You now have a *Config. The first call, SetHost("localhost"), invokes the method on that pointer. The method updates the host field inside the struct and returns the pointer. The return value is the same pointer you started with.

The next call, SetPort(8080), receives that pointer as the receiver. It updates the port field and returns the pointer again. The state accumulates in the single struct instance. Finally, Build() reads the fields and returns the string.

The chain is just a sequence of function calls where the return value of one becomes the receiver of the next. There is no magic. The compiler resolves each call based on the type returned by the previous one. If SetHost returned string, the compiler would reject SetPort because string has no SetPort method.

The copy trap

The most common mistake is returning the value instead of the pointer. If you change the return type to Config, the code compiles. The chain runs. The result is wrong.

func (c Config) SetHost(h string) Config {
    c.host = h
    return c // returns a copy, not the original pointer
}

When the receiver is a value, Go copies the struct into c. The method modifies the copy and returns the copy. The original struct remains unchanged. If you chain SetHost then SetPort, SetPort receives the copy from SetHost, modifies that copy, and returns a new copy. The final Build call sees a struct with no fields set, or only the last field set, depending on how you call it.

The compiler does not warn you. The types match. This is a runtime logic error. Always return the pointer receiver for builders. If you return a value, you are building a new object at each step, which defeats the purpose of accumulating state.

Chaining returns a pointer. Forgetting that returns a copy and breaks your state.

Realistic example

Real code often needs to accumulate slices or handle optional parameters. A query builder demonstrates this. You add multiple filters, set a limit, and execute. The builder accumulates the options and generates the final output.

package main

import "fmt"

type Query struct {
    table   string
    filters []string
    limit   int
}

// NewQuery creates a query for the given table.
func NewQuery(t string) *Query {
    return &Query{table: t}
}

// Where adds a filter condition and returns the query.
func (q *Query) Where(cond string) *Query {
    q.filters = append(q.filters, cond)
    return q // return receiver to continue building
}

// Limit sets the row limit and returns the query.
func (q *Query) Limit(n int) *Query {
    q.limit = n
    return q
}

// Execute returns the SQL string representation.
func (q *Query) Execute() string {
    sql := fmt.Sprintf("SELECT * FROM %s", q.table)
    if len(q.filters) > 0 {
        sql += " WHERE " + q.filters[0]
        for _, f := range q.filters[1:] {
            sql += " AND " + f
        }
    }
    if q.limit > 0 {
        sql += fmt.Sprintf(" LIMIT %d", q.limit)
    }
    return sql
}

func main() {
    // Build a query with multiple filters and a limit.
    sql := NewQuery("users").Where("age > 18").Where("active = true").Limit(10).Execute()
    fmt.Println(sql)
}

The Where method appends to a slice. Slices are reference-like, but the slice header is passed by value. Since the receiver is a pointer, q.filters refers to the slice inside the struct. append might reallocate the underlying array, but it updates the slice header in the struct. The change persists across the chain.

Errors and readability

Chaining hides errors. Go culture treats errors as values you must handle. If a method can fail, it returns an error. Chaining makes it hard to check that error without breaking the flow.

If SetHost returns (*Config, error), you cannot chain SetPort directly. You have to unpack the error. The chain breaks. You end up writing sequential code anyway.

c, err := builder.SetHost("localhost")
if err != nil {
    return err
}
c, err = c.SetPort(8080)
if err != nil {
    return err
}

This is verbose. It is also explicit. The Go community accepts the boilerplate because it makes the unhappy path visible. Chaining forces you to defer error checking or swallow errors. Swallowing errors is a bug waiting to happen.

Chaining also hurts readability when lines get long. gofmt wraps long lines, but a chain of five method calls can span multiple lines and obscure the structure. You cannot easily inspect intermediate state in a debugger. You have to break the chain to set a breakpoint.

Many Go libraries avoid chaining in favor of functional options. You pass functions that configure a struct. This allows composition without exposing pointer receivers or chaining syntax. It is more verbose but composable and type-safe. The functional options pattern is the standard idiom for complex configuration in Go.

Error handling beats chaining every time.

Decision matrix

Use method chaining when building immutable configurations or fluent builders where errors are unlikely or deferred to a final build step. Use sequential assignment when you need to handle errors immediately after each step. Use a struct with explicit fields when the configuration is simple and chaining adds no readability benefit. Use functional options when you need to compose behaviors without exposing internal state or pointer receivers. Use plain sequential code when you don't need concurrency or complex building: the simplest thing that works is usually the right thing.

Where to go next