Implementing Go Dependency Injection with Wire - What You Need To Know
Wire is a dependency injection tool for Golang that generates code to build your components using a dependency graph for ordering the build process. At Shipyard, we had the opportunity to use Wire in a new project. These are our insights and design decisions that we encountered while writing the project, along with a sample repository for reference in the discussion.
The new project was a clean slate for dependency injection, and while our other projects have manually written approaches, we wanted to avoid repeating the manual approach because it is brittle and increasingly difficult to change. The new project was an experiment for using Wire and its further adoption within our code base.
The content of the repository is a partially built out Todo application. The application has users, and those users can operate on todos. It is “partially built out” in the sense that the repo is not a fully working application (though it does compile) - there is no frontend, and some functionality is left out. The main intent of the repo is to show how Wire works with the application components.
Layout
The layout of the application is as follows:
src/config
- Package for configuration types and helpers.src/config
- Package for configuration types and helpers.src/domain
- Houses all of our business logic types and functionality.src/infra
- Provides real world implementations of some domain interfaces. Infra packages are the actual infrastructure connections in the application. Subpackages provide an http server, a SQL database connection along with repo implementations, and a fake APM implementation.src/todos
- The top level application package.main.go
- The main package that we compile.
The Code
Getting into Wire
The “entrypoint” into wire (the wire.Build
call) is located in src/todos/wire.go
. It contains the single injector, New
, that we use to build an App. The two parameters enver
and logger
are values that live “outside” of the App. This means that different environments or logging services could be passed into a new instance of the App without additional configuration. Since they aren’t values under complete control of the App instance, we provide them as parameters to the injector, which then become available to all other providers.
Code Insights
Here are a couple of specific insights we learned while working with the wire
package:
- We keep all calls to
wire.Bind
aswire.Build
arguments. This makes explicit what concrete types provided by other packages are used as implementations for the defined interfaces. This also keeps in line with the general Go convention of returning concrete types. - When using
wire.Bind
, you can have the same concrete type provide the implementation for multiple interfaces. E.g.*somerealapm.APM
is used as anapm.APM
and atodos.APM
. This is useful since we can keep our interfaces small and independent while having a single concrete type implement multiple of them. - The readability of the
wire.Build
arguments can be improved by taking a convention for grouping and ordering the arguments. Instead of adding in providers as they come up in code, you can order the arguments by level in the dependency graph. For example, we keep the lowest level components (or closest to the edges) of the application at the top and the actualApp
provider as the last argument. This allows the reader to know what is available by reading top to bottom. Additionally, grouping different providers with whitespace allows the reader to see what components are related by interfaces, values, structs, etc. You can see this in practice in the code snippet above. - One approach that proved useful to us while filling out the
Build
arguments is to first remove all arguments from the top level provider (sonewApp
in this case). This means you can callwire.Build(newApp)
and have Wire run successfully. Through each iteration of this process, you can add one argument at a time, get those providers in order withinwire.Build
, and have a working wire generation at each step.
Code Design Decisions
Some Shipyard-specific design decisions while using wire
are worth noting as well:
- We can see two different styles for wiring up the
sqldb
andsomerealapm
packages versus the service and repo packages. With thesqldb
andsomerealapm
packages, every single provider from the respective package is listed as arguments to theBuild
call directly. For the other packages, we create a newwire.go
file in each of those packages and export and variable withwire.NewSet
. The result of aNewSet
call is equivalent to listing all the arguments inNewSet
in theBuild
call. It becomes a matter of preference and / or convenience by allowing individual packages to export wired variables, or to have all wiring in a centralBuild
call. At Shipyard, we’ve adopted the convention of exporting aWired = wire.NewSet
variable and including those in theBuild
call. - You can see that we have many
package.Config
types in the repository. And then each of thoseConfig
types are used as the first parameter in the<package>.New
functions that provide each core package component. Additionally, the parameter(s) for theNewConfig
providers are from the “environment”. In our case, the literalos.Getenv
function is passed in frommain.go
totodos.New()
. The environment variables for the process are injected into theseConfig
types, and these config types alone. The components then use the outside configuration as the first parameter and all trailing parameters are components inside the application - an approach that groups config together logically, and avoids the “single type” restriction imposed by Wire.
- The notion of “the outside” and “the inside” of an application relate to differences in process invocation as opposed to what the code itself knows it needs in the dependency chain. The outside could include configurations other than just environment variables, such as command line arguments.
- When we started on our new project inside Shipyard, we had plenty of existing components, along with some new ones, that needed to be connected to providers and external configuration. By using Wire, we ran into issues where existing components did not provide a consistent way to expose providers, with “outside” information being mixed with “inside” information. We ended up refactoring those existing components to be consistent with what we have here. In a sense, using Wire forces a welcomed design pattern on your applications components. Using Wire gives a clear sense of what is configured at runtime versus what other sub-components are required at compile time, and for a way to consistently make that explicit in code.
Conclusion
Our experiment using Wire for dependency injection proved successful. For the size of code that we have, it definitely makes connecting components much easier with a large increase in flexibility.
Wire can greatly improve your code and the application bootstrapping process. You can see that even in this small example, Wire has generated 34 lines of code. But it's more than just those lines, it’s the logic for how all of the dependencies fit together that we also don’t have to think or worry about. In a real world application, the benefits are that much greater.
We are already looking forward to using Wire in the rest of our projects because of the advantages over a manual approach for dependency injection.
About Shipyard:
Shipyard is a modern data orchestration platform for data engineers to easily connect tools, automate workflows, and build a solid data infrastructure from day one.
Shipyard offers low-code templates that are configured using a visual interface, replacing the need to write code to build data workflows while enabling data engineers to get their work into production faster. If a solution can’t be built with existing templates, engineers can always automate scripts in the language of their choice to bring any internal or external process into their workflows.
The Shipyard team has built data products for some of the largest brands in business and deeply understands the problems that come with scale. Observability and alerting are built into the Shipyard platform, ensuring that breakages are identified before being discovered downstream by business teams.
With a high level of concurrency and end-to-end encryption, Shipyard enables data teams to accomplish more without relying on other teams or worrying about infrastructure challenges, while also ensuring that business teams trust the data made available to them.
For more information, visit www.shipyardapp.com or get started for free.