How I Like to Build Rails Apps

This post is going to be a bit different from previous posts: I’ll cover my preferred approach to developing applications using Ruby on Rails, providing recommendations regarding application design and offering some specific gem recommendations. Some of the recommendations are applicable to frameworks other than Rails, and I’m not saying much about Rails that hasn’t already been said by others.

This post isn’t meant to cover every facet of Rails development but instead is intended to provide a place to which I can point when asked about how I prefer to build Rails applications, or to otherwise provide a jumping off point to start discussions about Rails development.

Why Rails?

The first iteration of Ruby on Rails was released in 2004, so needless to say, the framework is no spring chicken. Since that time, Rails has been favored by many-a-startup, and even if it has perhaps irretrievably lost some of its luster on its hype-cycle downtrend, Rails has continued to evolve and remains the go-to framework for web development in Ruby. It also remains my own favored framework whenever I am working on a deadline or collaborating with other developers. I have iterated on plenty of Rails apps started by developers that had long since left the company, and I have felt good about leaving Rails apps in the hands of others when it has been my own time to depart; with the right expertise and sufficient attention to detail, Rails apps can easily stand the test of time.

Here are Rails’ main strengths as I see them:

Before getting into specific recommendations, I want to briefly highlight Rails’ philosophical underpinnings: created by Danish programmer David Heinemeier Hansson, aka. DHH, Rails has since its beginning been positioned as a reaction to enterprise software development, i.e. Java, with its heavy emphasis on abstraction.

Here’s a breakdown of positive and negative aspects of “the Ruby way” and “the Java way” (not to be taken with excessive seriousness):

Language Positive Negative
Java Mature; made for enterprise Rigid; verbose; rote
Ruby Simple; made for developers Chaotic; wild west; spaghetti code

The purpose of the above table is to illustrate that precisely what makes Ruby, and by extension, Rails, great, its emphasis on simplicity and developer-centric philosophy, also carries potential for self-destruction, in the form of excessive technical debt, if additional abstractions are not introduced and rigorously adhered to.

The anti-feature designations and app-architecture recommendations that I provide in this post should, I think, be viewed in the context of moving the pendulum slightly back towards “enterprise”.

Why not Rails?

There are some situations in which you should probably shy away from Rails altogether.

Recommendations

Encapsulate business logic in service objects

Controllers should contain only HTTP-related logic, like accessing request fields and rendering templates.

Business logic should be contained in a separate class: your controller tests will then only need to test HTTP related logic. Mock the abstraction class or stub sending it a message and returning a result.

Similar to controllers, models should be thin. In your application’s ActiveRecord classes, define only associations, scopes, and validations related to data consistency; validate required attributes for columns that have NOT NULL constraints, etc.

You may want to consider trailblazer-operation for its “railway-oriented programming” abstraction. I have barely scratched the surface of the features provided by trailblazer-operation, but have found its most essential features to provide some useful structure to service objects.

Otherwise, if you don’t want the additional dependency or want to keep things simple, just define a plain old ruby class that you can instantiate in the controller action; add a separate tests subdirectory for your service classes and write tests for any public methods that you define in the service class.

Define classes for rendering and validating forms

You probably should not ever directly instantiate ActiveRecord objects in controllers or views. Relating to form submissions, you don’t want the data needed from a form submission to dictate the underlying schema design, or vice versa.

Use dry-struct and dry-types to define form-rendering classes decoupled from your schema design. The dry-struct object should define all fields rendered in the form HTML.

Use dry-validation to process the form requests. Pass the request body parameters, as a hash, and any other needed fields from the HTTP request, to the service object, which should instantiate and call the dry-validation contract object.

Here’s a snippet pulled from a controller that shows instantiating a dry-struct object in the #new action and a trailblazer-operation object in the #create action to handle the form submission.

def new
  @form = Organization::UserRegistration::Form::Create.new(
    email: "",
    full_name: "",
    send_invite: true
  )
end

