- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have a Go service. It runs in production. Every package in it imports the same config struct. The order service reads config.Get().DatabaseURL. The notification service reads config.Get().SMTPHost. The auth middleware reads config.Get().JWTSecret. Fifteen packages. One import they all share.
That config package is not a convenience. It is a dependency monster wearing a utility belt.
The pattern everyone uses
It usually starts like this. A config package with a global getter:
// internal/config/config.go
package config
import (
"os"
"sync"
)
type Config struct {
DatabaseURL string
SMTPHost string
SMTPPort string
JWTSecret string
CacheAddr string
}
The singleton loads once and hands back a pointer:
var (
instance *Config
once sync.Once
)
func Get() *Config {
once.Do(func() {
instance = &Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: os.Getenv("SMTP_PORT"),
JWTSecret: os.Getenv("JWT_SECRET"),
CacheAddr: os.Getenv("CACHE_ADDR"),
}
})
return instance
}
Clean. Singleton. Loads from environment variables. Nothing controversial here.
The problem is what happens next. Every package in your project reaches for it:
// internal/order/service.go
package order
import "myapp/internal/config"
type Service struct{}
func (s *Service) Create(
customerID string,
) (Order, error) {
db, err := sql.Open(
"postgres",
config.Get().DatabaseURL,
)
// ...
}
// internal/notification/sender.go
package notification
import "myapp/internal/config"
func SendWelcome(email string) error {
host := config.Get().SMTPHost
port := config.Get().SMTPPort
// ...
}
// internal/auth/middleware.go
package auth
import "myapp/internal/config"
func ValidateToken(token string) error {
secret := config.Get().JWTSecret
// ...
}
Three packages with three separate concerns, all coupled through the same import. The config package is now the gravitational center of your entire codebase.
Why this is worse than it looks
Every package knows about every other package's config. The order service has compile-time access to config.Get().JWTSecret. It has no business reading that. But it can. And one day, someone will.
Testing requires the environment. Want to unit test order.Service? You need DATABASE_URL set. Want to test notification.SendWelcome? You need SMTP_HOST and SMTP_PORT. A test for order placement now depends on SMTP configuration. That is absurd.
You also cannot run two configurations in the same process. Integration tests that need different database URLs for parallel test suites? Impossible. The singleton locks you to one configuration per binary.
The dependency graph is a star. Draw the import graph of your project. Every package points at config. Change the config struct---add a field, rename a field, restructure it---and every package recompiles. Worse, every package's tests might break.
Refactoring is blocked too. Want to extract the notification package into its own module? You cannot. It imports config, which imports the shape of your entire application's configuration. Your "independent" package depends on the whole world.
What config actually is
Config is infrastructure. It is how your application learns about the outside world: where the database lives, what credentials to use, which ports to bind. It belongs in the same category as HTTP handlers, database drivers, and message queue adapters.
Your domain and business logic should not know where their dependencies come from. An order service needs a database connection. It does not need to know that the connection string came from an environment variable called DATABASE_URL. That is a wiring detail.
The fix: treat config as an adapter, not a utility. The domain declares what it needs. main() reads the config and provides those dependencies.
The fix: constructor injection
Start with the order service. It needs a database connection. Give it one:
// internal/order/service.go
package order
import "database/sql"
type Service struct {
db *sql.DB
}
func NewService(db *sql.DB) *Service {
return &Service{db: db}
}
func (s *Service) Create(
customerID string,
) (Order, error) {
// use s.db directly
row := s.db.QueryRow(
"INSERT INTO orders ...",
customerID,
)
// ...
}
The config import is gone. The order service does not know about environment variables. It does not know about sync.Once. It receives a database connection and uses it. That is its entire world.
Same treatment for notifications:
// internal/notification/sender.go
package notification
type SMTPConfig struct {
Host string
Port string
}
type Sender struct {
smtp SMTPConfig
}
func NewSender(smtp SMTPConfig) *Sender {
return &Sender{smtp: smtp}
}
func (s *Sender) SendWelcome(
email string,
) error {
// use s.smtp.Host, s.smtp.Port
// ...
}
The notification package defines what it needs: a host and a port. It does not import a global config struct that also knows about database URLs and JWT secrets. Its dependency surface is two strings.
And auth:
// internal/auth/validator.go
package auth
type Validator struct {
jwtSecret string
}
func NewValidator(jwtSecret string) *Validator {
return &Validator{jwtSecret: jwtSecret}
}
func (v *Validator) ValidateToken(
token string,
) error {
// use v.jwtSecret
// ...
}
A string. That is all the auth package needs from the outside world. Not a config struct with fifteen fields. A string.
main() does the wiring
All the config reading and dependency construction moves to one place:
// cmd/api/main.go
package main
import (
"database/sql"
"log"
"os"
"myapp/internal/auth"
"myapp/internal/notification"
"myapp/internal/order"
)
func main() {
db, err := sql.Open(
"postgres",
os.Getenv("DATABASE_URL"),
)
if err != nil {
log.Fatal(err)
}
Database connection established. Now wire the domain services:
orderSvc := order.NewService(db)
notifier := notification.NewSender(
notification.SMTPConfig{
Host: os.Getenv("SMTP_HOST"),
Port: os.Getenv("SMTP_PORT"),
},
)
authValidator := auth.NewValidator(
os.Getenv("JWT_SECRET"),
)
// wire into HTTP handlers, start server...
_ = orderSvc
_ = notifier
_ = authValidator
}
main() is the only place that reads environment variables. It is the only place that knows the full shape of the configuration. Every other package receives exactly what it needs through its constructor. Nothing more.
You can still keep a config package if you want to centralize the parsing, validation, and defaults. The difference is that no other package imports it:
// internal/config/config.go
package config
import "os"
type Config struct {
DatabaseURL string
SMTPHost string
SMTPPort string
JWTSecret string
}
func Load() Config {
return Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: os.Getenv("SMTP_PORT"),
JWTSecret: os.Getenv("JWT_SECRET"),
}
}
// cmd/api/main.go
func main() {
cfg := config.Load()
db, err := sql.Open("postgres", cfg.DatabaseURL)
// ...
orderSvc := order.NewService(db)
notifier := notification.NewSender(
notification.SMTPConfig{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
},
)
authValidator := auth.NewValidator(cfg.JWTSecret)
}
Config is loaded once, in main(). Then values flow outward through constructors. The config package is imported by main() and only by main().
Testing becomes obvious
Before, testing the order service meant setting environment variables:
func TestCreateOrder(t *testing.T) {
os.Setenv("DATABASE_URL", "postgres://...")
os.Setenv("SMTP_HOST", "localhost")
// SMTP_HOST? For an order test?
svc := &order.Service{}
// ...
}
After, testing is direct:
func TestCreateOrder(t *testing.T) {
db := setupTestDB(t)
svc := order.NewService(db)
got, err := svc.Create("customer-123")
if err != nil {
t.Fatal(err)
}
// assert on got
}
No environment variables. No global state. No SMTP configuration leaking into order tests. The test sets up exactly what the service needs and nothing else.
Want to test notifications without a real SMTP server? Pass a fake config:
func TestSendWelcome(t *testing.T) {
sender := notification.NewSender(
notification.SMTPConfig{
Host: "localhost",
Port: "2525",
},
)
err := sender.SendWelcome("test@example.com")
// ...
}
Each package is testable in isolation. No shared global state bleeding between tests. No race conditions from parallel tests mutating the same singleton.
Going further: interface-based config
For packages that need more flexibility, define an interface for the configuration dependency:
// internal/notification/sender.go
package notification
type MailConfig interface {
SMTPHost() string
SMTPPort() string
}
type Sender struct {
mail MailConfig
}
func NewSender(mail MailConfig) *Sender {
return &Sender{mail: mail}
}
Now tests can supply a stub, production can supply the real config, and the notification package owns the definition of what "mail configuration" means. It does not depend on any external struct shape. The adapter (whoever implements MailConfig) depends on the notification package's interface, not the other way around.
This is the dependency rule in action. Inner layers define interfaces. Outer layers implement them. The arrows point inward.
The checklist
If your project uses a global config pattern, here is how to migrate:
Audit imports. Run
go list -f '{{.Imports}}' ./...and count how many packages import your config package. That number is your coupling score.Start with one package. Pick the package with the fewest config fields. Replace the
config.Get()calls with constructor parameters. Updatemain()to pass the values.Repeat. Move outward one package at a time. Each migration is a small, reviewable diff.
Delete the singleton. Once no package except
main()imports config, removeGet()and thesync.Once. Replace with a plainLoad()function that returns a struct.Verify. Run
go list -f '{{.Imports}}' ./internal/order/...for each domain package. If config appears, you missed a spot.
The migration is mechanical and requires no big rewrite or architecture astronaut phase. One package at a time, one constructor at a time, until the star dependency collapses into a tree with main() at the root.
The principle behind it
A package should declare what it needs to do its job. It should not reach into a global bag of everything the application knows. When your order service imports config, it is saying: "I depend on the shape of the entire application's configuration." When it accepts a *sql.DB through its constructor, it is saying: "I need a database connection."
The first statement is a lie. The second is the truth.
Global config is a service locator in disguise. It hides real dependencies behind one convenient import. It makes the dependency graph look simpler than it is. And it makes every package in your codebase impossible to use, test, or extract without dragging the entire configuration along.
Kill the monster. Push config to the edges. Let main() do the wiring. Your packages will thank you by being testable, extractable, and honest about what they need.
If this was useful
This pattern---pushing infrastructure to the edges and keeping domain packages dependency-free---is the core of hexagonal architecture. My book Hexagonal Architecture in Go walks through the full implementation: ports, adapters, the dependency rule, and how main() becomes the composition root that ties everything together. If the config refactoring in this post clicked for you, the book gives you the complete framework.

Top comments (0)