Orthodox Backend





The modern backend is diverse, but still obeys some unspoken rules. Many of us who develop server applications are faced with generally accepted approaches, such as Clean Architecture, SOLID, Persistence Ignorance, Dependency Injection and others. Many of the attributes of server development are so hackneyed that they do not raise any questions and are used thoughtlessly. They talk a lot about some, but never use it. The meaning of the rest is either incorrectly interpreted or distorted. The article talks about how to build a simple, completely typical, backend architecture, which not only can follow the precepts of famous programming theorists without any damage, but can also improve them to some extent.



Dedicated to all those who do not think programming without beauty and do not accept beauty in the midst of absurdity.



Domain Model



Modeling is where software development in an ideal world should begin. But we are all not perfect, we talk a lot about it, but we do everything as usual. Often the reason is the imperfection of existing tools. And to be honest, our laziness and fear of taking responsibility to get away from "best practices". In an imperfect world, software development begins, at best, with scaffolding, and at worst, nothing is done with performance optimization. Nevertheless, I would like to discard the hard examples of “outstanding” architects and speculate on things more ordinary.



So, we have a technical task, and even have a user interface design (or not, if the UI is not provided). The next step is to reflect the requirements in the domain model. To get started, you can sketch a diagram of model objects for clarity:







Then, as a rule, we begin to project the model on the means of its implementation - a programming language, an object-relational converter (Object-Relational Mapper, ORM), or on some kind of complex framework like ASP.NET MVC or Ruby on Rails, in other words - start writing code. In this case, we are moving along the path of the framework, which I consider to be incorrect within the framework of development based on the model, however convenient it may seem initially. Here you make a huge assumption, which subsequently negates the benefits of domain-based development. As a freer option, not limited by the scope of any tool, I would suggest dwelling on the use of only syntactic tools of a programming language for constructing an object model of a subject area. In my work I use several programming languages ​​- C #, JavaScript, Ruby. Fate has decreed that the Java and C # ecosystems are my inspiration, JS is my main income, and Ruby is the language I like. Therefore, I will continue to show simple examples in Ruby: I am convinced that this will not cause problems for developers in other languages ​​to understand. So, port the model to the Invoice class in Ruby:



class Invoice attr_reader :amount, :date, :created_at, :paid_at def initialize(attrs, payment_service) @created_at = DateTime.now @paid_at = nil @amount = attrs[:amount] @date = attrs[:date] @subscription = attrs[:subscription] @payment_service = payment_service end def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) @paid_at = DateTime.now end end
      
      





Those. we have a class whose constructor takes a Hash of attributes, object dependencies and initializes its fields, and a pay method that can change the state of the object. Everything is very simple. Now we do not think about how and where we will display and store this object. It simply is, we can create it, change its state, interact with other objects. Please note that the code does not contain any foreign artifacts like BaseEntity and other garbage that is not related to the model. It is very important. By the way, at this stage we can already begin development through testing (TDD), using stub objects instead of dependencies like payment_service:



 RSpec.describe Invoice do before :each do @payment_service = double(:payment_service) allow(@payment_service).to receive(:charge) @amount = 100 @credit_card = CreditCard.new({...}) @customer = Customer.new({credit_card: @credit_card, ...}) @subscription = Subscription.new({customer: customer, ...}) @invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) end describe 'pay' do it "charges customer's credit card" do expect(@payment_service).to receive(:charge).with(@credit_card, @amount) @invoice.pay end it 'makes the invoice paid' do expect(@invoice.paid_at).not_to be_nil @invoice.pay end end end
      
      





or even play with the model in the interpreter (irb for Ruby), which may well be, although not very friendly, the user interface:



 irb > invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) irb > invoice.pay
      
      





Why is it important to avoid “foreign artifacts” at this stage? The fact is that the model should not have any idea how it will be saved or whether it will be saved at all. In the end, for some systems storage of objects directly in memory may be quite suitable. At the time of modeling, we must completely abstract from this detail. This approach is called Persistence Ignorance. It should be emphasized that we do not ignore the issues of working with the repository, be it a relational or any other database, we only neglect the details of interacting with it at the modeling stage. Persistence Ignorance means the intentional elimination of mechanisms for working with the state of the model, as well as all kinds of metadata related to this process, from the model itself. Examples:



 #  class User < Entity #     table :users #     # mapping  field :name, type: 'String' #   def save ... end end user = User.load(id) #     user.save #    
      
      





 #  class User #   ,      attr_accessor :name, :lastname end user = repo.load(id) #     repo.save(user) #    
      
      





