DEV Community

Cover image for Your Go Config Package Is a Hidden Dependency Monster
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your Go Config Package Is a Hidden Dependency Monster


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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    )
    // ...
}
Enter fullscreen mode Exit fullscreen mode
// internal/notification/sender.go
package notification

import "myapp/internal/config"

func SendWelcome(email string) error {
    host := config.Get().SMTPHost
    port := config.Get().SMTPPort
    // ...
}
Enter fullscreen mode Exit fullscreen mode
// internal/auth/middleware.go
package auth

import "myapp/internal/config"

func ValidateToken(token string) error {
    secret := config.Get().JWTSecret
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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,
    )
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"),
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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)
}
Enter fullscreen mode Exit fullscreen mode

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{}
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Audit imports. Run go list -f '{{.Imports}}' ./... and count how many packages import your config package. That number is your coupling score.

  2. Start with one package. Pick the package with the fewest config fields. Replace the config.Get() calls with constructor parameters. Update main() to pass the values.

  3. Repeat. Move outward one package at a time. Each migration is a small, reviewable diff.

  4. Delete the singleton. Once no package except main() imports config, remove Get() and the sync.Once. Replace with a plain Load() function that returns a struct.

  5. 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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)