DEV Community

Cover image for A Dependency Injection tool for Go developers who hate frameworks
Soner Astan
Soner Astan

Posted on

A Dependency Injection tool for Go developers who hate frameworks

1. The DI Dilemma in Go

If you have ever built a production-grade Go application, you know the exact moment your main.go file stops being a simple entry point and turns into a 500-line monster of initialization code.

At first, manually wiring dependencies is the "Go way" and feels perfectly fine. You instantiate a database connection, pass it to a repository, pass the repository to a service, and hand that service to your HTTP handler. But as your application scales to dozens of components, Dependency Injection (DI) becomes a tedious, unmaintainable chore. Every new service requires you to touch the central initialization logic, leading to cluttered code and merge conflicts.

Historically, Go developers looking to solve this scaling issue have been forced to choose between two painful extremes: writing endless manual boilerplate to satisfy compile-time generators, or accepting the hidden dangers of runtime reflection and "magic."

Today, I want to introduce a third option.

2. The Two Painful Extremes

Before we look at the solution, let's understand why the current ecosystem leaves many developers frustrated. When stepping away from manual wiring, you generally have to pick your poison between two fundamentally different approaches.

Extreme 1: Compile-Time DI (e.g., Google Wire)
Tools like Google Wire are brilliant from a pure engineering perspective because they generate actual Go code.

  • The Good: They are 100% compile-time safe, extremely fast, and add zero runtime overhead.
  • The Bad: You have to manually maintain massive ProviderSets. You are essentially writing boilerplate Go code just to generate other boilerplate Go code. As your project grows, maintaining these sets becomes a tedious chore, leading to a poor Developer Experience (DX). Every time you add a new service, you have to remember to manually register it in a central file.

Extreme 2: Runtime DI (e.g., Uber Fx / Dig)
These frameworks try to bring the convenient "Spring Boot" feeling to the Go ecosystem.

  • The Good: Very convenient, automatic wiring, and minimal setup code.
  • The Bad: They heavily rely on reflect. This circumvents Go's strict compiler checks and can slow down your startup times. But the absolute dealbreaker for many Go purists is this: If you forget to provide a dependency, your application will compile perfectly fine only to panic and crash at runtime.

3. The Solution: Enter Flora v1.0.0

We needed something better. We needed the "Spring Boot" convenience of auto-discovery, but we demanded the bulletproof safety and performance of Google Wire.

I built Flora to solve this exact dilemma. Flora acts as a "Convention over Configuration" layer for Go.

Instead of forcing you to write massive provider sets manually or relying on dangerous runtime reflection, Flora takes a completely different approach. It parses your source code's Abstract Syntax Tree (AST) at compile time. It natively discovers your components, understands your interfaces, and resolves the entire dependency graph.

Then, the real magic happens: Flora automatically generates a strongly-typed DI container using Google Wire under the hood.

You get the developer experience of a modern, automated framework with the safety of purely static code. The result is exactly what Go developers want:

  • 0% Reflection
  • 0% Runtime Overhead
  • 100% Compile-Time Safety

The generated container is just as fast and memory-efficient as manually written Go code. And best of all: if your code compiles, your dependency graph is mathematically guaranteed to be safe. No missing dependencies, no unexpected runtime panics.

4. Flora in Action: The Logical Approach

Let's look at how much boilerplate Flora actually eliminates. Flora makes a very clear, logical distinction between code you own and code you don't own.

Whether you inject your own services or third-party libraries, Flora natively understands Go idioms: returning (Type, error) or (Type, func(), error) is supported everywhere. Flora automatically wires the cleanup functions for a graceful shutdown.

1. Code You Own (flora.Component)

For your own domain logic, you simply embed the flora.Component marker into your structs. Since these are your own types, you can use standard Go struct tags to configure their behavior (e.g., setting scopes or qualifiers).

For example, adding flora:primary tells the DI container: "Use this function to provide the struct as the primary implementation of his implementing interfaces if a other structs asks for one."

package service

import "github.com/soner3/flora"

type UserService struct {
    // We own this struct, so we can use struct tags for DI metadata:
    flora.Component `flora:"primary"` 
    repo UserRepository
}

// Flora discovers this constructor automatically.
// It also perfectly handles the returned error!
func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("repository cannot be nil")
    }
    return &UserService{repo: repo}, nil
}

Enter fullscreen mode Exit fullscreen mode

2. Code You Don't Own (flora.Configuration)

What if you need to inject a *sql.DB from the standard library or a *zap.Logger? You cannot embed flora.Component into third-party structs.

