In the previous article, we outlined the problem of lacking enforced architecture in frontend frameworks that also allow frontend developers to write backend code. In this article, we will look at what to pay attention to in order to start structuring your code at a low cost. However, before we delve into application architecture, it's worth making a few architectural decisions to shape our application.
Separating Queries from Commands
A common issue that causes code clutter and reduces maintainability is overly large objects. By "overly large," I mean objects that hold too much information simultaneously. The reason objects hold too much data is often due to read operations. Some data is needed to make decisions, while the rest is just to display to the user. It's beneficial to separate these two situations. Even if we store the data in a single database table, we should create two independent models: one for reading and another one for making decisions and updating.
To achieve this, we can apply the CQS (Command Query Separation) or CQRS (Command Query Responsibility Segregation) patterns in our code. They both assume the separation of reading (Query) from writing (Command). Queries never modify data, and Commands do not return data. The difference lies in implementation: in CQS, we might have a single service with Command and Query methods, while in CQRS, we have entirely separate services or even microservices for both types.
In the provided example, it might seem that not separating queries from commands generates less code. However, this is a very superficial conclusion. By using CQS/CQRS, we slim down our write models and can create more flexible queries for our read models. Adding additional read data does not affect our commands and tests, making them more resilient to changes. We also avoid multiple places for reading or writing the same model, making the code more reusable.
Code: Examples of CQS and CQRS. The repositories used are examples of Pure Fabrication from GRASP. They can also call Creator underneath, which instantiates returned objects.
Rich vs. Anemic Entities
Another important aspect is the approach to our data entities. Typically, there are two types:
-
Rich Entities - contain both data and behaviors operating on that data. They do not have getters and setters. Behavior methods change the object's state and enforce business rules in one place. Rich entities are an excellent example of Information Expert.
-
Anemic Entities - contain only data, along with getters and setters. Business logic is implemented in separate services that operate on these entities, change their state, and enforce business rules.
Existing backend frameworks often suggest the second approach, but using rich entities is a much better solution. Key behaviors, rules, and data are represented in one place, preventing entity bloat. In both cases, however, we should remember that entities should be as small as possible, containing only the data needed for specific decisions. A much better place for using anemic entities is DTOs (Data Transfer Objects), used for data transport between layers and modules, or objects for creating views (e.g., response from a Controller).
Code: Example of an anemic entity with a service handling it.
Code: Example of a rich entity with its use in a specific use case. AddOrderItemUseCase is an example of a Controller from GRASP.
Modules vs. Layers
Two techniques that help us divide our code into more manageable parts are modularization and layer division. An application module is more extensive and can be treated as a separate subprogram with its own application architecture (i.e., one that divides responsibilities according to layers) and its own API. Modularization is an individual, more significant topic, so we'll focus only on application architecture in this article. The following article will consider the two most popular application architectures: three-layer architecture and clean architecture. Regardless of the architecture type, it's important to remember that layers always have a unidirectional relationship, meaning the lower layer knows about the higher layer, but the higher does not know about the lower. This is crucial to avoid creating spaghetti code. Remember, the difference between a mere directory structure and layers primarily lies in respecting their relationship boundaries.
Did I Define the Layer Boundaries Correctly?
A good question is whether the boundaries I defined between layers are indeed boundaries. To this end, it's worth conducting a thought experiment. If I remove the lower layer and instead insert tests that call my layer, will the code of my layer still perform its tasks? If not, and there are missing dependencies to the lower layer, it means the boundary between layers is not respected, and our code will slowly degrade. Another more technical method is to use a simple architectural test that checks if there are imports to the lower layer's directory within the files of a given directory.
Application Architecture
Identifying and assigning responsibilities to objects in our code and determining the approach to our read and write entities is the basis for adequately dividing it into layers. In the next part, we will present the types of application architecture and how they interact with the concepts presented in this article. We will also identify the essential responsibilities of GRASP within the individual layers of known architectures.