A dramatic, photo-realistic image of complex gears and broken machinery representing hidden pitfalls in Go service architecture

Go Dependency Injection Pitfalls

Last week’s Coffee and Go post had me reflecting over an espresso: for a language that prides itself on simplicity, Go has a sneaky way of introducing complexity, especially when it comes to dependency injection. This week, I ran into some classic Go dependency injection pitfalls that reminded me just how careful you have to be when wiring up services.

This week, we wandered down some dark alleyways in Go’s service architecture. And like any good alleyway, it looked safe at first:until something rustled behind the bins.

Here’s what we ran into, and how we managed to wrestle things back under control.


1. The nil Interface Trap (aka the Panic Ninja)

Go’s interfaces are like polite party guests-they show up, smile, and don’t reveal much about what’s underneath. But every now and then, one of them forgets to bring a body.

Let me explain.

type Logger interface {
    Log(message string)
}

// This seems fine
var logger Logger = nil
if logger == nil {
    fmt.Println("Logger is nil")
}

// This? This is the bomb under the table.
type NullLogger struct{}
func (n *NullLogger) Log(message string) {}
var badLogger Logger = (*NullLogger)(nil)
if badLogger == nil {
    fmt.Println("This won't print")
}
badLogger.Log("Boom.")

What’s happening? The interface isn’t nil-it contains a nil. You can almost hear the compiler whispering, “That’s your problem now.”

Lesson learned: we now inspect our interfaces like suspicious packages. And we build soft landings wherever a crash might otherwise sneak in.


2. Circular Imports: Death by Mutual Dependency

Here’s a fun puzzle: Service A needs Service B. Service B needs Service A. And Go says: nope.

registry.Register("service_a", NewServiceA)
registry.Register("service_b", NewServiceB)

func (r *Registry) Resolve() {
    serviceA := r.factories["service_a"](r.services)
    serviceB := r.factories["service_b"](r.services)
}

Go’s compiler is like a stern parent in this case. “You two can’t be in the same room together.”

We tried to untangle the spaghetti, but eventually embraced a better approach.

We built a runtime registry. Each service registers itself. Dependencies are injected after the fact, once all the pieces are on the board.

registry.Register("service_a", NewServiceA)
registry.Register("service_b", NewServiceB)

func (r *Registry) Resolve() {
    serviceA := r.factories["service_a"](r.services)
    serviceB := r.factories["service_b"](r.services)
}

No import loops. Just clean runtime wiring.


3. The init() Function Trap

Ah, init(). Go’s silent servant. It runs when a package is imported, but only when it’s imported.

func init() {
    registry.RegisterService("myservice", NewMyService)
}

Here’s the kicker: if you forget to import the file (or the compiler optimizes it away), init() never runs. And suddenly your service registry is missing a limb. This is one of those Go dependency injection pitfalls that catches even experienced developers off guard.

Our solution? We made an explicit all.go file whose sole job is to import everything:

// services/all.go
package services

import (
    _ "example.com/app/services/service_a"
    _ "example.com/app/services/service_b"
)

It’s boring, yes, but it works—like brushing your teeth before bed.


4. Error Handling in the Service Mineshaft

Go makes you handle errors explicitly. And in a way, that’s beautiful. You see every crack before you step on it.

But when you’re deep in a service tree, and something below goes wrong, the surface error can look like: something broke.

Here’s how we used to do it:

func (s *Service) Start() error {
    for _, dep := range s.dependencies {
        if err := dep.Start(); err != nil {
            return err
        }
    }
    return nil
}

No idea which dependency failed. No idea why. Debugging became a séance.

Now we do this:

func (s *Service) Start() error {
    for _, dep := range s.dependencies {
        if err := dep.Start(); err != nil {
            return fmt.Errorf("failed to start dependency %s: %w", 
                dep.Name(), err)
        }
    }
    return nil
}

Readable. Traceable. Much less swearing.


5. The Priority Clash

We thought we were clever. Give each service a numeric priority. Lower number starts first. What could go wrong?

var ServiceA = Service{Priority: 10}
var ServiceB = Service{Priority: 10} // Collision course

Turns out: a lot. Two services with the same priority don’t always start in the same order. This created race conditions. Services would panic because their dependencies hadn’t woken up yet.

We replaced it with a smarter structure:

type ServiceMetadata struct {
    Name         string
    Priority     int
    DependsOn    []string
    OptionalDeps []string
}

Then we built a dependency graph and walked it like a cautious cat, no more startup roulette.

In the End…

Go is minimalist, sure, but it doesn’t babysit. And when you’re working at scale, small cracks widen fast.

What started as a few annoyances became a full architectural overhaul. And oddly, we’re thankful. Every bug taught us something. Every panic became a prompt to dig deeper.

We’re not done, of course. There’s always another edge case, another refactor, another line of code that looks innocent, until it isn’t.

But for now, the service framework is tighter, cleaner, and a little more battle-tested.

Ever fallen into one of these traps yourself? Drop a comment or send us a story. Misery loves company and good solutions.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.