We don't do that here: How to Go the Go way - Part 1

Published: (January 3, 2026 at 04:42 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Introduction

If you are like me, transitioning to Go from another tech‑stack or ecosystem, you might have found yourself unarmed of some of the most powerful tactics and weapons you gathered throughout your career. In simpler words, you would hit a wall that says: “we don’t do this here :)”.

Some of the best practices you learned in Java/Spring OOP or PHP/Symfony would be considered anti‑patterns and red flags in Go. What to do? Not many options, indeed. You only have to “go the Go way.”

Here we explain a common confusion for newcomers: straightforward dependency injection is not (from what I could research at least) often welcome within the idiomatic Go republic.

Disclaimer – There might be no single place that dictates “we don’t do this here,” but based on a couple of widely used open‑source projects, language authors and maintainers, and famous blog posts you can conclude this.

How do they do it the Go way?

Have you ever come across this code style and had a couple of question marks itching your brain for a while?

type UserAggregator struct {
    profileFetcher Fetcher
    ordersFetcher  Fetcher
    timeout        time.Duration
    logger         *slog.Logger
}

type Option func(*UserAggregator)

func WithTimeout(d time.Duration) Option {
    return func(u *UserAggregator) {
        u.timeout = d
    }
}

func WithLogger(l *slog.Logger) Option {
    return func(u *UserAggregator) {
        u.logger = l
    }
}

func NewUserAggregator(p Fetcher, o Fetcher, opts ...Option) *UserAggregator {
    // Default configuration
    agg := &UserAggregator{
        profileFetcher: p,
        ordersFetcher:  o,
        timeout:        5 * time.Second,
        logger:         slog.Default(),
    }

    for _, opt := range opts {
        opt(agg)
    }
    return agg
}

I assume you got that moment of confusion: “Isn’t this just a Builder or Setters with extra steps?”

You are spotting a pattern correctly. The snippet above is known as the Functional Options Pattern (popularized by Dave Cheney and Rob Pike).

Answer

Yes, this is analogous to the Builder pattern or Setter injection in OOP, but with a twist regarding mutability and safety.

In a typical OOP container (such as Spring Boot or Symfony) you often have two phases:

  1. Instantiation – the object is created (often with a no‑arg constructor).
  2. Hydration (Setters) – the container calls setLogger(), setTimeout(), etc.

The Go “anti‑pattern” concern here is state validity.
If you use setters (u.SetLogger(l)), you introduce a period of time where the object exists but is incomplete (e.g., the logger is nil). You also make the object mutable—anyone can change the logger halfway through the program’s lifecycle.

The Functional Options pattern lets you simulate a constructor that accepts optional parameters while ensuring that once the function returns, the object is immutable, fully configured, and ready to use.

The Alternatives: Why not New(a, b, c) or New(Config)?

You might wonder why Go ditches the explicit configuration object. Actually, Go doesn’t ditch it entirely (you will see New(Config) in the standard library), but Functional Options are preferred for libraries for at least the following two reasons.

1. The “Massive Constructor” Problem

New(logger, timeout, db, cache, metrics, …)
  • This is brittle. Adding a new dependency forces you to change every caller.

2. The New(Config) Problem

Passing a struct, like New(Config{Timeout: 10 * time.Second}), seems clean, but it has hidden edges:

  • Ambiguity of Zero Values – If you pass Config{Timeout: 0}, does that mean “no timeout” or “default timeout”? In Go, 0 is the default value for integers, making it hard to distinguish “I forgot to set this” from “I explicitly want 0”. There is no built‑in “nullable” parameter like ?int in PHP.
  • Boilerplate – If you only want to change one setting, you still have to construct the whole struct, potentially dealing with pointers to optional fields to distinguish nil from 0.

The Java/OOP Equivalent (The Builder Pattern)

// Usage
UserAggregator u = UserAggregator.builder()
    .timeout(Duration.ofSeconds(10))
    .logger(myLogger)
    .build();

// Inside the class
public class UserAggregator {
    private final Duration timeout; // final = immutable, like Go's approach
    private final Logger logger;

    private UserAggregator(Builder b) {
        this.timeout = b.timeout != null ? b.timeout : Duration.ofSeconds(5); // default handling
        this.logger = b.logger;
    }

    // Builder inner class omitted for brevity
}

Comparison: Go achieves this using functions as first‑class citizens, whereas Java requires a separate inner class (Builder) to hold the temporary state.

The PHP/Symfony Equivalent (Options Resolver)

// Usage
$aggregator = new UserAggregator([
    'timeout' => 10,
    'logger'  => $logger,
]);

// Inside the class
class UserAggregator {
    public function __construct(array $options = []) {
        $resolver = new OptionsResolver();
        $resolver->setDefaults(['timeout' => 5]);
        $resolver->setRequired(['logger']);

        $config = $resolver->resolve($options);
        // ... set properties
    }
}

Comparison: This is much looser. The Go pattern provides compile‑time safety and avoids the runtime overhead of a resolver.

Summary

  • The Functional Options Pattern gives you a clean, idiomatic way to handle optional configuration in Go.
  • It avoids the pitfalls of massive constructors and ambiguous zero values.
  • Compared to Java’s Builder or PHP’s OptionsResolver, it leverages Go’s first‑class functions to keep the API simple, type‑safe, and immutable after construction.

Compile‑time safety

You can’t pass a *timeout* typo option, whereas the PHP array approach needs runtime validation.

Summary

The Functional Options pattern is idiomatic in Go because it aligns with the language’s philosophy:

  • Explicit over implicit – this pattern rarely relies on magical DI hydration, and you can see the overrides with the naked eye.
  • Avoid the “million‑dollar mistake” – stay away from nil pointer exceptions that occur when you forget to call a setter in OOP. The New function guarantees a valid object.
  • “Infinite” extensibility – you can add WithDatabase() later without breaking existing code that calls New(). The options logic is delegated and encapsulated within each corresponding function.
Back to Blog

Related posts

Read more »

Go Away Python

Article URL: https://lorentz.app/blog-item.html?id=go-shebang Comments URL: https://news.ycombinator.com/item?id=46431028 Points: 27 Comments: 8...