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.
- Mountable engine β namespace-isolated, includes assets, namespaced controllers/helpers, a layout, and mounts inside a dummy testing application.
- Full engine β basic structure of an engine (app tree, routes,
engine.rb). No namespace isolation or assets. - 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.
Technical co-founder specialized in SaaS, DevOps, AI agents, and data platforms. Building and scaling with Ruby on Rails, n8n, and fast feedback loops.
Design Patterns in Rails
When and how to use Singleton, Factory, and other design patterns in Rails. Concrete examples, anti-patterns to avoid, and a rule of thumb for overuse.
Testing Gems and Plugins: Best Practices in Ruby on Rails
Four ways to test Rails gems and plugins - pure Ruby, partial Rails, embedded apps - and why 90% of Rails devs have never shipped one.
Best Practices for Managing Gems in Your Rails Application
Screen gems with the TAM method, modify safely via monkey patches or forks, and keep your Gemfile lean - the rules pros use to keep Rails apps healthy.