Skip to content

Latest commit

 

History

History
688 lines (525 loc) · 24.1 KB

6-the-missing-pieces.md

File metadata and controls

688 lines (525 loc) · 24.1 KB

The Missing Pieces

Once well defined roles are chosen for each layer of Rails, it becomes obvious that there is no answer about what category of objects should handle app-specific business logic. Controllers are responsible for HTTP, views manage formatting and presentation, and records deal with persistence. There is a big gap between these layers where the actual core piece of the app should live, agnostic of communication or databases.

Diagram 2

The cloud with questions marks above is exactly where the hard work of designing a good Rails architecture sits. That is the brain of the app, beyond simply transporting data from the database to the web via HTTP and markups. That is where the rules for permissions, validations, user flows, collaboration with other services, and everything that makes the app unique and useful to people. That is the app’s business logic.

The requirements for how business logic should be structured is familiar: there should be small objects that collaborate with each other through exchange of messages. Each object should have a single responsibility, a limited and concise public interface.

Moreover, these objects should not be coupled with Rails. The layers from the framework are already assigned other tasks related to persistence, presentation, and transport. The business logic should be decoupled from external libraries as much as possible, as it specializes in the universe the app is featured in.

For that, we will introduce new layers to the app stack: repositories, inputs, models, actions, and results. To better explain these concepts, let’s start with a simple example of Rails code: a classic blog app. This code is similar to the Blog example found in the Rails Guides.

In the example, there is one Active Record for the table of articles, with a couple of validations:

# app/models/article.rb

class Article < ApplicationRecord
  validates :title, presence: true
  validates :body, presence: true, length: { minimum: 10 }
end

Articles Controller interacts with the Article Record to instantiate, fetch, and persist data. This code was generated by the Rails scaffold command:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  # GET /articles
  def index
    @articles = Article.all
  end

  # GET /articles/1
  def show
  end

  # GET /articles/new
  def new
    @article = Article.new
  end

  # GET /articles/1/edit
  def edit
  end

  # POST /articles
  def create
    @article = Article.new(article_params)

    if @article.save
      redirect_to @article, notice: 'Article was successfully created.'
    else
      render :new
    end
  end

  # PATCH/PUT /articles/1
  def update
    if @article.update(article_params)
      redirect_to @article, notice: 'Article was successfully updated.'
    else
      render :edit
    end
  end

  # DELETE /articles/1
  def destroy
    @article.destroy
    redirect_to articles_url, notice: 'Article was successfully destroyed.'
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_article
    @article = Article.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def article_params
    params.require(:article).permit(:title, :body)
  end
end

Finally, the views invoked by Articles Controller renders instances of Article Record to list their contents, and to generate HTML forms. For example, the index view lists all Articles:

<p id="notice"><%= notice %></p>

<h1>Articles</h1>

<table>
 <thead>
   <tr>
     <th>Title</th>
     <th>Body</th>
     <th colspan="3"></th>
   </tr>
 </thead>

 <tbody>
   <% @articles.each do |article| %>
     <tr>
       <td><%= article.title %></td>
       <td><%= article.body %></td>
       <td><%= link_to 'Show', article %></td>
       <td><%= link_to 'Edit', edit_article_path(article) %></td>
       <td><%= link_to 'Destroy', article, method: :delete, data: { confirm: 'Are you sure?' } %></td>
     </tr>
   <% end %>
 </tbody>
</table>

<br>

<%= link_to 'New Article', new_article_path %>

The new view, for instance, renders an HTML form to allow users to write new Articles:

<h1>New Article</h1>

<%= form_with(model: @article) do |form| %>
 <% if @article.errors.any? %>
   <div id="error_explanation">
     <h2><%= pluralize(@article.errors.count, "error") %> prohibited this article from being saved:</h2>

     <ul>
       <% @article.errors.each do |error| %>
         <li><%= error.full_message %></li>
       <% end %>
     </ul>
   </div>
 <% end %>

 <div class="field">
   <%= form.label :title %>
   <%= form.text_field :title %>
 </div>

 <div class="field">
   <%= form.label :body %>
   <%= form.text_area :body %>
 </div>

 <div class="actions">
   <%= form.submit %>
 </div>
