Rails is a super convenient way to begin building an application and it instantly provides you with great utilities and default behaviors. However, beyond a certain threshold of complexity, the default “fat model” architecture began to cause some problems for us. Introducing more architecture in the form of a business logic service object layer has alleviated a lot of pain and allowed our team to grow beyond it’s original productive limits.
Do You or Someone You Know Suffer From Model Complexity?
We understood that our problem with Rails’ fat model architecture is that it violates many of the basic SOLID principles of object-oriented design. We had also observed a high degree of tight model coupling within model callbacks in particular, which created unnecessary dependency and knowledge between models. There were also times when we found knotty callback interactions to be hiding subtle and infuriating code defects.
Numerous model callbacks also multiplied our test suite run time. Without a way to isolate one model change from many others, we would end up wasting too much time for the simplest tests. Another particular point of pain was that our models were failing every measure of complexity in Code Climate.
It became clear that if we wanted to quickly introduce new engineers without risking more defects and dissatisfaction, we needed a much clearer architecture.
Why Not Concerns?
One prescriptive way to deal with fat models is to break them up into separate Rails Concern modules or model observers. However, in practice, these solutions don’t truly make models simple. They don’t enforce separate responsibility and often feel like sweeping complexity under the rug. Models are exactly as complex as before, but look cleaner on the surface. Additional, as a design principle, we prefer object composition to inheritance, which further dissuaded us from pursuing this strategy.
We identified three architecture design goals:
- Abstract actions involving multiple models into more familiar use cases which were previously handled by callbacks
- In doing so, free callers such as controllers and jobs from model persistence responsibilities
- Compartmentalize the domain so that it was easier for our growing team to understand
We decided to employ the single responsibility principle of object design when creating service objects in order to minimize guesswork among the engineering staff. Each service object would represent a single use case in our system, or a single job that supported that use case, and naming them became a reflection of their intent. For example, without deep knowledge of our domain you could probably guess what a Reminder::Scheduler or Idea::Approver could be responsible for. These objects would completely encapsulate business logic normally achieved by save and destroy callbacks, and would make finding and reusing code easier than before. They would also command persistence activities, which is important because those operations are performance-critical and conceptually subservient to client use cases.
For complex operations (like model operations normally employing numerous callbacks), multiple service objects would be created and used in aggregate with a single public interface object. This would keep individual classes simple and potent.
Once you have a complex app on your hands, refactoring model callbacks into service objects and converting all model callers is a difficult change. Fortunately, we have strong test coverage which caught most of the bugs we introduced during conversion.
Refactoring Tactics: Start By Converting Callers
We looked for every caller of save or save-like methods of a model, and re-routed them through a new object. Put simply:
@content.attributes = params @content.save
editor.update(params) # ... def editor Service::Content::Editor.new(@content) end
Inside the update method, we would continue to leverage the default model save behavior, with the new Editor object acting as a simple proxy at first.
Refactoring Tactics: Migrate One Callback at a Time
If you have many callbacks, it can be difficult and risky to migrate them all at once into a group of service objects with new interfaces because oftentimes you must also account for those changes in the tests themselves! We found that if we removed one callback at a time and reimplemented it inside our service, we could more safely investigate and understand the numerous test failures that occurred.
class Content after_save :raise_events before_save :update_state after_save :add_visibility before_save :check_content_type # and on and on and on # ...
Starting with any save callback, we moved the callback method to either before or after the call to save, refactoring our growing service object into an aggregate as we went while keeping our tests passing between each callback migration. It’s important to note that these new service activities no longer happen in a database transaction, which ActiveRecord wraps model callbacks in by default. This is easily fixed by wrapping new service object endpoints in an ActiveRecord::Base.transaction block.
With all these changes, the content model was now a dumb bucket of attributes and validations and Code Climate was finally beginning to seem friendly. Voila!
Refactoring Tactics: Policing Model Usage
Once you have a service object that becomes the only safe way to interact with a model according to your business rules, you may want to disallow access to the routine ActiveRecord model methods. Here are two alternatives to consider:
- Use the figleaf gem to disable public access to certain ActiveRecord methods, while retaining private access. Call unconventional methods on your model that internally use sensitive methods like save or destroy.
- More aggressively, marshal model reads through the use of Plain Old Ruby Objects and make the ActiveRecord models themselves private classes inside a service object.
These methods could be unrealistic because oftentimes tests and test factories depend on existing model behavior. Your results may vary.
Unconventional Architecture Criticisms
While not true in our case, it is possible to employ these techniques too early. For a simple application, it can be puzzling to new engineers to encounter an unconventional Rails application. Rails provides a level of convenience that works exceptionally well during the formative phases of an application and many, many people are already familiar with how it works. On the other hand, it can only become more difficult to refactor your application the longer you maintain the conventional Rails idioms.
Want to help us design a better Rails app at Kapost? We’re hiring! Head on over to kapost.com/careers and introduce yourself regardless of whatever positions are listed.