Root Engineering: Separating Data and Code in Rails Architecture

Separating Data and Code in Rails Architecture

Dan Manges
Root Engineering

--

The backend platform that powers Root is a Rails application. It’s comprised of 65k lines of Ruby application code and 135k lines of test code spread across 37 engines and 22 gems. We’ve been building it for three years and have a team of 30 engineers now working on it. Two techniques have been crucial to our success in sustaining a high velocity of delivery as the application has grown larger.

  • We avoid putting business logic in our ActiveRecord classes. We separate data and code.
  • Most of our code is stateless. We have very few classes or instance variables.

Although many web applications and backend platforms are built using object-oriented languages, separating data and code is often more effective than using object-oriented paradigms. Looking through the code for our backend platform at Root, we have shockingly little state in the code. Almost none. We notably don’t have a single feature that we’ve implemented at Root that requires maintaining state in running processes across requests in our Rails backend. I expect this is similar to the vast majority of web platforms at other companies.

Web backends in Ruby are built to be horizontally scalable with processes being considered ephemeral. Need more throughput? Run more processes. Have a process using too much memory? Kill it. The nature of this undermines the potential benefit of utilizing application-level state for functionality. Because all processes need to have consistent state from the perspective of clients, and because processes may not stick around, relational databases become a good home for any real application state. This generally results in all state being kept in databases and not in code. Ultimately, there’s a fundamental difference between building an application that runs as a single process and one that runs using a vast number of independent processes. This has implications on the best software architecture and paradigms to use.

The ephemeral nature of processes is even a first-class feature of Resque, the message queue library that we use at Root. Every job runs in a fresh process.

When a Resque worker reserves a job it immediately forks a child process. The child processes the job then exits. When the child has exited successfully, the worker reserves another job and repeats the process.

Abandoning Object-Oriented Programming

Large web platforms can be built more sustainably by separating data from code. This is inherently not object-oriented programming. Object-oriented programming encourages bundling data with the functions that operate on that data. The Tell Don’t Ask principle highlights this.

Tell-Don’t-Ask is a principle that helps people remember that object-orientation is about bundling data with the functions that operate on that data. It reminds us that rather than asking an object for data and acting on that data, we should instead tell an object what to do. This encourages [us] to move behavior into an object to go with the data.

Object-oriented programming often results in applications ending up with core domain entities turning into God Classes. This happens because most businesses have an extremely large number of functions which depend on the same core data. It’s not surprising that the `User` model in most applications becomes bloated. The majority of the business logic that is implemented will be concerned with users. Putting that logic in the User class doesn’t help keep it manageable. Instead, it combines a mix of unrelated business procedures into a single class, with the only commonality being their dependence on user data. This is prone to happen with other data models which are central to the domain. At Root, we’re susceptible to this happening with our InsurancePolicy model.

As applications grow larger over time, the ratio of functionality-to-data increases, especially for reading the data. Numerous portions of the functionality in Root’s backend platform need to access insurance policy data. Putting all of that logic for all of those disparate functions into the InsurancePolicy class itself would be unmanageable.

Avoiding the Active Record Pattern

Although putting business logic in models is a literal part of the definition of the Active Record pattern, we avoid it. In PoEAA, Active Record is defined as:

An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on that data.

Note the adds domain logic part. It’s central to the pattern. Doing this works well in small applications, but it becomes unwieldy in large applications. It becomes better in large applications to take the “adds domain logic” part of the pattern out, and only use the ActiveRecord library as a way to have an object that wraps a row in a database table and encapsulates the database access for it.

Transaction Script

The pattern that we use to build Root’s Rails backend most closely resembles the Transaction Script pattern from PoEAA:

Organizes business logic by procedures where each procedure handles a single request from the presentation.

Many of our procedures do handle a single request from the presentation, such as purchasing an insurance policy, although we’ve also extracted sub-procedures to handle different aspects of those requests.

