In the framework of the previous articles we described: scope , methodological foundations , example of architecture and structure . In this article, I would like to tell you how to describe processes, about the principles of requirements gathering, how business requirements differ from functional ones, how to move from requirements to code. Explain how the Use Case is applied and how they can help us. Sample examples of the implementation of Interactor and Service Layer design patterns.
The examples given in the article are given using our LunaPark solution, it will help you with the first steps in the described approaches.
Again and again it happens that many business ideas do not really turn into the final, intended product. Often this is due to the inability to understand the difference between business requirements and functional requirements, which ultimately leads to inappropriate collection of requirements, unnecessary documentation, project delays and major project failures.
Or sometimes we are confronted with situations in which, although the final decision meets the needs of customers, but somehow the business goals are not achieved.
Therefore, it is extremely important to separate business requirements and functional requirements, before you begin to define them. Let's take an example.
Suppose we are writing an application for a pizza delivery company, and we decided to make a courier tracking system. Business requirements are as follows:
"Implement a web-based mobile tracking system for employees based on mobile devices that captures couriers on their routes and increases efficiency by monitoring courier activity, their absence from work and productivity."
Here you can select a number of characteristic features that will indicate that these are requirements from the business:
Functional requirements are Actions that the system must perform in order to implement business requirements. Thus, the functional requirements are associated with the developed solution or software. We formulate the functional requirements for the above example:
Highlight the following features:
A few words should be said about non-functional requirements (also known as “quality requirements”) that impose restrictions on the design or implementation (for example, requirements for performance, safety, accessibility, reliability). Such requirements answer the question " what " should be the system.
Development is the translation of business requirements into functional ones. Application programming is the implementation of functional requirements, and system programming is non-functional.
The implementation of functional requirements is often the most difficult in commercial systems. In a pure architecture, functional requirements are implemented through the Use Case layer.
But for starters, I want to turn to the source. Ivar Jacobson, the author of the definition of Use Case , one of the authors of the UML, and the RUP methodology, in his article Use-Case 2.0 The Hub of Software Development identifies 6 principles of application Use cases :
We briefly consider each of these principles, they will be useful to us for further understanding. Below is my free translation, with abbreviations and inserts, I strongly recommend that you familiarize yourself with the original.
Narration is part of our culture; This is the easiest and most effective way to transfer knowledge, information from one person to another. This is the best way to communicate what the system should do and help the team focus on common goals.
Use cases reflect the purpose of the system. To understand the Usage Option, we tell, tell a certain story. The story tells how to achieve goals and how to solve problems arising on the way. Use cases, like a storybook, provide a way to identify and capture all of the different, but related stories in a simple, comprehensive way. This makes it easy to collect, distribute and understand system requirements.
This principle correlates with the partern Common Language (Ubiques language) from the DDD approach.
Regardless of which system you are developing, large, small, software, hardware, or a business system, understanding the big picture is very important. Without understanding the system as a whole, you will not be able to make the right decisions about what to include in the system, what to exclude, how much it will cost and what benefits it will bring.
Ivar Jacobson suggests using a use-case diagram , which is very convenient for gathering requirements. If the requirements are collected and clear, then the best option would be the Context Map (Context map) of Eric Evans. Often, the Scrum approach is interpreted in such a way that people do not spend time on a strategic plan, considering planning, more than two weeks later, a relic of the past. Jeff Sutherland’s propaganda descended on the waterflow, and the people who completed the two-week scram master training courses that were admitted to project management did their job. But common sense is aware of the importance of strategic planning. No need to make a detailed strategic plan, but it should be.
Trying to understand how the system will be used, it is always important to focus on the value that it will provide to its users and other interested parties. Value is formed only when the system is used. Therefore, it is much better to focus on how the system will be applied than on the long lists of functions or features that it can offer.
The use cases provide this focus, helping you concentrate on how the system will be used by a particular user to achieve his goal. The use cases cover a variety of ways to use the system: those that successfully achieve their goals, and those that solve any difficulties that arise.
Further, the author presents a remarkable scheme, to which one should pay the closest attention:
The diagram shows the use case, "Cash withdrawal at an ATM". The easiest way to achieve a goal is described in the Basic Flow. Other cases are described as Alternative Flows. These directions help with narration, structure the system and help with writing tests.
Most systems require a lot of work before they are ready to use. They have many requirements, most of which depend on other requirements, they must be implemented before the requirements are met and evaluated.
Big mistake to create such a system at a time at a time. The system should be built of pieces, each of which has a clear value for users.
These ideas have something in common with approaches of flexible development and with ideas of Domains (Domain).
Most software systems have evolved over many generations. They are not produced at one time; they are built as a series of issues, each of which is built on a previous release. Even the releases themselves often do not go beyond once, but develop through a series of intermediate versions. Each step provides a visual, usable version of the system. This is the way all systems should be created.
Unfortunately, there is no universal solution to software development problems; different teams and different situations require different styles and different levels of detail. Regardless of which methods and techniques you choose, you must ensure that they are adaptable enough to meet the current needs of the team.
Eric Evans in his book urges not to spend a lot of time describing all the processes through UML. Enough to use any visual schemes. Different teams, different projects require different levels of detail, the UML author himself speaks about this.
In pure architecture, Robert Martin gives the following definition of Use Options :
This is a case in point.
Let's try to translate these ideas into code. Let's remember the scheme from the third principle of using the Use Options and take it as a basis. Consider a really complex business process: "Cooking a pie with cabbage."
Let's try to decompose it:
We will implement this whole sequence through Interactor , and each step will be implemented through a function or Functional Object on the Service Layer.
I highly recommend starting the development of a complex business process with the Sequence of Action . More precisely not so, you should define the Domain area to which the business process belongs. Clarify all business requirements. Identify all Entities that are involved in the process. Document the requirements and definitions of each Entity in the knowledge base.
Paint everything on paper in steps. Sometimes a sequence diagram is required. Its author is the same who invented Use Cases (Use Case) - Ivar Jacobson. The diagram was invented by him when he developed a telephone service system for Erickson, based on a relay circuit. I really like this diagram, and the term Sequence , in my opinion, is more expressive than the term Interactor . But due to the greater prevalence of the latter, we will use the familiar term - Interactor .
A small hint, when you describe a business process as a good help for you, can be, the basic rule of workflow: "As a result of any economic activity, a document must be drawn up." For example, we are developing a discount system. Providing a discount, we in fact, from the point of view of business, conclude an agreement between the company and the client. This contract must contain all the conditions. That is, in the DiscountSystem domain, you will have Entites :: Contract. Do not tie a discount to the client, but create an Entity Contract, which describes the rules for its provision.
Back to the description of our business process, after it has become transparent to all the people involved in its development, and all your knowledge is recorded. I recommend starting to write the code with a sequence of actions .
The Sequence Design Pattern is responsible for:
I would like to dwell on the latter responsibility in more detail if we have a complicated process - we must describe it in such a way that it is clear what is happening without going into technical details. You should describe it as expressively as your programming skills allow . Entrust this class to the most experienced member of your team.
Returning to the cake: try to describe the process of cooking through Interactor .
I give an example of implementation, with our solution LunaPark , which we presented in a previous article.
module Kitchen module Sequences class CookingPieWithabbage < LunaPark::Interactors::Sequence TEMPERATURE = Values::Temperature.new(180, unit: :cel) def call! Services::CheckProductsAvailability.call list: ingredients dough = Services::BeatDough.call from: Repository::Products.get(beat_ingredients) filler = Services::MakeabbageFiller.call from: Repository::Products.get(filler_ingredients) pie = Services::MakePie.call dough, with: filler bake = Services::BakePie.new pie, temp: TEMPERATURE sleep 5.min until bake.call pie end private attr_accessor :beat_ingredients, :filler_ingredients attr_accessor :pie def ingredients_list beat_ingredients_list + filler_ingredients_list end end end end
As we can see, the call!
method call!
describes the whole business logic of the baking process. And it is convenient to use it to understand the logic of the application.
Also, we can easily describe the process of baking fish pie, replacing MakeabbageFiller
with MakeFishFiller
. Thereby, we very quickly change the business process, without significant code modifications. And also, we can leave both sequences at the same time, scaling business cases.
call!
is an obligatory method, it describes the order of Actions .attr_acessor
: class Foo < LunaPark::Interactors::Sequence # ... private attr_accessor :bar end Foo.call(bar: 42)
beat_ingredients = [ Entity::Product.new :flour, 500, :gr, Entity::Product.new :oil, 50, :gr, Entity::Product.new :salt, 1, :spoon, Entity::Product.new :milk, 150, :ml, Entity::Product.new :egg, 1, :unit, Entity::Product.new :yeast, 1, :spoon ] filler_ingredients = [ Entity::Product.new :cabbage, 500, :gr, Entity::Product.new :salt, 1, :spoon, Entity::Product.new :pepper, 1, :spoon ] cooking = CookingPieWithabbage.call( beat_ingredients: beat_ingredients, filler_ingredients: filler_ingredients ) # : cooking.success? # => true cooking.fail # => false cooking.fail_message # => '' cooking.data # => Entity::Pie # : cooking.success? # => false cooking.fail # => true cooking.fail_message # => 'The pie burned out' cooking.data # => nil
The process is represented through an object and we have all the necessary methods to call it - did the call pass successfully, did any error occur during the call process, and if so, which one?
If we now recall the third principle of using the Use Case, let us pay attention to the fact that in addition to the Main Direction , we also had Alternative Directions. These are errors that we must handle. Consider an example: we certainly don’t want events to go this way, but we can’t do anything about it, the harsh reality is that the cakes are burned occasionally.
Interactor intercepts all errors inherited from the class LunaPark::Errors::Processing
.
How do we keep track of the cake? To do this, we define the Burned
error in BakePie
Action .
module Kitchen module Errors class Burned < LunaPark::Errors::Processing; end end end
And during baking, check that our cake is not burned:
module Kitchen module Services class BakePie < LunaPark::Callable def call # ... rescue Errors::Burned, 'The pie burned out' if pie.burned? # ... end end end end
In this case, the error interceptor will work, and we will be able to deal with them in the .
Errors that are not inherited from Processing
are perceived as systemic and will be intercepted at the server level. If not indicated other conditions, the user will receive 500 ServerError.
Each Action should not be implemented by a separate method, it makes the code more bloated. You have to look through the whole class several times to understand how it works. Let's spoil the recipe for baking the cake:
module Service class CookingPieWithabbage < LunaPark::Interactors::Sequence def call! check_products_availability make_cabbage_filler make_pie bake end private def check_products_availability Services::CheckProductsAvailability.call list: ingredients end # ... end end
Use the action call right in the classroom. This approach from the point of view of ruby ​​may seem unusual, so it looks more readable:
class DrivingStart < LunaPark::Interactors::Sequence def call! Service::CheckEngine.call Service::StartUpTheIgnition.call car, with: key Service::ChangeGear.call car.gear_box, to: :drive Service::StepOnTheGas.call car.pedals[:right] end end
# good - , , . # . Sequence::RingingToPerson.call(params) # good - , e, # , , # . ring = Sequence::RingingToPerson.new(person) unless ring.success? ring.call sleep 5.min end
# bad - , # . module Services class BuildUser < LunaPark::Callable def initialize(first_name:, last_name:, phone:) @first_name = first_name @last_name = last_name @phone = phone end def call Entity::User.new( first_name: first_name, last_name: last_name, phone: phone ) end private attr_reader :first_name, :last_name, :phone end end module Sequences class RegisteringUser < LunaPark::Interactors::Sequence attr_accessor :first_name, :last_name, :phone def call! user = Service::BuildUser.call(first_name: first_name, last_name: last_name, phone: phone) end end end # good - , . # , # . module Sequences class RegisteringUser < LunaPark::Interactors::Sequence attr_accessor :first_name, :last_name, :phone def call! user #... end private def user @user = Entity::User.new( first_name: first_name, last_name: last_name, phone: phone ) end end end
The order of actions (Interactor), as we said, describes the business logic at the top level. The service layer (Service layer) already reveals the details of the implementation of functional requirements. If we are talking about making a cake, then at the level of the Order of Action (Interactor) we simply say “knead the dough”, without going into details on how to knead it. The kneading process is described at the Service level . Let's return to the original source, the big blue book :
In the applied domain, there are such operations that cannot find a natural place in an object of Entity or Value object. They are in essence not objects, but activities. But since our modeling paradigm is based on an object-based approach, we will try to turn them into objects.
At this point, it is easy to make a common mistake: to abandon the attempt to place an operation in an object suitable for it, and thus come to procedural programming. But if you forcibly place an operation in an object with a definition that is alien to it, the object itself will lose the purity of the plan, it will become harder to understand and refactor. If in a simple object to implement a lot of complex operations, it can turn into an incomprehensible thing, it is not clear what is busy Other domain objects are often involved in such operations, and they are coordinated to perform a joint task. Additional responsibility creates chains of dependencies between objects, mixing concepts that could be considered independently.
When choosing a place to implement a particular functionality, always use common sense. Your task is to make the model more expressive. Let's look at an example, "We need to chop firewood":
module Entities class Wood def chop # ... end end end
This method will be an error. Firewood does not chop itself, we will need an ax:
module Entities class Axe def chop(sacrifice) # ... end end end
If we use a simplified business model, that will be enough. But if the process needs to be modeled in more detail, we will need a person who will cut this wood, and perhaps some log that will be used as a support for the process.
module Entities class Human def chop_firewood(wood, axe, chock) # ... end end end
As you may have guessed, this is not a good idea. Not all of us are engaged in cutting wood, it is not a direct duty of man. We often see how overloaded are models in Ruby on Rails that store similar logic in them: getting a discount, adding a product to the cart, withdrawing money to the balance. This logic does not refer to an entity, but to a process in which this entity is involved.
module Services class ChopFirewood # ... end end
After we figured out what logic we store in the Services we will try to implement one of them. Most often services are implemented through methods or functional objects.
A functional object fulfills one functional requirement. In its most primitive form, a functional object has one single public method - call
.
module Serivices class Sum def initialize(x, y) @x = x @y = y end def call x + y end def self.call(x,y) new(x,y).call end private attr_reader :x, :y end end
Such objects have several advantages: they are concise, they are very easy to test. There is a drawback, such objects can get a large number. There are several ways to group such objects, we divide them by type in part of our projects:
In our service implementation, it implements a functional requirement and always returns a value.
module KorovaMilkBar module Services class FindMilk < LunaPark::Callable GLASS_SIZE = Values::Unit.wrap '200g' def initialize(fridge:) @fridge = fridge end def call fridge.shelfs.find { |shelf| shelf.has?(GLASS_SIZE, of: :milk) } end private attr_reader :fridge end end end FindMilk.call(fridge: the_red_one) # => #<Glass: ... >
In our implementation, the Command executes one Action , modifies the object, and returns true if successful. In fact, the Team does not create an object, but modifies an existing one.
module KorovaMilkBar module Commands class FillGlass < LunaPark::Callable def initialize(glass, with:) @glass = glass @content = with end def call glass << content true end private attr_reader :fridge end end end glass = Glass.empty milk = Milk.new(200, :gr) glass.empty? # => true FillGlass.call glass, with: milk # => true glass.empty? # => false
Watchman , performs a logical check, and in case of failure, gives a processing error. This type of object does not affect the Main direction in any way, but it switches us to the Alternative direction if something went wrong.
When serving milk, it is important to make sure that it is fresh:
module KorovaMilkBar module Guards class IsFresh < LunaPark::Callable def initialize(product) @products = products end def call products.each do |product| raise Errors::Rotten, "#{product.title} is not fresh" if product.expiration_date > Date.today end nil end private attr_reader :products end end end
You may find it convenient to separate functional objects by type. You can add your own, for example, Builder - creates an object based on parameters.
call
method is the only required public method.initialize
method is the only optional public method.LunaPark::Errors::Processing
class.It is necessary to separate 2 types of errors that can occur during the operation of an Action .
Such errors may occur as a result of violation of processing logic.
For example:
In all likelihood, the user will want to know about these errors. Also probably these are the mistakes
which we can foresee.
Such errors should be inherited from LunaPark::Errors::Processing
Errors that occurred as a result of a system failure.
For example:
In all likelihood, we cannot foresee these errors and cannot say anything to the user, except that everything is very bad, and send the developers a report calling for action. Such errors should be inherited from SystemError
There are also validation errors , which we will discuss in more detail in the next article.
module Fishing # bad - Serivices::Catch.call(fish, rod) # bad - Serivices::Catch.call(fish: fish, rod: rod) # good - Serivices::Catch.call(fish, with: rod) module Serivices class Catch def initialize(fish, with:) @fish = fish @rod = with # # . end # ... private attr_reader :fish, :rod end end end
Try to make the initializer simple if parameter handling is not its goal.
Pass objects, not parameters.
module Service # bad - -. # , . class Foo def initialize(foo_params:, bar_params:) @foo = Values::Foo.new(*foo_params) @bar = Values::Bar.new(*bar_params) end # ... end Services::Foo.call(foo: {a: 1, b: 2}, bar: 34) # good - -. class Bar def initialize(foo:, bar:) @foo = foo @bar = bar # ... end end foo = Values::Foo.new(a: 1, b: 2) bar = Values::Bar.new(34) Services::Bar.call(foo: foo, bar: bar) # good - - Builder. class BuildFoo def initialize(param_1:, param_2:) @param_1 = param_1 @param_1 = param_1 end def call Foo.new( param_1: param_1.foo, param_2: param_2.bar, param_3: some_magick ) end # ... end end
# bad module Services class Milk; end class Work; end class FooBuild; end class PasswordGenerator; end end # good module Services class GetMilk; end class WorkOnTable; end class BuildFoo; end class GeneratePassword; end end
Usually an instance of the class Action is rarely used except to write to make a call.
# good - . Services::BuildFoo.call(params) # good - Services::BuildFoo.(params) # good - , , # , , # . ring = Services::RingToPhone.new(phone: neighbour) 10.times do ring.call end
# bad - , . def call #... rescue SystemError => e return false end
Up to this point we have considered the implementation of the Service layer as a set of functional objects. But we can easily place methods on this layer:
module Services def sum(a, b) a + b end end
Another problem that confronts us is a large number of service facilities. Instead of those who scored the rails fat model, we get the services fat folder. There are several ways to organize the structure to reduce the scale of the tragedy. Eric Evans solves this by combining a series of functions into one class. Imagine that we need to model the business processes of a nanny, Arina Rodionovna, she can feed Pushkin and put him to sleep:
class NoonService def initialize(arina_radionovna, pushkin) # ... end def to_feed # ... end def to_sleep # ... end end
This approach is more correct in terms of OOP. But we suggest to refuse it, at least, at the initial stages. Not very experienced programmers begin to write a lot of code in this class, which ultimately leads to an increase in connectivity. Instead, you can use the module, representing the activity of some abstraction:
module Services module Noon class ToFeed def call! # ... end end class << self # , # def to_feed(arina_radionovna, pushkin) ToFeed.new(arina_radionovna, pushkin).call end # , def to_sleep(arina_radionovna, pushkin) arina_radionovna.tell_story pushkin pushkin.state = :sleep end end end end
When dividing into modules, low external dependence (low coupling) should be observed with high internal cohesion, we use such modules as Services, or Interactors, this also goes against the ideas of pure architecture. This is a conscious choice that facilitates perception. By the file name, we understand which design pattern a particular class implements, if it’s obvious to an experienced programmer, this is not always the case for a beginner. After your team is ready, discard this overkill.
I will quote another small excerpt from the big blue book:
Choose such modules that tell the story of the system and contain coherent sets of concepts. From this, the low dependence of the modules on each other often occurs by itself. But if this is not the case, find a way to change the model in such a way as to separate the concepts from each other, or look for the concept missing in the model, which could become the basis for the module and thus bring the elements of the model together in a natural, meaningful way. Achieve low dependence of modules on each other in the sense that concepts in different modules could be analyzed and perceived independently of each other. Modify the model until natural boundaries arise in it in accordance with the high-level concepts of the domain, and the corresponding code is not split accordingly.
Give the modules the names that will be included in the ONE LANGUAGE. Both the MODULES themselves and their names should reflect the knowledge and understanding of the subject area.
The topic of the modules is large and interesting, but in full it clearly goes beyond the topic of this article. Next time we will talk to you about repositories and adapters . We opened a cozy telegram channel where I would like to share materials on the topic of DDD. We will be glad to your questions and feedback.
Source: https://habr.com/ru/post/454668/
All Articles