Software Architecture with C++

By Adrian Ostrowski , Piotr Gaczkowski
    Advance your knowledge in tech with a Packt subscription

  • Instant online access to over 7,500+ books and videos
  • Constantly updated with 100+ new titles each month
  • Breadth and depth in over 1,000+ technologies
  1. Importance of Software Architecture and Principles of Great Design

About this book

Software architecture refers to the high-level design of complex applications. It is evolving just like the languages we use. Modern C++ allows developers to write high-performance apps in a high-level language without sacrificing readability and maintainability. If you're working with modern C++, this practical guide will help you put your knowledge to work and design distributed, large-scale apps. You'll start by getting up to speed with architectural concepts, including established patterns and rising trends. The book will then explain what software architecture is and help you explore its components. Next, you'll discover the design concepts involved in application architecture and the patterns in software development, before going on to learn how to build, package, integrate, and deploy your components. In the concluding chapters, you'll explore different architectural qualities, such as maintainability, reusability, testability, performance, scalability, and security. Finally, you will get an overview of distributed systems, such as service-oriented architecture, microservices, and cloud-native, and understand how to apply them in application development.

By the end of this book, you'll be able to build distributed services using modern C++ and associated tools to deliver solutions as per your clients' requirements.

Publication date:
April 2021
Publisher
Packt
Pages
540
ISBN
9781838554590

 
Importance of Software Architecture and Principles of Great Design

The purpose of this introductory chapter is to show what role software architecture plays in software development. It will focus on the key aspects to keep in mind when designing the architecture of a C++ solution. We'll discuss how to design efficient code with convenient and functional interfaces. We'll also introduce a domain-driven approach for both code and architecture.

In this chapter, we'll cover the following topics:

  • Understanding software architecture
  • Learning the importance of proper architecture
  • Exploring the fundamentals of good architecture
  • Developing architecture using Agile principles
  • The philosophy of C++
  • Following the SOLID and DRY principles
  • Domain-driven design
  • Coupling and cohesion
 

Technical requirements

 

Understanding software architecture

Let's begin by defining what software architecture actually is. When you create an application, library, or any software component, you need to think about how the elements you write will look and how they will interact with each other. In other words, you're designing them and their relations with their surroundings. Just like with urban architecture, it's important to think about the bigger picture to not end up in a haphazard state. On a small scale, every single building looks okay, but they don't combine into a sensible bigger picture – they just don't fit together well. This is what's called accidental architecture and it is one of the outcomes you want to avoid. However, keep in mind that whether you're putting your thoughts into it or not, when writing software you are creating an architecture.

So, what exactly should you be creating if you want to mindfully define the architecture of your solution? The Software Engineering Institute has this to say:

The software architecture of a system is the set of structures needed to reason about the system, which comprise software elements, relations among them, and properties of both.

This means that in order to define an architecture thoroughly, we should think about it from a few perspectives instead of just hopping into writing code.

 

Different ways to look at architecture

There are several scopes that can be used to look at architecture:

  • Enterprise architecture deals with the whole company or even a group of companies. It takes a holistic approach and is concerned about the strategy of whole enterprises. When thinking about enterprise architecture, you should be looking at how all the systems in a company behave and cooperate with each other. It's concerned about the alignment between business and IT.
  • Solution architecture is less abstract than its enterprise counterpart. It stands somewhere in the middle between enterprise and software architecture. Usually, solution architecture is concerned with one specific system and the way it interacts with its surroundings. A solution architect needs to come up with a way to fulfill a specific business need, usually by designing a whole software system or modifying existing ones.
  • Software architecture is even more concrete than solution architecture. It concentrates on a specific project, the technologies it uses, and how it interacts with other projects. A software architect is interested in the internals of the project's components.
  • Infrastructure architecture is, as the name suggests, concerned about the infrastructure that the software will use. It defines the deployment environment and strategy, how the application will scale, failover handling, site reliability, and other infrastructure-oriented aspects.

Solution architecture is based on both software and infrastructure architectures to satisfy the business requirements. In the following sections, we will talk about both those aspects to prepare you for both small- and large-scale architecture design. Before we jump into that, let's also answer one fundamental question: why is architecture important?

 