<% end %>

<%= link_to 'Back', articles_path %>

Repositories

A big subset of smells in Rails apps is found in Active Record classes. As seen previously, these objects just do way too much by default and are abused to operate both persistence and business logic roles. One of the goals of a sustainable Rails architecture is to isolate Active Record down to its basic database-related capabilities, and to keep the app’s core business logic as decoupled as possible from it. To this end a number of objects will be introduced on top of the persistence layer, starting with Repositories.

Repositories are responsible for the persistence layer of the app. They encapsulate Rails’ Active Record in a subset of simple methods for querying and persistence of data, and return simple read-only objects as a result. This allows the app to isolate Active Record only to this subset, exposing only the desired queries and methods to other layers through Repositories. Let’s refactor the previous example of the Blog app to encapsulate the Article Record behind a Repository.

As mentioned previously, Active Record objects are now referred to as simply Records. The previous Article class is now moved from app/models to app/records and renamed to Article Record.

# app/records/article_record.rb

class ArticleRecord < ApplicationRecord
 self.table_name = 'articles'

 validates :title, presence: true
 validates :body, presence: true, length: { minimum: 10 }
end

All operations previously handled by Article Record, such as finding, creating, and deleting records are now done through Article Repository.

# app/repositories/articles_repository.rb

class ArticleRepository
 def all
   ArticleRecord.all
 end

 def create(title:, body:)
   ArticleRecord.create!(title: title, body: body)
 end

 def find(id)
   ArticleRecord.find(id)
 end

 def update(id, title:, body:)
   record = find(id)
   record.update!(title: title, body: body)
   record
 end

 def delete(id)
   record = find(id)
   record.destroy!
 end
end
# app/controllers/articles_controller.rb

class ArticlesController < ApplicationController
 # GET /articles
 def index
   @articles = ArticleRepository.new.all
 end

 # GET /articles/1
 def show
   @article = ArticleRepository.new.find(params[:id])
 end

 # GET /articles/new
 def new
   @article = ArticleRecord.new
 end

 # GET /articles/1/edit
 def edit
   @article = ArticleRepository.new.find(params[:id])
 end

 # POST /articles
 def create
   @article = ArticleRepository.new.create(
     title: article_params[:title], body: article_params[:body]
   )

   redirect_to article_path(@article), notice: 'Article was successfully created.'
 rescue ActiveRecord::RecordInvalid => error
   @article = error.record
   render :new
 end

 # PATCH/PUT /articles/1
 def update
   @article = ArticleRepository.new.update(
     params[:id], title: article_params[:title], body: article_params[:body]
   )

   redirect_to article_path(@article), notice: 'Article was successfully updated.'
 rescue ActiveRecord::RecordInvalid => error
   @article = error.record
   render :edit
 end

 # DELETE /articles/1
 def destroy
   ArticleRepository.new.delete(params[:id])
   redirect_to articles_url, notice: 'Article was successfully destroyed.'
 end

 private


 # Only allow a list of trusted parameters through.
 def article_params
   params.require(:article_record).permit(:title, :body)
 end
end

Note, however, that Active Record is not completely encapsulated just yet. After all, the Repository still returns Record objects that controllers and views rely on in order to handle parameters and read data. These Records are used for multiple responsibilities: in some actions, such as new and edit, they represent the user’s input; in others, like in index and show, they play the role of actual persisted entities of the system. Records also hold the validation errors that might happen when a persistence operation is attempted.

In order to isolate Active Record completely, we must replace these cases with simpler objects for each of these responsibilities. Enter Inputs and Models.

Inputs

Inputs are objects that represent user-entered data. They are populated with information that is available for modification, such as in HTML forms or in API payloads, and they are passed on to Repositories as arguments for persistence operations, as provided by the app user. Inputs have knowledge about which attributes should be present in a payload, among other constraints, and are able to tell if its own state is valid or not.