This approach is also due to fundamental reasons - compliance with the principle of single responsibility (Single Responsibility Principle, S in SOLID). If the model, in addition to its functional component, describes the state preservation parameters, and also deals with its conservation and loading, then obviously it has too many responsibilities. The resulting and not the last advantage of Persistence Ignorance is the ability to replace the storage tool and even the type of storage itself during the development process.



Model-View-Controller



The MVC concept is so popular in the development environment of various, not just server, applications in different languages ​​and platforms that we no longer think about what it is and why it is needed at all. I have the most questions from this abbreviation called “Controller”. From the point of view of organizing the structure of the code, it is a good thing to group actions on the model. But the controller should not be a class at all, it should be rather a module that includes methods for accessing the model. Not only that, should it have a place to be at all? As a developer who followed the path of .NET -> Ruby -> Node.js, I was simply touched by the JS (ES5) controllers that implement within the framework of express.js. Having the opportunity to solve the task assigned to the controllers in a more functional style, the developers, as bewitched, write the magic “Controller” again and again. Why is a typical controller bad?



A typical controller is a set of methods that are not closely related to each other, united by only one - a certain essence of the model; and sometimes not just one, worse. Each individual method may require different dependencies. Looking ahead a bit, I note that I am a supporter of the practice of dependency inversion (Dependency Inversion, D in SOLID). Therefore, I need to initialize these dependencies somewhere outside and pass them to the controller constructor. For example, when creating a new account, I have to send notifications to the accountant, for which I need a notification service, and in other methods I do not need it:



 class InvoiceController def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def index @repository.get_all end def show(id) @repository.get_by_id(id) end def create(data) @repository.create(data) @notification_service.notify_accountant end end
      
      





Here the idea begs to be divided into methods for working with the model into separate classes, and why not?



 class ListInvoices def initialize(invoice_repository) @repository = invoice_repository end def call @repository.get_all end end class CreateInvoice def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def call @repository.create(data) @notification_service.notify_accountant end end
      
      





Well, instead of the controller, there is now a set of “functions” for accessing the model, which, by the way, can also be structured using file system directories, for example. Now you need to "open" these methods to the outside, i.e. organize something like a router. As a person tempted with any kind of DSL (Domain-Specific Language), I would prefer to have a more visual description of instructions for a web application than tricks in Ruby or another general-purpose language for specifying routes:



 `HTTP GET /invoices -> return all invoices` `HTTP POST /invoices -> create new invoice`
      
      





or at least



 `HTTP GET /invoices -> ./invoices/list_invoices` `HTTP POST /invoices -> ./invoices/create`
      
      





This is very similar to a typical Router, with the only difference being that it interacts not with the controllers, but directly with the actions on the model. It is clear that if we want to send and receive JSON, then we must take care of serialization and deserialization of objects and much more. One way or another, we can get rid of the controllers, shift part of their responsibility to the directory structure and the more advanced Router.



Dependency injection



I deliberately wrote a “more advanced Router." In order for a router to really be able to manage, at a declarative level, the flow of actions on a model using the dependency injection mechanism, it probably should be quite complex inside. The general scheme of his work should look something like this:







As you can see, my entire router is riddled with dependency injection using an IoC container. Why is this even necessary? The concept of “dependency injection” goes back to the Dependency Inversion technique, which is designed to reduce the connectivity of objects by moving dependency initialization out of scope of their use. Example:



 class Repository; end #  (   ) class A def initialize @repo = Repository.new end end #  (   ) class A def initialize(repo) @repo = repo end end
      
      





This approach greatly helps those who use Test-Driven Development. In the given example, we can easily put a stub in the constructor instead of the real repository object corresponding to its interface, without “hacking” the object model. This is not the only DI bonus: when applied correctly, this approach will bring a lot of pleasant magic to your application, but first things first. Dependency Injection is an approach that allows you to integrate the Dependency Inversion technique into a complete architectural solution. As an implementation tool, an IoC- (Inversion of Control) container is usually used. There are tons of really cool IoC containers in the Java and .NET world, there are dozens of them. In JS and Ruby, unfortunately, there are no decent options, only parodies, a kind of cargo cult. This would be my dry-container class:



 class Invoice include Import['payment_service'] def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) end end
      
      





Instead of the slender use of the constructor, we burden the class with the introduction of our own dependencies, which at the initial stage leads us away from a clean and independent model. Well, something and the model should not know about IoC at all! This is true for actions like CreateInvoice. For the given case, in my tests I am already obliged to use IoC as something inalienable. This is totally wrong. For the most part, application objects should not be aware of the existence of IoC. After searching and thinking a lot, I sketched my IoC , which would not be so intrusive.



Saving and loading a model



Persistence Ignorance requires an unobtrusive object converter. In this article, I will mean working with a relational database, the main points will be true for other types of storages. An object-relational converter - ORM (Object Relational Mapper) is used as a similar converter for relational databases. In the world of .NET and Java, there is an abundance of truly powerful ORM tools. All of them have some or other minor flaws to which you can close your eyes. There are no good solutions in JS and Ruby. All of them, one way or another, rigidly bind the model to the framework and force them to declare foreign elements, not to mention the inapplicability of Persistence Ignorance. As in the case of IoC, I thought about implementing ORM on my own, this is the state of affairs in Ruby. I did not do everything from scratch, but took as a basis a simple ORM Sequel, which provides unobtrusive tools for working with different relational DBMSs. First of all, I was interested in the ability to execute queries in the form of regular SQL, receiving an array of strings (hash objects) at the output. It only remained to implement your Mapper and provide Persistence Ignorance. As I already mentioned, I would not want to mix mapping fields into the domain model, so I implement Mapper in such a way that it uses a separate configuration file in the type format:



 entity Invoice do field :amount field :date field :start_date field :end_date field :created_at field :updated_at reference :user, type: User reference :subscription, type: Subscription end
      
      





Persistence Ignorance is quite simple to implement using an external object of the Repository type:



 repository.save(user)
      
      





But we will go further and implement the Unit of Work pattern. To do this, you need to highlight the concept of session. A session is an object that exists over time, during which a set of actions is performed on the model, which are a single logical operation. Over the course of a session, loading and changing model objects can occur. At the end of the session, transactional saving of the model state occurs.

Unit of work example:



 user = session.load(User, id: 1) plan = session.load(Plan, id: 1) subscription = Subscription.new(user, plan) session.attach(subscription) invoice = Invoice.new(subscription) session.attach(invoice) # ... # -       if Date.today.yday == 1 subscription.comment = 'New year offer' invoice.amount /= 2 end session.flush
      
      





As a result, 2 instructions will be executed in the database instead of 4, and both will be executed within the same transaction.



And then suddenly remember about the repositories! Here there is a feeling of déjà vu, as with the controllers: is not the repository the same rudimentary entity? Looking ahead, I will answer - yes, it is. The main purpose of the repository is to rid the layer of business logic from interacting with real storage. For example, in the context of relational databases, it means writing SQL queries directly in the business logic code. Undoubtedly, this is a very reasonable decision. But back to the moment when we got rid of the controller. From the point of view of OOP, the repository is essentially the same controller - the same set of methods, not only for processing requests, but for working with the repository. The repository can also be divided into actions. By all indications, these actions will not differ in any way from what we proposed instead of the controller. That is, we can refuse Repository and Controller in favor of a single unified Action!



 class LoadPlan def initialize(session) @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p WHERE p.id = 1 SQL @session.fetch(Plan, sql) end end
      
      





You probably noticed that I use SQL instead of some kind of object syntax. It's a matter of taste. I prefer SQL because it is a query language, a kind of DSL for working with data. It is clear that it is always easier to write Plan.load (id) than the corresponding SQL, but this is for trivial cases. When it comes to slightly more complex things, SQL becomes a very welcome tool. Sometimes you curse another ORM in trying to get it to do it like pure SQL, which "I would write in a couple of minutes." For those who are in doubt, I suggest looking into the MongoDB documentation , where the explanations are given in a SQL-like form, which looks very funny! Therefore, an interface for queries in the ORM JetSet , which I have written for their own purposes, is interspersed with minimal SQL type «AS ENTITY». By the way, in most cases, I don’t use model objects, various DTOs, etc. for displaying tabular data - I just write an SQL query, get an array of hash objects and display it in view. One way or another, few people manage to “scroll” big data by projecting related tables onto a model. In practice, flat projection (view) is more likely used, and very mature products come to the optimization stage when more complex solutions like CQRS (Command and Query Responsibility Segregation) begin to be used.



