CRDTs and Coordination Avoidance: Composing CRDTs into Domain Models
LESSON
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
- One JSON blob for the whole aggregate: Independent edits become artificial conflicts, and last-writer-wins discards useful work.
- Field-level CRDTs without domain rules: The object converges, but cross-field promises such as archived-state behavior can still fail.
- Hidden conflict resolution: Picking a winner for a title, owner, or workflow state may hide a decision the user or product should see.
- Derived values as independent truth: Counts, badges, and indexes can drift unless their authority is clear.
- Authorization as a normal set: Member removal is often safety-sensitive. A concurrent add or stale membership view may need stronger treatment.
- No operation gate: If every local edit becomes accepted state, the merge layer has to repair product promises after the fact.
- Schema changes that alter merge meaning: Changing a field from an OR-set to a register, or from a nested map to a blob, changes the contract of old and new clients.
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
009.mdintroduced nested CRDT maps; this lesson turns that structure into a domain modeling discipline.012.mdshowed that uniqueness needs allocation or coordination; composed models must keep those fields separate from ordinary mergeable state.014.mdmoves the same design outward: once fields and authorities are clear, topology decides where those authorities and replicas should live.
Resources
- [PAPER] A comprehensive study of Convergent and Commutative Replicated Data Types
- Focus: Use the formal CRDT catalog to see how maps, sets, counters, and registers compose.
- [PAPER] A Conflict-Free Replicated JSON Datatype
- Focus: Study how JSON-like application objects can preserve concurrent edits without treating the whole object as one register.
- [PAPER] Coordination Avoidance in Database Systems
- Focus: Recheck composed domain invariants against invariant confluence before assuming field-level merges are enough.
- [DOC] Riak Data Types: Maps
- Focus: Look at a production-facing example of nested CRDT maps and field-specific merge policies.
- [BOOK] Designing Data-Intensive Applications
- Focus: Connect these modeling choices to replication, constraints, derived data, and operational trade-offs.
Key Takeaways
- A domain object should be decomposed by promise: mergeable fields, coordinated fields, bounded fields, and derived views.
- Component-wise CRDT composition gives convergence, but cross-field domain invariants still need explicit operation rules or coordination.
- Derived state should have one source of truth; caches and indexes can lag only if they are repairable.
- The best composed model makes every slow path visible instead of hiding product decisions inside a generic merge function.