« Philippe Creux

Organize your Rails codebase with aaa engines

26 Sep 2024

Once a Rails app has 50+ models and controllers, it’s time to organize the code by feature using Rails engines.

We give each feature a directory containing the app code, tests, migrations, and routes specific to it. It looks like a mini-rails app that only contains the code related to that feature.

Working with these mini-rails apps is a breeze! You can fully expand the app/* directories, and they all neatly fit in the file explorer on the left-hand-side of your screen. It’s so satisfying to navigate such a small set of files.

I helped extract such engines on two large apps, and they were enthusiastically adopted. It doesn’t require much effort as long as you move files around without attempting to isolate the engine from the rest of the app.

Alright, how do we go about it?

aaa engines

At Zipline, we decided to name the directory that contains these engines aaa. In a file browser, it looks like this:

- zipline-app/
  - aaa/
    - admin/
    - checkout/
    - forum/
      - app/controllers/forum/
        - posts_controller.rb
        - comments_controller.rb
      - app/models/forum/
        - post.rb
        - comment.rb
      - config/routes.rb
      - db/migrate/20240802_forum_posts.rb
      - lib/
      - test/
      forum.gemspec
    - surveys/
  - app/
    - controllers/
    - models/
      - 100+ models :cry:
  - test/

Why aaa? So that it sits at the top of the file browser, above the app directory. 😅

I worked on an application where that directory was called engines. While it makes more sense than aaa, having it in the middle of the core application directories was messy—especially when you expanded a couple of core and engine directories. Now, while aaa doesn’t stand for anything, you could argue that it’s an acronym for “Auxiliary Application Addons”. 🙃 “aaa engines” is now part of Zipline’s common language. It also helps us differentiate them from other kinds of engines (e.g. engines offering capabilities such as view components, form auto-save, etc).

Engine boilerplate code

We leverage Rails Engines to make Rails aware of app code, routes, and migrations stored in an aaa subdirectory. We are NOT building configurable engines, isolated from the rest of the app, that get mounted at a specific path, like devise or active admin.

These simple aaa engines only need three files to be picked-up by rails: a gemspec, a lib/XXX.rb, and an engine.rb file. Here is the boilerplate code for an engine called forum:

# aaa/forum/forum.gemspec
$:.push File.expand_path("lib", __dir__)

Gem::Specification.new do |spec|
  spec.name        = "forum"
  spec.version     = "1.0.0"
  spec.authors     = ["AAA"]
  spec.summary     = "The Forum AAA Engine"
end
# aaa/forum/lib/forum.rb
require "forum/engine"

module Forum
  def self.table_name_prefix
    "forum_"
  end
end
# aaa/forum/lib/forum/engine.rb
module Forum
  class Engine < ::Rails::Engine
  end
end

Let’s make rails aware of the aaa engine by adding a line to the Gemfile:

# Gemfile
gem 'forum', path: 'aaa/forum'

Copy all the things!

Now that your engine is set up, you can move existing code from the core directories to the aaa engine directory.

mkdir -p aaa/forum/app/models
mv app/models/forum aaa/forum/app/models/

Here is a script to make it a bit easier: extract-forum-to-aaa-engine.rb

Restart your rails app and it will just work - including code reloading in the development environment. ✌️

Going further

Namespacing

Models, controllers, and views don’t get automagically prefixed. So a model called Forum::Post must be defined under aaa/forum/app/models/forum/post.rb. This also allows you to define classes in multiple namespaces (or not namespaced).

# aaa/forum/app/models/forum/post.rb
class Forum::Post < ApplicationRecord
end

Adding migrations

With the following lines added to your engine.rb, you can store migrations under aaa/forum/db/migrations.

module Forum
  class Engine < ::Rails::Engine
    initializer :append_migrations do |app|
      append_engine_paths("db/migrate", app)
    end

    private

    def append_engine_paths(type, app)
      return if app.root.to_s.match root.to_s

      config.paths[type].expanded.each do |expanded_path|
        app.config.paths[type] << expanded_path
      end
    end
  end
end

To generate a migration, you must run rails g migration ... at the root level and then copy the migration from db/migrations to aaa/forum/db/migrations.

Adding routes

Routes defined in aaa/forum/config/routes.rb will be loaded.

Rails.application.routes.draw do
  namespace :forum do
    resources :posts
    resources :comments
  end
end

Tests

You run tests from the root directory.

$> bin/rails test aaa/forum/test

You should update your CI config to run tests from aaa engines alongside the core rails app tests.

Third-party gems and services

If you use i18n-tasks, you should register your engine paths to i18n-config.yml.

If you use Sentry, I recommend adding stack.abs_path:**/aaa/** +app to “STACK TRACE RULES” so that the stack trace from aaa engines is considered part of the app and displayed by default.

That’s all, folks!

It is that simple. We leverage Rails Engines to make Rails aware of the code that lives under aaa sub-folders, and we enjoy working with smaller sets of files.

Some may want to isolate aaa engines from the rest of the application by adding a public API. In my experience, that adds quite a bit of weight and complexity but can be a good fit once you reach Shopify’s scale (see Shopify’s Monolith - they use Rails engines to encapsulate components and Packwerk to enforce isolation).

aaa engines make it easier to experiment with new patterns or libraries (ViewComponent, event sourcing, etc) as the scope of the experiment can be limited to a specific aaa engine.

I put together a sample rails app with an aaa engine called Forum. Have a look on GitHub!

Give it a try, and let me know what you think!