An alternative to model states - state models

Augusts Bautra - Feb 24 '23 - - Dev Community

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):

  1. Use domain models to create system state changes.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Have one large projects table that is used by both Project and OngoingProject models, but columns that are not relevant for either are disabled by using ignored_columns macro.
  2. Have a projects table for Project model, and an ongoing_projects table for OngoingProject model. Your call whether to merely link back to Project by having project_id column (I recommend this) or simply copying over all the data on start event.
  3. Have a projects table for Project model, an ongoing_projects table, but base OngoingProject on a database view that seemlessly combines the two. This complexes writing operations.

Having done this we reap at least two benefits:

  1. Any if started? checks are no longer needed, instead we can make sure OngoingProject records are used where appropriate and the model will attract appropriate logic into it.
  2. Everything pertaining to event :finish { transition started: :finished } can be taken out of the statemachine and moved into OngoingProject since it's the only thing that can be finished.

Caveats

  1. 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
Enter fullscreen mode Exit fullscreen mode
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .