Transcription
Oh, hello, guys. And do you write business logic? I want to ask you. In data classes, or perhaps in Pydantic classes? Or maybe you even know about the wonderful little library and write business logic in ESS classes. Or do you write business logic in your regular classes? How do you do it? Ask yourself such a question: yes, and how should it be done? Is it possible to write business logic, or is it even worth writing business logic in data classes, in Pydantic models, and so on? Let's talk about this, yes. I want to say that in most cases, the best choice for writing business logic is simple, regular classes. Not data classes, not Pydantic models, and not ESS classes. Now we will talk with you about why. In fact, it's a big and important question about code architecture and about the correct understanding and even use of OOP, object-oriented programming. I've wanted to talk about this topic for a long time, and I'm glad that we, uh, will finally sort it out, dear friends. So, as we all know, Python has many wonderful little things. Well, for example, we can create classes in Python, regular classes, in which we can store data and process it. This, generally speaking, is the essence of OOP, object-oriented programming, to be able to combine data and methods that process it into one class, so that this logic is not scattered throughout the entire codebase, throughout the entire project, and so that this class can have a convenient, beautiful, understandable, compact API, with which users of this wonderful class will interact, right? And this small public API will consist of a small number of public fields and methods, and all implementation details will be wonderfully hidden. How is this goal achieved with the help of regular classes? Well, generally speaking, we simply create the init method, the so-called Dunder method, which starts and ends with two underscores. Uh, and in this init method, all the fields that the instance of this class will have are defined. And also, this class has public and non-public fields and methods created. Well, for example, it might look like this. Here we have the meeting class, the meeting class, uh, to which participants can be invited. Participants can confirm their participation in this meeting or decline participation in this meeting. There are a number of business rules, for example, the minimum meeting duration, the maximum meeting duration. Uh, meetings cannot be rescheduled less than 2 hours in advance. So, the deadline for confirmation is 24 hours. At least 60% of invited participants must confirm their participation in this meeting for it to take place, and the maximum number of participants is 50 people. And, generally speaking, here we have, uh, the Dunder method init, the initializer of this class, which sets the protected fields of this class, the title, i.e., the name of this meeting. Host ID is the person who created this meeting. Participant IDs, the date of creation of the meeting, the start and end dates of this meeting, the status of this meeting, the reason for cancellation of this meeting, if the meeting was canceled, and the confirmation status, who confirmed and who declined participation in this wonderful meeting. And as we can see, this class, more or less, manages domain logic. For example, in self.title, we assign the result of calling the validate_title function. And here we have some logic, right, for example, the length of the meeting title cannot be less than three characters and cannot be more than 120 characters. Wonderful. Wonderful. Quite so. Uh, so, participant ID, right, this is also the participant identifier. We also have validate_participant here, which is responsible for some validation. In particular, here, for example, it is checked that the number of participants does not exceed the maximum number of meeting participants, which is set in our business rules in this parameter. Max_Participants. Wonderful. If we look further down this class, we have the business logic for renaming this meeting. We have the business logic for inviting a new participant to our meeting. We have the business logic for removing an invited participant from our meeting. We have the business logic for confirming participation in this meeting by a specific participant. We have the business logic for declining participation in this meeting by a specific participant. And, uh, all these methods of this wonderful meetings class manage domain logic and set the restrictions that exist for this meeting management business process. This is a regular classic OOP approach, the object-oriented programming approach. It consists in the fact that all data and methods that process this data are located in one place, in one class. Accordingly, these data and methods are easier to manage. Plus, there is a convenient, understandable, small public API that clients of this class need. Yes, clients of this class do not know the implementation details, but they can create a meeting, reschedule a meeting, add or remove participants, add confirmation or, uh, decline participation in a meeting by a specific potential participant, and so on. Yes, please note, everything in this class is very well protected. For example, we cannot just take and accidentally set the meeting end date earlier than the meeting start date. Why? Because the meeting end date and start date are stored in protected fields that start with an underscore, right, starts_at and ends_at. Accordingly, we cannot accidentally access these fields from outside this class. We can only change these fields by calling methods. And inside these methods, all the necessary business logic for checking the validity of this data is launched. and all necessary additional actions are performed, for example, changing the meeting status to some new status, and so on, and so on, and so on. We do not have direct public access to the meeting start and end date fields. This is how conventions work in Python: if a field or method is named with an underscore, it is not part of the public API of this class. And directly calling these methods and fields from outside this class is generally not recommended. That is, if we do this, we must understand why we are doing it. And we must do it, generally speaking, at our own risk, understanding that we might break something inside this class. That is, generally speaking, of course, one should use precisely the public API of the class. For example, we can call title on an instance of this class – this is a property, right, which will return self.title. We can see the meeting status, we can see the meeting start date, the meeting end date, we can see the meeting creator, we can see the participants, we can see who confirmed participation in this meeting, right, and we have methods for interacting with this meeting, we can rename the meeting, invite a new participant, and so on. Thus, the class itself maintains its invariants internally. Invariants are what must always be true. For example, the meeting end date must always be after the meeting start date. This is obvious, right, to us humans. But in business logic, it must be specified somewhere, because if we don't specify it, then a situation is possible where the meeting end date is earlier than the meeting start date, and, so to speak, it will be a complete mess. Yes, there are a huge number of invariants in this class. The minimum and maximum meeting duration is set, the maximum number of meeting participants, and so on, and so on, and so on. At the same time, as we can see, we had to create the init method ourselves, right, the initializer of this class, which might not be very convenient. Moreover, we had to create the repr method so that an instance of this class could be wonderfully used as a string somewhere in logs, not in logs, and so on. I recommend that for all classes you create, always implement the Dunder Method repr, which will represent an instance of this class as a understandable string. This is very convenient, it's just good practice. But one way or another, we had to do it ourselves here, implement the repr method and implement the init method. So, why don't we just take advantage of data classes? Well, Python has data classes, right, which can automatically generate a bunch of convenient methods for us, for example, the repr method, the init method, and other methods. And it seems like we get convenience on the one hand, we need to type less code ourselves, and on the other hand, it seems like everything is good and wonderful. And before we look at how all this will be in data classes, allow me a small digression. It's surprising to me, but I sometimes meet people offline, at conferences, or elsewhere, and they often tell me that they don't know that I have a web development course. I always write about it somewhere in the description under the video, but probably I should also talk about it in the video. Friends, for several years now, I have been developing and teaching my own large author's course, called Hardcore Web Development, to which I invite everyone who wants to improve their skills. It covers literally everything, starting from computer science, Linux command line, programming languages Python, JavaScript, TypeScript. We thoroughly study PostgreSQL, databases, database management systems. We study backend, we study frontend, layout, software testing, architectural issues, software development process quality, and many related topics. Come, it's expensive, because I invest an immeasurable amount of my time, my labor, and my soul into this course, and the price will be even higher with the release of new chapters. There is a huge amount of material, assignments, calls, and so on. There are already almost 1,000 assignments. Everything works on our specially developed platform. By the way, I need to shoot a separate material about this platform, about how we are writing it, how everything is done there, how to prepare such material sometime. We have also been developing this platform for 3 years, on which the course is conducted. I checked, there are already 280,000 lines of code without comments and empty lines. That is, it's already a quite large project, there's something to talk about and show. I'll do it sometime. So, the link to the course is visible on the screen now, and it is also, of course, in the description. You can pay in installments, your employer can pay. Both beginners and employees from almost all of Russian Big Tech take the course. There are guys from Yandex, VK, Avito, Sber, Tinkoff, Kaspersky Lab, and so on, and so on, and so on. Come if you are ready to work. There is no nonsense on my course, like guaranteed employment, blah-blah-blah. Here is only meat and hardcore. If you are ready to work, you are welcome. So, let's return to the topic. How does our meeting organization class look in the case of using data classes? Note that we have imported data classes. Here is our wonderful MeetingsDC class. And here, conveniently, all the fields that are involved in the instance of this class are directly specified, right, the meeting title, end date, meeting start date, meeting creator, participants, and so on. And thanks to the use of data classes, we no longer need to manually create the init method here, we don't need to manually create the repr method, and so on. And it seems like everything is convenient, clear, and so on. At the same time, it seems like we can also check invariants, because here we have, for example, the post_init method, which will be automatically called after the init method is created. And here we can check everything. Nevertheless, dear friends, and the domain methods below, I didn't show them here. Nevertheless, dear friends, there is, of course, a significant problem here, because we can create an instance of this MeetingDC class and then simply take, for example, and set the meeting end date earlier than the meeting start date. Yes, and our class will not prevent this in any way. We can reschedule the meeting without resetting confirmations. But the business logic tells us that confirmations must be reset when rescheduling a meeting, right? Or we can set the meeting to the confirmed status without a quorum, i.e., without 60% or more of the participants confirming their participation in this meeting. Or we can simply take and adjust the quorum rules at runtime, setting 1% instead of 60%. Yes. And thus, our wonderful class is somehow not wonderful at all. It seems like it does not support the very invariants and the very business logic that is necessary to apply. It seems like we can bypass it, right? At the same time, note that we are calling the public API here, right, because end_date is a public field, and since we see it in the public API, it means we can read it and we can modify it, right? That is, when we execute this line, assigning the meeting end date earlier than the meeting start date, nothing will break, everything is fine. That is, something will break somewhere at some point later, because, obviously, the meeting start date must be before the meeting end date. And we will have to deal with it, right? That is, our class currently does not support the invariants that it should support. Do you understand the problem? What is the problem? The problem is that any client of the class has write access to the internal fields of this class. And this is bad, because the very clients of the class can break the internal behavior of this class. It is also bad because the internal state of the class, the very internal fields of the class, are publicly visible to the client of the class. And the client of the class sees, including the implementation details of this class, which is also bad. In internal fields, the class may store data that relates to implementation details, and exposing them publicly to clients of the class is generally not necessary. Data classes, however, literally push us towards this. Essentially, a data class encourages the so-called anemic model. That is, it's a model in terms of domain-driven design. A model that contains only data, and functions for processing this data are located somewhere else, right? And a data class makes proper encapsulation more expensive and difficult than in a regular class. That is, it is more difficult in a data class to properly hide implementation details, more difficult to properly protect invariants, and so on, and so on, and so on. Is it possible to somehow fight this when using a data class? Yes, that is, is it possible to somehow make it so that the fields inside the class are still protected or private, i.e., start with an underscore, right, but at the same time can be set from the outside? Well, let's, uh, let's see. For example, we can make such a class Sample, which has two fields field1 and field2, which start with underscores. And we can indeed create an instance of this class, passing values 1 and 2 to it. Yes, we print S, and we get Sample field1=1, field2=2. And we can access s.field1. And field1 starts with an underscore. What's the problem here? First, when we print an instance of this class, i.e., convert an instance of this class to a string, it prints s._field1 and _field2. Those fields and methods that are named with an underscore in Python, by convention, are not part of the public API. And it's unclear to me, as a user of this class, why some implementation details are exposed here. This is not good, not beautiful, and it shouldn't be like that. This is the first point. And the second point, I cannot do this. field1 = 1, and field2 = 2. I cannot do that, because, obviously, this data class knows nothing about _field1, knows nothing about _field2. It only knows about field1 and field2, which start with underscores. But it's awkward to pass fields to the constructor of the Sample method, naming them with an underscore: "This is a bad experience, it's bad. Well, that is, it's a bit clumsy, it's a crutch, and you definitely shouldn't do it like that. Yes, of course, this can be overcome by something like this: implement your own init method, right, and in our data class, we will have _field1 and _field2, which are named with underscores, but at the same time, init accepts normal field1 and normal field2 without underscores and sets these protected fields with these values. But the repr method, which we don't implement here, will still show these fields with underscores, _field1 and _field2. And this is bad, it's a bad developer experience, firstly. And secondly, well, we are already starting to implement dunder_init, which data classes implement automatically. And generally, it's unclear what the point is and why it's needed, because we start to lose the advantages of using data classes. That is, it's still some kind of crutch. Thus, let's think about why this is so. Why do so many problems arise when using data classes for business logic? Let's, so to speak, ponder, ponder how this happens, right? Let's read the word data class itself. Data class, what does it mean, what is it about, right? The word data class, surprisingly, means a data class. It's just a container for data, right? And it's not a class for business logic. It's just a container for data with a defined structure, defined types, and that's it. And for such use cases, data classes are perfectly suitable, right? For example, a data class is perfectly suitable for so-called value objects. And what are value objects, if you don't know? They are immutable objects that have no identity and are compared by the values of their fields. For example, the Temperature class. It might look like this. It's a data class with Frozen=True and Slots=True parameters set. It stores temperature in Celsius internally. At the same time, it has an alternative constructor from_celsius, which allows creating a Temperature class instance from degrees Celsius. It has an alternative constructor from_fahrenheit, which allows setting, allows creating a Temperature class instance from temperature in Fahrenheit. It has a read-only property celsius. It has a read-only property fahrenheit. It has, just in case, a post_init method that maintains invariants. And here the invariant is maintained that the temperature must not be less than absolute zero, i.e., not less than -273.15°C. And this, dear friends, is a value object, because we can create an instance of the Temperature class from_celsius(100°), create another instance of the Temperature class from_celsius(100°). And these two instances will be equal to each other, because they are compared not by identity, but by the values of their fields. And these objects are immutable, i.e., after creating a Temperature object, it cannot be changed. Only a new object of the same Temperature class can be created. This is achieved thanks to the Frozen=True parameter of the data class, and thanks to the Slots=True parameter, we get more efficient behavior of this, uh, object, this Temperature class. As we can see, this class can have a small amount of logic, but it's simple logic that maintains invariants. For example, here, I repeat, the invariant that temperature is always above absolute zero is maintained. That is, for value objects, using data classes is perfectly okay, because value objects are essentially just immutable simple values that are needed to make the code self-documenting, right, so that everything is beautiful, so that invariants are maintained for such values, so that temperatures in Celsius and Fahrenheit are not confused in our program, and so on. Yes, examples of Value Objects that can also be are money. Two objects that store 100 rubles are equal to each other. That is, they are also compared by value. This is also a value object, right? Other examples. Percentages, for example, values from 0 to 100%, right, or from zero to one, depending on how you implement it. Uh, or coordinates, or the quantity of something with a unit of measurement, for example, three boxes, 2 tons, or four linear meters, and so on, right? Or a tax rate, or simply an identifier of some entity, for example, user ID, order ID, and so on, right? Data classes are also okay, besides value objects, for DTOs, Data Transfer Objects, or Data Transfer Objects, i.e., data structures that are simply passed between layers of our application. That is, we transfer data between application layers, and we can use instances of data classes for this. Instead of tuples with a bunch of elements, instead of dictionaries, typed dictionaries, I don't know, named tuples, right, we can and should use data classes, because they are precisely intended for this. That is, for example, one function returns five values. How to pack them? A function cannot return five values in Python. It can only return one value, but this one value can be some kind of container. For example, a tuple, a named tuple, a dictionary, and so on, right? Well, the correct answer is to return an instance of a data class. In rare cases, when, okay, there are few values, like two values, you can use a tuple for this, which will be unpacked immediately when calling the function. But most often, you should use a data class. It's simply excellent for this and, generally speaking, it's intended for this. Moreover, in such cases for DTOs, you should also try to use data classes with Frozen=True and Slots=True parameters, because for DTOs it's natural, right, they are generally immutable data structures, as a rule. Well, and of course, DTOs have no business logic. It's just a container, again, right, for data with a defined structure and defined typing. Besides all this, besides data classes, there is also Pydantic. And I often see people learn about Pydantic and use it literally everywhere, extensively, where they need to create some kind of data structure. They just take Pydantic, use it for DTOs for data transfer between different system layers, and so on, and so on, and so on. So, I don't consider this a good idea. And, uh, there are several explanations for this. Firstly, according to clean architecture, business logic should not depend on external libraries, right? And Pydantic is, of course, an external library. It's not part of the standard Python library, unlike data classes. Based on this alone, business logic should not know about Pydantic, i.e., should not import Pydantic, should not import Pydantic models, and so on, and so on, and so on. Secondly, as far as I'm concerned, the main task of Pydantic is convenient work with JSON. For example, validating incoming JSON in your web service. This is an excellent, excellent use case for Pydantic, right? Pydantic is perfectly suited for this, and here it is appropriate in the same Fast API framework or wherever else, right? Also, in the opposite direction, assembling a JSON from nested entities is quite convenient with Pydantic, but I consider using Pydantic somewhere in the domain layer completely inappropriate. There is also an external library attrs, which also needs to be installed, just like Pydantic, using pip, uv, poetry, whatever you like. I like uv now, uv is just one love. And, in fact, the attrs library is, to some extent, the progenitor of data classes, i.e., a significant part of the idea of data classes was taken from attrs, but attrs has significantly more functional capabilities than data classes. For example, you can conveniently implement those very private fields that cannot be nicely done in data classes. It might look like this. We import attrs.define, create a class. So, field1 and field2 start with underscores. We set types for these fields to int and set that these fields have aliases field1 and field2 without underscores. And then, in the initializer of this wonderful Sample class, we can pass field1 without an underscore and field2 without an underscore. And when we print, uh, so, print(s), it will still print Sample(field1=_field1, field2=_field2), which, of course, is again inconvenient and creates a clumsy developer experience. But at least here we don't have the ability to access _field1 in this way. That is, _field1 is still a private field. Firstly, it's a private field. Secondly, we can set this field in the constructor without an underscore. And it seems like everything works. That is, the clumsiness here only occurs in the repr method. Firstly, and secondly, the attrs library is also an external library that also needs to be installed. And this is also, accordingly, a bad tool for use in the domain and business logic layer. Business logic, I repeat once again, should not depend on volatile external libraries, so that updates and errors of these external libraries do not affect the operability and quality of the most complex and most important logic in your project, namely, domain and business logic. Thus, let's summarize, dear friends. The key concept of OOP, object-oriented programming, is that data and behaviors associated with that data are together in one class, right? If we have data separately, even in the form of an object, and separately some functions that process this data, then this is a bad implementation of OOP. Then the class encapsulates nothing, guarantees nothing properly, supports no invariants, the logic for processing this data can be scattered throughout the project, and so on. This is not object-oriented programming, despite the fact that the data may seem to be packaged precisely in a class. Yes, sometimes it is appropriate when a class defines just a data container, essentially a DTO, Data Transfer Object, it has no logic, it is most often immutable, it has no lifecycle, and so on. Then it can be appropriate. Moreover, in proper object-oriented programming, a class should represent a quality abstraction, i.e., provide its clients with a good, well-thought-out, minimally necessary API through which these clients of the class will interact with this class. Everything unnecessary for clients, this class hides in private and protected fields and methods. A class may have, for example, a couple of public methods, zero public properties, and a dozen private methods. And this is perfectly okay. This is perfectly wonderful, right? There is a clear, well-thought-out abstraction that is understandable how to use, and everything is wonderful. All implementation details are hidden from the clients of the class, which is good in itself, because we do not burden the client of the class with unnecessary nonsense, unnecessary knowledge, on the one hand, right? But on the other hand, this also allows us to easily change implementation details within the class later while preserving the public behavior of the class through its public API. Great. Great. The question of creating quality abstractions is, generally speaking, huge and not as simple as it might seem at first glance when you start studying object-oriented programming. Perhaps we will talk about this sometime separately in a separate video. It should also be said that what I am talking about here is, of course, not dogma. This is a general recommendation, a general, if you like, rule. And from this rule, of course, you can find exceptions, which is perfectly normal, right? But to deviate from the rule, you should at least know and understand why this rule was invented, why it was created, why it is good, why this rule. Yes, I hope you understand this now, dear friends. Wonderful. Thanks for watching. Stay in touch and until the next episodes. Bye-bye. Bye-bye.