Why common sense is more important than patterns, and Active Record is not so bad

It just so happened that developers, especially young ones, love patterns, they like to argue about which pattern should be applied here or there. Argue to the point of hoarseness: this is a facade or a proxy, or maybe even a singleton. And if your architecture is not clean, hexagonal, then some developers are ready to burn at the stake of the Holy Inquisition.



In doing so, they forget that patterns are only possible solutions. Patterns, like any principles, have limits of applicability, and it is important to understand them. The road to hell is paved with blind and religious adherence to even authoritative words.



And the presence of the necessary patterns in the framework does not guarantee their correct and conscious application.







The glitter and poverty of Active Record



Let's look at the Active Record pattern as an anti-pattern, which some programming languages ​​and frameworks try to avoid in every possible way.



The essence of Active Record is simple: we store business logic with entity storage logic. In other words, to put it very simply, each table in the database corresponds to an entity class along with a behavior.





There is a fairly strong opinion that combining business logic with storage logic in one class is a very bad, unusable pattern. It violates the principle of sole responsibility. And for this reason Django ORM is bad by design.



Indeed, it may not be very good to combine storage logic and domain logic in the same class.


Let's take User and Profile models for example. This is a fairly common pattern. There is a main plate, and there is an additional one, which stores not always obligatory, but sometimes necessary data.





It turns out that the entity of the β€œuser” domain is now stored in two tables, and in the code we have two classes. And every time we directly make some corrections in user.profile, we need to remember that this is a separate model and that we made changes in it. And save it separately.



   def create(self, validated_data):
        # create user 
        user = User.objects.create(
            url = validated_data['url'],
            email = validated_data['email'],
            # etc ...
        )

        profile_data = validated_data.pop('profile')
        # create profile
        profile = Profile.objects.create(
            user = user
            first_name = profile_data['first_name'],
            last_name = profile_data['last_name'],
            # etc...
        )

        return user


To get a list of users, it is imperative to think about whether an attribute will be taken from these users profilein order to immediately select two signs with a join and not get it SELECT N+1in a loop.



user = User.objects.get(email='example@examplemail.com')
user.userprofile.company_name
user.userprofile.country


Things get even worse if, within the microservice architecture, part of the user data is stored in another service - for example, roles and rights in LDAP.



At the same time, of course, I really don't want external users of the API to care about this somehow. There is a REST resource /users/{user_id}, and I would like to work with it without thinking about how data is stored inside. If they are stored in different sources, then it will be more difficult to change the user or get the list of data.



Generally speaking, ORM! = Domain Model!





And the more the real world differs from the assumption β€œone table in the database - one entity of the domain,” the more problems with the Active Record pattern.


It turns out that every time you write business logic, you must remember how the essence of the domain is stored.



ORM methods are the lowest level of abstraction. They do not support any limitations of the subject area, which means they give the opportunity to make mistakes. They also hide from the user what queries are actually made in the database, which leads to inefficient and long queries. The classic, when queries are made in loops, instead of a join or filter.



And what else, apart from querybuilding (the ability to build queries), does ORM give us? Never mind. Ability to move to a new database? And who in their right mind and firm memory moved to a new database and ORM helped him in this? If you perceive it not as an attempt to map the domain model (!) Into the database, but as a simple library that allows you to make queries to the database in a convenient way, then everything falls into place.



And even though they are used in the names of the classes Model, and in the names of the files models, they do not become models. Don't deceive yourself. This is just a description of the labels. They won't help encapsulate anything.



But if everything is so bad, then what to do? Patterns from layered architectures come to the rescue.



Layered architecture strikes back!



The idea behind layered architectures is simple: we separate business logic, storage logic, and usage logic.



It seems completely logical to separate storage from state change. Those. make a separate layer that can receive and save data from the "abstract" storage.



We leave all the storage logic, for example, in the storage class Repository. And controllers (or service layer) only use it to get and save entities. Then we can change the logic of storing and receiving as we like, and this will be one place! And when we write the client code, we can be sure that we have not forgotten one more place in which we need to save or from which we need to take, and we do not repeat the same code a bunch of times.





It doesn't matter to us if the entity consists of records in different tables or microservices. Or if entities with different behavior depending on the type are stored in one table.



But this division of responsibilities is not free . It should be understood that additional layers of abstraction are created in order to prevent β€œbad” code changes. Obviously, it Repositoryhides the fact that the object is stored in the SQL database, so we must try not to let the SQLism get out of bounds Repository. And all requests, even the most simple and obvious ones, will have to be dragged through the storage layer.



For example, if it becomes necessary to get an office by name and department, you will have to write like this:



#     
interface OfficeRepository: CrudRepository<OfficeEntity, Long> {
    @Query("select o from OfficeEntity o " +
            "where o.number = :office and o.branch.number = :branch")
    fun getOffice(@Param("branch") branch: String,
                  @Param("office") office: String): OfficeEntity?
 ...


And in the case of Active Record, everything is much simpler:



Office.objects.get(name=’Name’, branch=’Branch’)


It's not so simple even if the business entity is actually stored in a non-trivial way (in several tables, in different services, etc.). To implement this well (and correctly) - for which this pattern was created - most often you have to use such patterns as aggregates, Unit of work and Data mappers.



It is difficult to correctly select an aggregate, correctly observe all the restrictions imposed on it, and correctly make data mapping. And only a very good developer can cope with this task. The one that, in the case of Active Record, could do everything "right".



What happens to regular developers? Those who know all the patterns and are firmly convinced that if they use a layered architecture, then their code automatically becomes maintainable and good, not like Active Record. And they create CRUD repositories for each table. And they work in the concept of



one plate - one repository - one entity.



Not:



one repository - one domain object.





They also blindly believe that if a word is used in a class Entity, it reflects the domain model. Like a word Modelin Active Record.



The result is a more complex and less flexible storage layer that has all the negative properties of both Active Record and Repository / Data mappers.


But layered architecture doesn't end there. The service layer is also usually distinguished.



The correct implementation of such a service layer is also a difficult task. And, for example, inexperienced developers make a service layer, which is a service - proxy to repositories or ORM (DAO). Those. services are written so that they don't actually encapsulate business logic:



#      
@Service
class AccountServiceImpl(val accountDaoService: AccountDaoService) : AccountService {
    override fun saveAccount(account: Account) =
            accountDaoService.saveAccount(convertClass(account, AccountEntity::class.java))

    override fun deleteAccount(id: Long) =
            accountDaoService.deleteAccount(id)


And there is a combination of disadvantages of both Active Record and Service layer.



As a result, in layered Java frameworks and code written by young and inexperienced pattern lovers, the number of abstractions per unit of business logic begins to exceed all reasonable limits.





There are layers, but they are all trivial and are just layers for calling the next layer.



The presence of OOP patterns in the framework does not guarantee their correct and adequate application.



There is no silver bullet



It is quite clear that there is no silver bullet. Complex solutions are for complex problems, and simple solutions are for simple problems.



And there are no good and bad patterns. In one situation, Active Record is good, in others, layered architecture. And yes, for the vast majority of small to medium sized applications, Active Record works reasonably well. And for the vast majority of small and medium-sized applications, layered architecture (a la Spring) performs worse. And exactly the opposite for logic-rich complex applications and web services.



The simpler the application or service, the fewer layers of abstraction you need.



Within microservices, where there is not much business logic, it is often pointless to use layered architectures. Ordinary transactional scripts - scripts in the controller - may be perfectly adequate for the task at hand.



Actually, a good developer differs from a bad one in that he not only knows the patterns, but also understands when to apply them.



All Articles