Learning the importance of proper architecture

Actually, a better question would be: why is caring about your architecture important? As we mentioned earlier, regardless of whether you put conscious effort into building it or not, you will end up with some kind of architecture. If after several months or even years of development you still want your software to retain its qualities, you need to take some steps earlier in the process. If you won't think about your architecture, chances are it won't ever present the required qualities.

So, in order for your product to meet the business requirements and attributes such as performance, maintainability, scalability, or others, you need to take care of its architecture, and it is best if you do it as early as you can in the process. Let's now discuss two things that each good architect wants to protect their projects from.

 

Software decay

Even after you did the initial work and had a specific architecture in mind, you need to continuously monitor how the system evolves and whether it still aligns with its users' needs, as those may also change during the development and lifetime of your software. Software decay, sometimes also called erosion, occurs when the implementation decisions don't correspond to the planned architecture. All such differences should be treated as technical debt.

 

Accidental architecture

Failing to track if the development adheres to the chosen architecture or failing to intentionally plan how the architecture should look will often result in a so-called accidental architecture, and it can happen regardless of applying best practices in other areas, such as testing or having any specific development culture.

There are several anti-patterns that suggest your architecture is accidental. Code resembling a big ball of mud is the most obvious one. Having god objects is another important sign of this. Generally speaking, if your software is getting tightly coupled, perhaps with circular dependencies, but wasn't like that in the first place, it's an important signal to put more conscious effort into how the architecture looks.

Let's now describe what an architect must understand to deliver a viable solution.

 

Exploring the fundamentals of good architecture

It's important to know how to recognize a good architecture from a bad one, but it's not an easy task. Recognizing anti-patterns is an important aspect of it, but for an architecture to be good, primarily it has to support delivering what's expected from the software, whether it's about functional requirements, attributes of the solution, or dealing with the constraints coming from various places. Many of those can be easily derived from the architecture context.

 

Architecture context

The context is what an architect takes into account when designing a solid solution. It comprises requirements, assumptions, and constraints, which can come from the stakeholders, as well as the business and technical environments. It also influences the stakeholders and the environments, for example, by allowing the company to enter a new market segment.

 

Stakeholders

Stakeholders are all the people that are somehow involved with the product. Those can be your customers, the users of your system, or the management. Communication is a key skill for every architect and properly managing your stakeholder's needs is key to delivering what they expected and in a way they wanted.

Different things are important to different groups of stakeholders, so try to gather input from all those groups.

Your customers will probably care about the cost of writing and running the software, the functionality it delivers, its lifetime, time to market, and the quality of your solution.

The users of your system can be divided into two groups: end users and administrators. The first ones usually care about things such as the usability, user experience, and performance of the software. For the latter, more important aspects are user management, system configuration, security, backups, and recovery.

Finally, things that could matter for stakeholders working in management are keeping the development costs low, achieving business goals, being on track with the development schedule, and maintaining product quality.

 

Business and technical environments

Architecture can be influenced by the business side of the company. Important related aspects are the time to market, the rollout schedule, the organizational structure, utilization of the workforce, and investment in existing assets.

By technical environment, we mean the technologies already used in a company or those that are for any reason required to be part of the solution. Other systems that we need to integrate with are also a vital part of the technical environment. The technical expertise of the available software engineers is of importance here, too: the technological decisions an architect makes can impact staffing the project, and the ratio of junior to senior developers can influence how a project should be governed. Good architecture should take all of that into account.

Equipped with all this knowledge, let's now discuss a somewhat controversial topic that you'll most probably encounter as an architect in your daily work.

 

Developing architecture using Agile principles

Seemingly, architecture and Agile development methodologies are in an adversarial relationship, and there are many myths around this topic. There are a few simple principles that you should follow in order to develop your product in an Agile way while still caring about its architecture.

Agile, by nature, is iterative and incremental. This means preparing a big, upfront design is not an option in an Agile approach to architecture. Instead, a small, but still reasonable upfront design should be proposed. It's best if it comes with a log of decisions with the rationale for each of them. This way, if the product vision changes, the architecture can evolve with it. To support frequent release delivery, the upfront design should then be updated incrementally. Architecture developed this way is called evolutionary architecture.