It is important to note that Inputs differ from Records for not representing domain entities, but simply data entered by the user. Inputs do not have numeric identifiers, for example, as these are generated by the system and not set by users. There are also no strong expectations in regards to data integrity for inputs, since user-entered data can contain any information of different types, or even not to be present at all.

User input validation is a core part of any app’s business logic. It ensures that incoming data is sane, proper, and respects a predefined schema. A default Rails app overloads Record objects with yet another responsibility: being the place where validation rules are written and checked. While there is value in making sure that database constraints are respected, input validation should happen as part of the business logic layer, before persistence is invoked with invalid input. Input objects are a great fit for that task. By leveraging validation utilities from Active Model, Input objects can not only perform the same validations as Records but also seamlessly integrate with view helpers such as Rails form builders.

Let’s change the existing Blog code to use Inputs. We create the Article Input that can hold title and body, and make these the actual argument for the create and update methods in the Article Repository. We will also move the validation rules currently present in the Blog’s Article Record all the way to the Input object.

# app/inputs/article_input.rb

class ArticleInput
 include ActiveModel::Model

 attr_accessor :title, :body

 validates :title, presence: true
 validates :body, presence: true, length: { minimum: 10 }
end
class ArticleRecord < ApplicationRecord
 self.table_name = 'articles'
end
class ArticleRepository
 def all
   ArticleRecord.all
 end

 def create(input)
   ArticleRecord.create!(title: input.title, body: input.body)
 end

 def find(id)
   ArticleRecord.find(id)
 end

 def update(id, input)
   record = find(id)
   record.update!(title: input.title, body: input.body)
   record
 end

 def delete(id)
   record = find(id)
   record.destroy!
 end
end

We can now replace the cases in which empty Article Records are used in the controller and views with the Article Input. These are also the methods that hold errors via Active Model’s Errors.

class ArticlesController < ApplicationController
 # GET /articles
 def index
   @articles = ArticleRepository.new.all
 end

 # GET /articles/1
 def show
   @article = ArticleRepository.new.find(params[:id])
 end

 # GET /articles/new
 def new
   @input = ArticleInput.new
 end

 # GET /articles/1/edit
 def edit
   article = ArticleRepository.new.find(params[:id])
   @input = ArticleInput.new(title: article.title, body: article.body)
 end

 # POST /articles
 def create
   @input = ArticleInput.new(article_params)

   if @input.valid?
     article = ArticleRepository.new.create(@input)
     redirect_to article_path(article.id), notice: 'Article was successfully created.'
   else
     render :new
   end
 end

 # PATCH/PUT /articles/1
 def update
   @input = ArticleInput.new(article_params)

   if @input.valid?
     article = ArticleRepository.new.update(params[:id], @input)
     redirect_to article_path(article.id), notice: 'Article was successfully updated.'
   else
     render :edit
   end
 end

 # DELETE /articles/1
 def destroy
   ArticleRepository.new.delete(params[:id])
   redirect_to articles_url, notice: 'Article was successfully destroyed.'
 end

 private

 # Only allow a list of trusted parameters through.
 def article_params
   params.require(:article_input).permit(:title, :body)
 end
end
<h1>New Article</h1>

<%= form_with(model: @input, url: articles_path) do |form| %>
 <% if @input.errors.present? %>
   <div id="error_explanation">
     <h2><%= pluralize(@input.errors.count, "error") %> prohibited this article from being saved:</h2>

     <ul>
       <% @input.errors.each do |error| %>
         <li><%= error.full_message %></li>
       <% end %>
     </ul>
   </div>
 <% end %>

 <div class="field">
   <%= form.label :title %>
   <%= form.text_field :title %>
 </div>

 <div class="field">
   <%= form.label :body %>
   <%= form.text_area :body %>
 </div>

 <div class="actions">
   <%= form.submit 'Create' %>
 </div>
<% end %>

<%= link_to 'Back', articles_path %>

Models

Models are objects that represent core entities of the app’s business logic. These are usually persisted and can be fetched and created as needed. They have unique keys for identification (usually a numeric value), and, most importantly perhaps, they are immutable. This is the key difference between this new Model layer of objects and the Active Record instances regularly referred to as models in typical Rails default apps.

