What dependency injection actually is
Dependency injection (DI) is not a framework, a pattern library, or a design philosophy. It is a single, concrete technique:
Give objects what they need; don’t let them build it themselves.
That’s it. If a class constructs its own dependencies, it owns those dependencies and you can’t swap them. If a class receives its dependencies at construction, the caller decides what it gets.
1// NOT dependency injection
2class DataLogger {
3 Stm32SpiFlash flash_; // constructed here — you own it forever
4public:
5 DataLogger() : flash_(SPI1, GPIOA, GPIO_PIN_4) {}
6};
7
8// Dependency injection
9class DataLogger {
10 IStorage& storage_; // injected by caller
11public:
12 explicit DataLogger(IStorage& s) : storage_(s) {}
13};
The difference is one line of code in the class and one line of code at the call site. The payoff is testability, portability, and the ability to change implementations without touching business logic.
Constructor injection — the only form you need
There are three DI patterns: constructor, setter, and interface injection. Constructor injection is correct. The others are traps.
Constructor injection
1class SensorPipeline {
2 ISensor& sensor_;
3 IFilter& filter_;
4 IDisplay& display_;
5
6public:
7 SensorPipeline(ISensor& sensor, IFilter& filter, IDisplay& display)
8 : sensor_(sensor), filter_(filter), display_(display) {}
9
10 void tick() {
11 float raw = sensor_.read();
12 float filtered = filter_.process(raw);
13 display_.show(filtered);
14 }
15};
The object is valid from the first line after construction. There is no
“uninitialised state”. You can’t call tick() with a null sensor.
Setter injection — why to avoid it
1// Setter injection — object is broken until setters are called
2class SensorPipeline {
3 ISensor* sensor_ = nullptr;
4public:
5 void setSensor(ISensor* s) { sensor_ = s; }
6 void tick() {
7 sensor_->read(); // crashes if setSensor wasn't called
8 }
9};
The object has a temporal dependency on call order. The compiler can’t enforce it. You ship a null pointer dereference.
Use setter injection only when you genuinely need to swap a dependency at runtime — hot-reloading a renderer, for example. In embedded firmware and most business logic, you don’t.
The composition root
Where do you call new or create stack objects and wire them together?
In one place: the composition root — typically main() or the top-level
initialisation function.
1// main.cpp — the composition root
2int main() {
3 // Hardware layer
4 Stm32AdcReader adcReader(ADC1);
5 Bmp280Sensor bmpSensor(hi2c1);
6
7 // Business logic layer — no hardware types here
8 MovingAvgFilter filter(/*window=*/8);
9 TftDisplay display(hspi1);
10
11 SensorPipeline pipeline(bmpSensor, filter, display);
12
13 // Runtime loop
14 while (true) {
15 pipeline.tick();
16 HAL_Delay(100);
17 }
18}
main.cpp knows all the concrete types. Everything below it knows only interfaces.
This is dependency inversion applied at the architecture level: the arrows point
inward, toward abstractions, not outward toward hardware.
For a test build, main.cpp is replaced by a test fixture that injects mocks:
1TEST(SensorPipeline, FiltersAndDisplays) {
2 MockSensor sensor;
3 MockFilter filter;
4 MockDisplay display;
5
6 EXPECT_CALL(sensor, read()).WillRepeatedly(Return(42.0f));
7 EXPECT_CALL(filter, process(42.0f)).WillRepeatedly(Return(40.0f));
8 EXPECT_CALL(display, show(40.0f)).Times(1);
9
10 SensorPipeline pipeline(sensor, filter, display);
11 pipeline.tick();
12}
No hardware. No HAL. Runs on the CI server.
Lifetime management
Constructor injection passes references — the dependency must outlive the object. This is safe when both live on the stack in the same scope (which is almost always true in embedded code).
1// SAFE: both live in main(), which runs forever
2Bmp280Sensor sensor(hi2c1);
3SensorPipeline pipeline(sensor, filter, display);
When you need dynamic lifetime, use pointers — but make the null case explicit:
1class SensorPipeline {
2 ISensor* sensor_;
3public:
4 explicit SensorPipeline(ISensor* sensor)
5 : sensor_(sensor)
6 {
7 assert(sensor != nullptr); // fail loudly at construction, not at tick()
8 }
9};
Or use std::reference_wrapper<ISensor> to keep reference semantics while
allowing reassignment:
1std::reference_wrapper<ISensor> sensor_;
2// sensor_.get().read();
Service locator — the anti-pattern that looks like DI
The service locator pattern registers dependencies in a global registry and lets objects look them up:
1// Service locator
2class ServiceLocator {
3 static inline std::unordered_map<std::type_index, void*> services_;
4public:
5 template <typename T>
6 static void register_(T* service) {
7 services_[typeid(T)] = service;
8 }
9 template <typename T>
10 static T* get() {
11 return static_cast<T*>(services_[typeid(T)]);
12 }
13};
14
15class SensorPipeline {
16 void tick() {
17 auto* sensor = ServiceLocator::get<ISensor>(); // hidden dependency
18 sensor->read();
19 }
20};
This looks like DI because ISensor is an interface. It isn’t. The dependency
is hidden — you can’t see from the class signature that it needs a sensor.
You can’t verify at compile time that a sensor was registered. In a test, you
have to remember to register a mock before constructing SensorPipeline, and
unregister it after, and those operations share global state with every other
test.
Service locator is a global variable in a trenchcoat.
When to use it anyway: plugin systems where you genuinely can’t know all types at compile time. A game engine that dynamically loads renderer backends is a legitimate use case. Firmware on an STM32 is not.
Handling optional dependencies
Sometimes a dependency is optional — a logger that may or may not be wired in. Two options:
Null object pattern — provide a no-op implementation:
1struct ILogger {
2 virtual void log(std::string_view msg) = 0;
3};
4
5struct NullLogger : ILogger {
6 void log(std::string_view) override {} // does nothing
7};
8
9class SensorPipeline {
10 ILogger& logger_;
11public:
12 explicit SensorPipeline(ISensor& s, ILogger& log = NullLogger::instance())
13 : logger_(log) {}
14};
The caller omits the logger and gets silent operation. The code never checks for null — there is no null.
Default parameter to NullLogger::instance() requires a singleton — acceptable for a stateless no-op object.
Avoiding constructor parameter explosion
If a class takes 6 constructor parameters, something is wrong with the design — not with DI. The class has too many responsibilities. Split it.
When constructors legitimately grow (3-4 parameters is fine, 5+ is a smell), consider a parameter object:
1struct PipelineConfig {
2 ISensor& sensor;
3 IFilter& filter;
4 IDisplay& display;
5 ILogger& logger;
6};
7
8class SensorPipeline {
9public:
10 explicit SensorPipeline(PipelineConfig cfg) : cfg_(cfg) {}
11private:
12 PipelineConfig cfg_;
13};
This is not a service locator — PipelineConfig is a value type passed by the
composition root, not a global registry. The dependencies are still explicit
and visible at the call site.
The pattern in practice
Applied consistently, DI produces a codebase with a clear structure:
main.cpp (composition root)
│
├── constructs hardware adapters (Stm32AdcReader, TftDisplay, ...)
├── constructs business logic (SensorPipeline, AlarmManager, ...)
└── wires them together
│
▼
All other files: interfaces and implementations
No concrete types visible below the composition root
The composition root is the only file that knows about hardware. Swap the MCU, swap the hardware adapters, recompile. The pipeline logic doesn’t move.
Summary
- Constructor injection: give objects what they need at construction, no exceptions
- Composition root: one place wires everything — usually
main() - Service locator: a global variable with extra steps — avoid
- Null object: handle optional dependencies without null checks
- Parameter objects: group related dependencies to avoid long constructors
No framework needed. No macro magic. A handful of interfaces, a composition root, and a consistent habit of asking “where is this dependency coming from?” before writing a constructor.
What’s next
- Interface-based design — designing contracts that hold up under substitution
- SOLID in practice — DIP is the D in SOLID — see how it fits the full picture
- Layered firmware architecture — applying DI at the architecture level