Managing architecture doesn't need to mean keeping massive documentation. In fact, documentation should cover only what's essential as this way it's easier to keep it up to date. It should be simple and cover only the relevant views of the system.

There's also the myth of the architect as the single source of truth and the ultimate decision-maker. In Agile environments, it's the teams who are making decisions. Having said that, it's crucial that the stakeholders are contributing to the decision-making process – after all, their points of view shape how the solution should look.

An architect should remain part of the development team as often they're bringing strong technical expertise and years of experience to the table. They should also take part in making estimations and plan the architecture changes needed before each iteration.

In order for your team to remain Agile, you should think of ways to work efficiently and only on what's important. A good idea to embrace to achieve those goals is domain-driven design.

 

Domain-driven design

Domain-driven design, or DDD for short, is a term introduced by Eric Evans in his book of the same title. In essence, it's about improving communication between business and engineering and bringing the developers' attention to the domain model. Basing the implementation of this model often leads to designs that are easier to understand and evolve together with the model changes.

What has DDD got to do with Agile? Let's recall a part of the Agile Manifesto:

Individuals and interactions over processes and tools
Working software over comprehensive documentation
Customer collaboration over contract negotiation
Responding to change over following a plan

— The Agile Manifesto

In order to make the proper design decisions, you must understand the domain first. To do so, you'll need to talk to people a lot and encourage your developer teams to narrow the gap between them and business people. The concepts in the code should be named after entities that are part of ubiquitous language. It's basically the common part of business experts' jargon and technical experts' jargon. Countless misunderstandings can be caused by each of these groups using terms that the other understands differently, leading to flaws in business logic implementations and often subtle bugs. Naming things with care and using terms agreed by both groups can mean bliss for the project. Having a business analyst or other business domain experts as part of the team can help a lot here.

If you're modeling a bigger system, it might be hard to make all the terms mean the same to different teams. This is because each of those teams really operates in a different context. DDD proposes the use of bounded contexts to deal with this. If you're modeling, say, an e-commerce system, you might want to think of the terms just in terms of a shopping context, but upon a closer look, you may discover that the inventory, delivery, and accounting teams actually all have their own models and terms.

Each of those is a different subdomain of your e-commerce domain. Ideally, each can be mapped to its own bounded context – a part of your system with its own vocabulary. It's important to set clear boundaries of such contexts when splitting your solution into smaller modules. Just like its context, each module has clear responsibilities, its own database schema, and its own code base. To help communicate between the teams in larger systems, you might want to introduce a context map, which will show how the terms from different contexts relate to each other:

