RL ROLAND LOPEZ
// 5 min read

Creating Your First Rails Engine

Creating Your First Rails Engine β€” Build a mountable Rails engine and ship a real gem to rubygems.org. Step-by-step walkthrough using a literal_enum example, with concerns, dummy app, and tests.

Why learn Rails engines?

Many of the best practices and OOD principles can be learned by just reading the Rails gem.

The catch is that you need to know a bit more about Ruby and be proficient in Rails.

Then the last step is to understand how the engine works.

The result: anytime you have an issue, read the Rails code and you will always find a pattern that you can reuse in your codebase.

What we are going to build

The problem

Every time I want to use an enum in a model, my developer OCD trips me up.

You define a string in the helper as an enum value:

class Article < ApplicationRecord
  enum status: [:active, :archived], _suffix: true
end

But in the migration, you need to define an integer as a column:

class CreateArticles < ActiveRecord::Migration[7.1]
  def change
    create_table :articles do |t|
      t.string :name
      t.integer :status

      t.timestamps
    end
  end
end

This makes everything complicated to map the default.

If you change the order in your array, it blows up everything. And when you query the SQL database, you also get numbers when at the application layer you have complete abstraction.

This can be even more confusing.

The solution

We want to build something like this:

class Article < ApplicationRecord
  # @NOTE:
  # The column `status` is supposed to be of type string in your database.
  literal_enum :status, ["pending", "accepted"]
end

And it saves a string in the database:

class CreateArticles < ActiveRecord::Migration[7.1]
  def change
    create_table :articles do |t|
      t.string :name
      t.string :status

      t.timestamps
    end
  end
end

Pair programming session: building a gem together

STEP 0: Understanding the problem

Here is what we are trying to mimic β€” original gist.

Huge kudos to @hundredwatt (Jason Nochlin) for showing me how to do it.

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "rails"
  # If you want to test against edge Rails replace the previous line with this:
  # gem "rails", github: "rails/rails", branch: "main"

  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
    t.string :status
  end
end

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def self.literal_enum(name = nil, values = nil, **options)
    if values.is_a?(Array)
      values = values.map { |value| [value] * 2 }.to_h
    end

    enum(name, values, **options)
  end
end

class Post < ApplicationRecord
  literal_enum :status, ['pending', 'accepted']
end

post = Post.create! status: 'accepted'
p post.status_before_type_cast # => 'accepted'

The goal is to put self.literal_enum into a Rails engine, with the tests.

STEP 1: Create your Rails engine

The three types of Rails engines are mountable, full, and β€œnormal” engines.

  1. Mountable engine β€” namespace-isolated, includes assets, namespaced controllers/helpers, a layout, and mounts inside a dummy testing application.
  2. Full engine β€” basic structure of an engine (app tree, routes, engine.rb). No namespace isolation or assets.
  3. Just the plugin β€” no app directory, no namespace. Honestly I did not find a use case for this one.

We will use the --mountable version:

rails plugin new enum_literal_clone --mountable

STEP 2: Understanding the directory

Quick orientation:

  • ./app β€” basic Rails app, like a child Rails app that gets included when you install this gem into your other Rails apps.
  • ./lib/enum_literal_clone/engine.rb β€” the glue between your child Rails app (i.e. the engine generated) and your host Rails app.
  • ./test/dummy/app β€” a full host Rails app where your basic Rails app is already installed, for testing.

You can cd ./test/dummy and rails g model .... If you do the same in the root folder, those migrations will be copied into your main host Rails app when installing the gem β€” like Devise, when you generate User migrations.

STEP 3: Set up your dummy Rails app

cd ./test/dummy

rails g model Article name:string status:string
rails db:create
rails db:migrate

rails s
irb: welcome to rails
=> Article.create(name: "hello", status: "done")

If you cd ./enum_literal_clone and try rails s, you will notice that the Article model does not exist.

STEP 4: Create the concern

# app/models/concerns/literal_enum_clone/binder.rb
module LiteralEnumClone::Binder
  extend ActiveSupport::Concern

  class_methods do
    def literal_enum(name = nil, values = nil, **options)
      if values.is_a?(Array)
        values = values.map { |value| [value] * 2 }.to_h
      end

      enum(name, values, **options)
    end
  end
end

The important part is that your concern is namespaced with LiteralEnumClone.

STEP 5: Include the concern in engine.rb

# lib/literal_enum_clone/engine.rb
module LiteralEnumClone
  class Engine < ::Rails::Engine
    isolate_namespace LiteralEnum

    initializer "literal_enum_clone.active_record" do
      ActiveSupport.on_load :active_record do
        include LiteralEnumClone::Binder
      end
    end
  end
end

This includes LiteralEnumClone::Binder only when needed.

STEP 6: Update your dummy model

# test/dummy/app/models/article.rb
class Article < ApplicationRecord
  literal_enum :status, ["pending", "accepted"]
end

STEP 7: Manual testing in your console

cd ./test/dummy

rails s
irb: welcome to rails
=> article = Article.create(name: "hello", status: "pending")

# Supposed to fail with an argument error
Article.create(name: "hello", status: "done")

# Check at the DB level that you saved the string
ActiveRecord::Base.connection
      .execute("SELECT * FROM articles")
      .first["status"]

# Normal enum method working
article.pending?
=> true

STEP 8: Test your concern

# test/models/concern_binder_test.rb

require "test_helper"

class LiteralEnum::ConcernBinderTest < ActiveSupport::TestCase
  test "Enum methods work" do
    article = Article.new(name: "Hello", status: "pending")
    assert article.pending?
  end

  test "Statuses are validated" do
    assert_raises ArgumentError do
      Article.new(name: "Hello", status: "will_fail")
    end
  end

  test "Saves string to database" do
    article = Article.create(name: "Hello", status: "pending")
    database_status_value = ActiveRecord::Base.connection
      .execute("SELECT * FROM articles")
      .first["status"]
    assert_equal database_status_value, "pending"
  end

  test "Enum returns a string" do
    article = Article.create(name: "Hello", status: "pending")
    assert_equal article.status, "pending"
  end

  test "Works with a symbol" do
    article = Article.create(name: "Hello", status: :pending)
    assert_equal article.status, "pending"
  end
end

STEP 9: Ship it to rubygems.org

Here is the repo and the gem published:

Special thanks

Huge shout-out to @hundredwatt (Jason Nochlin).

I was tired of seeing integers in the database when using enums, and he jumped into the discussion on Twitter and showed me this.

Also to Alexandre Ruban from hotrails.dev for putting together this awesome deep dive about rebuilding turbo rails plugin.

I would have never tried otherwise β€” though the original Ruby on Rails guides about Rails engines are also a really good start.

What’s next?

I hope this tutorial helped you understand how you can extract pieces of logic that you have already seen in your career.

But most importantly, to see beyond the code and realize that contributing to the Rails ecosystem is not hard.

If you do not have the tools, you will never understand how to package your logic for someone else β€” whether it is your team or the community.

See you next time.

Roland Lopez
Written by
Roland Lopez

Technical co-founder specialized in SaaS, DevOps, AI agents, and data platforms. Building and scaling with Ruby on Rails, n8n, and fast feedback loops.

Built by Agent Skynet See the agency