CRDTs and Coordination Avoidance: Composing CRDTs into Domain Models

LESSON

CRDTs and Coordination Avoidance

013 30 min intermediate

CRDTs and Coordination Avoidance: Composing CRDTs into Domain Models

Core Insight

Imagine an offline-first project board. A user in Madrid renames a project, a user in Dublin adds a task, and a user in Lisbon reacts to a comment while all three devices are temporarily disconnected. When the network heals, the product should not collapse those updates into one last-writer-wins JSON blob. The useful result is a project that contains the new name, the new task, and the new reaction.

This is where CRDTs start to look like application design rather than data-structure exercises. A domain model is not usually one CRDT. It is a bundle of fields, child objects, derived views, and business promises. Some parts merge naturally. Some parts need a special policy. Some parts must be routed through an authority because a mergeable value would preserve the wrong thing.

The non-obvious insight is that CRDT composition is easy algebraically and hard semantically. If each field is a CRDT, the object can often merge field by field. But the product promise lives above the fields: who owns the workspace, whether an archived board can accept new tasks, whether a member removal should beat a concurrent edit, and whether a billing limit can be exceeded.

The practical skill is to draw a boundary around each promise. Use CRDTs where independent updates should survive. Use coordination, allocation, or explicit workflow states where the promise depends on absence, uniqueness, safety, or cross-field agreement.

The Object Is Not One Register

A common first design stores the whole project as one JSON document:

{
  "name": "Launch plan",
  "tasks": ["Write brief"],
  "members": ["ana"],
  "archived": false
}

Then each replica overwrites the document after a local edit.

Madrid:
  name = "Launch plan Q3"

Dublin:
  tasks += "Book venue"

last-writer-wins document merge:
  one whole document wins
  the other edit disappears

That is too coarse. The two edits did not conflict in the user's mind. They touched different facts.

A better shape treats the project as a map of smaller merge policies:

project:
  name      -> multi-value register or LWW register
  tasks     -> observed-remove map of task_id -> task
  members   -> observed-remove set, remove-wins set, or coordinated membership
  reactions -> counters or observed-remove sets
  archived  -> monotonic flag or workflow state

Now the merge is component-wise:

merge(project_a, project_b):
  name      = merge_name(project_a.name, project_b.name)
  tasks     = merge_tasks(project_a.tasks, project_b.tasks)
  members   = merge_members(project_a.members, project_b.members)
  reactions = merge_reactions(project_a.reactions, project_b.reactions)
  archived  = merge_archived(project_a.archived, project_b.archived)

Formally, this is a product of mergeable states: combine each component with its own join. Practically, it means the object can preserve independent updates because the design stopped treating every edit as a replacement of the entire object.

Start With Domain Promises

Before choosing field types, write the promises in plain language.

For the project board:

1. Adding different tasks concurrently should preserve both tasks.
2. Editing a task title concurrently should not silently discard one title.
3. Removing a member should prevent that member from making future accepted edits.
4. Archiving a project should stop new tasks unless the project is reopened.
5. A workspace slug must refer to one workspace.

Each promise has a different shape.

Promise 1:
  independent additions
  good CRDT fit

Promise 2:
  same field edited concurrently
  conflict must be shown, merged, or resolved by a policy

Promise 3:
  safety and authorization
  likely remove-wins, coordination, leases, or an authority boundary

Promise 4:
  cross-field workflow rule
  needs explicit operation rules, not just field merge

Promise 5:
  uniqueness
  needs allocation or coordination, as in the previous lesson

This is the design move that prevents CRDT overreach. You are not asking "which CRDT do we use for the project?" You are asking "which parts of the project have merge-safe promises, and which parts need a boundary?"

Worked Example: A Replicated Project

Use generated IDs for identity:

project_id = madrid:project:001
task_id    = dublin:task:884
comment_id = lisbon:comment:207

Generated IDs avoid accidental collisions. They are not the public slug. The public slug still follows the allocation rule from the previous lesson.

Now define the project state:

project = {
  id: immutable generated ID,
  slug: coordinated reservation,
  title: multi-value register,
  tasks: OR-map task_id -> task,
  members: remove-wins set of user IDs,
  archived_epoch: monotonic register,
  quota: bounded counter
}

