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
}
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:
- 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 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?intBoilerplate: If you only want to change one setting, you still have to construct the struct, potentially dealing with pointers to optional fields to distinguish
nilfrom0.
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;
}
// ...
}
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
}
}
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:
Explicit over implicit, as this pattern often won't rely on magical DI hydration, and you witness the override with nude eyes.
Stay away from the million dollar mistake and avoid
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.
Top comments (0)