Design Principles and Where to Find Them

In the past two years, I interviewed several candidates for embedded software roles. Regardless of the skill and experience level of the candidate, one question I asked was about Design Principles. Something like “Could you tell me about one Design Principle?”, very open, but the idea is to start talking about design principles, not checking the candidate knows a specific one in detail.

What struck me is that in two years, none of the candidates knew about Design Principles. Someone candidly and transparently answered that they don’t know; someone else started talking about design patterns; someone also glanced over the question. But not a single candidate could answer, not even those fresh from Software Engineering degrees.

A few days ago, in the usual LinkedIn stream, I noticed a job opening for a backend engineer role that explicitly mentioned “Design Principles”. So, I thought, it’s not something that exists just in my dreams; there are software industries where design principles are a thing.

I think design principles are important because they help the architect in designing the software, properly partitioning it, and making better choices. Usually these principles go under the SOLID acronym that stands for:

  • Single Responsibility
  • Open-Closed
  • Liskov Substitution
  • Interface Segregation
  • Dependency Inversion

Let’s have a closer look at them before trying to understand why they are not on the Joe Average Embedded Software Engineer radar.

Single Responsibility Principle

An old saying goes, “put all your eggs in one basket and then watch that basket”; well, that doesn’t work so well, at least not when we are talking about responsibility or purpose and software parts. Better: “do one and only one thing”. This allows for shorter and cleaner code, better testability and understandability.

This principle scales from functions to modules, and if you look at Unix command-line utilities to programs as well. When designing a part, ask yourself “what is the purpose of this part?” and if the answer contains “AND,” possibly you are violating this principle and your design may need a review.

One example that violates this principle is the “GetAndClear()” function that reads a value and then clears it. Possibly this value is produced by some other process, and clearing it allows the other process to produce another one, or something like that. If this is the case, this function is a poor and problematic substitute for a queue or some other synchronization mechanism. Coupling a non-destructive operation (read) with a destructive operation (clear) makes the software harder to refactor and to extend.

Open-Closed Principle

This principle sounds like a conundrum just because it is a contraption of the full title that reads like: “open for extension, but closed for modifications”.

Most components are meant to be reused, be they a module used more than once in your application, or a library reused by several applications. Regardless of the number of uses, the point is that every single use is likely to require a slightly different variant of your functionality.

Consider a CLI implementation for an embedded system. You want to be able to define your own commands and to define where the I/O happens. A naive implementation may require you to change the component to add a new command, or change from UART to a bus connection.

Following the Open-Closed Principle would force you to define interfaces (e.g., CliIO and CliCommand) and then let the CLI operate exclusively on those interfaces. The user will implement their commands and their I/O to complement the component. In this way, the CLI can be extended without the need of modification.

Liskov Substitution Principle

This is a quite famous principle known independently from the other principles of the SOLID acronym. It tells you that your system should be designed so that everywhere a type T is accepted, any type inheriting from T must be accepted as well.

Isn’t that always the case with inheritance? Well, not really.

Maybe your specific implementation or refinement doesn’t fit well with the ancestor interface. Typically you need either extra calls to let it perform, or you break the interface somehow. Breaking the interface is easier than it sounds when you include in the picture throwing unexpected exceptions. Another violation of LSP happens when the class you want to substitute requires constraints and invariants not enforced by the base class.

Consider an email address. It makes sense to deal with it as a specialization of a string. After all, we commonly say that email text is a special case of generic text. But if you consider this from Liskov’s point of view, you immediately realize it is wrong – the string interface offers so many ways to break the email format constraints. If a function expects a string, it is wrong to pass it an email address1.

Also consider the interface contract as the general interaction an object has with the outer environment – throwing exceptions, file/network access, peripheral interaction, logging, allocating dynamic memory, shared resources access…

Every difference counts. If an unexpected exception is thrown, the program might terminate. If a mutex is waited for, the generic code might timeout.

Interface Segregation Principle

This principle is a sort of specialization of the single responsibility principle, but the issue is looked at from the user perspective rather than from the provider. The principle states that the user must not be forced to depend on interfaces that are not needed to perform a required action.