Another difference between Models and Records is that, once instantiated, Models simply hold its attributes immutably, and they don’t have any capabilities to create or update any information in the persistence layer.

The collaboration between Repositories and Models is what allows Active Record to be completely hidden away from any other areas of the app. There are no references to Records in controllers, views, and anywhere else. Repositories are invoked instead, which in turn return read-only Models.

Let’s refactor the previous example of the Blog app to encapsulate the Article Record behind a Model and use those as return values in the Article Repository.

# app/models/article.rb

class Article
 attr_reader :id, :title, :body, :created_at, :updated_at

 def initialize(id:, title:, body:, created_at:, updated_at:)
   @id = id
   @title = title
   @body = body
  @created_at = created_at
  @updated_at = updated_at
 end
end
class ArticleRepository
 def all
   ArticleRecord.all.map { |record| to_model(record.attributes) }
 end

 def create(input)
   record = ArticleRecord.create!(title: input.title, body: input.body)
   to_model(record.attributes)
 end

 def find(id)
   record = ArticleRecord.find(id)
   to_model(record.attributes)
 end

 def update(id, input)
   record = ArticleRecord.find(id)
   record.update!(title: input.title, body: input.body)
   to_model(record.attributes)
 end

 def delete(id)
   record = ArticleRecord.find(id)
   record.destroy!
 end

 private

 def to_model(attributes)
   Article.new(**attributes.symbolize_keys)
 end
end

Note that since we were already using the Article Record as read-only objects, we were able to simply change the value returned by the Repository to a Model without breaking our controller and views.

Actions and Results

So far we have introduced new casts of objects that distribute the roles traditionally played by Active Record, decoupling the app’s business logic from Records and encapsulating them to database persistence. However, controllers are still sharing part of the responsibilities of business logic. In the Blog example, Articles Controller still sends the validate message to the input, which is a core logic that belongs to the business layer.

It is also important to note that in real world scenarios many other operations are involved in processing a request other than just input validation and a single call to the database. Emails are sent, jobs are enqueued, and requests to external services are performed, among others. If all this is handled at the controller level, the same smells previously explored will still be present, regardless of use of Repositories, Inputs, and Models. For those we need a boundary layer on top of the app’s business logic, so controllers can sit loosely on top of it and remain solely responsible for HTTP concerns. Enter Action objects.

Actions represent the entry points to the app’s core logic. These objects coordinate workflows in order to get operations and activities done. Ultimately, Actions are the public interface of the app’s business layers.

Rails controllers talk to the app’s internals by sending messages to specific Actions, optionally with the required inputs. Actions have a one-to-one relationship with incoming requests: they are paired symmetrically with end-user intents and demands. This is quite a special requirement from this layer: any given HTTP request handled by the app should be handled by a single Action.

The fact that each Action represents a meaningful and complete request-response cycle forces modularization for the app’s business logic, exposing immediately complex relationships between objects at the same time that frees up the app from scenarios such as interdependent requests. In other words, Actions do not have knowledge or coupling between other Actions whatsoever.

Actions respond to a single public method perform. Each Action defines its own set of required arguments for perform, as well what can be expected as the result of that method. The returned value is not any object, however. We have a special type of object dedicated to represent the outcome of an Action: Results.

Results are special Structs that are generated dynamically to accommodate a set of pre-defined members. Since different Actions might want to return zero to multiple values, they are always returned as members of a Result instance.

Regardless of the values the Action might want to return, a Result has one default member called errors, which holds any errors that might occur when the Action is performed. If Result errors are empty, the Result is a success; if there are errors present, however, the Result is a failure. This empowers Actions with a predictable public interface, so callers can expect how to evaluate if an operation was successful or not by simply checking the success or failure of a Result.

Additionally, Result instances behave like monadic values by offering bindings to be called only in case of success or failure, which further simplifies the caller’s code by not having to use conditional to check for errors.

For the sample Blog app, let’s refactor the existing code to use Actions and Results. As mentioned above, Result is a Struct with extra powers. The Result class can generate struct classes with additional members, which will allow Actions to generate custom Result Structs. Successful Results are instantiated using the success class method, which expects proper member values; failure Results are instantiated via the failure class method, receiving a collection of errors as argument.

class Result < Struct
 class << self
   def with_members(*members)
     new(*members, :errors, keyword_init: true)
   end

   def success(*values)
     new(*values)
   end

   def failure(errors)
     new(errors: errors)
   end
 end

 def initialize(*args)
   super(*args)
   self.errors ||= []
 end

 def and_then
   yield(*values) if errors.none?
   self
 end

 def or_else
   yield(errors) if errors.any?
   self
 end
end

Actions are specializations of the base Action class, which defines a few helper methods to generate Results. Each concrete Action is expected to define its own perform method thereafter and always return a Result.

class Action
 class << self
   def result_class
     @result_class ||= Result.with_members(:empty)
   end

   protected

   def result(*args)
     @result_class = Result.with_members(*args)
   end
 end

 def result
   self.class.result_class
 end
end

We are now ready to write concrete Actions that return Results. Starting with a simple Action that finds an Article for a given ID. This Action defines its own Result as having an article member that contains the desired Article instance. Note that this Action always returns a successful Result.

class ShowArticleAction < Action
 result :article

 def perform(id)
   result.success ArticleRepository.new.find(id)
 end
end

Here’s an example of how an Action takes an Article Input and returns an Article Result. This Action checks if the input is valid, and proceeds with calling the Repository for persistence. If the input is invalid, however, it returns a failure Result populated with the validation errors.

class CreateArticleAction < Action
 result :article

 def perform(input)
   if input.valid?
     article = ArticleRepository.new.create(input)
     result.success(article: article)
   else
     result.failure(input.errors)
   end
 end
end

Result fields are optional. Some Actions can simply return an empty success Result as a return value, such as the Delete Article Action:

class DeleteArticleAction < Action
 def perform(id)
   ArticleRepository.new.delete(id)
   result.success
 end
end

These Actions (and others that handle updating and listing Articles) are used in the controller as follows:

class ArticlesController < ApplicationController
 # GET /articles
 def index
   @articles = ListArticlesAction.new.perform.articles
 end

 # GET /articles/1
 def show
   @article = ShowArticleAction.new.perform(params[:id]).article
 end

 # GET /articles/new
 def new
   @input = ArticleInput.new
 end

 # GET /articles/1/edit
 def edit
   article = EditArticleAction.new.perform(params[:id]).article
   @input = ArticleInput.new(title: article.title, body: article.body)
 end

 # POST /articles
 def create
   @input = ArticleInput.new(article_params)

   CreateArticleAction.new.perform(@input)
     .and_then do |article|
       redirect_to(article_path(article.id), notice: 'Article was successfully created.')
     end
     .or_else do |errors|
       render :new
     end
 end

 # PATCH/PUT /articles/1
 def update
   @input = ArticleInput.new(article_params)

   UpdateArticleAction.new.perform(params[:id], @input)
     .and_then do |article|
       redirect_to(article_path(article.id), notice: 'Article was successfully updated.')
     end
     .or_else do |errors|
       render :edit
     end
 end

 # DELETE /articles/1
 def destroy
   DeleteArticleAction.new.perform(params[:id])
   redirect_to articles_url, notice: 'Article was successfully destroyed.'
 end

 private

 # Only allow a list of trusted parameters through.
 def article_params
   params.require(:article_input).permit(:title, :body)
 end
end

The controller now has the single responsibility of abstracting away HTTP concerns, such as extracting data from request parameters and forwarding them to the proper Actions. According to the Result returned, the controller then crafts the appropriate HTTP response. Controllers don’t hold any logic regarding validations, persistence, or anything else behind an Action.

Diagram 3

The combination of these additional layers of Actions, Results, Inputs, Models, and Repositories allows apps to have small objects with specific roles handling the business logic. The default Rails objects are specialized in single responsibilities, and the resulting architecture is one which requests are handled by a network of objects collaborating between themselves. These objects are easy to understand, test, and most importantly, much easier to change.

Links