An alternative to model states - state models
Tags: rails, architecture, design
Published: 2023-02-24 22:31:03

---
title: An alternative to model states - state models
published: true
description: Exploring alternatives in Rails modeling
tags: Rails, architecture, design
//cover_image: https://direct_url_to_image.jpg
---

[Shawn McCool's astute teardown of Active Record pattern](https://shawnmc.cool/2023-02-13_active-record-how-we-got-persistence-perfectly-wrong) is making the rounds and I wanted to distill some conclusions while they're still fresh.

He writes (emphasis mine):

> 8. Use domain models to __create__ system __state changes__. 
> 10. 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

```rb
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:

```rb
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:

```rb
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:

```rb
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:

```rb
# 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!

```rb
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.

```rb
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:
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.

```rb
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
```