Skip to content

Instantly share code, notes, and snippets.

@joaofnds
Created March 8, 2021 17:31
Show Gist options
  • Save joaofnds/187b1469d6927aa972863cebf9f5efbf to your computer and use it in GitHub Desktop.
Save joaofnds/187b1469d6927aa972863cebf9f5efbf to your computer and use it in GitHub Desktop.

Introduction

Ruby on Rails is the framework of choice for web apps at Shopify. It is an opinionated stack for quick and easy development of apps that need standard persistence with relational databases, an HTTP server, and HTML views.

By design, Rails does not define conventions for structuring business logic and domain-specific code, leaving developers to define their own architecture and best practices for a sustainable codebase.

In fast product development teams, budgets and deadlines interfere with this architectural work, leading to poorly written business logic and complicated code that is very hard to maintain long term. Even when developer teams take the time to think about what a good architecture in Rails look like, this work is likely required to be done all over again when a new Rails app needs to be created.

This project aims to make it easier for both new and existing Rails apps to adopt patterns that are proven to make code more sustainable long term, and codebases easier to maintain and extend. We will recommend a set of abstractions and practices that are simple, yet powerful in organizing code in Rails apps in a way that allows fast-growing apps to remain easy to change.

The Goal of Good Software Design

The fundamental truth of software development is that code is subject to change

  • all the time. As long as an app is in business, there are bugs to be fixed, new features to be implemented, and extensions to existing capabilities to be added. Therefore, the one practical aspect that reveals the health of a codebase is how easy it is to change it.

All meaningful efforts in planning and designing software has the ultimate goal of allowing changes to be easy and quick. The qualities observed in good software, such as loose coupling, small components, sane hierarchies, predictable and honoured contracts, are all there because they allow code to be easily understood and changed fast. Code that can accommodate changes easily and fast is the foundation of successful product development; code that is hard to change slow development teams until their products are unmaintainable and become obsolete, and finally gets them out of business.

The Ruby on Rails Advantage

Ruby on Rails gained so much popularity because it allows new teams to get up and running quickly. This speed comes first of all from Ruby itself: a dynamic, loosely typed language focused on simplicity and productivity. Ruby empowers developers to save a lot of syntax in order to get things done faster, cutting out cumbersome checks and syntax overload by trusting that the programmer is making right decisions - which at least in the beginning of an agile development for a small app maintained by a small team, is a completely acceptable compromise.

Rails itself is a step further, now specific to web app development: out of the box it gives you all you need to have a fully fledged web app up and running, including HTTP server, relational database, and view templates. For standard CRUD operations in static web pages, developers hardly need to write any code at all: everything is generated by Rails via the command line. Ruby on Rails pioneered the market of web frameworks that can get an app up and running in seconds, and it would be hardly justifiable to go with a different tooling if the goal is to go from zero to done as quickly as possible.

When Rails is just not enough

By design Rails does not give everything that is needed to create a robust app that is supposed to handle incoming changes in the long term. An app generated by Rails contains the bare minimum needed to be in production fast: basic HTTP capabilities, basic HTML templating, and database persistence. Rails does not differentiate between a pet project of a 10 minute blog development or an entire multi-merchant commerce platform. Adapting and extending what Rails gives by default to the business needs is the responsibility of development teams. Just like Ruby trusts developers not to pass an orange when an argument is supposed to be a banana, as well as handling nil return values, Rails trusts developers to keep their codebases sane.

Developers, on the other hand, cannot always afford to do this preemptive planning and design exercise. Less experienced developers simply trust that the “Rails way” is the unquestionable status quo, generating unmaintainable code; more senior developers, even when they know they should do better, delay such important design decisions to a “future refactor once the project is over”, accumulating technical debt that is never paid.

These situations are so common in the Rails world that it is almost the norm. If there is a Rails app minimally organized, it stands out immediately like a miracle. These apps are not miracles; they simply adopt preemptively good design practices and patterns that allow a Rails codebase to grow and change in a sane manner, and the team behind them are committed to the long term success of their products.

Code Smells in Rails Apps

A standard Rails app responds to incoming requests by exchanging messages down a stack of framework layers. From the Routes level, a request is delegated to a Controller action that processes the request and returns an HTTP response. By default the stack can be simplified as the diagram below.

Auxiliary frameworks such as Active Job, Action Mailer, and Views can be called by controller actions as needed.

This stack is a perfect fit for most features, as long as their business logic is simple and lightweight. In a real world scenario, where real people use such apps, this is never the case. Processing requests and crafting responses involves many steps, with preconditions to be checked, conditional statements, validations, and multiple combinations of different responses. The stack above does not bring answers to most of these, which leads to smells showing up across the codebase.

The lack of proper design for organizing business logic produces some very common smells in Rails apps. The list below is just the most common ones.

Illegible Routes

Routes map request patterns to controller actions. It employs a specific DSL that is optimized for resourceful routes: mappings that follow the seven standard REST operations for a given controller.