Figure 1.1 – Two bounding contexts with the matching terms mapped between them (image from one of Martin Fowler's articles on DDD: https://martinfowler.com/bliki/BoundedContext.html)

As you now understand some of the important project-management topics, we can switch to a few more technical ones.

 

The philosophy of C++

Let's now move closer to the programming language we'll be using the most throughout this book. C++ is a multi-paradigm language that has been around for a few decades now. During the years since its inception, it has changed a lot. When C++11 came out, Bjarne Stroustrup, the creator of the language, said that it felt like a completely new language. The release of C++20 marks another milestone in the evolution of this beast, bringing a similar revolution to how we write code. One thing, however, stayed the same during all those years: the language's philosophy.

In short, it can be summarized by three rules:

  • There should be no language beneath C++ (except assembly).
  • You only pay for what you use.
  • Offer high-level abstractions at low cost (there's a strong aim for zero-cost).

Not paying for what you don't use means that, for example, if you want to have your data member created on the stack, you can. Many languages allocate their objects on the heap, but it's not necessary for C++. Allocating on the heap has some cost to it – probably your allocator will have to lock a mutex for this, which can be a big burden in some types of applications. The good part is you can easily allocate variables without dynamically allocating memory each time pretty easily.

High-level abstractions are what differentiate C++ from lower-level languages such as C or assembly. They allow for expressing ideas and intent directly in the source code, which plays great with the language's type safety. Consider the following code snippet:

struct Duration {
int millis_;
};

void example() {
auto d = Duration{};
d.millis_ = 100;

auto timeout = 1; // second
d.millis_ = timeout; // ouch, we meant 1000 millis but assigned just 1
}

A much better idea would be to leverage the type-safety features offered by the language:

#include <chrono>

using namespace std::literals::chrono_literals;

struct Duration {
std::chrono::milliseconds millis_;
};

void example() {
auto d = Duration{};
// d.millis_ = 100; // compilation error, as 100 could mean anything
d.millis_ = 100ms; // okay

auto timeout = 1s; // or std::chrono::seconds(1);
d.millis_ =
timeout; // okay, converted automatically to milliseconds
}

The preceding abstraction can save us from mistakes and doesn't cost us anything while doing so; the assembly generated would be the same as for the first example. That's why it's called a zero-cost abstraction. Sometimes C++ allows us to use abstractions that actually result in better code than if they were not used. One example of a language feature that, when used, could often result in such benefit is coroutines from C++20.

Another great set of abstractions, offered by the standard library, are algorithms. Which of the following code snippets do you think is easier to read and easier to prove bug-free? Which expresses the intent better?

// Approach #1
int count_dots(const char *str, std::size_t len) {
int count = 0;
for (std::size_t i = 0; i < len; ++i) {
if (str[i] == '.') count++;
}
return count;
}

// Approach #2
int count_dots(std::string_view str) {
return std::count(std::begin(str), std::end(str), '.');
}

Okay, the second function has a different interface, but even it if was to stay the same, we could just create std::string_view from the pointer and the length. Since it's such a lightweight type, it should be optimized away by your compiler.

Using higher-level abstractions leads to simpler, more maintainable code. The C++ language has strived to provide zero-cost abstractions since its inception, so build upon that instead of redesigning the wheel using lower levels of abstraction.

Speaking of simple and maintainable code, the next section introduces some rules and heuristics that are invaluable on the path to writing such code.

 

Following the SOLID and DRY principles

There are many principles to keep in mind when writing code. When writing object-oriented code, you should be familiar with the quartet of abstraction, encapsulation, inheritance, and polymorphism. Regardless of whether your writing C++ in a mostly object-oriented programming manner or not, you should keep in mind the principles behind the two acronyms: SOLID and DRY.

SOLID is a set of practices that can help you write cleaner and less bug-prone software. It's an acronym made from the first letters of the five concepts behind it:

  • Single responsibility principle
  • Open-closed principle
  • Liskov substitution principle
  • Interface segregation
  • Dependency Inversion

We assume you already have the idea of how those principles relate to object-oriented programming, but since C++ is not always object-oriented, let's look at how they apply to different areas.

Some of the examples use dynamic polymorphism, but the same would apply to static polymorphism. If you're writing performance-oriented code (and you probably are if you chose C++), you should know that using dynamic polymorphism can be a bad idea in terms of performance, especially on the hot path. Further on in the book, you'll learn how to write statically polymorphic classes using the Curiously Recurring Template Pattern (CRTP).

 

Single responsibility principle

In short, the Single Responsibility Principle (SRP) means each code unit should have exactly one responsibility. This means writing functions that do one thing only, creating types that are responsible for a single thing, and creating higher-level components that are focused on one aspect only.

This means that if your class manages some type of resources, such as file handles, it should do only that, leaving parsing them, for example, to another type.

Often, if you see a function with "And" in its name, it's violating the SRP and should be refactored. Another sign is when a function has comments indicating what each section of the function (sic!) does. Each such section would probably be better off as a distinct function.

A related topic is the principle of least knowledge. In its essence, it says that no object should know no more than necessary about other objects, so it doesn't depend on any of their internals, for example. Applying it leads to more maintainable code with fewer interdependencies between components.

 

Open-closed principle

The Open-Closed Principle (OCP) means that code should be open for extension but closed for modification. Open for extension means that we could extend the list of types the code supports easily. Closed for modification means existing code shouldn't change, as this can often cause bugs somewhere else in the system. A great feature of C++ demonstrating this principle is operator<< of ostream. To extend it so that it supports your custom class, all you need to do is to write code similar to the following:

std::ostream &operator<<(std::ostream &stream, const MyPair<int, int> 
&mp) {
stream << mp.firstMember() << ", ";
stream << mp.secondMember();
return stream;
}

Note that our implementation of operator<< is a free (non-member) function. You should prefer those to member functions if possible as it actually helps encapsulation. For more details on this, consult the article by Scott Meyers in the Further reading section at the end of this chapter. If you don't want to provide public access to some field that you wish to print to ostream, you can make operator<< a friend function, like so:

class MyPair {
// ...
friend std::ostream &operator<<(std::ostream &stream,
const MyPair &mp);
};
std::ostream &operator<<(std::ostream &stream, const MyPair &mp) {
stream << mp.first_ << ", ";
stream << mp.second_ << ", ";
stream << mp.secretThirdMember_;
return stream;
}

Note that this definition of OCP is slightly different from the more common one related to polymorphism. The latter is about creating base classes that can't be modified themselves, but are open for others to inherit from them.

Speaking of polymorphism, let's move on to the next principle as it is all about using it correctly.

 

Liskov substitution principle

In essence, the Liskov Substitution Principle (LSP) states that if a function works with a pointer or reference to a base object, it must also work with a pointer or reference to any of its derived objects. This rule is sometimes broken because the techniques we apply in source code do not always work in real-world abstractions.

A famous example is a square and a rectangle. Mathematically speaking, the former is a specialization of the latter, so there's an "is a" relationship from one to the other. This tempts us to create a Square class that inherits from the Rectangle class. So, we could end up with code like the following:

class Rectangle {
public:
virtual ~Rectangle() = default;
virtual double area() { return width_ * height_; }
virtual void setWidth(double width) { width_ = width; }
virtual void setHeight(double height) { height_ = height; }
private:
double width_;
double height_;
};

class Square : public Rectangle {
public:
double area() override;
void setWidth(double width) override;
void setHeight(double height) override;
};

How should we implement the members of the Square class? If we want to follow the LSP and save the users of such classes from surprises, we can't: our square would stop being a square if we called setWidth. We can either stop having a square (not expressible using the preceding code) or modify the height as well, thus making the square look different than a rectangle.

If your code violates the LSP, it's likely that you're using an incorrect abstraction. In our case, Square shouldn't inherit from Rectangle after all. A better approach could be making the two implement a GeometricFigure interface.

Since we are on the topic of interfaces, let's move on to the next item, which is also related to them.

 

Interface segregation principle

The interface segregation principle is just about what its name suggests. It is formulated as follows:

No client should be forced to depend on methods that it does not use.

That sounds pretty obvious, but it has some connotations that aren't that obvious. Firstly, you should prefer more but smaller interfaces to a single big one. Secondly, when you're adding a derived class or are extending the functionality of an existing one, you should think before you extend the interface the class implements.

Let's show this on an example that violates this principle, starting with the following interface:

class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blend() = 0;
};

We could have a simple class that implements it:

class Blender : public IFoodProcessor {
public:
void blend() override;
};

So far so good. Now say we want to model another, more advanced food processor and we recklessly tried to add more methods to our interface:

class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blend() = 0;
virtual void slice() = 0;
virtual void dice() = 0;
};

