Skip to content
Rails engines: a gem that lives inside your app

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.

Published 9 min read

Also available in Português (Brasil)

Share

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

  1. Getting Started with Engines (Rails Guides) guides.rubyonrails.org
  2. Rails::Engine API documentation api.rubyonrails.org
  3. ActionController::Redirecting#redirect_to and allow_other_host api.rubyonrails.org
  4. Gemfile reference: path gems (Bundler) bundler.io
  5. Zeitwerk: file structure and constant naming github.com
  6. Mission Control Jobs (Rails team engine) github.com
  7. 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.