In a real world scenario, the overload of features and changes takes a toll in the routes organization, leading to an increasing number of non-resourceful routes to creep in, constraints to create exceptional mappings for edge cases, repetition, and so much more. The result is a routes file that is impossible to be deciphered.

Convoluted Controller Callbacks (Before Actions)

Before Actions, the popular callbacks for controller actions, are handy tools for checking and early responding to some common preconditions such as throttling and authentication.

After multiple iterations, bug fixes, and features added on top of one another, Before Actions are abused in order to handle a myriad of convoluted preconditions. It is not uncommon to see controller actions that, in order to be executed, need to pass many Before Actions.

These callbacks might also be conditional, or apply to just a subset of actions in a given controller, which further impedes understanding of what is actually happening in a controller. Not to mention when controllers inherit Before Actions from their ancestors.

Large Actions

The ultimate smell in Rails apps.

Incoming requests are handled by actions, the controller public methods. These methods are called once for each request, and they are responsible for crafting an appropriate HTTP response.

In theory, actions would collaborate with other layers of the application so they remain lightweight. The reality of most apps is very different: actions are long sequences of procedural code, with nested conditionals and coupling with multiple constants.

The reason why actions are long and complex in real world apps is because they are just the most convenient place in the entire Rails stack to shelve quick changes and fixes. Given that controller methods are the predictable paths of the code to be executed for a request, developers often can ignore all the rest and inject the code they want straight in the controller action and the job is done.

Obviously this is not a good design nor a healthy practice long term. As actions accumulate complex and long procedural code they become very difficult to understand, its behaviour unpredictable, leading to bugs and an increasingly slower development process. Large actions also negatively affect testing: since controller's behaviours are asserted with integration tests, in order to assure coverage a lot of integration test cases are written as system and browser tests that are much slower than unit tests, ultimately leading to terribly slow CI times.

Private Methods in Controllers

This specific smell is a consequence of long actions. A common refactor employed to address long methods such as actions is to break the code down into smaller private methods. This refactor is a worrisome anti-pattern that replaces the long method smell with a much worse-smelling collection of needless indirection.

It is interesting to find Rails apps in which controllers have a relatively lightweight collection of actions that actually hides a long and complex private interface. Masking out complex actions by using private methods doesn't do any good, and actually increases the overall size of controller classes.

A no-brainer fix for the private methods smell is to replace their calls in the action code with their contents. At least long actions are honest about their faults, exposing to the world their dire complexity.

Single-Use Mixins

Another attempt to hide the complexity of large actions is to break them down in smaller single-use methods, but instead of marking them as private in the controller, they are moved to a module that is included in the inheritance chain. Sometimes referred to as Concerns, these mixins are simply adding needless indirection without addressing the fact that the controller is doing too much.

Similarly to private methods, these mixins should be removed and their code incorporated back into the controller that includes them. Not as separate private methods, but to have their body reinserted directly in the actions that call them.

Extensive Ruby Code in Views

Views are templates where data is interpolated to generate the response body, usually ERB files that output HTML. Because these files can accommodate any Ruby code, they are often abused with nested conditionals, variable assignments, and even direct database queries. This is due to the overflow of business logic beyond controller actions and models: developers know their actions and models are already too big, so extra code simply finds the path of least resistance in view files. The problem extensive Ruby code in views cause is cluttering: files that were supposed to be made of mostly markup and data interpolation become long sequences of conditionals and procedure code.

Coupling in View Helpers

When additional presentation logic is required in view files helpers can be of use. View helpers are functional mixins that processes the given arguments and returns bits of output that are later interpolated in view templates. As of any other layer in the Rails stack, however, they are also abused to accommodate intrusive business logic beyond their initial responsibilities.

The most obvious symptom of unhealthy view helpers is when they are coupled to other parts of the codebase, referencing constants from models, controllers, and more. This is commonly seen when view helpers depend on instance variables from controllers; ideally helpers should receive all the data they require via arguments. This ensures that they are kept lightweight, idempotent, and easy to test.

Another recurring consequence of this coupling is that it makes testing view helpers quite painful. Often developers simply give up in giving helpers proper test coverage due to how difficult it is to assert their behaviour.

Active Record Callbacks

Rails offers hooks to execute certain routines before, after, and around Active Record operations. For example, checks and transformations before a record is saved in the database, or emails and jobs can be performed after a successful update.

Since these callback methods are called implicitly by the framework, they can become inadvertently cumbersome. By simply reading the Active Record model's code it is very hard to reason about everything that is happening around method calls and callbacks. Similar to controller callbacks, these can also apply to subsets of methods or be conditional to certain states, which further hinders understanding the possible logic paths and makes code very hard to change and maintain.

The very existence of callbacks in Active Record models is a smell in itself: there is no reason not to replace them with explicit, direct method calls through more thoughtful and meaningful software design.

Active Record Associations

Active Record is a robust and complex framework that abstracts in Ruby all the interface with the relational database. A significant subset of the framework is dedicated to abstract operations around foreign keys and references between database tables, the Active Record Associations.