class AnotherFoodProcessor : public IFoodProcessor {
public:
void blend() override;
void slice() override;
void dice() override;
};

Now we have an issue with the Blender class as it doesn't support this new interface – there's no proper way to implement it. We could try to hack a workaround or throw std::logic_error, but a much better solution would be to just split the interface into two, each with a separate responsibility:

class IBlender {
public:
virtual ~IBlender() = default;
virtual void blend() = 0;
};

class ICutter {
public:
virtual ~ICutter() = default;
virtual void slice() = 0;
virtual void dice() = 0;
};

Now our AnotherFoodProcessor can just implement both interfaces, and we don't need to change the implementation of our existing food processor.

We have one last SOLID principle left, so let's learn about it now.

 

Dependency inversion principle

Dependency inversion is a principle useful for decoupling. In essence, it means that high-level modules should not depend on lower-level ones. Instead, both should depend on abstractions.

C++ allows two ways to inverse the dependencies between your classes. The first one is the regular, polymorphic approach and the second uses templates. Let's see how to apply both of them in practice.

Assume you're modeling a software development project that is supposed to have frontend and backend developers. A simple approach would be to write it like so:

class FrontEndDeveloper {
public:
void developFrontEnd();
};

class BackEndDeveloper {
public:
void developBackEnd();
};

