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.
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.
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
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.
Here are a couple of specific insights we learned while working with the
- We keep all calls to
wire.Buildarguments. 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.APMis used as an
todos.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.Buildarguments 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 actual
Appprovider 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
Buildarguments is to first remove all arguments from the top level provider (so
newAppin this case). This means you can call
wire.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 within
wire.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
somerealapmpackages versus the service and repo packages. With the
somerealapmpackages, every single provider from the respective package is listed as arguments to the
Buildcall directly. For the other packages, we create a new
wire.gofile in each of those packages and export and variable with
wire.NewSet. The result of a
NewSetcall is equivalent to listing all the arguments in
Buildcall. It becomes a matter of preference and / or convenience by allowing individual packages to export wired variables, or to have all wiring in a central
Buildcall. At Shipyard, we’ve adopted the convention of exporting a
Wired = wire.NewSetvariable and including those in the
- You can see that we have many
package.Configtypes in the repository. And then each of those
Configtypes are used as the first parameter in the
<package>.Newfunctions that provide each core package component. Additionally, the parameter(s) for the
NewConfigproviders are from the “environment”. In our case, the literal
os.Getenvfunction is passed in from
todos.New(). The environment variables for the process are injected into these
Configtypes, 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.
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.