Compile-time DI vs. Run-time DI

      As the author of Dihedral, I often get asked: “Why should I use a compile-time dependency injection framework over a run-time DI framework?” For those of you who don’t know, Dihedral is a compile-time DI framework for Go, so it’s a natural question to ask when evaluating DI frameworks. Having used DI for many years, and having written a DI framework, I’ve come to the definitive conclusion that compile-time is better. The goal of this post is to outline why compile-time frameworks are better, and to convince you to use a compile-time DI framework for your next project.

If it compiles, it works

      Unsurprisingly, the most serious issues that occur when using a run-time DI framework happen during… run-time. The most common issue involves misconfiguration causing injection to fail. This is a very serious issue, and almost always results in a full or partial outage of an application. If an application can’t inject itself, it’s very hard for it to run. It’s terrifyingly easy to introduce an injection bug in a large application, especially an application containing many injection scopes. For instance, you might add a new injected field onto a class that looks like it’s always used in a specific scope. There may be, however, an edge case where this class is used in a smaller scope. During your testing, it may seem like everything is working fine. In production, however, anything hitting the smaller scope will now crash the application. Another class of problems are caused by differences in the run-time environment and the compile-time environment or even the development run-time environment and the production run-time environment. This is very common in Java and other dynamically-linked languages. For instance, Spring or Guice may fail to find classes during classpath scanning in production, even though those dependencies were there during development. The results are equally catastrophic.

      This entire class of issues is not present when using compile-time DI. If your application compiles, then it will work properly at run-time. Full stop. This peace of mind alone makes compile-time DI the clear winner. With run-time DI, each and every deploy may have hidden landmines lurking in the injection graph. With compile-time DI, one can rest easy.

Initialization speed

      The other great thing about compile-time DI is that it starts much faster. There isn’t any additional work that needs to be done at run-time, so the initialization time is essentially the same as if there was no dependency injection at all. Compile-time DI works by generating source code, so it’s even optimized by the compiler. Safer injection that’s also faster. That’s a strong argument in favor of compile-time DI.

Say goodbye to factories, forever

      These advantages of DI are great from an operations perspective, but compile-time DI also provides paradigms for writing nicer code. Often, one wants to combine injected dependencies with some type of run-time information. This is most often achieved with the use of a factory:

class ThingFactory @Inject constructor(
    private val injectedDependency: InjectedDependency
) {
    fun createThing(runtimeDependency: RuntimeDependency): Thing {
        return Thing(injectedDependency, runtimeDependency)
    }
}

      The issue with factories is that the caller of createThing also needs a reference to runtimeDependency. This means that if the caller is not the owner of runtimeDependency, then it also needs a factory to combine its injected dependencies, one of which is ThingFactory, with the run-time information. Compile-time DI handles this very elegantly via the use of “components.” Components are essentially factories for a given type, but they handled injecting the run-time information for you. Components are built via a “component builder” that takes run-time information and provides it to injection code generated at compile-time. This allows you to inject run-time values very deep in the injection graph without the need of a single factory. Here is an example in Dihedral:

type Module struct {
    provided embeds.ProvidedModule
    RuntimeValue *RuntimeValue
}
func (m *Module) providesRuntimeValue() *RuntimeValue { return m.runtimeValue }

type OuterType struct {
    inject embeds.Inject
    Inner InnerType
}

type InnerType struct {
    inject embeds.Inject
    Runtime *RuntimeValue
}

type Component interface {
    Modules() (*Module)
    OuterType() *OuterType
}

func main() {
    runtimeValue := &RuntimeValue{}
    component := digen.NewComponent(&Module{ RuntimeValue: runtimeValue })
    outerType := component.OuterType()
}

      This example illustrates how the runtime value can be provided directly to the InnerType without intermediate factories providing InnerType or OuterType.

      I hope you got something from this post. If you’re spinning up a new Go service, give Dihedral a shot.