We organize our procedures into a service layer, but it’s not an object-oriented service layer. We define them as static functions on modules. For example:

module PolicyService
def self.create
# ...
end
def self.cancel(policy_id)
# ...
end
end

In this approach, the services aren’t really service classes at all. They’re more namespaces for the procedures that help us keep the code organized. PolicyService.create is really a global procedure.

From Patterns of Enterprise Application Architecture:

The glory of Transaction Script is its simplicity. Organizing logic this way is natural for applications with only a small amount of logic, and it involves very little overhead either in performance or in understanding.

The glory is indeed in the simplicity and having little overhead in understanding, but it’s not only usable for applications with a small amount of logic. With good factoring, this pattern can scale.

Stateless Code

We consistently implement our service classes using static methods rather than instance methods. Using instance methods enables building more fluent interfaces, but static methods convey some important context — that no code-level state is involved in the function.

The elegant API design of Rails has created an appreciation among engineers for creating fluent interfaces which look nice from the perspective of callers. Admittedly, this code:

user.purchase_policy(params)

does look much nicer than this code:

PolicyService.purchase_policy(:user => user, :params => params)

It can be tempting to implement services in a way that creates a more fluent interface. That can be achieved by creating a class which sets some instance variables and then defines functions which use those instance variables. Effectively, the functions become like closures, closing over the state set in the constructor. Those classes look like this:

class PolicyService
def initialize(user)
@user = user
end
def purchase_policy(params:)
# purchase a policy using @user
end
end

However, those fluent interfaces come at the cost of clarity to an engineer trying to understand how state is involved. If an engineer working on this code needs to do something else with policies elsewhere, do they need to use the same service instance, or can they make a new one? Does it even matter? Fluent interfaces also often lead to poor class design, such as the example above where the logic was defined on the user object.

When considering the implementation with static methods, it’s obvious. Instances aren’t involved. State isn’t involved. Ultimately, if a class isn’t going to update any state once it’s initialized, there isn’t a reason to write it in an object-oriented way except for trying to make the interface more fluent. It’s preferable to use static functions instead of a fluent interface in those cases.

service = PolicyService.new(user)
service.purchase_policy(params)
# Does the state on the `service` instance ever change?
# Does the same service instance need to be used elsewhere?
# State could be relevant here.
PolicyService.purchase_policy(:user => user, :params => params)
# No instances involved, no code-level state involved. Purely procedural.

The class-for-closure approach can be identified by checking if the state of any instance variables changes once the constructor has set it. Updating the state in other functions would be true object-oriented programming. If the state doesn’t change, the constructor can be eliminated and the functions made static. The class could be refactored to:

class PolicyService
def self.purchase_policy(user, params)
# purchase a policy using user
end
end

Although it’s lovely having code that is readable like prose, it’s better to have implicit knowledge that the code is stateless than have a fluent interface.

Procedural Programming is Good

The ideal way to build large Rails backends is by implementing the majority of the domain logic as static methods that modify state in a database. This is the approach that we’ve taken to build our backend platform at Root. We have very little code-level state. We do not add business logic to our Controllers, Jobs, or ActiveRecord classes. We access data in a database through ActiveRecord, and then we implement a whole bunch of procedures. It’s a simple architecture that has been highly effective at allowing us to grow our code base even as the complexity of our business has increased. In summary:

  • models = data, stored in a database and accessed through ActiveRecord. No business logic.
  • services = procedural code that defines our business logic, implemented as static methods.
  • controllers = thin wrappers over the service layer to handle HTTP request parsing and response formatting.
  • jobs = thin wrappers over the service layer to tie into job infrastructure.

In general at Root, we’ve leveraged simple approaches that allow us to maintain a high velocity of delivery even as our team gets bigger, code base grows larger, and business domain increases in complexity. This is one of those approaches.

For more on Rails architecture and software engineering at Root, see our post on building a Modular Monolith and our Root Engineering website.

--

--