Skip to content

Librum's architecture

David Lazarescu edited this page Apr 1, 2024 · 19 revisions

Librum was designed with the SOLID principles and a clear dependency flow in mind.

The application itself follows an onion-like architecture, where Librum is divided into multiple layers:

Each layer serves a different purpose and is physically separated from the other ones by being compiled as shared libraries.
The most important rule is that each layer can only access layers that are further inside, thus the arrows in the diagram.
The outside layers are generally more "low-level" and thus more likely to change, where as the inner layers (e.g. the domain) are "high-level" and thus less likely to change.

The layers

Librum consists of 4 Layers:

  • The Domain layer contains the business logic of Librum. The Domain layer contains things that are not application-specific but domain-specific. For example, any application in the domain of books has a Book model, but not every book application needs to be able to print books. A rule of thumb is that everything that would still exist if Librum were to be a physical library, e.g. a book, belongs in the domain layer. But a book parser would not be in a physical store, so it doesn't belong in the domain layer.
  • The Application layer is what does the heavy lifting. It contains the components that automate something, e.g. book parsing. Following our analogy, the Application layer is what could make a physical store into an application. If we take a book store and keep the domain, the logic that we now need to write to make the application work belongs in the application layer, e.g. book parsing, printing books, etc... We call the components in this Layer "services", e.g. "UserService", would manage all of the user's data and provide functions to change it.
  • The outermost layer is divided into Presentation and Infrastructure. Both of these layers are responsible for I/O. The Presentation layer contains all of the UI code, so everything that you can see on the screen. The Infrastructure layer contains the components which handle the communication between the API and the application. An example would be the "UserStorageAccess" class, which can create or delete users. (The classes in the Infrastructure layer are oftentimes called "...Access", since they access the database.)
  • The Adapters layer is purely an abstraction layer. It exists to decouple the Presentation and Infrastructure layer from the Application layer. The Adapters layer mainly contains two types of classes: "Controllers" and "Gateways". Controllers are classes that are exposed to the UI (In our case registered to the Qml Engine), which when called, map the data provided by the UI to the data type the Application layer wants and then delegate the call to the appropriate service (Application layer). "Gateways" do the same, just between the Application and the Infrastructure. They get data from a service, map it to what the format the infrastructure class wants, and then delegate the call.

Vertical Slices

One could describe Librum as consisting out of multiple "vertical slices", each dealing with one aspect of the application. These vertical slices "cut" through the layered architecture, so that each vertical slice contains exactly 1 component from each layer.
For example a vertical slice dealing with the User would contain:

  • UserController (The class exposed to QML)
  • UserService (The class doing the actual processing)
  • UserStorageGateway (The class converting data to the format the API expects)
  • UserStorageAccess (The class making the actual API request)

Example

In this example, the user wants to add a book. To do this, the user needs to click the "Add" button and choose a book. When clicking the "Add" button and choosing a book, the control might flow like this:

  • When the book is chosen, the path of the book is sent as an argument to the LibraryController::addBook(string path) method
  • The LibraryController::addBook method (Adapters layer) maps the string to a local file path and calls LibraryService::addBook
  • The LibraryService::addBook method (Application layer) now creates types of the Book class (Domain layer) and adds it to the in-memory library. To save the book to the server, it then calls the LibraryStorageGateway::createBook(BookModel book) method.
  • The LibraryStorageGateway::createBook method (Adapters layer) maps the Book class to JSON and sends it to LibraryStorageAccess::createBook(BookDto book)
  • The last step is the LibraryStorageAccess::createBook method, which sends a request to the API, with the book as a payload, to be added to the database.

This actually is pretty precisely the data flow of adding a book in Librum.

General

In general we can say that any action that is triggered (as the "add book" action described above) calls a method in an xController class. This controller class then converts the data provided by the user to the data format the application requires and calls the xService.
The xService then processes the request, it potentially uses a class from the domain layer (e.g. the Book class) and then issues a storage request to the xGateway class.
This xGateway class has the same purpose as the xController class, it converts the data from one format to another, but instead of converting the user provided data to the data type the application needs (as the xController does), the xGateway class converts the data application provided to the data type the backend Server needs. This xGateway class then calls the xStorageAccess class which sends an HTTP request to the backend Server and thus stores the data.

At this point you might think that the xController and xGateway classes seem unnecessary and inconvenient, but these classes are key to archiving a loosely coupled system. Using these "conversion" classes, we make sure that the application does not depend on the UI nor on the backend Server. This means that we can change the UI and the backend Server as we want, without requiring changes to Librum's application layer.

Accessing outside layers from inside layers

You might have noticed that to make a call to the API, classes in the Application layer would need to access a "Gateway" class in the Adapters layer, but this would contradict the rule that says each layer can only access layers that are further inside. To not create a dependency from the inner classes on the outer classes but still be able to call them, we use dependency inversion. All this means is that:

  • Interfaces of outer layer classes, which should be called from an inner layer class, should be declared in that inner layer and then implemented by the outer layer class.
  • Dependencies on outer layers should be injected into the inner class (dependencies on inner layers are injected too, but that isn't because of dependency inversion).
Clone this wiki locally