Putting it all together



So what we have:





The only thing left is to implement the same “router”. Since we got rid of repositories and controllers in favor of actions, it is obvious that for one request we will need to perform several actions. Actions are autonomous and we cannot invest in each other. Therefore, as part of the Dandy framework, I implemented a router that allows you to create action chains. Configuration example (pay attention to / plans):



 :receive .-> :before -> common/open_db_session GET -> welcome -> :respond <- show_welcome /auth -> :before -> current_user@users/load_current_user /profile -> GET -> plan@plans/load_plan \ -> :respond <- users/show_user_profile PATCH -> users/update_profile /plans -> GET -> current_plan@plans/load_current_plan \ -> plans@plans/load_plans \ -> :respond <- plans/list :catch -> common/handle_errors
      
      





“GET / auth / plans” displays all available subscription plans and “highlights” the current one. The following happens:



  1. ": before -> common / open_db_session" - opening a JetSet session
  2. / auth ": before -> current_user @ users / load_current_user" - load the current user (by tokens). The result is registered in the IoC container as current_user (current_user @ instruction).
  3. / auth / plans "current_plan @ plans / load_current_plan" - load the current plan. For this, the value @current_user is taken from the container. The result is recorded in the IoC container as current_plan (current_plan @ instruction):



     class LoadCurrentPlan def initialize(current_user, session) @current_user = current_user @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p INNER JOIN subscriptions s ON s.user_id = :user_id AND s.current = 't' WHERE p.id = :user_id LIMIT 1 SQL @session.execute(sql, user_id: @current_user.id) do |row| map(Plan, row, 'plan') end end end
          
          





  4. "Plans @ plans / load_plans" - loading a list of all available plans. The result is registered in the IoC container as plans (instruction plans @).
  5. ": respond <- plans / list" - registered ViewBuilder, for example JBuilder, draws View 'plans / list' of type:



     json.plans @plans do |plan| json.id plan.id json.name plan.name json.price plan.price json.active plan.id == @current_plan.id end
          
          







As @plans and @current_plan, the values ​​from the container registered in the previous steps are retrieved. In the Action constructor, in general, you can “order” everything you need, or rather, everything that is registered in the container. An attentive reader will most likely have a question, is there isolation of such variables in the “multi-user” mode? Yes it does. The fact is that the Hypo IoC container has the ability to set the lifetime of objects and, moreover, bind it to the lifetime of other objects. Within Dandy, variables like @plans, @current_plan, @current_user are bound to the request object and will be destroyed the moment the request is completed. By the way, the JetSet session is also tied to the request - its status will also be reset at the moment the Dandy request is completed. Those. Each request has its own isolated context. Hypo rules Dandy's entire life cycle, no matter how fun this pun is in the literal translation of the names.



conclusions



Within the framework of the given architecture, I use the object model to describe the subject area; I use appropriate practices like Dependency Injection; I can even use inheritance. But, at the same time, all these Actions are essentially ordinary functions that can be chained together at a declarative level. We got the desired backend in a functional style, but with all the advantages of the object approach, when you do not experience problems with abstractions and testing your code. Using the DSL router Dandy as an example, we are free to create the necessary languages ​​for describing routes and more.



Conclusion



As part of this article, I conducted a kind of tour of the fundamental aspects of creating a backend as I see it. I repeat, the article is superficial, it did not touch on many important topics, such as, for example, performance optimization. I tried to focus only on those things that can really be useful to the community as food for thought, and not once again pour from empty to empty, what is SOLID, TDD, how does the MVC scheme look, and so on. Strict definitions of these and other terms used by an inquisitive reader can be easily found in the vast network, not to mention colleagues in the shop, for whom these abbreviations are part of everyday speech. And finally, I emphasize that try not to focus on the tools that I needed to implement to solve the problems posed. This is just a demonstration of the validity of thoughts, not their essence. If this article is of any interest, then I will write a separate material about these libraries.



All Articles