RidgeRun Developer Manual - Design Patterns - Hexagonal Architecture

From RidgeRun Developer Wiki





  Index  




Introduction

The hexagonal architecture was defined by Alistair Cockburn in 2005. Cockburn later named it “Port and Adapter Pattern”, but most people still prefer to use the former name as Hexagonal architecture [1]. This architecture divides the system into loosely-coupled interchangeable components; such as the application core, user interface, data repositories, test scripts and other system interfaces etc.

The hexagon is not a hexagon because the number six is important, but rather to allow the people drawing to have room to insert ports and adapters as they need, not being constrained by a one-dimensional layered drawing. The term hexagonal architecture comes from this visual effect.

The term "port and adapters" picks up the purposes of the parts of the drawing. A port identifies a purposeful conversation. There will typically be multiple adapters for any one port, for various technologies that may plug into that port. Typically, these might include a phone answering machine, a human voice, a touch-tone phone, a graphical human interface, a test harness, a batch driver, an HTTP interface, a direct program-to-program interface, a mock (in-memory) database, a real database (perhaps different databases for development, test, and real use).

In this approach, the application core contains the business domain information. It is surrounded by a layer that contains adapters handling bi-directional communication with other components generically.

The hexagonal architecture aims to:

  • Decouple the architecture (have a modular system which makes it easier to maintain).
  • Standardize the accesses through interfaces (or ports).
  • Isolate the dependencies. This makes it easier to find bugs and understand the code better.
  • Allow polymorphism.

Its key advantages are extensibility, maintainability, decoupling, readability, and others. Applying a proper design pattern always improves code quality and reduces extension development time. For RidgeRun, these features are crucial for software development.

Figure 1. Hexagonal Architecture.

The figure shown above illustrates how the code can be represented graphically. Please, notice the following characteristics:

  • The adapters do not communicate with each other directly. They can communicate through their interfaces.
  • Interfaces communicate the adapters with the domain or the application.

Concepts

It is important to explain the following elements that make up this design pattern:

Interfaces

An interface is a set of methods and properties that has no implementation. The implementation will be done by each element that specializes the interface depending on their needs.

Interfaces are important because they allow you to add adapters without having to change the logic of the other adapters.

The interfaces (or ports) are often represented as the way how the application (business logic) interacts with several adapters (or drivers). In C++, there is no clear concept of interfaces as Java does. For implementing interfaces, conveniently we use pure abstract classes, which are classes with all their methods purely virtualized. Also, there are two kinds of ports:

  • Incoming ports will be the interface(s) that your application implements. An example can be a console input.
  • Outgoing ports are the interfaces that your application depends on, like a database. "Incoming" and "Outgoing" are the terms that our team adopted. "Driving port" and "driven port" is the terminology you may find in other literature.

An example of an interface:

class IWriter {
 public:
  virtual ~IWriter(){};

  virtual ErrorCode Write(const std::string& message) = 0;
  virtual ErrorCode Open(const std::string& name) = 0;
};

IWriter is a pure virtual class, since their methods are pure virtual(virtual and assigned to zero). Some rules for interfaces:

  • The interfaces shall be described in a header file. You can write directly to the source file if the program is very small or it is known that it will not change over time.
  • The interface shall include (or import) only: C/C++/STL libraries, other ports and enumerations.
  • They are often named with an I prefix. This is a recommendation to add readability.

Note: The interfaces standardize the API of the adapters or the implementations to add extensibility to the code.

Adapters

An adapter is a software component that allows a technology to interact with a port in the hexagon. Given a port, there is an adapter for each desired technology that we want to use. Adapters are outside the application. Also, adapters are subclasses from the interfaces that implement the functionality. They often specialize interfaces since they are intended to add the implementation, absorb the dependencies, and comply with the API established through interfaces.

We can create a console writer inheriting from the interface IWriter as it follows:

class ConsoleWriter : public IWriter {
 public:
  virtual ~ConsoleWriter() {}

  ErrorCode Write(const std::string& message) override;
  ErrorCode Open(const std::string& name) override;
};

inline ErrorCode ConsoleWriter::Open(const std::string& name) {
  return ErrorCode::kOK;
};

inline ErrorCode ConsoleWriter::Write(const std::string& message) {
  std::cout << message << std::endl;
  return ErrorCode::kOK;
}

As the adapter shows, it inherits the from the interface and implements the pure abstract methods to comply with the API proposed by the interface. The adapters can also include other members (like private members) or friend members for modularization. Some rules for adapters:

  • Adapters should be replaceable (changing implementation should not be messy) and reusable (when we want to reuse the code somewhere else).
  • Do not include other adapters to the adapter. The adapters must be isolated.
  • Keep an adapter per file. Please, declare the adapter in a header file (.hpp) and define its methods in a source file (.cpp). You can make all the declarations in the (.cpp), only if you want to make a quick example or a very small program.
  • Try to keep the adapter header file free from any external dependency. You can import any other external library within the source file.
  • If the method is too simple, like a getter or setter, you can define it in the header and inline it to boost performance.

Note: Adapters implement the logic and absorb the dependencies.

Domain / Application

The domain is the top of the application. It summarizes the business logic and interconnects all the interfaces. For example:


int main() {
  auto console_writer = IWriter::Build(Writers::kConsole);
  auto file_writer = IWriter::Build(Writers::kFile);

  console_writer->Write("Hello world\n");

  file_writer->Open("log.txt");
  file_writer->Write("Hello world\n");
  return 0;
}

Factories

It is also important to describe what a factory is. It's not part of this pattern itself, but it's good practice to implement it this way. For further reading you can review the paper [3].

To avoid including the adapters within the domain, we use factories. A factory is a method or function which returns a std::ptr to the interface but it is specialized under the hood. The concept is the following:

std::ptr<IWriter> console_writer = GetNewWriter(Writers::kConsole);

GetNewWriter is in charge of creating and constructing a new writer. The parameter received is the target implementation or specialization. For practical reasons, a factory can be implemented as a static method of the interface class. Thus, the code is more elegant:

auto console_writer = IWriter::Build(Writers::kConsole);

We get an smart pointer called console_writer using the method IWriter::Build. Writers is an enum class and kConsole is an attribute in the enum.

Conclusion

  • Maintainability: We build layers that are loosely coupled and independent. It becomes easy to add new features in one layer without affecting other layers.
  • Testability: Unit tests are cheap to write and fast to run. We can write tests for each layer. We can mock the dependencies while testing. For example: We can mock a database dependency by adding an in-memory datastore.
  • Adaptability: Our core domain becomes independent of changes in external entities. For example: If we plan to use a different database, we don’t need to change the domain. We can introduce the appropriate database adapter.

References

[1] Hexagonal Architecture

[2] Ports and Adapters Pattern

[3] Better Construction with Factories


Index