Using macros such as “has many” and “belongs to”, associations allow Active Record models to define how they are linked to one another, dynamically defining methods that return collections of associated records for a given Active Record model instance. This is a classic behaviour in Rails apps that allow writing features around nested resources very easily.

In real world production apps, however, these shortcuts are often anti-patterns that make code interdependent, complex, and slow. Active Record Associations tie models together, creating unnecessary coupling between them. For most cases, models should not have knowledge of other Active Record Bases, since all their operations revolve around a single table.

Another problem with Active Record Associations is that these methods implement lazy loading by default. The business logic might simply send a message to read data from an already loaded method and inadvertently end up performing database queries from the view layer. This is ultimately the source of performance issues such as N+1 queries.

Giving Active Record models extra methods that reference and return instances of other model classes is also a precedent in which much of customizations are built upon, which makes it very hard to change code afterwards. The only instances in which models should reference other tables is to optimize database operations, such as joins.

Methods in Active Record Models

This smell is also known as the “thin controller, fat model” antipattern. Looking for a suitable location to inject business logic, many developers prefer to place this in Active Record classes. This is usually justified as a more object-oriented approach, since controllers easily become too procedural as seen in the previously listed smells.

Over time, Active Record models get overloaded with all sorts of responsibilities, from validations that are unrelated to any of their attributes, to sending emails, making network calls, and enqueueing jobs. These are written as a multitude of class and instance methods, usually quite long ones.

This excessive amount of methods and behaviour in Active Record models reach its worst point in specific classes known as the god objects of the app: resources that are so overloaded with behaviour that they have references to all other main constants of the codebase, as well as being referenced by everybody else.

Active Record classes that are crowded with methods bear too many responsibilities and are very hard to change. And the harder they are to change, the less they are to be eligible for refactorings. These objects become then the oldest and hardest technical debts to be paid.

Private Methods in Active Record Models

Similar to the problem of private methods in controllers, the creation of a private interface in Active Record models is an antipattern to hide away the fact that those classes are just too big and do too much. Like in controllers, there should not be private methods in models except for reusability or if referenced in macros when required by the framework.

Software Design Principles

The list of code smells commonly found in Rails apps could go on and on. Despite how extensive that list is, the code smells are symptoms of just a few underlying problems in software design. There are principles of good software design that are often ignored in Rails apps, leading to this variety of smells. Understanding and using these principles is crucial to make good technical decisions that lead to a sustainable software architecture.

Small Objects

As an object oriented language, Ruby is designed and optimized for execution flows consisting of message exchange between a neighborhood of objects that collaborate between themselves. And a successful object oriented flow requires multiple, specialized small objects exchanging messages between them.

There is a cost associated with breaking down the overall app logic into smaller objects. It requires the developer to reflect about the different, fine-grained concepts, goals, and responsibilities involved in the main operations of their apps. Another cost is the extra message roundtrips between these small objects, since smaller objects can do less by themselves, constantly requiring other objects' inputs and outputs. This is known as indirection: a complex operation that could be done by a single, long method in a big object is instead performed indirectly by smaller objects that collaborate to get the job done.

Small collaborative objects are better than big objects with long methods, or big objects that send multiple messages to themselves. That's because when complex operations are broken into multiple, simpler routines, these concepts are decoupled and allow introduction of changes much faster and easier. In big objects, one must read and understand the entire body of the operation. In sets of small collaborative objects, on the other hand, it is possible to know everything that is needed for changes just by observing the exchange of messages between the objects. By encapsulating concepts and responsibilities in smaller objects, the internals of those subsystems can be altered freely without having to modify the rest of the system. Smaller objects are also much easier to test, and these tests much easier to change as well when modifications in behaviour are required.

Across Rails codebases, however, big objects usually prevail: from long controllers to big models, developers add more and more code to existing classes and rarely pause to refactor these into smaller objects.

Single Responsibility

Objects should encapsulate no more than one responsibility. The reason different objects exist in an app is because there are multiple concepts and responsibilities to be delegated in order to properly execute extensive operations. Distributing these among objects means to assign one specific task for each of them.

Objects with a single responsibility have a discrete public interface, with just a couple of messages they are able to respond to, all of them clearly related to the one responsibility that object has. Objects that respect this principle are also easy to reason about and understand, since the responsibility they hold is clear.

Another way to phrase this principle is that objects should have only one reason to change. Since their task is well defined and discrete, it is obvious when code needs to be modified. Developers should not have to reason for too long to questions such as “should I put this code in that class?” if objects have a clear, single responsibility.

In Active Record models, controllers, and even view helpers of most Rails apps what exist is a collection of big objects that do way too much. There is no definition about the single responsibility they hold anymore. These objects just do too much, and they are changed at any time for any reason.

It is important to note that not only big objects violate this principle. Sometimes even small objects could have an identity crisis for doing too much. That's because a lot of the object's behaviour is coming from its ancestor chain, which causes even objects with no direct methods to actually have an incredibly large public interface.