class Project {
public:
void deliver() {
fed_.developFrontEnd();
bed_.developBackEnd();
}
private:
FrontEndDeveloper fed_;
BackEndDeveloper bed_;
};

Each developer is constructed by the Project class. This approach is not ideal, though, since now the higher-level concept, Project, depends on lower-level ones – modules for individual developers. Let's see how applying dependency inversion using polymorphism changes this. We can define our developers to depend on an interface as follows:

class Developer {
public:
virtual ~Developer() = default;
virtual void develop() = 0;
};

class FrontEndDeveloper : public Developer {
public:
void develop() override { developFrontEnd(); }
private:
void developFrontEnd();
};

class BackEndDeveloper : public Developer {
public:
void develop() override { developBackEnd(); }
private:
void developBackEnd();
};

Now, the Project class no longer has to know the implementations of the developers. Because of this, it has to accept them as constructor arguments:

class Project {
public:
using Developers = std::vector<std::unique_ptr<Developer>>;
explicit Project(Developers developers)
: developers_{std::move(developers)} {}

void deliver() {
for (auto &developer : developers_) {
developer->develop();
}
}

private:
Developers developers_;
};

In this approach, Project is decoupled from the concrete implementations and instead depends only on the polymorphic interface named Developer. The "lower-level" concrete classes also depend on this interface. This can help you shorten your build time and allows for much easier unit testing – now you can easily pass mocks as arguments in your test code.

Using dependency inversion with virtual dispatch comes at a cost, however, as now we're dealing with memory allocations and the dynamic dispatch has overhead on its own. Sometimes C++ compilers can detect that only one implementation is being used for a given interface and will remove the overhead by performing devirtualization (often you need to mark the function as final for this to work). Here, however, two implementations are used, so the cost of dynamic dispatch (commonly implemented as jumping through virtual method tables, or vtables for short) must be paid.

There is another way of inverting dependencies that doesn't have those drawbacks. Let's see how this can be done using a variadic template, a generic lambda from C++14, and variant, either from C++17 or a third-party library such as Abseil or Boost. First are the developer classes:

class FrontEndDeveloper {
public:
void develop() { developFrontEnd(); }
private:
void developFrontEnd();
};

class BackEndDeveloper {
public:
void develop() { developBackEnd(); }
private:
void developBackEnd();
};

Now we don't rely on an interface anymore, so no virtual dispatch will be done. The Project class will still accept a vector of Developers:

template <typename... Devs>
class Project {
public:
using Developers = std::vector<std::variant<Devs...>>;

explicit Project(Developers developers)
: developers_{std::move(developers)} {}

void deliver() {
for (auto &developer : developers_) {
std::visit([](auto &dev) { dev.develop(); }, developer);
}
}

private:
Developers developers_;
};

If you're not familiar with variant, it's just a class that can hold any of the types passed as template parameters. Because we're using a variadic template, we can pass however many types we like. To call a function on the object stored in the variant, we can either extract it using std::get or use std::visit and a callable object – in our case, the generic lambda. It shows how duck-typing looks in practice. Since all our developer classes implement the develop function, the code will compile and run. If your developer classes would have different methods, you could, for instance, create a function object that has overloads of operator() for different types.

Because Project is now a template, we have to either specify the list of types each time we create it or provide a type alias. You can use the final class like so:

using MyProject = Project<FrontEndDeveloper, BackEndDeveloper>;
auto alice = FrontEndDeveloper{};
auto bob = BackEndDeveloper{};
auto new_project = MyProject{{alice, bob}};
new_project.deliver();

This approach is guaranteed to not allocate separate memory for each developer or use a virtual table. However, in some cases, this approach results in less extensibility, since once the variant is declared, you cannot add another type to it.

As the last thing to mention about dependency inversion, we'd like to note that there is a similarly named idea called dependency injection, which we even used in our examples. It's about injecting the dependencies through constructors or setters, which can be beneficial to code testability (think about injecting mock objects, for example). There are even whole frameworks for injecting dependencies throughout whole applications, such as Boost.DI. Those two concepts are related and often used together.

 

