-
-
Notifications
You must be signed in to change notification settings - Fork 182
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 a shared library.
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 encapsulates the business logic of Librum. It focuses on concepts that are specific to the domain rather than any particular application. This layer primarily includes models like the Book model and the User model.
For example, any system related to books will have a Book model, but not every book-related application needs to support printing. A good rule of thumb is that anything that would still exist if Librum were a physical library, such as a book, belongs in the Domain layer. On the other hand, components like a book parser, which wouldn’t exist in a physical library, do not belong in this layer.
-
The Application layer is where the heavy lifting happens. It contains the components that automate specific tasks, such as book parsing or rendering. Continuing our analogy, the Application layer is what transforms a physical store into a functioning application.
If we imagine a bookstore and keep the domain intact, the additional logic needed to make the application work—like parsing books or printing them—belongs in the Application layer. The components in this layer are often called "services." For example, a
UserService
would manage all user data and provide functions to modify 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, 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 serves as a simple but important abstraction layer. Its main purpose is to separate the Presentation and Infrastructure layers from the Application layer, reducing direct dependencies between them.
This layer mainly consists of two types of classes:
-
Controllers: These classes act as a bridge between the UI (in our case, registered with the QML engine) and the Application layer. When the UI calls a controller, it converts the UI data into the format the Application layer expects, then forwards the request to the appropriate service.
-
Gateways: These work similarly but connect the Application layer to the Infrastructure layer. They take data from a service, convert it into the format needed by the infrastructure components (like databases or external systems), and then pass the call along.
At first, the Adapters layer might seem like extra overhead, but it plays a crucial role in keeping the core application separate from UI or database details. This separation means the Application and Domain layers remain independent of the presentation or infrastructure technology. As a result, we can swap out the backend or UI platform with minimal effort, making the system more flexible and maintainable.
-
Librum consists 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 one 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)
Let’s walk through a typical user login to make the architecture clearer. You can follow along with the diagram above, which shows how control flows through the layers.
-
User Input (Presentation Layer)
The user clicks the Login button. A JavaScript click handler is triggered, which callsAuthenticationController::login()
with the data the user wrote into the email and password textboxes. -
Controller Delegates (Adapters Layer)
The controller receives the login data, maps it to the appropriate internal types, and callsAuthenticationService::login()
in the Application layer. -
Business Logic (Application Layer)
The service performs any logic needed to prepare for login—e.g., creating aLoginModel
(Domain layer) to represent the user credentials. Then it callsAuthenticationGateway::login()
to start the actual login process. -
Talking to the Outside World (Adapters Layer)
The gateway converts the internal model into a format the infrastructure layer needs (JSON) and callsAuthenticationAccess::login()
, which performs the API request. -
API Call (Infrastructure Layer)
The request is sent to the server. Once the login is complete, a callback is triggered via a signal/slot mechanism (e.g.,onLoginReady()
). -
Propagating the Result Back
The signal flows back through the gateway and service, ultimately reaching the controller, which notifies the UI that the login is done.
🧠 Note on Signal/Slot Mechanism
This architecture uses signals and slots to react to async events likeonLoginReady()
. These make sure the application remains responsive and decoupled—data can flow back without directly tying components together.
In general we can say that any action that is triggered (such as the login action) 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 if it needs to, and then issues a storage request to thexGateway
class.
- This
xGateway
class has the same purpose as thexController
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, thexGateway
class converts data provided by the application to the data type the backend Server needs. - This
xGateway
class then calls thexAccess
class which sends an HTTP request to the backend Server and thus stores the data.
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 be implemented by the outer layer class.
- Dependencies on outer layers should then be injected into the inner class. This happens automatically when the classes are registered with our dependency injection library (see the dependency injection file)
Dependency inversion and injection is a topic in itself and lies the foundations for the SOLID architectural principles, feel free to read up on it.
➡️ Next: 🔌 Talking to the Backend