Making Data Races Unrepresentable
Read-Modify-Write Considered Harmful (for business logic flows)
TL;DR: Read-modify-write is too powerful for business logic. Move it behind intent-based APIs. No, you don't need actors or event sourcing—just implement message passing over your existing database.
For the purpose of this post, "unrepresentable" means that the API to express a dangerous pattern does not exist. You can't write buggy code if the buggy code can't be written.
Clarification: In this post, 'read-modify-write' only refers to the application-level pattern, not atomic CPU operations.
Introduction
Were you ever bitten by data races, digging through piles of logs to reconstruct what had happened to your database?
Or have you ever felt that the locks inside business logic flows looked a bit ugly, but couldn't really tell what exactly the problem was?
Or have you ever wanted the data-race-free property of the actor model with event sourcing, but gave up because it was such a significant infrastructure investment?
Read on...
HTTP Example
Let's look at a configuration service, because the account balance example to illustrate concurrency problem is... overused way too much. 😄
Here are two ways to design its API:
Service A: State Transfer
GET /config → returns full configuration
PUT /config → replaces full configuration
Client usage:
const config = await fetch('/config').then(r => r.json());
config.notifications.email = false;
await fetch('/config', {
method: 'PUT',
body: JSON.stringify(config)
});
Service B: Intent Transfer
POST /config/update → accepts partial updates
Client usage:
await fetch('/config/update', {
method: 'POST',
body: JSON.stringify({
notifications: { email: false }
})
});
Both achieve the same outcome. The difference appears when two clients update concurrently:
Concurrent Updates Timeline
Time | Service A - Alice | Service A - Bob | Result |
---|---|---|---|
T1 | GET /config | ||
T2 | GET /config | ||
T3 | Sets email=false locally | ||
T4 | Sets theme="dark" locally | ||
T5 | PUT /config (entire object) | email=false, theme="light" | |
T6 | PUT /config (entire object) | email=true, theme="dark" ❌ |
Time | Service B - Alice | Service B - Bob | Result |
---|---|---|---|
T1 | GET /config | ||
T2 | GET /config | ||
T3 | POST {notifications: {email: false}} | ||
T4 | POST {theme: "dark"} | ||
T5 | Server applies Alice's intent | email=false, theme="light" | |
T6 | Server applies Bob's intent | email=false, theme="dark" ✓ |
Service A loses Alice's update, but Service B preserves both.
Where Can a Race Be Represented?
I must clarify that there is no guarantee that Service B preserves both updates; it's merely a possibility. If the updates by Alice and Bob to Service B arrive simultaneously, it is actually possible that only one update gets applied!
The key distinction isn't that Service B prevents races—it's about where races can be represented:
// Service A: Race happens in client's flow code
const config = await getConfig(); // READ
config.field = newValue; // MODIFY
await saveConfig(config); // WRITE
// Service B: Race (if any) moves to server implementation
const config = await getConfig(); // READ
await updateConfig({ field: newValue }); // Just intent
With Service B, the race didn't disappear—it's moved. The client can no longer express a race-prone sequence, but the server implementation could still have races:
Here's an example of an unsafe implementation:
app.post('/config/update', async (req, res) => {
const intent = req.body;
// Server doing read-modify-write without protection
const config = await db.query('SELECT * FROM config WHERE id = $1', [id]);
const updated = { ...config, ...intent };
await db.query('UPDATE config SET data = $1 WHERE id = $2', [updated, id]);
});
And an example of a safe implementation:
app.post('/config/update', async (req, res) => {
const intent = req.body;
// Atomic update with proper locking.
// Locking is merely an example;
// pick the mechanism that works best for you.
await db.transaction(async tx => {
const config = await tx.query(
'SELECT * FROM config WHERE id = $1 FOR UPDATE',
[id]
);
const updated = { ...config.data, ...intent };
await tx.query(
'UPDATE config SET data = $1 WHERE id = $2',
[updated, id]
);
});
});
If requests arrive simultaneously and if your implementation is unsafe, data races can still happen. But with the safe implementation, the database's locking ensures updates are serialized.
This is actually progress, for the following reasons:
- Client code can't mess up: No matter how junior the developer, they can't accidentally write a data race in the client flow
- One place to fix: The data race exists in exactly one place (the server handler) instead of being scattered across every client interaction
- Easier to test: You only need to verify the server's concurrency handling, and you don't need to verify how it's called
The Same Pattern in ORMs
The previous example was between an HTTP server and a client, but the exact same thing happens between your backend app and your database. See how raw operations ORMs expose are suboptimal for writing flows: they bring the database's read-modify-write pattern directly into your business logic:
// Standard ORM: Exposes RMW to every callsite
const user = await repo.findById(id); // READ
user.settings.theme = 'dark'; // MODIFY
await repo.save(user); // WRITE
Here's a real bug this creates:
// Two background jobs running nearly simultaneously
// Job A: Mark user as premium after payment
async function upgradeToPremium(userId: string) {
const user = await repo.findById(userId);
user.isPremium = true;
user.subscriptionExpiry = new Date('2025-12-31');
await repo.save(user); // Takes 50ms
}
// Job B: Update last login
async function recordLogin(userId: string) {
const user = await repo.findById(userId);
user.lastLogin = new Date();
user.loginCount += 1;
await repo.save(user); // Takes 30ms
}
// If both run at T=0:
// - Job A reads user at T=0ms (isPremium=false, loginCount=10)
// - Job B reads user at T=5ms (isPremium=false, loginCount=10)
// - Job B saves at T=35ms (isPremium=false, loginCount=11)
// - Job A saves at T=50ms (isPremium=true, loginCount=10) ❌
// Result: Login count reverted from 11 to 10!
I must note that ORMs do offer concurrency control primitives:
// Optimistic locking
const user = await repo.findById(id);
user.settings.theme = 'dark';
const success = await repo.saveIfVersion(user, user.version);
if (!success) throw new RetryException();
// Pessimistic locking
await repo.transaction(async tx => {
const user = await tx.findByIdForUpdate(id);
user.settings.theme = 'dark';
await tx.save(user);
});
But notice: the callsite decides whether to be safe or not. Every programmer at every callsite must remember to use the safe pattern. Forget once, and you have a data race. Another drawback: you mix your business logic with your choice of concurrency control.
The Real Problem: Entity-Object Impedance Mismatch
Now that I've (hopefully) convinced you that RMW isn't great for writing flows, I'd like to show a more fundamental tension that I like to call entity-object impedance mismatch. Allow me to explain:
- Entities have identity and location. User #123 exists in exactly one place—your database. It's the single source of truth.
- Objects in memory are just copies. When you
findById(123)
, you get a snapshot—a photograph of the entity at that moment.
The problem with ORMs is that they pretend this copy IS the entity:
const user = await repo.findById(123); // This is just a COPY
user.name = "Alice"; // Modifying the copy
await repo.save(user); // Pretending the copy is authoritative
This is like:
- Taking a photo of your house
- Drawing renovations on the photo
- Showing up with the edited photo, expecting the house to match
When two people do this simultaneously with different photos, whose edits win? With locks, you're saying "Nobody else touches the house while I'm on site!", and with OCC, you're saying "If someone else changed it in the meantime, I'll have to reconsider". They are merely optional mechanisms to mitigate the mismatch in abstraction levels.
Intent-based APIs acknowledge the reality:
await commands.updateName(123, "Alice"); // Send intent to the actual entity
It doesn't pretend to manipulate the entity directly. You're sending a message to wherever the entity actually lives.
The Solution: Intent-Based APIs Over Any Storage
Instead of exposing ORM's raw operations, wrap them with intent-based APIs:
// Don't expose this:
class UserRepository {
find(id): Promise<User>
save(user): Promise<void> // ← RMW exposed to every callsite
}
// Expose this instead:
class UserCommands {
async updateSettings(userId: string, changes: Partial<Settings>) {
await this.db.transaction(async tx => {
const current = await tx.query(
'SELECT settings FROM users WHERE id = $1 FOR UPDATE',
[userId]
);
const merged = { ...current.settings, ...changes };
await tx.query(
'UPDATE users SET settings = $2 WHERE id = $1',
[userId, merged]
);
});
}
async recordLogin(userId: string, ip: string) {
await this.db.query(
`UPDATE users
SET last_login = NOW(),
login_count = login_count + 1,
last_ip = $2
WHERE id = $1`,
[userId, ip]
);
// Note: Atomic increment, no RMW needed!
}
async upgradeSubscription(userId: string, plan: string, expiryDate: Date) {
await this.db.transaction(async tx => {
// Complex business logic that needs multiple checks
const user = await tx.query(
'SELECT * FROM users WHERE id = $1 FOR UPDATE',
[userId]
);
if (user.isPremium && user.subscriptionExpiry > new Date()) {
throw new Error('User already has active subscription');
}
await tx.query(
`UPDATE users
SET is_premium = true,
subscription_plan = $2,
subscription_expiry = $3
WHERE id = $1`,
[userId, plan, expiryDate]
);
await tx.query(
'INSERT INTO subscription_history (user_id, plan, started_at) VALUES ($1, $2, NOW())',
[userId, plan]
);
});
}
}
Now the data race can only exist in one place—inside each command method—where it's easier to handle them properly. Callsites literally can't express races now:
// Multiple callsites, zero data races
await commands.updateSettings(id, { theme: 'dark' });
await commands.recordLogin(id, request.ip);
await commands.upgradeSubscription(id, 'premium', expiry);
Why This Works: Commands as Universal Concurrency Control
Intent-based APIs aren't just a nice pattern—they're the abstraction that unifies different concurrency mechanisms. The same command interface works regardless of your implementation:
// The interface stays the same...
await commands.updateSettings(userId, { theme: 'dark' });
// ...whether the implementation uses:
// Pessimistic locking
async updateSettings(id, changes) {
await db.transaction(tx => {
await tx.query('SELECT ... FOR UPDATE');
await tx.query('UPDATE ...');
});
}
// Optimistic locking
async updateSettings(id, changes) {
const success = await db.updateWithVersion(...);
if (!success) retry();
}
// Event sourcing
async updateSettings(id, changes) {
await eventStore.append(new SettingsUpdatedEvent(id, changes));
}
// STM (Software Transactional Memory)
async updateSettings(id, changes) {
await stm.atomically(() => {
const user = stm.read(userRef);
stm.write(userRef, { ...user, settings: { ...user.settings, ...changes }});
});
}
This is why intent-based APIs are at the "just right" abstraction level for business logic flows: they're not tied to any particular concurrency control mechanism, while exposing a rich interface that conveys meaning in the sense of business logic. Concurrency control does not get mixed with business logic; they become mere implementation details, which, in my opinion, is where they belong.
This is quite convenient once you fully understand the implications. For your MVP, you can use in-memory STM without a backing store. As you grow, you might move to Postgres. If the impossible happens and your company becomes a unicorn (congratulations), you can move to event sourcing, and business logic requires no update (!).
Additionally, you can have a strategy per entity type or even per command handler! Those with low contention can use OCC, and those with high contention may use locking. Or any other sophisticated strategy; the business logic does not know.
Addressing Common Concerns
"But this creates an explosion of methods!"
Not exactly true. It's valid to have only one method: update
that updates any arbitrary list of fields. As long as the command handler is properly implemented, it will prevent data races. However, you're missing out on code organization: your business operations (intent) could be obscured in code.
Therefore, I do recommend making some effort to make the methods not too fine-grained or too coarse. The problem is in the realm of domain modeling.
"How do I handle validation that depends on current state?"
Put the validation inside your command handler. You can even put it in the transaction if your implementation supports it:
async function addToCart(userId: string, itemId: string, quantity: number) {
await this.db.transaction(async tx => {
const cart = await tx.query('SELECT * FROM carts WHERE user_id = $1 FOR UPDATE', [userId]);
if (cart.items.length >= 100) {
throw new Error('Cart cannot exceed 100 items');
}
// Safe to proceed, we have the lock
await tx.query('INSERT INTO cart_items ...');
});
}
"How do I do database transactions?"
The straightforward way is to pass in transaction connections into the command handlers, but that's not the only way.
A more sophisticated approach could be: the command handlers read from a read replica and return uncommitted "events" (which is basically unit of work pattern), which then gets aggregated until the end of the flow and committed in a brief transaction using OCC.
The above are merely examples; you can choose how simple or sophisticated it can be.
The Tradeoffs
What you lose:
- Familiar direct object manipulation or the Active Record pattern (they are merely easy, not simple–watch Simple Made Easy if you haven't)
What you gain:
- Data races impossible to represent at callsites
- Concurrency control in exactly one place per operation, as implementation detail, separated from business logic
- Clearer modeling of entities, by being forced to think "what are the possible state transition intents sent to this entity?"
- Audit trail of actual business operations, not just field changes, if you log at the command handler
A Middle Ground
Intent-based APIs are a middle ground between traditional ORMs and more radical approaches:
Traditional ORM: Full RMW everywhere, safety is optional
Intent-based (this article): RMW hidden behind safe APIs, accessible when needed
Event Sourcing: No RMW at all, append-only events
You get most of the safety benefits of event sourcing or actors, without the architectural complexity. You're just being disciplined about your existing database.
The Principle
This pattern works with any storage technology you already have:
- Avoid exposing
save(entity)
to business logic by default - Do expose
doBusinessOperation(id, parameters)
instead - Implement the command handler correctly once, possibly using RMW
The question isn't "how do we handle concurrent modifications?" but rather "how do we design APIs where concurrent modifications can't be expressed unsafely?"
Make the wrong thing unrepresentable. Then the right thing becomes the only thing.
Remember: You don't need to rebuild your architecture. Start with your next feature—wrap its database access in intent-based APIs. Gradually migrate existing code as you fix bugs or add features. The transition can be incremental.