-
-
Notifications
You must be signed in to change notification settings - Fork 134
Librum's architecture
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.
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.
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)
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 callsLibraryService::addBook
- The
LibraryService::addBook
method (Application layer) now creates types of theBook
class (Domain layer) and adds it to the in-memory library. To save the book to the server, it then calls theLibraryStorageGateway::createBook(BookModel book)
method. - The
LibraryStorageGateway::createBook
method (Adapters layer) maps theBook
class to JSON and sends it toLibraryStorageAccess::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.
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.
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).