Design principles
Before proceeding further, we should discuss how to structure an application properly. This requires a broader discussion of software architecture, and software qualities.
What is architecture?
Expert developers working on a project have a shared understanding of the system design. This shared understanding is called ‘architecture’ and includes how the system is divided into components and how the components interact through interfaces.
— Martin Fowler, Who Needs an Architect? (2003).
Architecture is the holistic undertanding of how your software is structured, and the effect that structure has on it’s characteristics and qualities. Architecture suggests that structuring software should be a deliberate action.
Why is architecture important?
Decisions like “how to divide a system into components” have a huge impact on the characteristics of the software that you produce.
- Some architectural decisions are necessary for your software to work properly.
- The structure of your software will determine how well it runs, how quickly it performs essential operations, how well it handles errors.
- Well-designed software can be a joy to work with; poorly-designed software is frustrating to work with, difficult to evolve and change.
I like this quote by Bob Martin:
It doesn’t take a huge amount of knowledge and skill to get a program working. Kids in high school do it all the time… The code they produce may not be pretty; but it works. It works because getting something to work once just isn’t that hard.
Getting software right is hard. When software is done right, it requires a fraction of the human resources to create and maintain. Changes are simple and rapid. Defects are few and far between. Effort is minimized, and functionality and flexibility are maximized.
— Robert C. Martin, Clean Architecture (2016).
In Martin’s view, software should be enduring. Software that you produce should be reliable, and continue to function for a long period of time. You should expect to make adjustments over time as defects are found and fixed, and new features are introduced, but these changes should be relatively easy to make.
Building software to this level of quality does not happen accidentally, but requires deliberate action on our part.
https://www.atr.org/40-years-of-failure-irs-unable-to-fix-computer-system/
Software qualities
Let’s think about the qualities that we want in the software that we build. What would we expect from any piece of software that we produce?
Usability
A system must be usable for its intended purpose, and meet its design objectives. This includes both functional and non-functional requirements.
- Functional: features that are required to address the problem that the software is intended to solve; desirable features for our users.
- Non-Functional: qualities or characteristics of software that emerge from its structure. e.g., performance, reliability and other quality metrics.
Usability requires us to carefully ensure that we are meeting all reasonable requirements up-front, typically by collaborating with our users to define problems and solutions. See Process Models.
Extensibility
You should expect to adapt and modify your software over a lifetime of use:
- You will find errors in your code (aka bugs) that will need to be addressed.
- You might find that requirements were incomplete, so existing features may need to be modified.
- You might uncover new features that need to be implemented.
- Your operating environment might change (e.g. OS update) which necessitates updates.
We need to design systems such that you can respond and adapt your software to these changes, effectively, and with the least amount of work.
Your design goal is not to deliver software once, it’s to design in a way that supports delivering frequently and reliably over the life of your solution.
Scalability
Extensibility refers to handling new or changing requirements. Scalability refers to the ability to handle increased data, number of users, or features. e.g., an online system might initially need to handle hundreds of concurrent users, but could be expected to scale to tends of thousands over time. Scalability is challenging because you don’t want to incur the deployment costs up-front, but instead you want to design in a way that lets you expand your system over time. This can include replacing modules with more capable ones at a later time, e.g., swapping out a low-performance database for something more performant.
Robustness
The system should be able to handle error conditions gracefully, without compromising data. This includes user input errors, errors processing data from external sources, and recovering from error conditions, e.g., power outages.
Reusability
Software is expensive and time-consuming to produce, so anything that reduces the required cost or time is welcome. Reusability or code reuse is often positioned as the easiest way to accomplish this. Reusing code also reduces defects, since you’re presumably reusing code that is tested and known-good.
I see three levels of reuse. At the lowest level, you reuse classes: class libraries, containers, maybe some class “teams” like container/iterator.
Frameworks are at the highest level. They really try to distill your design decisions. They identify the key abstractions for solving a problem, represent them by classes and define relationships between them. JUnit is a small framework, for example. It is the “Hello, World” of frameworks. It has Test, TestCase, TestSuite and relationships defined.
A framework is typically larger-grained than just a single class. Also, you hook into frameworks by subclassing somewhere. They use the so-called Hollywood principle of “don’t call us, we’ll call you.” The framework lets you define your custom behaviour, and it will call you when it’s your turn to do something. Same with JUnit, right? It calls you when it wants to execute a test for you, but the rest happens in the framework.
There also is a middle level. This is where I see patterns. Design patterns are both smaller and more abstract than frameworks. They’re really a description about how a couple of classes can relate to and interact with.
– Shvets (citing Erich Gamma), Dive Into Design Patterns (2019).
SOLID principles
SOLID was introduced by Robert (“Uncle Bob”) Martin around 2002. Some ideas were inspired by other practitioners, but he was the first to codify them.
The SOLID Principles tell us how to arrange our functions and data structures into classes, and how those classes should be interconnected. The goal of the principles is the creation of mid-level software structures that:
- Tolerate change (extensibility),
- Are easy to understand (readability), and
- Are the basis of components that can be used in many software systems (reusability).
If there is one principle that Martin emphasizes, it’s the notion that software is ever-changing. There are always bugs to fix, features to add. In his approach, a well-architected system has features that facilitate rapid but reliable change.
The SOLID principles are as follows. The diagrams and examples are from Ugonna Thelma’s Medium post “The S.O.L.I.D. Principles in Pictures.”
1. Single Responsibility
“A module should be responsible to one, and only one, user or stakeholder.”
– Robert C. Martin (2002)
The Single Responsibility Principle (SRP) claims to result in code that is easier to maintain. The principle states that we want classes to do a single thing. This is meant to ensure that are classes are focused, but also to reduce pressure to expand or change that class. In other words:
- A class has responsibility over a single functionality.
- There is only one single reason for a class to change.
- There should only be one “driver” of change for a module.
2. Open-Closed Principle
“A software artifact should be open for extension but closed for modification. In other words, the behaviour of a software artifact ought to be extensible, without having to modify that artifact.” – Bertrand Meyers (1988)
This principle champions subclassing as the primary form of code reuse.
- A particular module (or class) should be reusable without needing to change its implementation.
- Often used to justify class hierarchies (i.e. derive to specialize).
This principle is also warning against changing classes in a hierarchy, since any behaviour changes will also be inherited by that class’s children! In other words, if you need different behaviour, create a new subclass and leave the existing classes alone.
The Design Pattern principle of Composition over inheritance runs counter to this, suggesting instead that code reuse through composition is often more suitable.
3. Liskov-Substitution Principle
“If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behaviour of P is unchanged when o1 is substituted for o2, then S is a subtype of T.” – Barbara Liskov (1988)
It should be possible to substitute a derived class for a base class, since the derived class should still be capable of all the base class functionality. In other words, a child should always be able to substitute for its parent.
In the example below, if Sam can make coffee but Eden cannot, then you’ve modelled the wrong relationship between them.
4. Interface Substitution
It should be possible to change classes independently of the classes on which they depend.
Also described as “program to an interface, not an implementation.” This means focusing your design on what the code is doing, not how it does it. Never make assumptions about what the underlying code is doing—if you code to the interface, it allows flexibility, and the ability to substitute other valid implementations that meet the functional needs.
5. Dependency Inversion Principle
The most flexible systems are those in which source code dependencies refer to abstractions (interfaces) rather than concretions (implementations). This reduces the dependency between these two classes.
- High-level modules should not import from low-level modules. Both should depend on abstractions (e.g., interfaces).
- Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
This is valuable because it reduces coupling between two classes, and allows for more effective reuse.
In the example below, the PizzaCutterBot
should be able to cut with any tool (class) provided to it that meets the requirements of able-to-cut-pizza. This might include a pizza cutter, knife or any other sharp implement. PizzaCutterBot
should be designed to work with the able-to-cut interface, not a specific tool.
Generalized principles
Can we generalize from these? What if we’re not building a pure OO system; are they still applicable?
Enforce separation of concerns
“A module should be responsible to one, and only one, user or stakeholder.“ - Single Responsibility Principle
It follows from the SOLID principles that our software should be written as a set of components, where each one has specific responsibilities. By component
, we can mean a single class, or a set of classes that work closely together, or even a function that delivers functionality.
Modularity refers to the logical grouping of source code into these areas of responsibility. Modularity can be implemented through namespaces (C++), packages (Java or Kotlin). When discussing modularity, we often use two related and probably familiar concepts: cohesion, and coupling.
Cohesion is a measure of how related the parts of a module are to one another. A good indication that the classes or other components belong in the same module is that there are few, if any, calls to source code outside the module (and removing anything from the module would necessitate calls to it outside the module).
Coupling refers to the calls that are made between components; they are said to be tightly coupled based on their dependency on one another for their functionality. Loosely coupled means that there is little coupling, or it could be avoided in practice; tight coupling suggests that the components probably belong in the same module, or perhaps even as part of a larger component.
When designing modules, we want high cohesion (where components in a module belong together) and low coupling between modules (meaning fewer dependencies). This increases the flexibility of our software, and makes it easier to achieve desirable characteristics, e.g. scalability.
Diagrams courtesy of Buketa & Balint, [Jetpack Compose by Tutorials](https://www.kodeco.com/books/ jetpack-compose-by-tutorials/v1.0#) (2021).
Interface vs. implementation
“Program to an interface, not an implementation. Depend on abstractions, not on concretions.” - Interface Substitution
When classes rely on one another, you want to minimize the dependency—we say that you want loose coupling between the classes. This allows for maximum flexibility. To accomplish this, you extract an abstract interface, and use that interface to describe the desired behaviour between the classes.
For example, in the diagram below, our cat on the left can eat sausage, but only sausage. The cat on the right can eat anything that provides nutrition, including sausage. The introduction of the food interface complicates the model, but provides much more flexibility to our classes.
Avoid unnecessary inheritance
Multiple inheritence is terrrible. Who thought of this? - Prof. Avery
Inheritance is a useful tool for reusing code. In principle, it sounds great: derive from a base class, and you get all of its behaviour for free! Unfortunately, it’s rarely that simple. There are sometimes negative side effects of inheritance.
- A subclass cannot reduce the interface of the base class. You have to implement all abstract methods, even if you don’t need them.
- When overriding methods, you need to make sure that your new behaviour is compatible with the old behaviour. In other words, the derived class needs to act like the base class.
- Inheritance breaks encapsulation because the details of the parent class are potentially exposed to the derived class.
- Subclasses are tightly coupled to superclasses. A change in the superclass can break subclasses.
- Reusing code through inheritance can lead to parallel inheritance hierarchies, and an explosion of classes. See below for an example.
A useful alternative to inheritance is composition.
Where inheritance represents an is-a relationship (a car is a vehicle), composition represents a has-a relationship (a car has an engine).
Imagine a catalog application for cars and trucks:
Here’s the same application using composition. We can create unique classes representing each vehicle which just implement the appropriate interfaces, without the intervening abstract classes.
Avoid side effects
At a high-level, functional programming emphasizes a number of principles:
- Functions as first-class citizens
- Pure functions, with no side effects
- Favouring immutable data
Although Kotlin is not a “pure functional language” (however, you care to define that), we can certainly adopt some of these principles.
The easiest, and perhaps the most important to program stability is the idea of avoiding side effects in your code. What is a side effect? It is an unintended change to your program’s state that is not expected when you call a function. In other words, it’s the characteristic of a function that does more than it should.
An obvious example of this is the use of global variables, when are then mutated in a function. As your program grows, you may find more and more functions that update these variables, to the point where it is often challenging to determine what caused them to change! Side effects make your code brittle, more difficult to debug and much more difficult to scale.
The opposite are functions that do just what they are designed to do. A function should:
- Use parameters as its only inputs (i.e. don’t look to any external state when determining a function’s behaviour)
- Mutate that data and return consistent results based solely on the input.
We’ll discuss functional approaches in Kotlin a little later in these notes.
Find the “right” abstractions
Part of the value of an object-oriented approach is that you can model “real-world objects”. We often decompose our context and problem space to identify entities that we wish to model in our software, and build classes reflecting those objects. Then, we can add logical behaviours to our objects to reflect how these objects would react in the real-world.
It’s important to keep in mind a few things when taking this approach:
- Not all software objects need to have corresponding real-world objects (e.g., a software doesn’t need to operate under the same rules as a real factory).
- Conversely, you don’t need to convert every real-world object into a software entity. There is nothing wrong with your models differing from the real-world.
What’s important when modeling behaviours in software is that you get the correct behaviours being captured and reflected in your system. (More on this soon).