888-487-9048
Log in

Protect your home with coverage that could save you money every year.

Typically only takes a few minutes.

Engine-ification or: How I Learned to Stop Worrying and Love the Rails Monolith

Introduction

$ rails new my_app
$ find ./my_app -name "*.rb" -o -name "*.js" | xargs wc -l | tail -n 1

    656 total

A Rails application begins its life with less than 700 lines of code. Despite starting small, the application is already—in the words of Rails' creator, David Heinemeier Hansson (DHH)—a "majestic monolith." Rails handles the interactive elements of your frontend, the management of your database's schema, and everything in-between. In fact, Rails chose integrated systems as one of its core pillars. Because Rails provides such a system, it enables integrated teams of generalists to build the application. All in all, monoliths are a feature of Rails, not a bug.

I wrote this blog because you don't have to decompose your app into a distributed system, and you shouldn't do so. Instead, you should modularize your application. A modular monolith can decomplicate your system without fragmenting it or adding additional latency. Rails makes modularization easy via Engines, plugins that can be their own miniature applications. My team, the Documents team, is using this pattern to decouple our code and introduce a domain layer. Here's how we did it.


Engine-fying Documents - A Case Study

Why we needed to decompose documents

A deeply entrenched, entangled model is complicated to refactor

A deeply entrenched, entangled model is complicated to refactor

Kin is an insurance company, and a major part of any insurance operation is policy documents. We maintain documents ranging from single-page receipts for individual payments to ones that describe the terms of a policy over hundreds of pages. The documents we generate are crucial to customer experience and from a regulatory standpoint, helped contribute to a major issue we have today.

The Document model is deeply intertwined with dozens of concerns. Multiple subsystems not owned by our team have direct access to the model. Having so many code units directly depending on the core model of our domain makes refactoring difficult without breaking compatibility. Generating massive amounts of documents isn't trivial, and a lack of separation of boundaries makes the process much more complicated.

Creating an Empty Engine

The first step is to create and install an empty engine. Creating an engine can be done by creating a plugin with the --mountable flag. Rails provides additional options, which can be viewed via rails plugin --help.

$ rails plugin new my_engine --mountable

This will create the file structure described below in Anatomy of a Rails Engine.

Installing the Empty Engine into the Monolith

Installing the Empty Engine into the Monolith

We created an engine via:

$ rails plugin new document_engine --mountable

Rails engines are gems, and therefore can be installed via the Gemfile:

# Gemfile
gem "document_engine"

Afterwards, the main app mounts the engine in its config/routes.rb:

# config/routes.rb
Rails.application.routes.draw do
  mount DocumentEngine::Engine, at: "/documents"
end

This initial first step allowed us to ensure that our gem's publishing pipeline was working before we proceeded with the next steps.

Aside – Anatomy of a Rails Engine

Abbreviated anatomy of a new Rails Engine

Abbreviated anatomy of a new Rails Engine

my_engine.gemspec: Gem declaration for the engine. If your engine has gem dependencies, adding them here makes them a dependency in the mounted application.

app/: Contains all the same concepts found in a Rails application's app/ folder. These are automatically loaded into your application as if they were defined within your application. By default, these are namespaced to your engine's name to prevent collisions.

bin/: Contains a linter executable and a rails executable. This Rails executable boots up the Rails application in test/dummy/ and can run the same basic commands as a Rails application. This includes bin/rails c and bin/rails s.

config/: Contains a routes.rb file that defines the routes within your engine. These will be mounted by the Rails application. You can also define a config/initializers/ directory, whose contents will be run before the engine is loaded.

lib/: Contains the base namespace for your engine. Within that namespace it defines the version of the engine gem and the engine class itself. The engine class, defined in lib/my_engine/engine.rb, is mounted by Rails applications.

test/: The home of your engine's tests. It also contains a test/dummy/ directory, which houses a miniature Rails application.

Moving Models

Moving Models

$ cd document_engine
$ mv ../monolith/app/models/document.rb ./app/models/document.rb

Because the engine is loaded by the main application, the functionality of the application is unchanged. Even associations to documents from models still in the monolith continue to work:

# monolith/app/models/model_a.rb
class ModelA <ApplicationRecord
  has_many :documents # Still finds Document in the DocumentEngine!
end

It's important to note that this should be considered an intermediary step. In the long term, the models should be namespaced within the engine that now owns them.

Creating a Top-Level, Contractual Interface

Creating a Top-Level, Contractual Interface

The next step is to create a top-level communication layer between the engine and monolith: an interface. The interface plays a similar role to the API layer one would need for a microservice, but with none of the overhead. This interface can be built one use-case at a time, slowly removing direct usages of the Document model in the monolith. Eventually, this interface will be the single point of contact between the engine and monolith.

This is crucial for re-architecting the document code; as long as the engine fulfills the contract of its interface, the internals of the engine can be updated freely. The engine can use contract and/or role testing to ensure this compatibility. This makes the CI of the document code leaner and more efficient, which makes re-architecting and new features alike easier to implement.


Conclusion

If we had gone with a microservice, we would have to start over from scratch. Joel Spolsky, the co-creator of Stack Overflow, wrote an excellent article on why that would be a bad idea. We would also need to build the communication between the monolith and our new service. Method calls in memory are faster and less error-prone than HTTP calls between services.

Our team can now happily re-architect our aging code in a way where we don't have to support two different versions of our domain. We're not the only ones happy with this outcome; our downstream dependencies are happy to not need to replace method calls with HTTP clients. In addition, we're able to accomplish this in a way that is less labor-intensive and doesn't add to our Cloud bill. These overhead cost-savings go directly into offering lower premiums, and so Engine-ification is a decision that can even bring happiness to our customers.

In the Rails doctrine, DHH says, "optimizing for happiness is perhaps the most formative key to Ruby on Rails." By choosing to modularize our monolith, we're able to optimize happiness (and success!) more than if we had completely cleaved our domain. If you're feeling the pain of an aging, large Rails application, stop worrying and modularize it. If you do, you'll find yourself loving your monolith again.