The DRY rule

DRY is short for "don't repeat yourself." It means you should avoid code duplication and reuse when it's possible. This means you should extract a function or a function template when your code repeats similar operations a few times. Also, instead of creating several similar types, you should consider writing a template.

It's also important not to reinvent the wheel when it's not necessary, that is, not to repeat others' work. Nowadays there are dozens of well-written and mature libraries that can help you with writing high-quality software faster. We'd like to specifically mention a few of them:

Sometimes duplicating code can have its benefits, however. One such scenario is developing microservices. Of course, it's always a good idea to follow DRY inside a single microservice, but violating the DRY rule for code used in multiple services can actually be worth it. Whether we're talking about model entities or logic, it's easier to maintain multiple services when code duplication is allowed.

Imagine having multiple microservices reusing the same code for an entity. Suddenly one of them needs to modify one field. All the other services now have to be modified as well. The same goes for dependencies of any common code. With dozens or more microservices that have to be modified because of changes unrelated to them, it's often easier for maintenance to just duplicate the code.

Since we're talking about dependencies and maintenance, let's proceed to the next section, which discusses a closely related topic.

 

Coupling and cohesion

Coupling and cohesion are two terms that go hand in hand in software. Let's see what each of them means and how they relate to each other.

 

Coupling

Coupling is a measure of how strongly one software unit depends on other units. A unit with high coupling relies on many other units. The lower the coupling, the better.

For example, if a class depends on private members of another class, it means they're tightly coupled. A change in the second class would probably mean that the first one needs to be changed as well, which is why it's not a desirable situation.

To weaken the coupling in the preceding scenario, we could think about adding parameters for the member functions instead of directly accessing other classes' private members.

Another example of tightly coupled classes is the first implementation of the Project and developer classes in the dependency inversion section. Let's see what would happen if we were to add yet another developer type:

class MiddlewareDeveloper {
public:
void developMiddleware() {}
};

class Project {
public:
void deliver() {
fed_.developFrontEnd();
med_.developMiddleware();
bed_.developBackEnd();
}

private:
FrontEndDeveloper fed_;
MiddlewareDeveloper med_;
BackEndDeveloper bed_;
};

It looks like instead of just adding the MiddlewareDeveloper class, we had to modify the public interface of the Project class. This means they're tightly coupled and that this implementation of the Project class actually breaks the OCP. For comparison, let's now see how the same modification would be applied to the implementation using dependency inversion:

class MiddlewareDeveloper {
public:
void develop() { developMiddleware(); }

private:
void developMiddleware();
};

No changes to the Project class were required, so now the classes are loosely coupled. All we needed to do was to add the MiddlewareDeveloper class. Structuring our code this way allows for smaller rebuilds, faster development, and easier testing, all with less code that's easier to maintain. To use our new class, we only need to modify the calling code:

using MyProject = Project<FrontEndDeveloper, MiddlewareDeveloper, BackEndDeveloper>;
auto alice = FrontEndDeveloper{};
auto bob = BackEndDeveloper{};
auto charlie = MiddlewareDeveloper{};
auto new_project = MyProject{{alice, charlie, bob}};
new_project.deliver();

This shows coupling on a class level. On a larger scale, for instance, between two services, the low coupling can be achieved by introducing techniques such as message queueing. The services wouldn't then depend on each other directly, but just on the message format. If you're having a microservice architecture, a common mistake is to have multiple services use the same database. This causes coupling between those services as you cannot freely modify the database schema without affecting all the microservices that use it.

Let's now move on to cohesion.

 

Cohesion

Cohesion is a measure of how strongly a software unit's elements are related. In a highly cohesive system, the functionality offered by components in the same module is strongly related. It feels like such components just belong together.

On a class level, the more fields a method manipulates, the more cohesive it is to the class. This means that the most commonly spotted low-cohesion data types are those big monolithic ones. When there's too much going on in a class, it most probably is not cohesive and breaks the SRP, too. This makes such classes hard to maintain and bug-prone.

Smaller classes can be incohesive as well. Consider the following example. It may seem trivial, but posting real-life scenarios, often hundreds if not thousands of lines long, would be impractical:

class CachingProcessor {
public:
Result process(WorkItem work);
Results processBatch(WorkBatch batch);
void addListener(const Listener &listener);
void removeListener(const Listener &listener);

private:
void addToCache(const WorkItem &work, const Result &result);
void findInCache(const WorkItem &work);
void limitCacheSize(std::size_t size);
void notifyListeners(const Result &result);
// ...
};

We can see that our processor actually does three types of work: the actual work, the caching of the results, and managing listeners. A common way to increase cohesion in such scenarios is to extract a class or even multiple ones:

class WorkResultsCache {
public:
void addToCache(const WorkItem &work, const Result &result);
void findInCache(const WorkItem &work);
void limitCacheSize(std::size_t size);
private:
// ...
};

class ResultNotifier {
public:
void addListener(const Listener &listener);
void removeListener(const Listener &listener);
void notify(const Result &result);
private:
// ...
};

class CachingProcessor {
public:
explicit CachingProcessor(ResultNotifier &notifier);
Result process(WorkItem work);
Results processBatch(WorkBatch batch);
private:
WorkResultsCache cache_;
ResultNotifier notifier_;
// ...
};

Now each part is done by a separate, cohesive entity. Reusing them is now possible without much hassle. Even making them a template class should require little work. Last but not least, testing such classes should be easier as well.

Putting this on a component or system level is straightforward – each component, service, and system you design should be concise and focus on doing one thing and doing it right:

Figure 1.2 – Coupling versus cohesion

Low cohesion and high coupling are usually associated with software that's difficult to test, reuse, maintain, or even understand, so it lacks many of the quality attributes usually desired to have in software.

Those terms often go together because often one trait influences the other, regardless of whether the unit we talk about is a function, class, library, service, or even a whole system. To give an example, usually, monoliths are highly coupled and low cohesive, while distributed services tend to be at the other end of the spectrum.

This concludes our introductory chapter. Let's now summarize what we've learned.

 

Summary

In this chapter, we discussed what software architecture is and why it's worth caring about it. We've shown what happens when architecture is not updated along with the changing requirements and implementation and how to treat architecture in an Agile environment. Then we moved on to some core principles of the C++ language.

We learned that many terms from software development can be perceived differently in C++ because C++ allows more than writing object-oriented code. Finally, we discussed terms such as coupling and cohesion.

You should now be able to point out many design flaws in code reviews and refactor your solutions for greater maintainability, as well as being less bug-prone as a developer. You can now design class interfaces that are robust, self-explanatory, and complete.

In the next chapter, we will learn about the different architectural approaches or styles. We will also learn about how and when we can use them to gain better results. 

 

Questions

  1. Why care about software architecture?
  2. Should the architect be the ultimate decision-maker in an Agile team?
  3. How is the SRP related to cohesion?
  4. In what phases of a project's lifetime can it benefit from having an architect?
  5. What's the benefit of following the SRP?
 

Further reading

About the Authors

  • Adrian Ostrowski

    Adrian Ostrowski is a modern C++ enthusiast interested in the development of both the C++ language itself and the high-quality code written in it. A lifelong learner with over a decade of experience in the IT industry and more than 8 years of experience with C++ specifically, he's always eager to share his knowledge. His past projects range from parallel computing, through fiber networking, to working on a commodity exchange's trading system. Currently, he's one of the architects of Intel and Habana's integration with machine learning frameworks.

    In his spare time, Adrian used to promote music bands together with Piotr and has learned how to fly a glider. Currently, he likes riding his bicycle, going to music events, and browsing memes.

    Browse publications by this author
  • Piotr Gaczkowski

    Piotr Gaczkowski has more than 10 years of experience in programming and practicing DevOps and uses his skills to improve people's lives. He likes building simple solutions to human problems, organizing cultural events, and teaching fellow professionals. Piotr is keen on automating boring activities and using his experience to share knowledge by conducting courses and writing articles about personal growth and remote work.

    He has worked in the IT industry both in full-time positions and as a freelancer, but his true passion is music. When not making his skills useful at work, you can find him building communities.

    Browse publications by this author
Book Title
Unlock this book and the full library for only $5/m
Access now