This is exactly what flora.Configuration is for. You create a configuration struct and write provider methods. To tell Flora how to handle these methods, we use magic comments (since Go doesn't allow struct tags on functions).

package config

import (
    "database/sql"
    "github.com/soner3/flora"
)

type DatabaseConfig struct {
    flora.Configuration
}

// flora:primary
func (c *DatabaseConfig) ProvidePostgres() (*sql.DB, func(), error) {
    db, err := sql.Open("postgres", "...")
    if err != nil {
        return nil, nil, err
    }

    // Flora registers this for graceful shutdown!
    cleanup := func() { db.Close() }
    return db, cleanup, nil 
}

Enter fullscreen mode Exit fullscreen mode

3. Advanced Patterns: Multi-Binding & Prototypes

Enterprise applications often require complex DI patterns. Because Flora acts as a compiler step, it easily supports them:

  • Prototypes (Factories): Need a fresh instance every time instead of a Singleton? Just tag it with "scope=prototype".
  • Multi-Binding (Slices): Building an extensible plugin architecture? Mark multiple implementations of a Plugin interface with an order tag, if neccessary (// flora:order=1, // flora:order=2). Flora automatically collects them and injects a cleanly sorted slice ([]Plugin) directly into your core system!

4. Generating the Container

To wire everything together, you run a single command:

flora gen

Enter fullscreen mode Exit fullscreen mode

(Note: If your root components are located in the main package, make sure to generate the container there as well by using flora gen -o . so the Go compiler can resolve them).

Here is the best part: It is not a black box. The generated container is just pure, statically typed Go code. You can open it, read it, and understand exactly how your application is wired. If you ever decide to drop Flora, you can literally just keep the generated file and move on without any vendor lock-in!

5. The Elephant in the Room: Clean Architecture & Memory Footprint

I can already hear the software architects and Domain-Driven Design (DDD) purists typing furiously in the comments:

"Wait a minute... I have to embed a framework struct (flora.Component) directly into my core domain models? What about Clean Architecture? And doesn't that bloat my structs?"

You are absolutely right to ask these questions. Let's break down why Flora is completely harmless to both your memory and your architecture.

1. Zero Memory Footprint (Empty Structs)
First, let's talk about memory. flora.Component and flora.Configuration are defined as empty structs (type Component struct{}). In Go, an empty struct consumes exactly zero bytes of memory. As long as it is not the very last field in your struct (where Go might add 1 byte of padding for pointer safety), it is completely invisible at runtime. It exists purely as a compile-time marker for the AST scanner. Your application carries zero memory bloat.

2. Zero Framework Pollution (Type Aliases)
In Clean Architecture or Hexagonal Architecture, your core domain must remain completely ignorant of external frameworks. Your domain should only depend on your own code.

This is where Floraโ€™s deep integration with Go's native compiler tools (go/types) truly shines. Flora doesn't just do dumb regex string matching; it performs full semantic type resolution. This means Flora natively supports Go Type Aliases. You can keep your domain 100% free of external framework imports by creating a simple alias in one of your internal shared packages.

Step 1: Define the alias in your own internal package

// File: internal/shared/di.go
package shared

import "github.com/soner3/flora"

// Create a completely neutral alias (still 0 bytes in memory!)
type DIComponent = flora.Component 

Enter fullscreen mode Exit fullscreen mode

Step 2: Use your own alias in the core domain

// File: internal/domain/payment.go
package domain

// Look: No external github.com/soner3/flora import here!
import "your-app/internal/shared"

type PaymentService struct {
    shared.DIComponent `flora:"primary"` 
    repo               PaymentRepository
}

func NewPaymentService(repo PaymentRepository) *PaymentService {
    return &PaymentService{repo: repo}
}

Enter fullscreen mode Exit fullscreen mode

When you run flora gen, the AST parser analyzes shared.DIComponent, follows the alias down to its root, and recognizes it as a valid Flora component.

Your domain remains completely pure, your architecture stays strictly clean, your memory footprint stays at zero bytes, and you still get all the automated DI generation benefits. It is the absolute best of both worlds.

6. Conclusion: Bringing the Joy Back to DI

Go is a language beloved for its simplicity, extreme performance, and explicit nature. Dependency Injection shouldn't feel like you are fighting the language, writing endless boilerplate, or introducing dangerous runtime magic that crashes your app in production.

Flora bridges this gap. It gives you the modern, automated Developer Experience of heavily reflection-based frameworks, but strictly adheres to Go's core philosophy by keeping the final result compile-time safe, reflection-free, and blazing fast.

With the release of v1.0.0, the core API is stable, thoroughly tested (100% test coverage), and ready for production.

I need your feedback!

I built Flora to solve a pain point I felt every day, and I am incredibly excited to share it with the Go community. Now, I would love for you to try it out in your next side project or microservice.

๐ŸŒฟ Check out the repository here: github.com/soner3/flora

  • Try it: Follow the quickstart in the README.
  • Star it: If you like the approach of combining AST auto-discovery with Google Wire, I would be incredibly grateful if you gave the project a โญ on GitHub. It helps a ton with visibility!
  • Discuss it: What do you think about this approach? Does it solve your DI pain points? Let me know in the comments below!

Thank you for reading, and happy wiring!

Top comments (0)