📜 ⬆️ ⬇️

Developer Cookbook: DDD Recipes (Part 4, Structures)

Introduction


So, we have already decided on the scope , methodology and architecture . Let's move from theory to practice, to writing code. I would like to start with design patterns that describe the business logic - Service and Interactor . But before proceeding with them, let's examine the structural patterns - ValueObject and Entity . We will develop in the language of ruby . In future articles we will analyze all the patterns needed for development using Variable architecture . All the developments that are annexes to this series of articles will be collected in a separate framework.


Blacjack & hockers


And we have already picked up a suitable name - LunaPark .
Current developments are posted on Github .
Having examined all the templates, we will assemble one full-fledged microservice.


So historically


There was a need to refactor a complex corporate application written in Ruby on Rails. There was a ready-made team of ruby ​​developers. The Domain Driven Development methodology was fine for these tasks, but there was no ready-made solution in the language used. Despite the fact that the choice of language was mainly due to our specialization, it turned out to be quite successful. Among all the languages ​​that are used for web-applications, ruby, in my opinion, is the most expressive. And therefore it is more suitable for modeling real objects. This is not just my opinion.


That is the java world. Then you have the new comers like Ruby. It is a very good language for DDD (although I have not heard of it). Rails has been made a lot of excitement. There is a great deal of freedom in the past. It’s a little bit more than that. It is a clear case for DDD. (A few infrastructure pieces would probably have to be filled in.)

Eric Evans 2006

Unfortunately, over the past 13 years, nothing much has changed. On the Internet, you can find attempts to adapt Rails for this, but they all look terrible. The Rails framework is heavy, slow and inconsistent with the principles of SOLID. Watching without tears how someone is trying to portray the implementation of the Repository pattern on the basis of AstiveRecord is very hard. We decided to adopt some microframework and modify it to our needs. We tried Grape , the idea of ​​auto-documenting seemed good, but otherwise it was abandoned and we quickly abandoned the idea of ​​using it. And almost immediately began to use another solution - Sinatra . We still continue to use it for REST Controllers and Endpoints .


REST?

If you developed web applications, you already have an idea about technology. It has its pros and cons, a complete listing of which is beyond the scope of this article. But for us, as developers of corporate applications, the main disadvantage is that REST (this is understandable even from the name) does not reflect the process, but its state. And the advantage is its clarity - the technology is clear to both back-end developers and front-end developers.
But then it may not focus on REST, but implement its http + json solution? Even if you manage to develop your service API, then providing its description to third parties you will receive many questions. Much more than if you provide the usual REST.
We will consider using REST as a compromise solution. We use JSON for brevity and jsonapi standard, so as not to waste developers time on holy wars about the format of requests.
Later, when we analyze Endpoint , we will see that in order to get rid of rest, it is enough to rewrite just one class. So REST should not bother at all if there are doubts about it.


In the course of writing several microservices, we got some groundwork - a set of abstract classes. Each such class can be written in half an hour, its code is easy to understand, if you know what this code is for.


Here the main difficulties arose. New employees who did not deal with DDD practitioners and clean architecture could not understand the code and its purpose. If I saw this code for the first time before I read Evans, I would take it as legacy, over-engineering.


To overcome this obstacle, it was decided to write documentation (guideline) describing the philosophy of the approaches used. Sketches of this documentation seemed successful and it was decided to post them on Habré. Abstract classes that were repeated from project to project were decided to be put into a separate gem.


Philosophy


legacy-way
If you recall some classic martial arts film, then there will be a tough guy who very cleverly draws a pole. A pole is essentially a stick, a very primitive tool, one of the first to fall into a person’s arms. But in the hands of the master, he becomes a formidable weapon.
You can spend time creating a gun that doesn’t shoot you in the leg, or you can spend time learning how to use shooting techniques. We have identified 4 basic principles:



A similar philosophy can be traced such as ArchLinux - The Arch Way . On my Linux laptop for a long time I did not take root, sooner or later it broke and I constantly had to reinstall it. This caused a number of problems, sometimes serious ones like breaking the deadline at work. But after spending 2-3 days once on installing Arch, I figured out how my OS works. After that, she began to work more stable, without failures. My notes helped me install it on new PCs in a couple of hours. And abundant documentation helped me solve new problems.


The framework has an absolutely high-level character. The classes that describe it are responsible for the structure of the application. Third-party solutions are used to interact with databases, implement the http protocol and other low-level things. We would like the programmer to peek into the code and understand how a particular class works, and the documentation would allow us to understand how to manage them. Understanding the engine device will not allow you to drive a car.


Framework


It is difficult to call LunaPark a framework in the usual sense. Frame - frame, Work - work. We urge not to limit ourselves to the framework. The only frame that we declare is the one that prompts the class in which one or another logic should be described. It is rather a set of tools with extensive instructions for them.
Each class is abstract and has three levels:


module LunaPark #  module Forms #  class Single # / end end end 

If you want to implement a form that creates one element, you inherit from this class:


 module Forms class Create < LunaPark::Forms::Single 

If there are several elements, we will use another Implementation .


 module Forms class Create < LunaPark::Forms::Multiple 

At the moment, not all developments are given in perfect order and the gem is in the alpha version. We will bring it in stages, in coordination with the publication of articles. Those. if you see an article about ValueObject and Entity , then these two templates have already been implemented. By the end of the cycle, they will all be suitable for use on the project. Since the framework itself is not very useful without a bundle with sinatra \ roda, a separate repository will be created, which will show how to “tie” everything for a quick start of your project.


The framework is primarily a documentation supplement. You should not take these articles as documentation for the framework.


So let's get down to business.


Object Value


- How tall is your girlfriend?
- 151
- You began to meet with the statue of liberty?

Something like this could happen in Indiana. Human height is not just a number, but also a unit of measurement. Object attributes are not always described by primitives only (Integer, String, Boolean, etc.), sometimes their combinations are required:



On the other hand, this is not always a combination; perhaps this is some kind of primitive extension.
The telephone number is often perceived as a number. On the other hand, it is unlikely that he should have a method of addition or division. Perhaps there is a method that will issue a country code and a method defining the city code. Perhaps there will be some kind of decorative method that will present it not just with the string of numbers 79001231212 , but with the readable string: 7-900-123-12-12 .


maybe the decorator?

If we proceed from dogma, then undoubtedly - yes. If we approach this dilemma from the side of common sense, then when we decide to call this number, we will give the object itself to the telephone:


 phone.call Values::PhoneNumber.new(79001231212) 

And if we decided to present it as a string, then this is clearly done for a person. So why don't we make this line readable for a person right away?


 Values::PhoneNumber.new(79001231212).to_s 

Imagine that we are creating an online casino website “Three Axes” and selling card games. We need the class 'playing card'.


 module Values class PlayingCard < Lunapark::Values::Compound attr_reader :suit, :rank end end 

So, our class has two read-only attributes:



These attributes are set only when the map is created and cannot change when it is used. Of course you can take a playing card and cross out 8 , write Q, but this is unacceptable. In a decent society, you are likely to shoot. The inability to change attributes after the creation of an object determines the first property of the Value Object - immunity.
The second important property of Value Object is how we compare them.


 module Values RSpec.describe PlayingCard do let(:card) { described_class.new suit: :clubs, rank: 10 } let(:other) { described_class.new suit: :clubs, rank: 10 } it 'should be eql' do expect(card).to eq other end end end 

This test will not pass, as they will be compared at. In order for the test to pass, we must compare Value-Obects by value, for this we add the comparison method:


 def ==(other) suit == other.suit && rank == other.rank end 

Now our test will pass. We can also add methods that are responsible for the comparison, but how do we compare 10 and K? As you have probably guessed, we will also represent them in the form of Value Objects . Ok, so now we will have to initiate the top ten of a club like this:


 ten = Values::Rank.new('10') clubs = Values::Suits.new(:clubs) ten_clubs = Values::PlayingCards.new(rank: ten, clubs: clubs) 

Three lines is quite a lot for ruby. In order to circumvent this restriction, we introduce the third property of the Object-Value - turnover. Let us have a special method of the class .wrap , which can take values ​​of various types and convert them to the desired one.


 class PlayingCard < Lunapark::Values::Compound def self.wrap(obj) case obj.is_a? self.class #      PlayingCard obj #      case obj.is_a? Hash #    ,      new(obj) #    case obj.is_a String #    ,     new rank: obj[0..-2], suit:[-1] # ,  -  . else #       raise ArgumentError #  . end end def initialize(suit:, rank:) #     @suit = Suit.wrap(suit) #      @rank = Rank.wrap(rank) end end 

This approach gives a great advantage:


 ten = Values::Rank.new('10') clubs = Values::Suits.new(:clubs) from_values = Values::PlayingCard.wrap rank: ten, suit: clubs from_hash = Values::PlayingCard.wrap rank: '10', suit: :clubs from_obj = Values::PlayingCard.wrap from_values from_str = Values::PlayingCard.wrap '10C' #        utf ,  ,  . 

All these cards will be equal to each other. If the wrap method grows into good practice, it will be moved to a separate class. From the point of view of a dogmatic approach, a separate class will also be mandatory.
Hm, what about the place in the deck? How to find out if this card is a trump card? This is not a playing card. This is the value of the playing card. This is exactly the inscription 10, which you lead on the corner of cardboard.
The Value Object needs to be treated the same way as a primitive, which for some reason was not implemented in ruby. Hence the last property arises - Object-Value is not tied to any domain.


Recommendations


Among the variety of methods and tools used at each moment of each process, there is always one method and tool that works faster and better than others.

Frederick Taylor 1914

Arithmetic operations must return a new object.

 # GOOD class Money < LunaPark::Values::Compound def +(other) other = self.class.wrap(other) raise ArgumentError unless same_currency? other self.class.new( amount: amount + other.amount, currency: currency ) end end 

Object-Value Attributes can only be primitives or other Value Objects

 # GOOD class Weight < LunaPark::Values::Compound def intialize(value:, unit:) @value = value @unit = Unit.wrap(unit) end end # BAD class PlaingCard < LunaPark::Value def initialize(rank:, suit:, deck:) ... @deck = Entity::Deck.wrap(deck) #    end end 

Keep simple operations inside class methods

 # GOOD class Weight < LunaPark::Values::Compound def >(other) value > other.convert_to(unit).value end end 

If the operation "conversion" is large, then it may be worthwhile to put it into a separate class.

 # UGLY class Weight < LunaPark::Values::Compound def convert_to(unit) unit = Unit.wrap(unit) case { self.unit.to_sym => unit.to_sym } when { :kg => :ft } Weight.new(value: 2.2046 * value, unit.to_sym) when # ... end end end # GOOD #./lib/values/weight/converter.rb class Weight class Converter < LunaPark::Services::Simple def initialize(weight, to:) ... end end end #./lib/values/weight.rb class Weight < LunaPark::Values::Compound def convert_to(unit) Converter.call! self, to: unit end end 

Such a removal of logic into a separate Service is possible only if the Service is isolated: it does not use data from any external sources. This service should be limited to the context of the Value Object itself.


Object value can not know anything about domain logic

Suppose we write an online store, and we have a rating of goods. To get it, you need to make a request to the database via the repository .


 # DEADLY BAD class Rate < LunaPark::Values::Single def top?(10) Repository::Rates.top(first: 10).include? self end end 

Entity


The Essence class is responsible for some real object. It can be a contract, a chair, a real estate agent, a pie, an iron, a cat, a refrigerator — anything. Any object that you may need to model your business processes is the Entity .
The concept of Essence is different in Evans and in Martin. From the point of view of Evans, an entity is an object characterized by something that emphasizes its individuality.


Essence on Zvansu
If an object is determined by a unique individual existence, rather than a set of attributes, this property should be read most importantly when defining an object in a model. The class definition should be simple and be built around the continuity and uniqueness of the object's life cycle. Find a way to distinguish each object, regardless of its form or history. Pay special attention to the technical requirements related to the comparison of objects by their attributes. Set an operation that would necessarily give a unique result for each such object - perhaps, for this, you will have to associate a certain character with guaranteed uniqueness. Such a means of identification may be of external origin, but it can also be an arbitrary identifier generated by the system for its own convenience. However, such a tool must comply with the rules for distinguishing objects in a model. The model should be given a precise definition of what is the same objects.

From the point of view of Martin, Entity is not an object, but a layer. This layer will combine both the object and the business logic for its change.


Disenchantment from Martin
Application Independent Business rules. They are not simply data objects. They may hold references to data objects; It can be used for many different applications.
')
Gateways return Entities. This is where the implementation of the data is taken. This can be done either with containment or inheritance.

For example:

public class MyEntity {private MyDataStructure data;}

or

public class MyEntity extends MyDataStructure {...}

And remember, we are all pirates by nature; and the rules.

We will mean only the structure under the Essence . In its simplest form, the Entity class will look like this:


 module Entities class MeatBag < LunaPark::Entities::Simple attr_accessor :id, :name, :hegiht, :weight, :birthday end end 

A mutable object that describes the structure of a business model may contain primitive types and Values .
The LunaPark::Entites::Simple class is incredibly simple, you can see its code, it gives us only one thing - easy initialization.


LunaPark :: Entites :: Simple
 module LunaPark module Entities class Simple def initialize(params) set_attributes params end private def set_attributes(hash) hash.each { |k, v| send(:"#{k}=", v) } end end end end 

You can write:


 john_doe = Entity::MeatBag.new( id: 42, name: 'John Doe', height: '180cm', weight: '80kg', birthday: '01-01-1970' ) 

As you may have guessed, we want to wrap the weight, height and date of birth in Objects-values .


 module Entities class MeatBag < LunaPark::Entites::Simple attr_accessor :id, :name attr_reader :heiht, :wight, :birthday def height=(height) @height = Values::Height.wrap(height) end def weight=(height) @height = Values::Weight.wrap(weight) end def birthday=(day) @birthday = Date.parse(day) end end end 

In order not to waste time on such constructors, we have prepared a more complex Implementation of LunaPark::Entites::Nested :


 module Entities class MeatBag < LunaPark::Entities::Nested attr :id attr :name attr :heiht, Values::Height, :wrap attr :weight, Values::Weight, :wrap attr :birthday, Values::Date, :parse end end 

As the name suggests, this implementation allows you to make tree structures.


Let's satisfy my passion for large-sized household appliances. In the last article, we made an analogy between the "twist" of a washing machine and architecture . And now we describe such an important business object as a refrigerator:


Refregerator


 class Refregerator < LunaPark::Entites::Nested attr :id, attr :brand attr :title namespace :fridge do namespace :door do attr :upper, Shelf, :wrap attr :lower, Shelf, :wrap end attr :upper, Shelf, :wrap attr :lower, Shelf, :wrap end namespace :main do namespace :door do attr :first, Shelf, :wrap attr :second, Shelf, :wrap attr :third, Shelf, :wrap end namespace :boxes do attr :left, Box, :wrap attr :right, Box, :wrap end attr :first, Shelf, :wrap attr :second, Shelf, :wrap attr :third, Shelf, :wrap attr :fourth, Shelf, :wrap end attr :last_open_at, comparable: false end 

Such an approach saves us from creating unnecessary Entities such as the door from the refrigerator. Without a refrigerator, it should be part of the refrigerator. This approach is convenient for compiling relatively large documents, such as an application for the purchase of insurance.


The LunaPark::Entites::Nested class has 2 more important properties:


Comparability:


 module Entites class User < LunaPark::Entites::Nested attr :email attr :registred_at end end u1 = Entites::User.new(email: 'john.doe@mail.com', registred_at: Time.now) u2 = Entites::User.new(email: 'john.doe@mail.com', registred_at: Time.now) u1 == u2 # => false 

These two users are not equivalent, since they were created at different times and therefore the value of the registred_at attribute will be different. But if we delete the attribute from the list of compared:


 module Entites class User < LunaPark::Entites::Nested attr :email attr :registred_at, comparable: false end end 

then we get two comparable objects.


This implementation also has the property of turnover - we can use the class method `wrap


 Entites::User.wrap(email: 'john.doe@mail.com', registred_at: Time.now) 

You can use Hash, OpenStruct, or any gem you like to help you realize the structure of your entity.


Entity is a business object model, keep it simple. If a property is not used by your business, do not describe it.


Entity changes


As you noticed, the Essence class does not have any methods of its own change. All changes are made from the outside. The value object is also immutable. All those functions that are present in it, by and large decorate the essence or create new objects. The essence itself remains unchanged. For the developer of Ruby on Rails, this approach will be unusual. From the outside it may seem that we generally use OOP-language for something else. But if you look deeper, it is not. Can a window open by itself? Car get to work, hotel booked, cute cat get a new subscriber? These are all external influences. Something happens in the real world, and we reflect it in ourselves. For each request, we make changes to our model. And thus we keep it up to date, sufficient for our business tasks. It is necessary to separate the state of the model and the processes causing changes in this state. How to do this, we will look at the next article.

Source: https://habr.com/ru/post/435920/


All Articles