We don't do that here: How to Go the Go way - Part 1
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:
- Instantiation – the object is created (often with a no‑arg constructor).
- 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,0is 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?intin 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
nilfrom0.
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
nilpointer exceptions that occur when you forget to call a setter in OOP. TheNewfunction guarantees a valid object. - “Infinite” extensibility – you can add
WithDatabase()later without breaking existing code that callsNew(). The options logic is delegated and encapsulated within each corresponding function.