Why SOLID matters on a microcontroller
A 50k-line STM32 project is exactly where SOLID hurts most when ignored.
Untestable ISR handlers, 800-line main.cpp, recompiling the HAL to swap a sensor —
these are the symptoms of a codebase that grew without architectural discipline.
This article walks through each principle with concrete before/after C++ examples drawn from a sensor → filter → display pipeline on STM32. Every principle gets:
- a one-line definition
- a violation pulled from real firmware code
- a fix
- a callout on why it matters on a microcontroller specifically
S — Single responsibility principle
A class should have one reason to change.
Violation
1class SensorManager {
2public:
3 float readAdc(); // reads hardware register
4 float calibrate(float); // applies calibration curve
5 std::string toJson(); // formats output
6 void sendUart(); // writes to peripheral
7 void logError(); // writes to flash log
8};
Changing the UART baud rate forces you to touch the calibration logic — same file, different reasons. Impossible to unit-test calibration without initialising hardware.
Fix
Split responsibilities by actor:
1class AdcReader { public: float read(); };
2class SensorCalibrator { public: float calibrate(float raw); };
3class JsonSerializer { public: std::string serialize(float v); };
4class UartTransport { public: void send(std::string_view data); };
Each class fits on one screen, has one constructor argument group, and one test file.
Embedded callout: your ISR should only set a flag and return — all logic lives in
the task loop. An ISR that calls calibrate(), formats a string, and transmits via
UART is a single-responsibility violation with real-time consequences.
1// BAD: ISR doing too much
2void ADC_IRQHandler() {
3 float raw = ADC1->DR;
4 float cal = calibrate(raw); // floating point in ISR
5 sendUart(std::to_string(cal)); // blocking I/O
6}
7
8// GOOD: flag only, logic in task
9volatile bool adcReady = false;
10volatile uint16_t adcRaw = 0;
11
12void ADC_IRQHandler() {
13 adcRaw = ADC1->DR;
14 adcReady = true;
15}
16
17// main loop or RTOS task:
18if (adcReady) {
19 adcReady = false;
20 float v = calibrator.calibrate(adcRaw);
21 transport.send(serializer.serialize(v));
22}
O — Open / closed principle
Software entities should be open for extension, closed for modification.
Violation
1float readSensor(SensorType t) {
2 switch (t) {
3 case SensorType::BMP280: return bmp280_read();
4 case SensorType::NTC10K: return ntc10k_read();
5 // adding sensor = editing this tested function
6 }
7}
Every new sensor type requires editing — and retesting — already-validated code.
Fix
Define an abstraction, extend by adding new types:
1struct ISensor {
2 virtual float read() = 0;
3 virtual ~ISensor() = default;
4};
5
6class Bmp280Sensor : public ISensor {
7public:
8 float read() override { return bmp280_read_temperature(); }
9};
10
11class Ntc10kSensor : public ISensor {
12public:
13 explicit Ntc10kSensor(uint8_t channel) : ch_(channel) {}
14 float read() override { return ntc_read(ch_); }
15private:
16 uint8_t ch_;
17};
18
19// Adding a new sensor = new file, zero changes to existing code
20class Sht31Sensor : public ISensor {
21public:
22 float read() override { return sht31_read_humidity(); }
23};
Embedded callout: on resource-constrained targets, virtual dispatch has a cost — one pointer indirection and a vtable entry per object. For hot paths on a Cortex-M0, consider CRTP (Curiously Recurring Template Pattern) for zero-overhead static polymorphism. That’s a separate article; for most firmware the vtable cost is negligible.
L — Liskov substitution principle
Subtypes must be substitutable for their base types without breaking the program.
This one is subtler than the others. You can violate it without any compilation error.
Violation — throwing from a required method
1struct IDisplay {
2 virtual void setBrightness(uint8_t level) = 0;
3 virtual void draw(const Frame&) = 0;
4};
5
6class UartDebugDisplay : public IDisplay {
7public:
8 void setBrightness(uint8_t) override {
9 throw std::runtime_error("not supported"); // LSP violation
10 }
11 void draw(const Frame& f) override { uart_print(f.to_string()); }
12};
Any code that calls setBrightness on an IDisplay* breaks at runtime when handed
a UartDebugDisplay. The subtype is not substitutable.
Violation — strengthening preconditions
1struct IStorage {
2 virtual void write(const uint8_t* data, size_t len) = 0;
3 virtual void read(uint8_t* buf, size_t len) = 0;
4};
5
6class ReadOnlyFlash : public IStorage {
7public:
8 void write(const uint8_t*, size_t) override {
9 HAL_assert(false); // strengthened precondition: never call this
10 }
11 void read(uint8_t* buf, size_t len) override { /* ... */ }
12};
The base type promises write() works. The subtype silently breaks that contract.
Fix
Split the interface to match actual capabilities:
1struct IReadable { virtual void read(uint8_t* buf, size_t len) = 0; };
2struct IWritable { virtual void write(const uint8_t* data, size_t len) = 0; };
3struct IStorage : IReadable, IWritable {}; // full read-write
4
5class ReadOnlyFlash : public IReadable {
6public:
7 void read(uint8_t* buf, size_t len) override { /* ... */ }
8 // write() doesn't exist — no false promise
9};
For the display case, split the interface so dimmable is opt-in:
1struct IDisplay { virtual void draw(const Frame&) = 0; };
2struct IDimmable { virtual void setBrightness(uint8_t) = 0; };
3struct IFullDisplay : IDisplay, IDimmable {};
4
5class UartDebugDisplay : public IDisplay { /* only draw() */ };
6class TftDisplay : public IFullDisplay { /* both */ };
Embedded callout: LSP violations are especially painful on firmware because you
often can’t catch exceptions at all (-fno-exceptions is common). A violated contract
becomes a silent HAL_assert that fires at 3 AM during a production run.
I — Interface segregation principle
Clients should not be forced to depend on interfaces they do not use.
Violation
1struct IDevice {
2 virtual void read() = 0;
3 virtual void write() = 0;
4 virtual void configure() = 0;
5 virtual void reset() = 0;
6 virtual void getDiagnostics() = 0;
7};
A sensor driver that only reads is forced to stub out write(), configure(),
reset(), and getDiagnostics() with empty bodies or assert(false). Now your
mock for unit tests carries five methods when you only care about one.
Fix
Compose small, focused interfaces:
1struct IReadable { virtual float read() = 0; };
2struct IWritable { virtual void write(float v) = 0; };
3struct IConfigurable { virtual void configure(Config) = 0; };
4struct IDiagnosable { virtual Diag getDiagnostics() = 0; };
5
6// Drivers implement only what they provide:
7class Bmp280 : public IReadable, public IConfigurable { /* ... */ };
8class Eeprom : public IReadable, public IWritable { /* ... */ };
9
10// Consumers depend only on what they use:
11class DataPipeline {
12 IReadable& sensor_;
13public:
14 explicit DataPipeline(IReadable& s) : sensor_(s) {}
15};
Embedded callout: small interfaces mean small mocks. When your mock only needs one method, it’s three lines of code and impossible to get wrong. This is the principle that makes hardware-in-the-loop testing tractable — your test doubles don’t have to pretend to be full devices.
D — Dependency inversion principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Violation
1class DataLogger {
2 Stm32SpiFlash flash; // concrete hardware, constructed here
3public:
4 DataLogger() : flash(SPI1, GPIOA, GPIO_PIN_4) {}
5 void log(float v) { flash.write(v); }
6};
The dependency arrow points from business logic into hardware — backwards. Impossible to run on a PC, impossible to inject a mock.
DataLogger ──depends on──▶ Stm32SpiFlash
Fix
Invert the dependency through an abstraction:
1struct IStorage {
2 virtual void write(float v) = 0;
3 virtual ~IStorage() = default;
4};
5
6class DataLogger {
7 IStorage& storage_;
8public:
9 explicit DataLogger(IStorage& s) : storage_(s) {}
10 void log(float v) { storage_.write(v); }
11};
DataLogger ──depends on──▶ IStorage ◀── Stm32SpiFlash
◀── MockStorage
◀── RamBuffer
Now the arrow from DataLogger points up to the abstraction, not down to
hardware. You wire the concrete type at the composition root — main.cpp on target,
the test fixture in CI.
1// On target:
2Stm32SpiFlash flash(SPI1, GPIOA, GPIO_PIN_4);
3DataLogger logger(flash);
4
5// In tests — no hardware needed:
6struct MockStorage : IStorage {
7 std::vector<float> log;
8 void write(float v) override { log.push_back(v); }
9};
10
11TEST(DataLogger, WritesValue) {
12 MockStorage mock;
13 DataLogger logger(mock);
14 logger.log(3.14f);
15 EXPECT_EQ(mock.log.back(), 3.14f);
16}
Embedded callout: DIP is the one principle that unlocks everything else — testability, portability, the ability to swap the entire HAL layer for a Linux simulation build with a recompile. If you only apply one principle from this list, apply this one.
The compound effect
Applied together, these five principles transform a monolithic firmware into a set of modules with clean boundaries:
| Principle | One-line definition | Common smell | Fix in 10 words |
|---|---|---|---|
| S | One reason to change | God class, ISR doing I/O | Split by actor, flag-only ISRs |
| O | Extend without modifying | switch (sensorType) |
Abstract base + subclass per variant |
| L | Subtypes are substitutable | throw in override, silent assert |
Split interface to match capability |
| I | No forced dependencies | 5-method interface, 4 stubbed | Compose small focused interfaces |
| D | Depend on abstractions | new ConcreteHardware inside class |
Inject via constructor |
The real payoff comes when you need to port to a new MCU. If you’ve applied DIP throughout, you swap the HAL implementations and recompile — the business logic doesn’t move. That’s the compound effect of architectural discipline.
What NOT to do
Over-engineering is the mirror failure. A 50-line driver for a single sensor with five interfaces, three abstract base classes, and a factory doesn’t need SOLID — it needs a single concrete class and a delete key.
Apply these principles where the code has multiple reasons to change, where tests are hard to write, or where hardware swappability matters. Small, stable, one-off code can stay simple.
What’s next
- Dependency injection without a framework — wiring the composition root cleanly
- Interface-based design — designing contracts that don’t lie
- Finite state machines — three approaches — applying SRP and OCP to firmware control flow