DEV Community

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

Posted on

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

If you are like me transitioning to Go from another tech-stack or ecosystem, you might have found yourself unarmed from some of your most powerful tactics and weapons you gathered throughout your career. In simpler words, you would hit a wall on which is written: "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 the newcomer: the straightforward dependency injection is not (from what I could research at least) often the very welcome within the idiomatic Go republic.

Disclaimer: There might be no single place to dictate to you "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 might conclude this.

How they do it the Go way?

Have you ever came across this code style, and had couple 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
}
Enter fullscreen mode Exit fullscreen mode

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 code snippet above is known as the Functional Options Pattern (popularized by Dave Cheney and Rob Pike).

To answer your question: 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 allows you to 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)?

Have you just wondered why Go ditches the explicit configuration object. Actually, Go doesn't ditch it entirely (you will see New(Config) in standard libraries), 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. If you add a new dependency, you break every caller in your code-base.

2. The New(Config) Problem

Passing a struct, like New(Config{Timeout: 10s}), 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, It's hard to distinguish "I forgot to set this" from "I explicitly want 0". There isn't the luxury of the "nullable" parameter as you'd face in PHP as ?int

  • Boilerplate: If you only want to change one setting, you still have to construct the struct, potentially dealing with pointers to optional fields to distinguish nil from 0.

The Java/OOP Equivalent (The Builder Pattern)

In Java/Spring, to achieve the same "safety" (immutability + optional parameters), you wouldn't typically use raw Setters; you would use a Builder.

// 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;
    }
    // ...
}

Enter fullscreen mode Exit fullscreen mode

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)

In PHP, specifically Symfony, this is often handled via the OptionsResolver component because PHP lacks named arguments (historically) and strict typing (historically).

// 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
    }
}

Enter fullscreen mode Exit fullscreen mode

Comparison: This is much looser. The Go pattern provides compile-time safety (you can't pass a "timout" 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:

  1. Explicit over implicit, as this pattern often won't rely on magical DI hydration, and you witness the override with nude eyes.

  2. Stay away from the million dollar mistake and avoid nil pointer exceptions that occur when you forget to call a Setter in OOP. The New function guarantees a valid object.

  3. "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.

Top comments (0)