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.
Repository Layout

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.

func New(enver config.Enver, logger *log.Logger) (*App, error) {
	wire.Build(

		// sqldb to DB.
		sqldb.NewConfig,
		sqldb.New,
		wire.Bind(new(DB), new(*sql.DB)),

		// Our imposter APM.
		somerealapm.NewConfig,
		somerealapm.New,
		wire.Bind(new(apm.APM), new(*somerealapm.APM)),
		wire.Bind(new(APM), new(*somerealapm.APM)),

		// User Repo.
		userrepo.Wired,
		wire.Bind(new(user.Repo), new(*userrepo.Repo)),

		// User Service.
		userservice.Wired,
		wire.Bind(new(user.Service), new(*userservice.Service)),

		// Todo Repo.
		todorepo.Wired,
		wire.Bind(new(todo.Repo), new(*todorepo.Repo)),

		// Todo Service.
		todoservice.Wired,
		wire.Bind(new(todo.Service), new(*todoservice.Service)),

		// API as our http.Handler.
		api.Wired,
		wire.Bind(new(http.Handler), new(*api.API)),

		// Http server.
		server.Wired,

		// This package - the application in app.go
		newApp,
	)

	// Requirement for compilation.
	return nil, nil
}
src/todos/wire.go

Code Insights

Here are a couple of specific insights we learned while working with the wire package:

  1. We keep all calls to wire.Bind as wire.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.
  2. When using wire.Bind, you can have the same concrete type provide the implementation for multiple interfaces. E.g. *somerealapm.APM is used as an apm.APM and a todos.APM. This is useful since we can keep our interfaces small and independent while having a single concrete type implement multiple of them.
  3. 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 actual App 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.
  4. One approach that proved useful to us while filling out the Build arguments is to first remove all arguments from the top level provider (so newApp in 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:

  1. We can see two different styles for wiring up the sqldb and somerealapm packages versus the service and repo packages. With the sqldb and somerealapm packages, every single provider from the respective package is listed as arguments to the Build call directly. For the other packages, we create a new wire.go file in each of those packages and export and variable with wire.NewSet. The result of a NewSet call is equivalent to listing all the arguments in NewSet in the Build 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 central Build call. At Shipyard, we’ve adopted the convention of exporting a Wired = wire.NewSet variable and including those in the Build call.
  2. You can see that we have many package.Config types in the repository. And then each of those Config types are used as the first parameter in the <package>.New functions that provide each core package component. Additionally, the parameter(s) for the NewConfig providers are from the “environment”. In our case, the literal os.Getenv function is passed in from main.go to todos.New(). The environment variables for the process are injected into these Config 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.
func New(config Config, apm apm.APM, logger *log.Logger, users user.Service, todos todo.Service) *API {
	return &API{
		config: config,
		apm:    apm,
		logger: logger,
		users:  users,
		todos:  todos,
	}
}
src/todos/handler/api/api.go
  1. 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.
  2. 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.