Each task is also a small domain object:

task = {
  title: multi-value register,
  status: workflow register,
  assignees: OR-set of user IDs,
  comments: OR-map comment_id -> comment,
  deleted: remove-wins marker
}

Now replay a disconnected edit.

Madrid:
  title := "Launch plan Q3"

Dublin:
  add task dublin:task:884 "Book venue"

Lisbon:
  add reaction "thumbs-up" to comment lisbon:comment:207

After merge:

project.title:
  "Launch plan Q3"

project.tasks:
  contains dublin:task:884

comment.reactions:
  includes Lisbon's reaction

Those updates survive because each touched a compatible part of the model.

Now add a harder case:

Madrid:
  archive project

Dublin, while partitioned:
  add task "Book venue"

If archived is only a boolean field and tasks is only an OR-map, the merged state can contain both facts:

archived = true
tasks contains "Book venue"

That may or may not be valid. The CRDT preserved information. The domain still has to decide what it means.

Possible policies:

archive-wins:
  keep the task as rejected or hidden
  show the user that the add did not become active

workflow state:
  "archiving" blocks new task acceptance through an authority

reopen-aware:
  allow task if it was causally before the archive
  reject task if concurrent with or after the archive

product change:
  archived projects can still collect draft tasks
  but do not show them as active until reopened

The trade-off is not just technical. Archive-wins is safer but can surprise users who worked offline. Add-wins is more available but weakens what "archived" means. Coordination gives a cleaner promise but makes the archive boundary a slow path during partitions.

Derived State Should Have One Source

Composed models often contain derived values:

open_task_count
last_activity_at
member_count
has_overdue_tasks

These are usually views, not separate truths.

If tasks is authoritative, open_task_count should be recomputed from tasks or maintained as a cache that can be repaired. If both are independently mergeable, they can disagree:

tasks:
  10 open tasks

open_task_count:
  9

That is not a CRDT failure. It is an authority mistake. A derived value needs one of these designs:

recompute:
  read source state and calculate the view

repairable cache:
  store for speed, but allow background correction

separate authoritative field:
  use only when the derived value has its own domain meaning

The same rule applies to search indexes, notifications, and analytics counters. They can lag. They can be rebuilt. They should not become a second source of truth for the domain promise unless the product deliberately accepts that split.

Operation Gates Before Merge

Merge is not the only place where correctness lives. Local operations should check whether they are allowed before adding state.

For example:

add_task(project, task):
  if project is archived locally:
    reject or create a pending draft
  else if user is not an active member locally:
    reject or route to authority
  else if quota rights are unavailable:
    wait, fail, or request rights
  else:
    add task to OR-map

These checks are not perfect global knowledge. They are the local gate that decides whether the system can safely use the fast path.

The rule of thumb is:

mergeable promise:
  accept locally and merge later

bounded promise:
  accept locally only with rights

unique promise:
  accept only at the authority for that value

safety or authorization promise:
  prefer remove-wins, leases, epochs, or coordination

ambiguous promise:
  preserve conflict and expose it to a resolver

This turns the domain model into a set of explicit paths instead of one vague "eventual consistency" mode.

Failure Modes

Practice

Design a mergeable workspace object with these fields:

workspace_slug
display_name
members
documents
document_comments
storage_quota
last_activity_at
archived

For each field, write one line:

field -> source of truth -> merge policy -> slow path if any

Then answer:

1. Which fields can be updated offline without coordination?
2. Which fields need allocation, rights, or an authority?
3. Which fields are derived views rather than authoritative state?
4. What happens if a member is removed while their offline device edits a document?
5. What conflict should be shown to a user instead of hidden by a resolver?

The goal is to make the model legible. A reviewer should be able to point at every field and say what kind of promise it carries.

Connections

Resources

Key Takeaways

PREVIOUS CRDTs and Coordination Avoidance: Uniqueness, Allocation, and Coordination Requirements NEXT CRDTs and Coordination Avoidance: Topology, Placement, and Locality Trade-Offs