Consider a UART driver; you want it equipped with a bunch of callbacks

  • received data callback
  • transmission buffer available callback

You may be tempted to define a single struct with function pointers for everything. That would be a violation of the Interface Segregation Principle! In fact, suppose you have a device that only receives messages over the UART; it never sends anything back to the MCU. The driver for such a device would have to use the UART driver and thus implement all the callbacks, including those for receiving data, even if they won’t be used. This is not good because we are coupling those two pieces of software beyond what is strictly needed. Should the UART receive interface change (e.g., by adding a receive timeout callback), the dependent service has to be updated accordingly, although it is not interested and will never receive from the device.

Dependency Inversion Principle

Dependency Inversion Principle mandates that high-level modules do not depend on low-level modules. Instead, they both have to depend on abstractions.

Traditionally, higher-level modules depended on lower-level ones. This creates a strong bond between the two levels. But how can you follow this principle if GNSS cannot depend on UART?

The trick is that both have to depend on an abstracted interface. This approach allows you to decouple the two levels, simplifying the update or retargeting of the levels. The loose coupling also helps with testing, where it makes it easier to mock up a well-defined and restricted interface rather than an entire component.

Why Aren’t Embedded Software Engineers SOLID?

That’s the question… why, in almost two years of interviews, nobody has been able to give a satisfying answer to the Design Principles question?

I suspect the reason lies in the misapplication of Not Applicable Here (NAH) principle. Consider the following list, replacing X with some specific industry (videogames, embedded system) and check whether it rings a bell

  • X needs assembly; you can’t use a high-level language
  • X needs C, you can’t use C++
  • X needs C++, you can’t use Java/Python/Whatever
  • X cannot afford OO design; imperative is the only way
  • In X, we can’t do unit testing
  • In X we can’t use a generic framework/engine; we need to do bare metal

NAH is often a legitimate objection – in the 80s, the constraints of videogame platforms and the limitation of tools didn’t allow for coding in anything other than assembly. Back then, the proposal of writing a game in, say, C had to be rejected with the NAH reason. And that rejection was a winning choice. This is a positive reinforcement that justifies aversion to change and adds up innovation risk and fatigue to learn new things.

So SOLID principles come from the high level/enterprise software development, where large code bases need rigor and discipline to be successfully tamed. They fall in the group of object orientation and software engineering, and they are feared to bring overly complex and inefficient code.

Embedded systems are getting larger and larger and increasingly need to reconcile real-time and functional-safety constraints with higher-level functionality like networking, GUIs, and user-friendly features. For sure, real-time and functional safety may prevent full OOP solutions from being applied. If your coding standards forbid the use of function pointers, most of the principles cannot be employed.

There are two counter-arguments:

  1. C++ popularized the concept of zero-cost abstraction, meaning that abstraction doesn’t necessarily incur a cost. Consider the principle, consider the code, and consider what can be done at least in line with the principle. Put from another point of view, don’t fight the principles only because you cannot fully apply them.
  2. Real-time and functional safety can usually be confined to delimited and small sections of the application. This gives the opportunity to lower the cost of the development of the code outside that boundary by employing higher level language or techniques.

This can be done as I demonstrated a long time ago and then several times:

  • developing Rayman GBC, using an OOP approach in Z80 assembly;
  • keeping the OOP approach with PIC18 and basic C;
  • implementing pathfinding and fog-of-war in Rogue Spear for GBA 16Mhz ARM7TDMI;
  • using C++14 on an embedded ARM Cortex-M4;
  • using Scala on embedded Linux.

So it is now time we, embedded software developers, embrace the SOLID design principles (and if you are going to apply for the company I work for, now you know how to impress me)

  1. Well, if the function expects a string as an input only, i.e., the function won’t modify the string, then it is fine to pass an email address. This is really a type-qualifier issue: const T& and T& are different types with different interfaces, related but distinct, and LSP has to be evaluated separately for each.
    Interestingly, Rust builds this difference into the type system. In Rust, shared references (&T) and mutable references (&mut T) are types with dissimilar capabilities, and the borrow checker enforces that you can’t treat one as freely substitutable for the other. ↩︎

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.