If having a sane codebase is desired, it is crucial for developers and teams to reflect about what single responsibility an object is intended to have. This is why it is so important to give good names to classes and objects: their names should make obvious their single responsibility.

Another equally important exercise is to document at the very top of the class file the responsibility that class has. Unfortunately code documentation is not even allowed in many teams, as the self documenting code culture still persists in some groups, while others share the belief that tests are replacement for documentation.

A Better Architecture

It is possible to design an architecture in Rails apps that allow changes to be easily introduced and the development process sustainable for the long term. The guideline for a good Rails design is to think about ways to have smaller objects with single responsibilities. These small objects should collaborate with each other by exchanging messages, while maintaining loose coupling. This is achieved by clarifying and enforcing strict tasks for the existing objects provided by Rails, as well as increasing indirection by introducing new layers of objects, also with clear responsibilities.

Let's start by revisiting the existing object layers Rails provides by default and give them clear single responsibilities.

Controllers

Apart from the routes, controllers are the outermost layer of a Rails app. It is where HTTP requests first arrive in the Ruby code and where the final HTTP response is returned.

Controllers should be responsible for dealing with the specifics of HTTP, making sure that everything the app needs to know from requests are properly extracted and that no intricacies of the protocol is leaked to the other layers of Ruby code. Therefore, anything HTTP-specific such as headers, query parameters, session storage, cookies, etags, and others, belong to the controller layer.

As a corollary, controllers should not contain business logic of any kind. Validations, queries, enqueueing jobs and sending emails are examples of responsibilities that do not fit into controllers, as they are not related to HTTP transformations. Controllers should simply fetch data from requests and send them to other objects, and finally receive what these objects return in order to craft the proper HTTP response.

Views

The view layer is actually part of the controllers. Since controllers craft the HTTP response, it needs to define the response body as well. But since the body in itself can be quite complex, it is necessary to delegate this part of response crafting to a specialized resource. This is the view layer, with the view context, template files, and helpers.

Views are, therefore, responsible for properly assembling the response body. In most cases the body content is an HTML document.

In views, the resulting data from business logic layers returned to the controller are formatted for presentation. For HTML, views would append and interpolate data in tags and assign proper attributes.

Views should receive the response data already processed and ready for presentation. It is not the responsibility of the view layer to encapsulate business logic of any kind. For example, conditionals in views should be rare, limited to logic that is strictly related to presentation concerns. If presentation logic is needed, these should go in view helpers in order to avoid polluting templates with Ruby code.

Active Record Models

There is no obvious answer about what the single responsibility of Active Record models should be. That's because by default they are already overloaded with features and capabilities. Even when Active Record classes have an empty body, they are packed with multiple frameworks and mixins such as Active Record and Active Model, along their own submodules. Beyond being just a data transporting resource, the resulting object inherits a broad public interface, capable of doing validations, database persistence, transformations, callbacks, as well as wiring themselves with other models through associations.

In order to keep a sane codebase, it is crucial to select a single responsibility for these objects. The sensible choice is to assign to them the task of managing database persistence of particular sets of resources. In other words, a more thoughtful software design approach to Rails requires to strip Active Record models from most of their features, and keep only what is directly related to database operations.

As discussed previously, the single responsibility of objects should be reflected in their naming. The term “model” is too vague to represent database persistence. Therefore, the classic Rails models should be renamed to “records”. They are the database layer of the codebase, using a subset of Active Record to deal with the DBMS.

