
Rails engines: a gem that lives inside your app
TL;DR
A Rails engine can live in a folder of your app, loaded as a path gem. With isolate_namespace, prefixed tables and migrations appended to the host, a feature like short links becomes one folder you can understand or delete. The only host dependency is a single lambda hook. The cost: specs, autoloading and schema visibility.
How I built short links and click tracking as a Rails engine in a folder of the main app, with the real code and the tradeoffs.
Also available in Português (Brasil)
Last week I needed short links for a Rails API. Users share a place or a trip, the app returns a short url, and every click gets recorded. Nothing hard. But the main app already had 70 tables and a config/routes.rb that scrolls for a while. I did not want to add three more models, a controller and two migrations to that pile.
So I built it as an engine. This post is what I learned, with the real code, trimmed a bit.
What an engine is
A Rails engine is a Rails app without the boot code. It has its own app/, config/routes.rb, db/migrate and a gemspec. The host app loads it like any other gem, mounts its routes at a path, and runs its migrations with the rest. Devise is an engine. So is Mission Control Jobs, the job dashboard from the Rails team. Your own app is one too: Rails::Application inherits from Rails::Engine.
The part people miss is that the engine does not have to live in a separate repo. You can keep it in a folder inside the project and point the Gemfile at that folder. You get the isolation of a gem without the release process of one.
The layout
engines/link_tracker/
link_tracker.gemspec
lib/link_tracker.rb
lib/link_tracker/engine.rb
lib/link_tracker/version.rb
config/routes.rb
app/controllers/link_tracker/links_controller.rb
app/models/link_tracker/application_record.rb
app/models/link_tracker/link.rb
app/models/link_tracker/visit.rb
db/migrate/20261008214407_create_link_tracker_tables.rb
rails plugin new engines/link_tracker --mountable generates most of this, plus a dummy app and a pile of files you will delete. I wrote it by hand instead. It is ten files.
The gemspec
require_relative "lib/link_tracker/version"
Gem::Specification.new do |spec|
spec.name = "link_tracker"
spec.version = LinkTracker::VERSION
spec.authors = ["Wilbert"]
spec.summary = "Short links and visit tracking"
spec.files = Dir["{app,config,db,lib}/**/*"]
spec.required_ruby_version = ">= 2.7"
spec.add_dependency "rails", ">= 6.0"
end
And in the host's Gemfile:
gem "link_tracker", path: "engines/link_tracker"
Bundler treats a path: gem like a published one. If the engine someday needs to move to its own repo, that line becomes git: or a version and nothing else changes. I like having that exit without paying for it today.
The engine class
This is the file that makes it an engine rather than a folder of Ruby.
module LinkTracker
class Engine < ::Rails::Engine
isolate_namespace LinkTracker
initializer "link_tracker.append_migrations" do |app|
config.paths["db/migrate"].expanded.each do |path|
app.config.paths["db/migrate"] << path
end
end
end
end
isolate_namespace does three things. Routes inside the engine do not see the host's routes, so link_path inside the engine means the engine's link, not some other links resource in the host. When the engine does need a host route, it has to ask for it explicitly through main_app. Helpers stay inside the namespace. And every model gets a table prefix, so LinkTracker::Link reads from link_tracker_links. That last one is what keeps the host's schema readable. You open db/schema.rb, see link_tracker_ and know where to look.
The initializer appends the engine's migrations to the host's migration paths. Without it you would run rails link_tracker:install:migrations, which copies the files into the host's db/migrate. Copying means two sources of truth. Appending means rails db:migrate in the host just picks them up. One command, one place.
One trap here. The engine's migration timestamps share a sequence with the host's. If someone on the team writes a host migration with the same timestamp, Rails complains. In practice this never happened to me, but I check the version number before I commit.
Routes
The engine's routes:
LinkTracker::Engine.routes.draw do
get ":slug", to: "links#show", as: :link, constraints: {slug: /[A-Za-z0-9]+/}
end
The host's routes:
mount LinkTracker::Engine => "/l"
That is the whole wiring. GET /l/aB3kQ9xZ resolves inside the engine, and the host has no idea what a slug is. If I move the mount to /s tomorrow, the engine does not change. The engine never sees its own prefix, which is exactly the point.
The model
module LinkTracker
class Link < ApplicationRecord
SLUG_LENGTH = 8
belongs_to :linkable, polymorphic: true
belongs_to :user, optional: true
has_many :visits, dependent: :delete_all
validates :slug, presence: true, uniqueness: true
validates :target_url, presence: true
before_validation :assign_slug, on: :create
def self.register(linkable:, target_url:, user: nil)
link = find_or_initialize_by(linkable: linkable, user: user)
link.target_url = target_url
link.save!
link
rescue ActiveRecord::RecordNotUnique
find_by!(linkable: linkable, user: user)
end
def record_visit!(attributes = {})
visit = visits.create!(attributes.reverse_merge(visited_at: Time.current))
update_column(:last_visited_at, visit.visited_at)
visit
end
private
def assign_slug
return if slug.present?
loop do
self.slug = SecureRandom.alphanumeric(SLUG_LENGTH)
break unless self.class.exists?(slug: slug)
end
end
end
end
Notice the ApplicationRecord it inherits from. It is the engine's own:
module LinkTracker
class ApplicationRecord < ::ApplicationRecord
self.abstract_class = true
end
end
The generator gives you ApplicationRecord < ActiveRecord::Base. I changed it to inherit from the host's ::ApplicationRecord because the host has a primary and a replica, and I wanted the engine's reads to go to the replica like everything else. This is a small leak in the isolation. I accepted it. Full isolation would mean the engine configures its own database connection, and I do not want two connection configs for one database.
The one place the engine needs the host
Visits should be tied to a signed-in user when there is one. The engine does not know how the host authenticates. It should not know. If it did, the engine would depend on the host, and the whole point was to avoid that.
So the engine asks for a function:
# lib/link_tracker.rb
module LinkTracker
class << self
attr_accessor :visitor_resolver
def visitor_for(request)
visitor_resolver&.call(request)
end
end
end
And the host supplies one in an initializer:
# config/initializers/link_tracker.rb
LinkTracker.visitor_resolver = lambda do |request|
token = request.headers["access-token"]
next if token.blank?
session = AuthToken.find_by(access_token: token)
session.user if session&.active?
end
The engine calls the lambda, gets a user or nil, and stores whatever it got. The host owns the auth logic. Swap the auth system and only the initializer changes.
The controller is short:
module LinkTracker
class LinksController < ActionController::Base
def show
link = Link.find_by(slug: params[:slug])
return render(plain: "Link not found", status: :not_found) if link.nil?
link.record_visit!(
ip: request.remote_ip,
user_agent: request.user_agent&.truncate(512),
referer: request.referer&.truncate(2048),
user: LinkTracker.visitor_for(request)
) unless request.head?
expires_now
redirect_to link.target_url, allow_other_host: true
end
end
end
It inherits from ActionController::Base, not the host's ApplicationController. The host's base controller runs auth filters and sets up a current user. A short link is public. Inheriting from the host would mean opting out of all of that, filter by filter. Starting from the Rails base is cleaner.
One more detail: allow_other_host: true. Since Rails 7.0, redirect_to has open redirect protection, and depending on your framework defaults a url on another host gets logged or raises ActionController::Redirecting::UnsafeRedirectError. A short link points somewhere else by design. Opting out is safe here because the target comes from the database, not from params.
Which paradigm is this
I want to be honest here, because "engines are good architecture" gets said a lot without saying why.
The engine is object-oriented in the ordinary Rails way. Link is an object with state and behavior. Nothing new.
The interesting part is the boundary. The visitor_resolver lambda is dependency inversion. The engine defines what it needs, a function from request to user, and the host provides it. The low-level module (auth) depends on the interface the high-level module (link tracking) declared. That is the D in SOLID, and a lambda is the smallest possible interface for it. No abstract class, no adapter pattern, no registry. One callable.
It is also a bit functional. visitor_for takes a request and returns a value. PartnerLink.target_url(poi, partner) in the full version is a module function that takes data and returns a url or nil, with no state anywhere. I reach for module functions when a thing is a computation and not a thing. Ruby lets you mix these styles and I think that is one of its better qualities. The model is an object because a link has identity and a lifecycle. The url validation is a function because it does not.
And the engine as a whole is about encapsulation at the module level, not the class level. Classes hide their internals from other classes. An engine hides a whole feature from the rest of the app. Its tables have a prefix. Its routes have a mount point. Its one dependency on the host is a single named hook. When I want to understand link tracking, I open one folder. When I want to delete it, I remove one folder, one Gemfile line, one mount line and one initializer.
That is the measure I use now. How many places do I touch to remove this feature? For the engine it is four, and they are all one line each. For the same feature spread through app/, I would be grepping.
Where engines hurt
They are not free. Three things got in the way.
Specs. The engine's tests run inside the host's test suite, because the engine inherits the host's ApplicationRecord and needs the host's database. A standalone engine would ship a dummy app for this. I skipped it. If you want the engine to be testable on its own, you pay for the dummy app up front.
Autoloading. Zeitwerk wants app/models/link_tracker/link.rb to define LinkTracker::Link. Get the nesting wrong and you get a confusing load error that mentions neither file. Follow the convention exactly.
Schema drift. Because the migrations live in engines/, a reviewer skimming db/migrate will not see them. On my PR the automated review flagged schema changes "with no migration behind them". The migrations were there, just not where it looked. Say in the PR description where the migrations are.
When to do this
I would not build an engine for every feature. Most of a Rails app is the app, and splitting it into twenty engines gives you twenty gemspecs and the same coupling with more folders.
The feature was a good fit because it has its own tables, its own public route, no views, and exactly one thing it needs from the host. If a feature needs five things from the host, it is part of the host. If it needs one, give it a folder and a gemspec.
The code above is cut down from a working engine. The full version also handles partner links, one slug per place per partner with no user, and skips visits from link-preview bots. Same structure, a few more lines.
Sources
- Getting Started with Engines (Rails Guides) guides.rubyonrails.org
- Rails::Engine API documentation api.rubyonrails.org
- ActionController::Redirecting#redirect_to and allow_other_host api.rubyonrails.org
- Gemfile reference: path gems (Bundler) bundler.io
- Zeitwerk: file structure and constant naming github.com
- Mission Control Jobs (Rails team engine) github.com
- Devise (authentication engine) github.com
FAQ
Does a Rails engine have to be a separate repository or published gem?
No. Keep it in a folder inside the app and reference it with `gem "link_tracker", path: "engines/link_tracker"`. Bundler loads it like any gem, and moving it to its own repo later is a one-line Gemfile change.
What does isolate_namespace actually do?
It scopes routes and helpers to the engine, so route helpers inside it resolve to the engine's own routes, host routes require `main_app`, and every model gets a table prefix such as `link_tracker_links`.
Should I copy the engine's migrations into the host or append them?
Append them in an initializer by pushing the engine's `db/migrate` path into `app.config.paths["db/migrate"]`. Copying with `rails engine:install:migrations` creates two sources of truth; appending lets `rails db:migrate` pick them up in place.
How does the engine use the host's authentication without depending on it?
The engine exposes a configurable lambda, `LinkTracker.visitor_resolver`, that takes a request and returns a user or nil. The host assigns it in an initializer. Swapping the auth system only touches that initializer.
Should the engine's controllers inherit from the host's ApplicationController?
For a public endpoint like a short link, no. Inherit from `ActionController::Base` so you do not have to opt out of the host's auth filters one by one.
When is a feature a bad candidate for an engine?
When it needs many things from the host. A good candidate owns its tables and routes and asks the host for one thing. Splitting a whole app into many engines gives you more gemspecs with the same coupling.
Comments
Sign in with GitHub to comment.
No comments yet.