How to Produce a Rich Domain Model with Active Record

Kevin Smith ·

You can't. It's not possible.

I know that sounds like an outrageous claim given the popularity of the Active Record pattern, but it's true.

Active Record provides complete access to a database row's fields through an object's public properties (or "attributes" of an "Active Record model", as they're called in both Ruby on Rails' and Laravel's ORMs). Any part of the codebase can access those attributes to read or change any of the data in the database row, making it easy to start working with a real database in your code.

The trouble starts when the complexity of the business inevitably reveals itself and you need to restrict the inherent permissiveness of Active Record to ensure that information is only retrieved or modified according to the rules of the business. In other words, you need a rich domain model: a layer of the codebase where the expressiveness of the business is encoded into a community of collaborating objects, each properly bounded and equipped with the information and behavior they need to model the relevant interactions and responsibilities of the business.

Need to make a property private so that only the object has access to it, a fundamental principle of software design called information hiding? It's not possible.

Need to require multiple pieces of information for an update and validate them before accepting the changes, like making sure you have both longitude and latitude before updating geo-coordinates? Sorry, you can't enforce that.

The best you can do is apply business rules in domain services that wrap the Active Record model, but you can't actually enforce the rules anywhere.1 This is the critical, inescapable problem with using Active Record in the domain. Nothing is stopping the rest of the codebase (or worse, third-party packages) from bypassing your constraints to manipulate the data directly. And make no mistake, that will happen. The inevitable outcome is data corruption, inscrutable bugs, and feature development slowing to a crawl as you try to regain stability in your system.

By its very nature, Active Record's impact on a domain cannot be contained and thus will largely determine the resulting system architecture, despite whatever plans its developers may have at the outset. Given enough time under real-world dynamics, it will lead to a big ball of mud that becomes increasingly expensive to maintain.

Now you could try to contain Active Record to the edge of your application, only using it as a conduit between your database and domain objects that properly enforce the necessary constraints. This would keep it out of your domain and go a long way toward avoiding architectural distortion.

But in practice, this tends to be a fool's errand. The most popular Active Record implementations are clearly not meant to be confined to such a petty task. The documentation for Rails and Laravel both assume you'll be using it in the heart of your system and passing these Active Record models around to use everywhere. All the third-party packages assume the same. So do the creators of these frameworks, as do their developer communities. You'd be swimming against a mighty current, and nothing would prevent the "batteries included" features of these ecosystems from bypassing your domain entirely.

To put it plainly: Active Record fundamentally cannot allow the enforcement of constraints on its objects, leaving you with no option but to apply all your business rules in services and therefore "completely miss the point of what object-oriented design is all about" and "incur all of the costs of a [rich] domain model, without yielding any of the benefits".

Behold, the anemic domain model.

  1. This includes clever tooling in some Active Record implementations that will allow you to accept value objects to update multiple attributes. Nothing enforces that as the only way to update those attributes.