Shawn McCool's astute teardown of Active Record pattern is making the rounds and I wanted to distill some conclusions while they're still fresh.
He writes (emphasis mine):
- Use domain models to create system state changes.
- Create use-case read models in order to service consumption patterns.
With this in mind let's review a common pattern in Rails apps - models with a state
or status
column (less awful if it's an integer enum one, not a string one that has no null: false
constraint).
Business as usual
class Project < ApplicationRecord
enum state: {draft: 0, published: 1, started: 2, paused: 3, finished: 4}
end
Naively, the models may start out simple, with state-changing methods and/or callbacks:
class Project < ApplicationRecord
enum state: {draft: 0, published: 1, started: 2, paused: 3, finished: 4}
def publish!
return unless draft?
update!(state: :published)
enqueue_publishing_emails!
end
end
Over time the methods may become unwieldy and extraction into services happens:
class Project < ApplicationRecord
enum state: {draft: 0, published: 1, started: 2, paused: 3, finished: 4}
def publish!
ProjectPublisher.call(project: self)
end
end
Maybe the life-cycle of the object starts becoming so complex that mere guard clauses on state transition do not cut it and some form of state-machine is introduced:
class Project < ApplicationRecord
enum state: {draft: 0, published: 1, started: 2, paused: 3, finished: 4}
state_machine :state, initial: :draft do
before_transition from: :draft, do: :validate_publishability
event :publish do
transition draft: :published
end
after_transition on: :publish, do: :enqueue_publishing_emails
event :start do
transition published: :started
end
event :finish do
transition started: :finished
end
end
end
Each of these layers has increased complexity and made it harder to understand how exactly state transitions are to be called. In particularly unfortunate cases it can be like this:
# some overall state orchestrating super-service
ProjectState.apply(project, :publish)
# internally calls state-machine method (and callbacks around it)
project.publish!
# which calls publishing service
ProjectPublisher.call(project: project)
# which does the actual publishing.
project.update!(state: :published)
Taking a step back
Let's forget Rails models for a minute and put the DDD cap on. What principles can we use to design a better domain model? Two come to mind - focusing on actors and life-cycle events of the model, specifically, how an event changes each actor's relationship to the model, and what further events are possible.
In this example let's imagine publishing is a relatively benign event, it makes a Project available to be started for some workers, but let's imagine starting is more substantial - it assigns a specific worker to the project, allowing them further interactions and denies interference from other workers.
Breaking out Life-Cycle-specific models
Now that we've identified started projects as being significantly different from draft and published ones, let's define a completely separate model for them!
class OngoingProject < ApplicationRecord
end
Heck, following up on "use-case read models", we may also deign to define a read-only model for unrelated workers so they can review progress or somesuch.
class ProjectOverview < ApplicationRecord
end
Now, accessing data may require some tinkering. First, I'd like to warn against using Rails' STI mechanism here (type
column), because OngoingProject
is NOT just another flavour of Project
. We created the model precisely to encapsulate this difference.
Instead, you'll have to consider these alternatives:
- Have one large
projects
table that is used by bothProject
andOngoingProject
models, but columns that are not relevant for either are disabled by usingignored_columns
macro. - Have a
projects
table forProject
model, and anongoing_projects
table forOngoingProject
model. Your call whether to merely link back toProject
by havingproject_id
column (I recommend this) or simply copying over all the data onstart
event. - Have a
projects
table forProject
model, anongoing_projects
table, but baseOngoingProject
on a database view that seemlessly combines the two. This complexes writing operations.
Having done this we reap at least two benefits:
- Any
if started?
checks are no longer needed, instead we can make sureOngoingProject
records are used where appropriate and the model will attract appropriate logic into it. - Everything pertaining to
event :finish { transition started: :finished }
can be taken out of the statemachine and moved intoOngoingProject
since it's the only thing that can be finished.
Caveats
- With sufficiently complex life-cycle, it may become unobvious which class to instantiate when working with a record. Ideally, state alone will be sufficient to tell, and some instantiating service with a mapping will be helpful.
class ProjectLoader
MAPPING = {
draft: "Project"
started: "OngoingProject",
}.freeze
def self.call(project_id)
state = Project.where(id: project_id).pick(:state)
MAPPING[state].constantize.find_by_project_id!(project_id)
end
end
Top comments (0)