Skip to content

Instantly share code, notes, and snippets.

@vsavkin
Created August 25, 2012 14:23
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save vsavkin/3466415 to your computer and use it in GitHub Desktop.
Save vsavkin/3466415 to your computer and use it in GitHub Desktop.
Building Rich Domain Models in Rails

Building Rich Domain Models in Rails

Abstract

Domain model is an effective tool for software development. It can be used to express really complex business logic, and to verify and validate the understanding of the domain among stakeholders. Building rich domain models in Rails is hard. Primarily, because of Active Record, which doesn't play well with the domain model approach.

One way to deal with this problem is to use an ORM implementing the data mapper pattern. Unfortunately, there is no production ready ORM doing that for Ruby. DataMapper 2 is going to be the first one.

Another way is to use Active Record just as a persistence mechanism and build a rich domain model on top of it. That's what I'm going to talk about here.

Problems with Active Record

First, let's take a look at some problem caused by using a class extending Active Record for expressing a domain concept:

  • The class is aware of Active Record. Therefore, you need to load Active Record to run your tests.

  • An instance of the class is responsible for saving and updating itself. It makes mocking and stubbing harder.

  • Every instance exposes such low-level methods as 'update_attribute!'. They give you too much power of changing the internal state of objects. Power corrupts. That's why we see 'update_attributes' used in so many places.

  • "Has many" associations allow bypassing an aggregate root. Too much power, and as we all know, it corrupts.

  • Every instance is responsible for validating itself. It's hard to test. On top of that, it makes validations much harder to compose.

Solution

Following Rich Hickey's motto of splitting things apart, the best solution I see is to split every Active Record class into three different classes:

  • Entity
  • Data Object
  • Repository

The core idea here is that every entity when instantiated is given a data object. The entity delegates its fields' access to the data object. The data object doesn't have to be an Active Record object. You can always provider a stub or an OpenStruct instead. Since the entity is a plain old ruby object, it doesn't know how to save/validate/update itself. It also doesn't know how to fetch itself from the database.

A repository is responsible for fetching data objects from the database and constructing entities. It is also responsible for creating and updating entities.

Example

Let's take a look at a practical application of this approach. Order and Item are two entities that form an aggregate. This is the schema we can use to store them in the database:

create_table "orders", :force => true do |t|
  t.decimal  "amount", :null => false
  t.date     "deliver_at"
  t.datetime "created_at", :null => false
  t.datetime "updated_at", :null => false
end

create_table "items", :force => true do |t|
  t.string   "name", :null => false
  t.decimal  "amount", :null => false
  t.integer  "order_id", :null => false
  t.datetime "created_at", :null => false
  t.datetime "updated_at", :null => false
end

As you can see we didn't have to adapt the schema for this approach.

All entities are plain old ruby objects including the Model module:

class Order
  include Model

  # id, id=, amount, amount=, deliver_at, deliver_at= will be delegated to _data.
  fields :id, :amount, :deliver_at

  def items
  	# _data is the data object storing state for this entity.
    _data.items.map{|_|Item.new _}
  end

  def add_item attrs
    _data.items.new attrs
  end
end

class Item
  include Model

  fields :id, :amount, :name
end

where the Model module is defined as:

module Model
  def self.included(base)
    base.extend ClassMethods
  end

  attr_accessor :_data

  def initialize _data = OpenStruct.new
    if _data.kind_of?(Hash)
      @_data = OpenStruct.new _data
    else
      @_data = _data
    end
  end

  module ClassMethods
    def fields *field_names
      field_names.each do |field_name|
        self.delegate field_name, to: :_data
        self.delegate "#{field_name}=", to: :_data
      end
    end
  end
end

As the Order and Item classes form an aggregate, we can get a reference to an item only through its order. Therefore, we need to implement only one repository:

module OrderRepository
  extend Repository

  # All ActiveRecord classes are defined in the repository.
  class OrderData < ActiveRecord::Base
    self.table_name = "orders"

    attr_accessible :amount, :deliver_at

    # All validations here are data integrity validations.
    validates :amount, numericality: true
    has_many :items, class_name: 'OrderRepository::ItemData', foreign_key: 'order_id'
  end

  class ItemData < ActiveRecord::Base
    self.table_name = "items"

    attr_accessible :amount, :name

    validates :amount, numericality: true
    validates :name, presence: true
  end

  # We use this class to query the database.
  set_data_class OrderData

  # All data objects will be wrapped into instances of this class.
  set_model_class Order

  def self.find_by_amount amount
    where(amount: amount)
  end
end

Where the Repository module is defined as:

module Repository
  def persist model
    data(model).save!
  end

  def find id
    data = data_class.find id
    model_class.new data
  end

  def where attrs
    data_class.where(attrs).map do |data|
      model_class.new data
    end
  end

  def data model
    res = model._data
    if res.kind_of? OpenStruct
      res = data_class.new res.marshal_dump
    end
    model._data = res
    res
  end

  def set_data_class clazz
    singleton_class.send :define_method, :data_class do
      clazz
    end
  end

  def set_model_class clazz
    singleton_class.send :define_method, :model_class do
      clazz
    end
  end
end

Now, let's see how we can use all these classes in an application.

test "using a data object directly (maybe used for reporting purposes)" do
  order = OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today
  order.items.create! amount: 6, name: 'Item 1'
  order.items.create! amount: 4, name: 'Item 2'

  assert_equal 2, order.reload.items.size
  assert_equal 6, order.items.first.amount
end

test "using a saved model" do
  order_data = OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today

  order = Order.new(order_data)
  order.amount = 15

  assert_equal 15, order.amount
end

test "creating a new model" do
  order = Order.new
  order.amount = 15

  assert_equal 15, order.amount
end

test "using hash to initialize a model" do
  order = Order.new amount: 15

  assert_equal 15, order.amount
end

test "using a saved aggregate with children" do
  order_data = OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today
  order_data.items.create! amount: 6, name: 'Item 1'

  order = Order.new order_data

  assert_equal 6, order.items.first.amount
end

test "using a repository to fetch models from the database" do
  OrderRepository::OrderData.create! amount: 10, deliver_at: Date.today

  orders = OrderRepository.find_by_amount 10

  assert_equal 10, orders.first.amount
end

test "persisting models" do
  order = Order.new
  order.amount = 10

  OrderRepository.persist order

  assert order.id.present?
  assert_equal 10, order.amount
end

test "persisting an aggregate with children" do
  order = Order.new
  order.amount = 10    
  OrderRepository.persist order

  order.add_item name: 'item1', amount: 5
  OrderRepository.persist order

  from_db = OrderRepository.find(order.id)

  assert_equal 5, from_db.items.first.amount
end

test "using data structure instead of a data object (can be used for testing)" do
  order = Order.new OpenStruct.new
  order.amount = 99
  assert_equal 99, order.amount
end

What we got

  • The persistence logic has been extracted into OrderRepository. Having a separate object is beneficial in many ways. For instance, it simplifies testing, as it can be mocked up or faked.

  • Instances of Order and Item are no longer responsible for saving or updating themselves. The only way to do it is to use domain specific methods.

  • Low-level methods (such as update_attributes!) are no longer exposed.

  • There is no ItemRepository and no has_many associations. The result of it is an enforced aggregate boundary.

@reshadman
Copy link

Productivity Killer Approach. If you want Entity/Repository Combination use a Data Mapper not AR.
Both Entities and Active Record "MODELS" can be domain models they just don't throw their hat because they have metadata or talk to database.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment