Dependency Injection Demystified
Write robust and maintainable software using modern language features
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
Unit testing will inherently nudge you towards writing your code in a maintainable way. You’ll separate concerns, design sensible interfaces, and break your code up into small, easy-to-reason-about chunks.
Modern language features like async / await and functional reactive programming bring incredible ergonomics to our code, however your tests for this code can be flakey if you’re not careful.
I’ve always wanted to write this one, since I rarely see it explained well - frankly, it’s a tough set of concepts, and clearly I think a lot of myself if I’m writing a 6-part series:
Part II Mocking like a Pro
Part III Unit Testing with async/await
Part V (interlude) — Implement your Data Access layer with Combine
Part VI Combine, async/await, and Unit Testing
Part I - Dependency Injection
Dependency injection, a.k.a. DI, is a handy concept that’s often explained terribly. So-called “senior devs” (i.e. nerds) invoke cryptic jargon like inversion of control, and parrot unhelpful platitudes like DI is a 25-dollar term for a 5-cent concept.
I viscerally remember these exact phrases making me feel like both an idiot and an imposter when I was a junior. Hell, maybe when I was a mid.
What is DI?
I was fortunate enough to marry this year, and duly abandoned my obligation to stay in shape - so I’ll indulge in a culinary analogy.
Think of DI like a fancy restaurant. Your class (or struct, module, etc.) is head chef, and DI is the sous-chef who hands her the ingredients she need to cook up a storm. By separating the tasks of "gathering ingredients" and "cooking," our menu is far more testable (I never said the analogy would make sense).
Without dependency injection here, we’d force the chef to make up the ingredients herself, rush to cook, and leave us with an unmaintainable dumpster fire of a kitchen.
In a nutshell, DI divides the duties of “get what I need” and “do what I need to do” like a perfectly sharpened cleaver. Bon appétit!
What does this have to do with testing?
Hold your horses!
I could dive right into testing, and tech you how to write basic tests to prove that 1+1= 2, or that the string you hardcoded matches… well, your hardcoded string.
I’m starting slow and steady with DI because I want to give you a proper foundation to start from.
Essentially, what I’m saying is, you don’t want to brush your teeth (unit test) until after you’ve eaten your veggies (created mocks); and you need to preheat the oven (learn dependency injection) before you can cook your veggies.
When you use DI to inject services into your classes, you can create a corresponding buffet of mock services. These conform to the same interface - they look and act like the real deal - but really it’s you pulling the strings.
This is where the magic happens:
With your mock, you can count how many times the mock’s functions are called, stub out test data to return, or even make it scream
ERROR!
in digital agony. This control allows you to write test cases for every conceivable outcome.You’ll get a much more in-depth explanation, along with worked examples, in Part II: Mocking like a Pro and Part III: Unit testing async code - the basics.
Worked example: Bev app
Here’s some sample code I whipped up for an app called Bev, which uses the fantastic PunkAPI to get data on Brewdog beers. It’ll be our gourmet code reference throughout this series.
Bev app and architecture and data flow
The structure is a basic layered modular architecture, (wildly overkill for a project this size, but perfect for future blog topics).
The key layers in our technological lasagna are:
UI / Presentation Layer - display UI + handle events (such as user actions)
Data Access Layer - defines “what” data we want to get
Network Layer - deals with “how” we get this data
Each layer depends on the layer underneath: With DI, we can serve our class up something to allow smooth communication between the layers.
As well as nicely separating your concerns, this technique is extremely important when it comes to testing your code - wait for Part II: Mocking like a Pro to see how.
I’ve waffled for long enough. It’s time to show you some code.
UI Layer - BeerViewModel
MVVM is the most popular way to structure SwiftUI apps, building our screens with Views that react to state changes in associated View Models.
The view model behaves like a movie director, with the view as a stereotypical brain-dead actor. The view model tells the view what to do, what to look like, how to act, and the view’s job is to look pretty and avoid thinking for itself.
This view model, at the top layer, is charged with dealing with stuff happening in the “real world,” such as user input and app lifecycle events.
Here, we are injecting the data layer dependency. This means we’re initialising the view model with a reference to a Repository - defined in the Data Layer below.
When we call loadBeers()
, we are instructing the repository to load the data and setting the result as an array of Beer
models, which is rendered to the user as the list of tasty beverages.
This approach separates the concerns of “creating the beer repository object” and “asking the repository to load some beers.”
Data Access Layer - BeerRepository
As mentioned before, the Data Access Layer defines what data we want to get, not how we get it.
The data layer is like the kitchen, with the interface as the menu: “I’ll give you this meal when you ask for it.” The UI layer - a hungry patron - doesn’t know or care how the sausage is made.
If we wanted to be posh, we’d say the data layer is an abstraction on top of how we actually get the data.
Here, we define the interface with the BeerRepository
protocol (an interface
if you’re from Kotlin), and then implement an object that conforms to that protocol, handling the beer-fetching duties.
The protocol defines all the behaviour - and keeps the messy details of how the beer is fetched hidden from the layer above. The implementation of interface, BeerRepositoryImpl
, has the API dependency injected.
This is the “how” we’re fetching data, and it’s hidden from the protocol definition. The repository asks the API to get the beers, and forwards this to the UI layer.
“What’s the point of a data access layer if all it’s doing is forwarding the request from the layer above?”
In a more complex app, the data access layer can do a lot more, for example, it might have multiple ways of accessing the Beer data.
There might be a local database. The data access layer could fetch locally persisted data if it exists, quickly return that to the UI layer, and also request the latest data from the network to update the UI with the most up-to-date info.
This hides the complexity of managing these disparate data sources from the layer above.
Network Layer - API
Like a skilled waiter, the Network Layer defines how we are actually getting the data. In this case, we’re making an API request to fetch it from the internet. This is the “how” of getting the data we crave.
Maintaining my poshness, this is the concrete implementation of our data access - the lowest-level layer.
We’re ‘injecting’ the dependency of the URL session, which is the thing that lets us actually ask for things over the network. The “concrete” implementation of URLSession is handled by Foundation, a fundamental iOS library, which handles the nitty-gritty of sending our humble API request through the internet (at least, sending to the Transport layer).
You did it. You finished Part I.
Congratulations. You made it this far.
In the stand-up comedy circuit, the hardest step - the step which gets you ahead of 99% of people - is starting. Reading the following 5 courses in my tasting menu of async testing will turn you into a true connoisseur.
In summary, dependency injection is a technique that allows you to separate the responsibility of gathering the tools an entity needs to do its job with actually using those tools.
We learned a little about how we might thoughtfully structure an application with layers, and use DI to facilitate the flow of data between these layers like a well-oiled machine.
But you haven’t seen the pièce de résistance of dependency injection until you’ve put it to the test… as in, unit testing.
Read on for Part II: Mocking like a Pro.
Part II Mocking like a Pro
Part III Unit testing async code - the basics
Part IV Advanced async testing - handling unstructured concurrency
Part V Interlude - implementing the Repository pattern with Combine
Part VI Combine, async/await, and unit testing
Great series so far! I'd like to know your thoughts about reusing the same Beer model across all the modules. What are your thoughts about having different models for each module and using a mapper. ie: have different structs for the backend model and the model used by the UI
Would it be better to create initializers using generics instead of using the protocol directly since using the protocol would result in a boxed type and have a performance penalty?