DEV Community

Alex Aslam
Alex Aslam

Posted on

Sculpting Monoliths: The Art of Packaging Rails with Components, Gems, and Engines

Every seasoned Rails developer knows the feeling. You start with a clean app/ directory, a canvas of pure potential. The controllers are lean, the models are eloquent, and the views are pristine. Fast forward two years. You have a app/models directory that scrolls for days, concerns that concern everyone and no one, and a test suite that groans under the weight of a thousand implicit dependencies.

Your masterpiece has become a marble block you can no longer lift, let alone reshape.

This is the journey every growing application and every maturing developer must take: the path from a single, unified codebase to a composed architecture of distinct, purposeful parts. It's not just engineering; it's an art form. It's the art of packaging.

Let's explore our three primary chisels: the focused Component, the sharable Gem, and the integrated Engine.

The Journey Begins: Recognizing the Cracks in the Marble

You don't start by breaking things. You start by noticing the strain.

  • The Domain Blur: Is your User model responsible for authentication, billing, content creation, and logging? It's a god object, and it's tired.
  • The Test Molasses: To run a unit test for a Post, do you need to boot the entire universe, including the payment gateway and email service? Your tests are telling you something.
  • The Team Treadmill: Four teams are committing to the same repository, and every merge is a high-stakes negotiation. The friction is a drag on velocity.

When you see these patterns, it's time to pick up your tools.


Chisel #1: The Component - The Art of Internal Encapsulation

The Philosophy: A Component isn't a packaged artifact you gem install. It's a pattern, a discipline of internal organization. It’s the first and most crucial step in defining boundaries within your monolith.

In the old days, we might have used a lib/ or app/concerns folder. The modern, Rails-way is to leverage Zeitwerk and autoloading to create pristine namespaces.

The Artistry in Practice:

Imagine your bloated app/models folder. You have a User model that's deeply entangled with a Subscription, Plan, and Invoice. This is your Billing domain.

You don't need a gem or an engine yet. You need a boundary.

You create a new home:

app/
  components/
    billing/
      app/
        models/
          billing/subscription.rb
          billing/plan.rb
          billing/invoice.rb
        controllers/
          billing/subscriptions_controller.rb
        jobs/
          billing/charge_customer_job.rb
      lib/
        billing/version.rb
      component.rb
Enter fullscreen mode Exit fullscreen mode

The magic file is component.rb, which acts as the main require point.

# app/components/billing/component.rb
module Billing
  # This file bootstraps the component for the main application.
  # It can require all necessary files and configure the component.
  require_relative "app/models/billing/subscription"
  require_relative "app/models/billing/plan"
  # ... and so on

  # Perhaps some component-wide configuration
  class << self
    attr_accessor :default_currency
  end
  @default_currency = :usd
end
Enter fullscreen mode Exit fullscreen mode

When to Wield This Chisel:

  • Logical Separation: You want to isolate a business domain (Billing, Content, Analytics) without the overhead of a separate package.
  • Team Scalability: You can assign a team to "own" the billing component. The public interface is clear, the internals are private.
  • The First Step: This is often the perfect precursor to extracting a gem or engine. You prove the boundary works before you cut it loose.

The Payoff: You've introduced structure and clarity. Your main app/models is lighter. Your Billing domain is no longer a scattered set of files but a cohesive, conceptual unit.


Chisel #2: The Gem - The Art of the Pure Abstraction

The Philosophy: A Gem is a packaged unit of reusable code. Its essence is independence. A well-crafted gem doesn't know or care if it's being used in a Rails app, a Sinatra app, or a plain Ruby script. It solves one problem elegantly.

The Artistry in Practice:

You look at your Billing component and realize the core logic—creating plans, calculating prorations, applying taxes—is pure business logic. It has no direct dependency on Rails.

This is a candidate for a gem.

You run bundle gem mycorp-billing and you begin the careful surgery. You extract the models, but you don't call them ActiveRecord::Base anymore. They are Plain Old Ruby Objects (POROs). You extract the ChargeCustomerJob, but it becomes a class that expects a logger and a payment_gateway object to be injected.

Your gem's structure is classic and clean:

mycorp-billing/
  lib/
    mycorp/
      billing/
        subscription.rb
        plan.rb
        invoice_calculator.rb
    mycorp-billing.rb
  mycorp-billing.gemspec
Enter fullscreen mode Exit fullscreen mode

The gem's entry point sets up the namespace and requires the files.

# lib/mycorp-billing.rb
require_relative "mycorp/billing/subscription"
require_relative "mycorp/billing/plan"
# ...

module Mycorp
  module Billing
    # Your elegant, framework-agnostic code lives here.
  end
end
Enter fullscreen mode Exit fullscreen mode

When to Wield This Chisel:

  • Reusable Logic: You have code that is used across multiple, disparate applications.
  • Algorithm Isolation: The core of your domain is a complex algorithm (e.g., a search index, a billing calculator) that deserves a life of its own.
  • Dependency Minimization: You want to keep this unit of code lean and free from the "Rails Way" if it doesn't need it.

The Payoff: You now have a distributable, versioned, and tested library. It's a sharp, focused tool in your organization's utility belt.


Chisel #3: The Engine - The Art of the Integrated Mini-Application

The Philosophy: An Engine is a mini-Rails application that can be mounted inside another. It's the full-stack package. If a Gem is a library, an Engine is a plugin. It expects to be inside a Rails context and can contain models, controllers, views, routes, and assets.

The Artistry in Practice:

Your Billing component has views for a subscription management panel. It has routes like /billing/subscriptions. It has mailers for sending invoices. This isn't just logic; it's a full-fledged feature.

This is the perfect candidate for a Rails Engine.

You create it with rails plugin new Mycorp::Billing --mountable --full.

The --mountable flag creates the isolated namespace and a dedicated Engine class. The --full flag ensures it gets the full treatment with ActiveRecord, ActionMailer, etc.

Your structure now mirrors a full Rails app:

mycorp-billing/
  app/
    controllers/
      mycorp/billing/subscriptions_controller.rb
    models/
      mycorp/billing/subscription.rb
    views/
      mycorp/billing/subscriptions/index.html.erb
  config/
    routes.rb
  lib/
    mycorp/billing/engine.rb # The heart of the engine
    mycorp/billing.rb
Enter fullscreen mode Exit fullscreen mode

The magic is in the engine.rb and the main file.

# lib/mycorp/billing.rb
module Mycorp
  module Billing
  end
end

require "mycorp/billing/engine" # This is critical!
Enter fullscreen mode Exit fullscreen mode
# lib/mycorp/billing/engine.rb
module Mycorp
  module Billing
    class Engine < ::Rails::Engine
      isolate_namespace Mycorp::Billing # This prevents conflicts with the host app

      # Optional: Auto-load migrations from our path
      initializer "mycorp-billing.load_migrations" do |app|
        app.config.paths["db/migrate"] += paths["db/migrate"].existent
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

In your host application's config/routes.rb:

Rails.application.routes.draw do
  mount Mycorp::Billing::Engine, at: "/billing"
  # ... your other routes
end
Enter fullscreen mode Exit fullscreen mode

And just like that, all engine routes are available under /billing.

When to Wield This Chisel:

  • Full-Feature Extraction: You are packaging a complete vertical slice of your application (e.g., an admin panel, a blog, a helpdesk).
  • Shared UI & Behavior: Multiple applications need the same set of controllers, views, and routes.
  • Product Modularity: You want to be able to optionally include features in different deployments of your main product.

The Payoff: You have created a self-contained, reusable feature that integrates seamlessly into a host Rails application. It's a masterpiece of modularity.

The Master's Touch: Knowing Which Tool to Reach For

The artistry isn't in knowing how to use the tools; it's in knowing when.

  1. Start with a Component. Feel the boundaries of your domain within the monolith. Live with it. Refine the public API.
  2. Does it need to be framework-agnostic? Is the core logic valuable outside of a web context? Extract it as a Gem. Your Engine can then depend on this gem, layering the web interface on top of the pure logic.
  3. Is it a full-stack feature? Does it need routes, views, and a UI? Package it as an Engine.

The most elegant solutions often use a combination: a pure logic Gem, wrapped by a full-stack Engine, both developed and tested using the disciplined structure of a Component inside a host application.

This journey from a tangled monolith to a symphony of well-defined parts is the highest calling of a senior engineer. It's not just about making code work; it's about sculpting it into something maintainable, scalable, and beautiful.

Now go forth and sculpt. Your marble block awaits.

Top comments (0)