def create
  result = Organization::UserRegistration::Operation::Create.(
    current_user: current_user,
    params: params[:organization_user].permit!.to_h
  )

  if result.success?
    user = result[:user]

    return redirect_to "/organization/users/#{user.user_id}",
                       flash: {
                         notice: "User registered."
                       }
  end

  ## This trailblazer-operation object sets an :error keyword in its
  ## result hash, as well as an :error_message string. The HTTP-related
  ## controller logic might branch depending on the error keyword, but
  ## here we always redirect to the #new page. To ensure all error codes
  ## are handled where branching is involved, it likely makes sense to
  ## use an algebraic data type library (TBD).
  ##
  ## result[:error] might be one of:
  ## :unauthorized, :invalid_params, :invalid_email, :non_unique_email
  ## :persistence_error, :generate_token_error, :send_email_error

  redirect_to "/organization/users/new",
              status: 303,
              flash: {
                error: result[:error_message]
              }
end

The following is extracted from the operation class and shows how parameters are validated using dry-validation.

def validate_params(ctx, params:, **)
  contract = ::Organization::UserRegistration::Contract::Create.new
  ctx[:validation_result] = contract.call(params)
  ctx[:validation_result].success?
end

The return value of the call to success? determines whether to switch to the left (failure) track (see the trailblazer docs to learn more).

Encapsulate presentation logic in presenter objects

I recommend setting one instance variable per controller action, from the action itself and not a before-handler. Keep any logic within the template to a minimum, and test the public methods of the presenter class.

Avoid making any database queries from templates: run any needed queries in presenter, called from the controller when instantiating the presenter object.

Use Hotwire Turbo & Stimulus instead of a JS framework

Hotwire is a framework that lets you program realtime UI effects while writing only a minimal amount of JavaScript. I’m a big fan, as it means you don’t have to to split your application into a frontend and a backend to make it feel responsive or deal with related time sinks like state management for JavaScript applications. Instead, there’s just one programming model: render Turbo-stream templates when you want realtime UI effects, and gracefully degrade to redirects for clients that don’t support JS.

The basic design is elegant and simple: the browser client runs the Turbo client JS, which understands how to process turbo-stream documents (text/vnd.turbo-stream.html) served by the Rails app, and can also preload links to make your app feel ultra-responsive, like a SPA. This blog actually relies on Turbo for its link preloading behavior, even though the blog is deployed to a CDN as a static site.

Your Rails app renders turbo-stream documents, which are just custom-elements that describe DOM operations to perform when processed by the Turbo client JS: think CRUD for DOM elements. When Turbo isn’t enough, you can write Stimulus JavaScript classes to decorate your HTML elements with custom behavior.

ActionCable lets you broadcast turbo-streams over websocket connections, so your application can perform some UI update in response to a system event. For example, if you were building an auction application, submitting a bid might trigger a broadcast to other auction participants, who would immediately see that a bid was placed.

Choose PostgreSQL and take advantage of its features

There are two schools of thought I’ve encountered related to how to treat the database in Rails apps.

The first school says to treat the database as dumb storage: don’t bother adding NOT NULL constraints, foreign key constraints, etc. because you already define validate_required validations and associations in your ActiveRecord models. You can develop your app without advanced database features, and it will be portable between different DBMS vendors should you ever switch.

The second school says the opposite: pick a DBMS vendor after careful consideration and define constraints like NOT NULL, foreign key constraints, etc. as it provides a higher level of data integrity.

Data integrity is non-optional, so I’m firmly in the second camp and specifically recommend PostgreSQL. Keep your schema normalized and define composite primary keys and UUID primary keys where appropriate. Preferably all columns should have NOT NULL constraints, and all ActiveRecord associations should have matching foreign key constraints. Define a PostgreSQL schema to contain all your application’s objects, and use singular names for tables (e.g. “myapp.user” not “myapp.users”).

A really excellent book that goes into using composite primary keys in Rails is “Enterprise Rails” by Dan Chak. It is made available at no cost on the author’s website and made a big impression on me when I first read it. Rails 7+ provides built-in support for composite primary keys, but in the past I have also used the gem composite_primary_keys, which is demonstrated throughout Enterprise Rails.

Carefully consider your scaling requirements before you go live to production; perform load-testing. If your app runs in the cloud, you can consider using a service like AlloyDB (GCP) or RDS (AWS).

Related to scaling, you can also consider Citus, which is installed as a PostgreSQL extension and allows you to scale out by sharding your data to multiple nodes. If your application fits a multi-tenant data model, you can use the gem activerecord-multi-tenant (maintained by Citus) to route queries to a tenant shard.

