Model your domain with composed models
In many Rails applications the modeling is limited only to creating classes inheritng from ActiveRecord::Base
which are 1:1 mapped to database tables. Having AR models like User
and Project
doesn't tell much about the domain of your application. Well, you can add some domain logic to the models but that way you will easily end up having giant classes with thousands lines of code, so let's just use models for database-related stuff only. Other option would be to create service objects for every usecase. That's a good and clean way, but it's merely the interaction layer between different parts of your application. You may end up easily with all logic encapsulated within service objects that will start looking more like procedural programming rather than proper OOP and not real domain API at all. The good news is that you can easily counteract it: time to use composed models.
Definition and examples composed models
Composed models are classes which emphasize the relation between entities and the interactions between them. In most applications the models have multiple different relations between each other, rarely are they self-contained. So what happens if we take some entities and try to put them in one class?
Imagine you are developing project management application and you've got User
, Project
and Task
models. What are the possible interactions between these models? User can be assigned to many tasks in a given project, so we can both query for the existing tasks and add some new tasks. We would probably query for finished tasks, currently being done and the ones not started. We may also check if user is in given project or can add/remove him/her from the project. In this case, the predominant relation is the one between the user
and project
, so let's create a class UserWithProject
. We will make it a decorator over these two models, so the class will take both user
and project
to constructor:
class UserWithProject
attr_reader :user, :project
private :user, :project
def initialize(user, project)
@user = user
@project = project
end
end
Let's add some actual logic to our composed model: querying for different tasks for related user
and project
, adding new tasks, checking if user is assigned to the project and maybe leaving the project.
class UserWithProject
attr_reader :user, :project
private :user, :project
def initialize(user, project)
@user = user
@project = project
end
def tasks
@tasks ||= project.tasks.with_assignee(user)
end
def pending_tasks
tasks.pending
end
def finished_tasks
tasks.finished
end
def not_started_tasks
tasks.not_started
end
def add_task(task)
task.asignee = user
task.project = project
task.save!
end
def assigned_to_project?
project.users.include?(user)
end
def leave_project
project.users.destroy(user)
end
end
Most methods are probably self-explanatory and don't need to be discussed. Now we have an actual domain model which neatly encapsulates interactions between users
, projects
and tasks
. Looks like a real API for application that can be simply (re)used.
One usecase for composed models is about interactions between models. But this patterns also shines when you consider some modifiers of values. By modifier I mean an object that has some kind of influence on values being returned by methods of other object. For example you might be developing an e-commerce app and have Order
with total_price
. Let's imagine that you need to handle discounts for orders, which as you max expect, are going to decrease total_price
. With composed model pattern you could create OrderWithDiscount
class. To make it still behave like an Order
instance, the class may inherit from SimpleDelegator
and all the method calls not implemented by OrderWithDiscount
are going to be delegated to Order
:
class OrderWithDiscount < SimpleDelegator
attr_reader :order, :discount
private :order, :discount
def initialize(order, discount)
@order = order
@discount = discount
super(order)
end
def total_price
# somehow apply the discount.value to order.total_price
end
end
That way you can still have total_price
on Order without adding additional arguments, conditionals etc. for handling discounts and have a separate object for special usecases.
Extracting existing logic to composed models
Now that you know how what are the composed models for, you may be wondering how to extract already existing codebase to that pattern. Fortunately, it's easy to tell in many cases if a particular method is a good fit to move. When you have multiple methods in one class taking the same kind of argument(s), that's probably a good idea to think about some changes. Let's use the example with User
and Project
. If that pattern hadn't been used there we would probably have had some code in Project
model looking like this:
class Project < ActiveRecord::Base
# associations and stuff like that
def tasks_for_user(user)
# some logic
end
def pending_tasks_for_user(user)
# some logic
end
def finished_tasks_for_user(user)
# some logic
end
def add_task_for_user(task, user)
# some logic
end
end
This class really begs for refactoring ;). Another sign would be having methods where there might be an argument modyfing the value or may not. Using the Order
example, there could be a method like:
class Order < ActiveRecord::Base
def total_price(discount: nil)
# somehow calculate the total_price
if discount?
# apply the discount
end
end
end
Doesn't really look great, having separate class makes it much easier to read and understand.
Wrapping up
I've shown a pretty cool pattern I've started using recently, which works really great and makes a big difference when looking at the domain logic of the application. I hope you will find it useful.