Part 1 covered the structural elements dependency injection and the style in which you write code using dependency injection. In this part, we’ll talk about additional architectural features that are enabled or at least made easier with dependency injection.
We are going to talk about two points:
- Supporting different configurations, e.g. different database back-ends, in your code
- Supporting changing code behavior without touching your own code.
The differences between the two are subtle, but because the use cases are conceptually quite different, we’ll discuss them separately.
Injecting configurations
“Configurations” here means actual parts of the system, e.g. different database back-ends, cache implementations, etc. One obvious and common use case for this is in testing, where you often want to replace complicated pieces of code with simpler fakes or stubs. Even if you never intend to support different configurations in your production system, the support for much easier testing means that this is a critical technique you should always apply.
So how does it work?
In part 1 I showed how to inject objects that a piece of code depends on directly. To support different configurations, we have to add an additional abstraction, an interface, to describe what kind of thing has to be injected.
Let’s say we have a request handler that has to look up data is some storage system. At the start, this may look like this:
type UserQueryHandler struct {
store *DB
}
func HandleQuery(query string) error {
if err := sanitizeQuery(query); err != nil {
return err
}
d, err := store.Get(query)
if err != nil {
return err
}
// use d somehow
}
type DB struct {
conn *sql.DB
}
func (d *DB) Get(key string) (Data, error) {
// Build SQL query from key.
// Build Data from SQL return
}
func (d *DB) Set(key string, value Data) error {
// Write to DB
}
func controller() {
db := sql.OpenDB("")
handler := UserQueryHandler{store: &DB{conn: db}}
// register with HTTP/RPC stack.
}
For testing, you would now always have to provide a DB
struct with a SQL backend, which makes tests heavy-weight. Additionally, when running your system, you will likely discover that backend queries are slow and costly, so you might want to introduce a cache.
Since your code is already set up for dependency injection, the changes to get this done are small:
type Store interface {
Get(key string) (Data, error)
Set(key string, value Data) error
}
type UserQueryHandler struct {
store Store
}
func HandleQuery(query string) error {
if err := sanitizeQuery(query); err != nil {
return err
}
d, err := store.Get(query)
if err != nil {
return err
}
// use d somehow
}
type DB struct {
conn *sql.DB
}
func (d *DB) Get(key string) (Data, error) {
// Build SQL query from key.
// Build Data from SQL return
}
func (d *DB) Set(key string, value Data) error {
// Write to DB
}
type InMemoryCache struct {
db DB
data map[string]Data
}
func (c *InMemoryCache) Get(key string) (Data, error) {
if d, ok := c.data[key]; ok {
return d, nil
}
return c.db.Get(key)
}
func (c *InMemoryCache) Set(key string, value Data) error {
// Update cache if needed and write to DB if needed.
}
func controller() {
db := sql.OpenDB("")
cache := InMemoryCache{db: &DB{conn: db}, data: make(map[string]Data)}
handler := UserQueryHandler{store: &DB{conn: db}}
// register with HTTP/RPC stack.
}
The interface now just declares the operations your code needs, and decouples your code completely from how providers implement them. The providers, in this example both DB
and InMemoryCache
, implement the interface. So the handler could accept either as the backend store. This is the same principle that frameworks use when they provide hooks that you can implement.
In Part 1 I argued that you don’t need injection frameworks to make use of the power of dependency injection. Consider briefly what the injection framework would have to do to support different configurations for production and testing, and maybe even allow flag-controlled settings like --use_in_memory_cache
vs --use_memcached
.
Powerful frameworks like Guice make this possible, but the cost in terms of indirection is high. Tracing back where objects come from is hidden behind a lot or reflective, and thus invisible, automation.
Don’t believe me? As an exercise, either use a framework like Guice for the use cases I outlined above, or, if you want a real challenge, implement a simple injection framework for your favorite language that can support this.
Injecting different behaviors
Injecting different behaviors is very similar to injecting configuration. In the above example, we already did this, because the pass-through logic of the cache is already a slightly different behavior than before. However, in this case, the use of the interface was aimed more at injecting different providers, rather than modifying algorithms.
The Strategy pattern focuses explicitly on injecting algorithms rather than data objects. A very simple case is the sort.Slice()
function from Go’s standard library. The sorting is generic, but you have to provide a comparator function for it to work. In the example below, we’ll do this by passing a lambda instead of an explicit function.
type data struct {
i int
s string
}
func controller() {
ints := []int{3, 6, 1, 65, 1, 21}
d := []data{
{1, "g"},
{76, "a"},
{3, "b"},
{19, "t"},
{123, "e"},
}
sort.Slice(ints, func(i, j int) bool {
return ints[i] > ints[j]
})
sort.Slice(d, func(i, j int) bool {
if d[i].i == d[j].i {
return d[i].s < d[j].s
}
return d[i].i < d[j].i
})
}
This example is a bit simple, but it shows the principle. In languages where functions are first-class objects, you can directly inject them. Otherwise, you’ll have to go the indirect route via defining an interface with a single function, have an object implement this interface, and inject the object instance.
What’s next?
So far we’ve covered the use cases where dependency injection is powerful, but we’ve ignored some questions of context. Object lifetime and sharing are important practical considerations when you separate object creation from use. We will cover these topics in part 3 of the series.
Required Reading
- Dependency Injection on Wikipedia
- Gamma, Erich, Richard Helm, Ralph Johnson, John Vlissides, and Grady Booch. 1994. Design Patterns: Elements of Reusable Object-Oriented Software. 1st edition. Reading, Mass: Addison-Wesley Professional.