You can also consider one of the distributed SQL DBMS vendors advertised as compatible with PostgreSQL clients, i.e. YugabyteDB or CockroachDB.

The gem store_model is used to define ActiveModel-like fields and validations for a JSON attribute. PostgreSQL allows you to define jsonb or json columns, which means you can use store_model to validate your JSON data on insert. Just don’t abuse PostgreSQL’s JSON support: it’s a complement to a thoughtfully designed, normalized schema, not a replacement. Define a column with the jsonb datatype when you need to be able to query the JSON data; the json datatype can be used when you won’t need to query the document.

Use Sidekiq for background jobs

Use Sidekiq for background processing: it’s popular and it works. Job data is written to Redis and a thread pool picks up the jobs. If you cannot tolerate lost jobs in case of node failure, etc. you should configure Redis to enable writing to its Append Only File and enable fsync on every write.

As mentioned in the Sidekiq wiki, Sidekiq should have its own Redis instance, as its persistence requirements are likely to be different from those of the Rails cache, which can also be backed by Redis.

I also recommend sidekiq-cron and sidekiq-batch.

Where Sidekiq falls short is if you need a language-agnostic jobs system. In that case, you would need to reach for something like RabbitMQ.

Redis instances should be single-purpose

In addition to the Rails cache and Sidekiq, you’ll likely find more reasons to use Redis (or another Redis-compatible store such as Valkey).

To name a few that I commonly add to Rails projects:

Similar to Sidekiq’s recommendations, I recommend that you should have separate Redis stores for each of your Redis use-cases. If, for example, the Rails cache reaches its memory limit and starts evicting keys, you wouldn’t want sessions to also be evicted as a consequence.

Use lockbox and blind_index for encryption of sensitive data

You can easily encrypt all PII and other sensitive data stored by your application using Lockbox, and you can retain the ability to query encrypted fields using Blind Index, which works by storing a hashed value that can be compared to hashed input. Lockbox even comes with support for hybrid encryption, so if your app does not need to query encrypted data, your app can be configured with an encryption key and without the corresponding decryption key. You can also configure Lockbox to use a remote key management service: AWS KMS, Google Cloud KMS, or Vault.

Just about every application stores some sensitive data, even if it’s just users’ names. These gems can be crucial tools for bringing your app into compliance with certain regulations, and since these gems bring tangible security benefits and incur little cost in terms of development time, there’s little reason not to reach for them by default.

Deploy static assets to a CDN

Rails allows you to deploy your all assets generated by the asset pipeline to a CDN. This is a really powerful feature, as it means reduced latency for your users. I recommend taking advantage of this feature.

Rails anti-features

Avoid the following Rails features.

YAML environment configuration

You should ideally write your application so that you could, even if you never would, open source your application at a moment’s notice.

If you have dynamically provisioned developer or QA environments, or if you have multiple production or staging environments, you likely already know that Rails’ approach to configuration comes with some drawbacks.

The YAML based approach encourages violations of the 12-factor app guidelines, as it is hugely tempting to hardcode environment-specific configuration into the YAML configs. Definitely don’t add URLs or anything specific about an environment to these files.

I recommend in most cases configuring your app through environment variables, read and set to constants from initializers. Here’s an example that reads an environment variable and coerces the value to an integer before setting a constant:

# config/initializers/foo_count.rb

FOO_COUNT = begin
              value = ENV["FOO_COUNT"].to_i
              raise ArgumentError if value < 0
              value
            end

I also recommend not using Rails credentials, as they are inherently environment-specific.

Route helpers

I don’t love the idea of ActiveRecord models being associated with routes; it’s a little too much magic for my taste, since the underlying schema might be quite different to what I want to expose to end-users, and because I prefer to be explicit.

When a composite primary key is used as the primary key in Rails 7, the composite key is reflected in the path parameter using an underscore separator between column values, even though, depending on the routing structure, this might not really be necessary and a single column value could provide sufficient uniqueness. So my soft recommendation is to specify path strings and avoid route helpers.

Always test all possible redirection paths or URLs in controller tests, whether or not you are using route helpers.

ActiveRecord Lifecycle Callbacks

I prefer to encapsulate all business logic in service objects, as the logic is linear and easy to test and follow; so I recommend eschewing ActiveRecord lifecycle callbacks.