Records should not have any business logic beyond code that is directly related to database persistence. Validations should not be performed in Records, as data integrity can be ensured with constraints in the database schema; certain constraints are even only effectively enforced that way, such as uniqueness of certain keys. Records should be as close as possible to the raw representation of database values in Ruby, with no additional capabilities and transformations. Needless to say that callbacks and associations should be avoided at all costs.

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.

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(article: 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.

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.

Extensions

The new layers presented in this document were introduced in the context of a simple, small Blog app in Rails. In the real world, however, apps are much more complex than just a set of synchronous CRUD operations. This section showcases how to organize business logic objects in other common cases and additional layers.

Associations

In this architecture, all persistence and database concerns are encapsulated under the Repository layer. The goal of this design is to have the business logic sending singular messages to a Repository for any query or writes to the database, and receive read-only Model instances as a result. There is no lazy loading whatsoever, as all data requested is fetched at once; there is also no method chaining that would end up allowing the business logic layer to compose its own queries with artifacts such as scopes and filters either. Repositories expose public methods for every single kind of query and operation required by the business logic.

This design affects directly how associations are implemented in a Rails app. Models do not have knowledge about how to load associated objects, nor do they know how to fetch Records at all. It is up to the Repository to perform the proper query with foreign keys in the database and instantiate the Models with the proper attributes and nested structures.

To better showcase associations, let's implement an example in the Blog app. We create Comments as Records that belong to Articles. Comments have an Article ID as an attribute, which is the foreign key to an Article. Comments can be created and deleted in the context of an Article, and these endpoints are nested in the Article's resourceful routes:

Rails.application.routes.draw do
 resources :articles do
   resources :comments, only: [:new, :create, :destroy]
 end
end

With nested resources we are ensured that all actions in Comments Controller will have an Article ID as parameter. This is used to instantiate the Comment Input so the Comment is created for the target Article in the Create Comment Action, which returns a Comment as the Result.

class CommentInput
 include ActiveModel::Model

 attr_accessor :article_id, :author, :body

 validates :article_id, presence: true
 validates :author, presence: true
 validates :body, presence: true, length: { minimum: 10 }
end
class CommentsController < ApplicationController
 def new
   @input = CommentInput.new(article_id: params[:article_id])
 end

 def create
   @input = CommentInput.new(comment_params)
   @input.article_id = params[:article_id]

   CreateCommentAction.new.perform(@input)
     .and_then do |comment:|
       redirect_to(article_path(comment.article_id), notice: 'Comment was successfully created.')
     end
     .or_else do |errors|
       render :new
     end
 end

 def destroy
   DeleteCommentAction.new.perform(params[:id])
   redirect_to article_path(params[:article_id]), notice: 'Comment was successfully destroyed.'
 end

 private

 # Only allow a list of trusted parameters through.
 def comment_params
   params.require(:comment_input).permit(:author, :body)
 end
end

Note that neither the Comment Model nor the Comment Repository have any coupling with Article. All they handle is the Article ID as a simple numeric attribute with no extra knowledge about what it is used for.

class Comment
 attr_reader :id, :article_id, :author, :body

 def initialize(id:, article_id:, author:, body:)
   @id = id
   @article_id = article_id
   @author = author
   @body = body
 end
end
class CommentRepository
 def create(input)
   record = CommentRecord.create!(
     article_id: input.article_id, author: input.author, body: input.body
   )
   to_model(record.attributes)
 end

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

 private

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

The usefulness of the Article ID comes when we want to render all Comments for an Article in the show page, as well as render the number of Comments each Article has in the index page. We start by defining the association in the Article Record:

class ArticleRecord < ApplicationRecord
 self.table_name = 'articles'

 has_many :comment_records, foreign_key: :article_id, dependent: :destroy
end

Next we give Article Repository the ability to compose Articles with associated Comments. We want to change the .all method to return all Articles each with its own list of Comments. This method will take care of crafting an optimized query with proper joins so we avoid the N+1 problem. We also want to have the ability to fetch a particular Article loaded with Comments, but instead of changing .find, we create a new .findwithcomments method. That's because not all Actions need to have a list of Comments all the time. For example, when we fetch the Article for the edit page the list of Comments is unnecessary.

class ArticleRepository
 def all
   ArticleRecord.all.includes(:comment_records).map do |record|
     comments = record.comment_records.map do |comment_record|
       to_comment_model(comment_record.attributes)
     end

     to_model(record.attributes.merge(comments: comments))
   end
 end

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

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

 def find_with_comments(id)
   record = ArticleRecord.find(id)

   comments = record.comment_records.map do |comment_record|
     to_comment_model(comment_record.attributes)
   end

   to_model(record.attributes.merge(comments: comments))
 end

 def update(id, input)
   record = ArticleRecord.find(id)
   record.update!(
     title: input.title, email: input.email, 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

 def to_comment_model(attributes)
   Comment.new(**attributes.symbolize_keys)
 end
end

The Article Model must be changed in order to receive an array of Comments during initialization. As mentioned before, associations are optionally loaded by the Repository, meaning that the Model's associations are optional attributes. It is a good practice, therefore, to ensure that only Model instances previously initialized with associations can actually respond to an association message, so we will make the Model raise an error in case someone requests a list of Comments to an Article that was not initialized with one.

class Article
 class AssocationNotLoadedError < StandardError; end

 attr_reader :id, :title, :email, :body, :created_at, :updated_at

 def initialize(id:, title:, email:, body:, created_at:, updated_at:, comments: nil)
   @id = id
   @title = title
   @email = email
   @body = body
   @created_at = created_at
   @updated_at = updated_at
   @comments = comments
 end

 def comments
   raise AssocationNotLoadedError if @comments.nil?
   @comments
 end
end

The Show Article Action now can request an Article along with its Comments by sending the proper message to the Article Repository.

class ShowArticleAction < Action
 result :article

 def perform(id)
   article = ArticleRepository.new.find_with_comments(id)

   result.success(article: article)
 end
end

The Controller won't need to be changed at all, since it simply extracts the Article from the Result and forwards it to the view. The view now can render the Comments by reading the Model's attribute.

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

<p>
 <strong>Title:</strong>
 <%= @article.title %>
</p>

<p>
 <strong>Body:</strong>
 <%= @article.body %>
</p>

<%= link_to 'Edit', edit_article_path(@article.id) %> |
<%= link_to 'Back', articles_path %>

<h3>Comments</h3>

<% @article.comments.each do |comment| %>
 <p>
   <strong><%= comment.author %> says:</strong>
 </p>

 <p><%= comment.body %></p>

 <%= button_to 'Delete', article_comment_path(@article.id, comment.id), method: :delete %>
<% end %>

<%= link_to 'Create comment', new_article_comment_path(@article.id) %>


The index view can also now render the Comments count for each Article:
<p id="notice"><%= notice %></p>

<h1>Articles</h1>

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

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

<br>

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

Note that since we are just rendering the number of Comments, having all these Models loaded in each Article is actually a waste of resources. A more optimized design could be made by adding a comments count attribute in Article and setting it in the Article Repository. But the overall design principle remains the same: Repositories return immutable, read-only Model instances with the data properly mapped.

As mentioned before, associations are not required to be loaded for every single use case. For instance, when we render the edit page to update an Article: only the Article that is supposed to be edited is fetched to prefill the form, but not its Comments. That is why it is crucial to have specific Action objects handling specific requests: each Action takes care of only fetching what is necessary for each use case by sending the proper message to the Repository.

Mailers

Rails apps often need to send emails as part of handling a request, which is done through the Action Mailer framework. Mailer classes receive data as parameters and render the body of emails to be sent using views. Therefore, Mailers should be limited to hold logic related to composing emails alone, and coupling with business logic objects such as Actions and Repositories should be avoided.

Back to the Blog example, let's say we want to notify a user that a new Comment was posted in one of their Articles. After introducing a new email attribute in Articles, we can create a Mailer to send the email. The Mailer will be invoked when a new Comment is created in the Create Comment Action, with the data it needs already fetched: the Comment to be included in the message as well the Article that has the email address of the author.

class NotificationMailer < ApplicationMailer
 default from: 'notifications@example.com'

 def new_comment
   @article = params[:article]
   @comment = params[:comment]
   mail(to: @article.email, subject: 'New comment in your article')
 end
end
Hello!

<%= @comment.author %> just posted the following comment in your article
"<%= @article.title %>":

<%= @comment.body %>
class CreateCommentAction < Action
 result :comment

 def perform(input)
   if input.valid?
     comment = CommentRepository.new.create(input)
     article = ArticleRepository.new.find(comment.article_id)
     NotificationMailer.with(
       comment: comment, article: article
     ).new_comment.deliver_now

     result.success(comment: comment)
   else
     result.failure(input.errors)
   end
 end
end

Note that the approach above only works for synchronous email deliveries (the ones performed with delivernow). Mailers have the ability to deliver emails through a background job, but that would require arguments to be serializable according to Active Job's expectations, such as using Global ID. We are using Models as parameters, and those do not meet the API requirements by design. Instead, for asynchronous email deliveries with Models, we should implement our own jobs to properly retrieve the required data later in the background, and still perform email deliveries synchronously in the job queue.

Background Jobs

Jobs are used to defer part of the business logic for later execution. They are enqueued with certain parameters that are serialized, and eventually performed in a background queue, which deserializes the parameters and calls the Job. Active Job is the Rails framework that provides the APIs for this workflow so the app can be abstracted away from the specifics of the background queue in use.

In this architecture, we identify Jobs as being part of the business logic layer of the app, integrated with the Action workflow. Jobs are simply a subset of an Action's content that is deferred to be performed asynchronously: their arguments are already validated and part of the request processing workflow.

Another key difference between Actions and Jobs is that Jobs don't return any values. Instead, they define retry mechanisms for eventual failures. The business logic performed by Jobs should take this into account and be designed to be idempotent and retried without unwanted side-effects.

Let's revisit the Blog app and introduce a Job so we can perform the email delivery in the background.

class NewCommentEmailJob < ApplicationJob
 queue_as :default

 def perform(comment_id)
   comment = CommentRepository.new.find(comment_id)
   article = ArticleRepository.new.find(comment.article_id)
   NotificationMailer.with(comment: comment, article: article)
     .new_comment.deliver_now
 end
end
class CreateCommentAction < Action
 result :comment

 def perform(input)
   if input.valid?
     comment = CommentRepository.new.create(input)
     NewCommentEmailJob.perform_later(comment.id)

     result.success(comment: comment)
   else
     result.failure(input.errors)
   end
 end
end

A benefit of moving part of the business logic from Create Comment Action into New Comment Email Job is that it ended up reducing the coupling between the Action and other objects of the system: the Action no longer needs to call Article Repository or the Mailer. These are now part of the Job's business logic.

GraphQL

An increasing number of contemporary Rails apps have adopted GraphQL as their API layer. Usually these are background services for web and mobile apps, or part of a network of distributed systems. GraphQL is usually implemented by exposing a single HTTP endpoint in Rails that receive payloads that are then processed by a set of GraphQL related objects to translate this data as queries and mutations according to a previously defined schema.

The GraphQL layer of the Rails app is similar to the controller and view layers: they should be responsible only for extracting data from incoming payloads, forward them to Actions, and prepare Action Results for presentation as return values. queries and mutations should not hold any business logic. Since Actions and Results have well-defined structures, they are ideal building blocks to create clear and sustainable GraphQL APIs.

Let's see some examples in the context of the Blog app. We can enable the creation of Articles via the GraphQL API through a mutation that receives the values as arguments and returns the proper Result fields. Since all our mutations will return a Result, they are sure to include fields such as a success boolean and an optional collection of errors. These can be defined in a mutation base class:

# app/graphql/mutations/base_mutation.rb

module Mutations
 class BaseMutation < GraphQL::Schema::Mutation
   field_class Types::BaseField

   field :success, Boolean, null: false
   field :errors, [Types::ErrorType], null: false
 end
end

Note that the errors field in the mutation base is an array of errors as defined by the Error type. Each error should include a message, an error code, and an optional field to make it easier for API clients to understand them accordingly.

# app/graphql/types/error_type.rb

module Types
 class ErrorType < BaseObject
   field :field, String, null: true
   field :code, String, null: false
   field :message, String, null: false
 end
end

Now we can go ahead and implement the Create Article mutation:

# app/graphql/mutations/create_article.rb

module Mutations
 class CreateArticle < BaseMutation
   argument :title, String, required: true
   argument :email, String, required: true
   argument :body, String, required: true

   field :article, Types::ArticleType, null: true

   def resolve(title:, email:, body:)
     input = ArticleInput.new(title: title, email: email, body: body)

     CreateArticleAction.new.perform(input)
   end
 end
end
# app/graphql/types/article_type.rb

module Types
 class ArticleType < BaseObject
   field :title, String, null: false
   field :email, String, null: false
   field :body, String, null: false
 end
end

As seen above, the mutation itself simply instantiates an Input and passes it to the proper Action. The Result that contains the created Article is then mapped to the Article GraphQL type seamlessly. In case of errors, the mutation will have an empty article field but the errors field will be populated with the proper messages and codes. All of that without ever leaking business logic into the GraphQL layer.

Caveats

No design is perfect. The architecture proposed in this document provides a good starting point for sustainable, long term development in Rails, but by no means it gives answers to all possible cases and complications that could arise along the way. It is crucial for developers to always keep in mind the principles of object oriented design while adopting this architecture, such as keeping objects small and simple, with thoughtful introduction of indirection when necessary.

This section exemplifies complications that might arise from the use of the patterns here prescribed, with suggestions on how to address these limitations when possible.

Complex Actions

Actions are designed to be the centerpieces of business logic, coordinating all the steps required to process a user request. For that reason, they are the objects with the highest tendency to become big, complex, and coupled with many other structures. Actions are by definition aware of Inputs, Repositories, Models, Jobs, and many other objects from the system. It won't take too long for Actions to lean towards accumulating nested code, conditionals, and many other code smells.

Actions are such magnets for complexity because of their very nature of being the core units of business logic. After all, the business logic layer of any app is modified far more often than any other layer; the churn of business logic code is incredibly high when compared to the rest of the system, and there is no escape for that. This complexity can be minimized through refactorings and indirection, but apps will always need to have business logic entry points where everything is tied together.

In order to avoid excessive complexity in Actions, it is crucial to model these objects atomically enough so they can be specialized to handle very particular requests in isolation. Designing simpler Actions might impact even the routes and controllers design. For example, instead of having one big Action that receives many optional arguments, it is preferable to split it in different Actions (and therefore different endpoints) for each specific case.

Actions can also collaborate with auxiliary objects that handle certain aspects of business logic beyond Repositories and Jobs. Each app might require additional objects and layers so the burden in Actions is reduced. For instance, the specifics of crafting outgoing HTTP requests to services might be deferred to service objects that play that role, and Actions can simply instantiate and send messages to them.

Duplication in Actions

Actions might end up sharing similarities to other Actions, leading to an excess of duplicated code in these objects. Given that by design Actions are not allowed to interact with each other, and also given that there can only be one Action for each request, it might get tricky to remove the repeated logic.

First of all, it is important to emphasize that not all duplication needs to be removed in order to achieve a sustainable architecture. More often than not, attempts to remove duplication lead to a poor, complex design and an overall worse code than the previous duplicated code. Developers feel the urge to remove every and single duplication as a deceitful instinct of fixing a possible code smell. The truth, however, is that duplication is much easier to understand and change than abstractions that are poorly designed. Before addressing any duplication, the best course of action is to wait until the duplicated logic is referenced too many times, copied at least three times over.

Once the duplication becomes a real concern, the code can be refactored by extracting the repeated logic into specialized objects Actions can interact with. A particular case is when the duplication happens around composing the Result object. An app might introduce a new layer of Result composers for that purpose that Actions can rely on and delegate that process to.

Testing Actions

The topic of testing is not covered in this document, but a word is needed regarding testing Actions. Actions don't need to be tested through unit tests. Instead, these objects' behaviours should be asserted indirectly through proper integration test coverage.

Given that Actions represent an entire feature or use case, developers might be tempted to end up writing unit tests that are actually integration tests in disguise. For example, a unit test for the Create Article Action might end up asserting that a Record is persisted. This test is not a unit test, since the code in the Action is completely decoupled from the database or from Active Record itself. Such assertions are important, but should be written as integration or system tests.

In theory, unit testing for Actions needs to ensure that the Action is sending the expected messages to its collaborator objects and that the Result returned by the Action has the right values, and nothing more. This would likely require mocking/stubbing libraries, so tests are performed fast by not actually making network calls or database operations. Once again, this does not replace the need for integration tests whatsoever, since only these end-to-end assertions can ensure that the system as a whole is functional and the network of objects behave properly in the real world.

Given that the only way to test Actions is through excessive stubbing and mocking, and that these unit tests do not replace integration tests, writing unit tests for Actions is an unnecessary burden. Instead, the Actions behaviours should be asserted indirectly through proper integration test coverage.

Validation Antipatterns

This architecture proposes the use of validation in Input objects by including Active Model validations. By inheriting from Active Model, Inputs can have the same well-known validation API and seamlessly integrate with view forms.

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

This approach comes with costs, however. Firstly, this choice lets Inputs inherit the Rails antipattern of mutating objects in order to fetch validation results. A valid object has no errors, but only until someone else asks it for its valid state, in which it might end up populating its own errors, mutating its own state. This is a brittle design that violates the idea of an Input representing the user's entries and nothing more. Worst even, this mutation happens by calling a predicate method. Predicates, as any query method, should simply return information about the object but not alter the state of a subsystem.

Secondly, validation errors are represented as instances of Active Model Errors, which is quite limited in its API. There are no standardized formats for individual errors, which might negatively impact the design of meaningful representation of validation messages, as seen in the GraphQL section.

Apps that face these costs might opt for not using Active Model for input validation, but instead introduce their own Validators as collaborator objects that Actions can invoke in order to check for the validity of Inputs. Similarly, errors can be represented using well-defined structs with proper fields such as messages, codes, and attribute names.

Transactions

Database transactions guarantee that a series of steps are performed atomically, making possible to roll them all back in case of errors. In Rails, transactions are defined as blocks where all their database operations are committed to the database together in case of a successful yield, or rolled back if an error is raised.

Grouping logical steps in atomic groups with transactions is part of the business logic layer. When the app requires transactions, there is a risk for Actions to end up coupled with Active Record once again. In order to achieve a sustainable architecture, however, it is important to respect the design decision to encapsulate Active Record behind Repositories in this case as well. Additionally, transactions are excellent candidates to be extracted into more specialized objects.

Back to the Blog example, let's say we don't want to persist a comment if the Job that delivers the notification email fails to be enqueued. This requires grouping the Comment creation and the email notification delivery in a transaction.

The first thing we can do is to create a method in the Repository to encapsulate Active Record:

class CommentRepository
 class << self
   def transaction
     ActiveRecord::Base.transaction { yield(new) }
   end
 end
end

We can now model our transaction as an object that executes the piece of protected business logic that is rolled back all at once in case of errors:

class CreateCommentTransaction
 def perform(input)
   CommentRepository.transaction do |repository|
     comment = repository.create(input)
     NewCommentEmailJob.perform_later(comment.id)
     comment
   end
 end
end

The Action can now simply validate the input and perform the Transaction, remaining decoupled from Active Record, or the transaction block itself:

class CreateCommentAction < Action
 result :comment

 def perform(input)
   if input.valid?
     comment = CreateCommentTransaction.new.perform(input)
     result.success(comment: comment)
   else
     result.failure(input.errors)
   end
 end
end

Final Words

Ruby on Rails has reshaped the world of web frameworks by empowering developers to ship apps incredibly fast. Through well designed features, conventions, and the clever use of the strengths of the Ruby language, Rails offers a quick and easy starting point for the creation of feature-rich apps.

When thinking about long-term, sustainable development, however, one must weigh the basic architecture offered by Rails and consider ways to redesign it so changes can be introduced without much hassle.

Granted, not all Rails apps require this thoughtful design exercise. It all comes down to the business strategies and scenarios where the technology is inserted into. For experiments, companies and teams looking for short-term solutions, or small cases in which growth is not expected, the simple Rails default architecture might be the perfect fit.

More often than not, though, developers use Ruby on Rails in fast growing companies with ambitious business goals, targeting expanding existing features, adding new ones, quickly addressing bugs in production, and hiring more and more engineers to work on the same codebase. For such teams, the importance of being able to change code quickly cannot be overstated. Companies that can ship changes fast are the ones who innovate, grow, and make a difference in the world; companies that are not able to change fast end up lagging behind, lose market share, and ultimately die.

The architecture defined in this document is a more robust starting point for such teams and companies. It offers a more robust starting point for Rails apps, which initially will need to invest more in indirection and overall more code volume. This design investment pays off once the app is in production and used by people, by allowing developers to evolve it sustainably.

It is important to mention that this architecture won't give all the answers for all cases, and it doesn't even attempt to. Ultimately the responsibility to maintain a good design still relies on the team that owns the codebase, and constant evaluation of business plans aligned with principles of good software design is required at all times.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment