DEV Community

Cover image for Designing a Database Around Immutable State
Derek Mwale
Derek Mwale

Posted on

Designing a Database Around Immutable State

There is a particular moment in software engineering when a database stops feeling like a collection of tables and starts feeling like a model of reality.

It usually happens when the system becomes complicated enough that “just update the row” is no longer a satisfying answer.

A user changes their email address.

An order moves from pending to paid.

A payment fails and is retried.

A subscription is cancelled and later reactivated.

An administrator changes a customer's permissions.

A financial transaction is corrected.

At first, the obvious database design is simple.

Store the current state.

Update it when something changes.

users
-----
id
name
email
status
Enter fullscreen mode Exit fullscreen mode

If the user changes their email, run:

UPDATE users
SET email = 'new@example.com'
WHERE id = 42;
Enter fullscreen mode Exit fullscreen mode

Problem solved.

Except it isn't.

You have just destroyed information.

You know the user's email now.

You don't necessarily know what it was yesterday.

You don't know when it changed.

You don't know why it changed.

You don't know which operation caused the change.

You don't know whether it was changed by the user, an administrator, an automated process, or a compromised account.

The database contains the present, but the past has disappeared.

This is where immutable state becomes interesting.

Instead of treating state as something that should be continuously overwritten, we can treat state transitions as facts.

The database becomes less like a whiteboard where we repeatedly erase old information and more like a ledger where new facts are appended.

And this seemingly small change has enormous consequences for architecture.


The Problem With Mutable State

Most traditional CRUD applications are built around mutable state.

Create a record.

Read a record.

Update a record.

Delete a record.

It is an incredibly useful abstraction.

But it quietly assumes that the latest version of a record is the only version worth keeping.

Imagine an order:

Order #1001
status = pending
Enter fullscreen mode Exit fullscreen mode

Five minutes later:

Order #1001
status = paid
Enter fullscreen mode Exit fullscreen mode

Then:

Order #1001
status = shipped
Enter fullscreen mode Exit fullscreen mode

Finally:

Order #1001
status = delivered
Enter fullscreen mode Exit fullscreen mode

A conventional database might contain:

id: 1001
status: delivered
Enter fullscreen mode Exit fullscreen mode

The database tells you where the order is.

It doesn't tell you how it got there.

That distinction becomes increasingly important as systems become distributed.

Suppose a customer claims:

“I never cancelled this order.”

Your database says:

status = cancelled
Enter fullscreen mode Exit fullscreen mode

But why?

Was it cancelled by the customer?

An administrator?

A payment provider?

A background worker?

A webhook?

A race condition?

A retry?

An API client?

The current row cannot answer those questions.

You need history.

And once you need history everywhere, you begin discovering that history isn't merely an optional feature.

It is part of the domain.


State Is a Sequence, Not a Snapshot

One of the most useful mental models in database architecture is this:

A system is not merely a collection of states. It is a sequence of transitions between states.

Consider a bank account.

Its current balance might be:

$4,200
Enter fullscreen mode Exit fullscreen mode

But the number itself isn't particularly interesting.

The interesting part is how the account arrived at $4,200.

Perhaps:

Opening balance: $1,000

+ $5,000 deposit
- $800 withdrawal
- $1,000 payment
Enter fullscreen mode Exit fullscreen mode

The current state is the result of history.

Conceptually:

S0 → S1 → S2 → S3 → S4
Enter fullscreen mode Exit fullscreen mode

Where every transition represents a fact.

Instead of saying:

balance = 4200
Enter fullscreen mode Exit fullscreen mode

we can say:

deposit +5000
withdraw -800
payment -1000
Enter fullscreen mode Exit fullscreen mode

Then:

balance = reduce(history)
Enter fullscreen mode Exit fullscreen mode

This is a profound shift.

The database no longer needs to remember only the answer.

It remembers the evidence from which the answer can be derived.


Immutability Changes the Meaning of an Update

In a mutable database, an update means:

Replace the old truth with a new truth.

In an immutable database model, an update means:

Record a new fact that changes the current interpretation of the state.

Those are very different ideas.

Suppose an account starts with:

balance = 1000
Enter fullscreen mode Exit fullscreen mode

A conventional implementation might execute:

UPDATE accounts
SET balance = 1500
WHERE id = 1;
Enter fullscreen mode Exit fullscreen mode

But what happened to the original 1000?

It is gone.

An immutable approach might instead record:

account_id | type    | amount
-----------+---------+-------
1          | deposit | 500
Enter fullscreen mode Exit fullscreen mode

Now the state can be reconstructed.

1000 + 500 = 1500
Enter fullscreen mode Exit fullscreen mode

The event is immutable.

The interpretation of the event can change.

But the fact that the deposit happened remains.

This is the basic idea behind event-oriented architectures and event sourcing, but you don't need to build a massive event-sourced distributed system to benefit from the concept.

You can apply the principle selectively.


The Database as a Timeline

Imagine a user profile.

Instead of storing:

users
-----
id
name
email
role
Enter fullscreen mode Exit fullscreen mode

and constantly modifying it, you could separate identity from state transitions.

users
-----
id
created_at
Enter fullscreen mode Exit fullscreen mode

Then:

user_events
-----------
id
user_id
event_type
payload
created_at
Enter fullscreen mode Exit fullscreen mode

A user's history might look like:

USER_CREATED
EMAIL_CHANGED
ROLE_CHANGED
EMAIL_CHANGED
ACCOUNT_SUSPENDED
ACCOUNT_REACTIVATED
Enter fullscreen mode Exit fullscreen mode

The current user state is now a projection of those events.

Conceptually:

User Created
      ↓
Email Changed
      ↓
Role Changed
      ↓
Email Changed
      ↓
Suspended
      ↓
Reactivated
Enter fullscreen mode Exit fullscreen mode

This makes the database behave more like a timeline.

And timelines are powerful because they preserve causality.


Immutable Does Not Mean “Never Change Anything”

This is where people often misunderstand immutability.

An immutable database does not necessarily mean that every table in the system is frozen forever.

It means that certain facts, once recorded, should not be rewritten.

Consider a payment.

You don't want this:

payment.status = failed
Enter fullscreen mode Exit fullscreen mode

to later become:

payment.status = successful
Enter fullscreen mode Exit fullscreen mode

without preserving the transition.

A better model is:

PaymentCreated
PaymentProcessing
PaymentFailed
PaymentRetried
PaymentSucceeded
Enter fullscreen mode Exit fullscreen mode

The payment's current status can still be:

succeeded
Enter fullscreen mode Exit fullscreen mode

But the historical facts remain intact.

This gives you both worlds:

  • fast access to current state
  • complete historical state transitions

That combination is extremely useful.


Current State and Historical State

One of my favorite designs is to maintain two conceptual layers.

The first layer is the immutable history.

events
------
id
aggregate_id
type
payload
created_at
Enter fullscreen mode Exit fullscreen mode

The second layer is the current projection.

orders
------
id
customer_id
status
total
updated_at
Enter fullscreen mode Exit fullscreen mode

The event history is authoritative.

The projection is optimized for reading.

For example:

OrderCreated
      ↓
PaymentReceived
      ↓
OrderPacked
      ↓
OrderShipped
Enter fullscreen mode Exit fullscreen mode

The projection might simply say:

orders.status = shipped
Enter fullscreen mode Exit fullscreen mode

This gives you an important architectural separation.

The event log answers:

What happened?

The projection answers:

What is true right now?

Those are different questions.

And databases become easier to reason about when different structures answer different questions.


Why This Makes Debugging Better

Debugging distributed systems is often less about finding the broken line of code and more about reconstructing history.

Something happened.

You don't know what.

The system now looks wrong.

You inspect the database.

You see:

status = failed
Enter fullscreen mode Exit fullscreen mode

Now you're guessing.

With immutable transitions, you might see:

09:31:02 OrderCreated
09:31:04 PaymentStarted
09:31:07 PaymentProviderTimeout
09:31:08 PaymentRetryScheduled
09:32:02 PaymentStarted
09:32:06 PaymentSucceeded
09:32:07 OrderConfirmed
Enter fullscreen mode Exit fullscreen mode

Suddenly the bug becomes a timeline.

You can reason about it.

You can ask:

  • Did the retry happen?
  • Did two workers process the same event?
  • Did the webhook arrive twice?
  • Did the state transition occur out of order?
  • Did the payment provider respond after our timeout?
  • Did the system process a stale command?

Immutable state turns debugging from archaeology into observation.


Idempotency Becomes Easier to Understand

Distributed systems love sending the same message twice.

A payment webhook might arrive twice.

A queue might redeliver a message.

A client might retry an HTTP request.

A worker might crash after processing something but before acknowledging it.

If your system is built around destructive updates, duplicate messages can produce strange results.

But immutable state encourages you to identify transitions explicitly.

Suppose you receive:

PaymentSucceeded
Enter fullscreen mode Exit fullscreen mode

with event ID:

evt_8392
Enter fullscreen mode Exit fullscreen mode

You can store the event ID with a uniqueness constraint:

UNIQUE(event_id)
Enter fullscreen mode Exit fullscreen mode

Now if the provider sends the same event again, the database can recognize that the fact has already been recorded.

This is much cleaner than trying to determine whether the current state “looks like” the event was already processed.


State Machines Become Natural

Once you think in immutable transitions, state machines become almost unavoidable.

Consider a deployment.

created
   ↓
queued
   ↓
building
   ↓
testing
   ↓
deploying
   ↓
deployed
Enter fullscreen mode Exit fullscreen mode

But not every transition should be allowed.

You shouldn't be able to randomly do:

deployed → building
Enter fullscreen mode Exit fullscreen mode

unless the domain explicitly supports that.

So the system can define:

created → queued
queued → building
building → testing
testing → deploying
deploying → deployed
Enter fullscreen mode Exit fullscreen mode

And perhaps:

building → failed
testing → failed
deploying → failed
Enter fullscreen mode Exit fullscreen mode

Now the database isn't simply storing strings.

It is preserving a legal sequence of state transitions.

This is one of the most powerful ideas in backend architecture:

The database should protect the invariants of the domain, not merely store whatever the application sends it.


The Database Can Become a Guardian

Application code can contain this:

if order.status == "paid":
    ...
Enter fullscreen mode Exit fullscreen mode

But if multiple services can modify the same order, application-level rules may not be enough.

One service might understand the order lifecycle.

Another might accidentally write:

status = shipped
Enter fullscreen mode Exit fullscreen mode

before payment succeeds.

A third service might retry an old command.

The database needs to participate in protecting the state machine.

That might involve:

  • foreign keys
  • unique constraints
  • check constraints
  • transaction boundaries
  • optimistic concurrency
  • version numbers
  • append-only records
  • transition validation

For example:

order_id
version
event_type
Enter fullscreen mode Exit fullscreen mode

could enforce that the next event is based on the expected version.

If the current version is:

7
Enter fullscreen mode Exit fullscreen mode

a command might say:

expected_version = 7
Enter fullscreen mode Exit fullscreen mode

If another process already changed the order to version 8, the update fails.

This prevents stale writers from silently overwriting newer state.


Optimistic Concurrency Fits Naturally

Suppose two workers read:

order.version = 10
Enter fullscreen mode Exit fullscreen mode

Worker A changes it.

Worker B also changes it.

Without concurrency control, the second write may overwrite the first.

With versioning:

UPDATE orders
SET status = 'shipped',
    version = 11
WHERE id = 1001
AND version = 10;
Enter fullscreen mode Exit fullscreen mode

If another worker already changed the version to 11, this update affects zero rows.

That is not merely a database error.

It is valuable information.

The database is saying:

Your understanding of reality is stale.

That is exactly what a distributed system needs to know.


Immutable State Gives You Time Travel

Once state transitions are preserved, you gain something that ordinary CRUD systems struggle with:

historical reconstruction.

Suppose an order currently says:

total = $2,400
Enter fullscreen mode Exit fullscreen mode

A customer asks:

What did the order look like at 10:30 yesterday?

If you have only mutable state, that may be impossible.

With immutable transitions, you can replay events until the desired timestamp.

OrderCreated
ItemAdded
ItemAdded
DiscountApplied
ShippingChanged
PaymentReceived
Enter fullscreen mode Exit fullscreen mode

Replay only the events that happened before 10:30.

You now have a historical snapshot.

This is effectively time travel.

And time travel is useful for much more than debugging.

It can support:

  • audits
  • financial reconciliation
  • customer support
  • analytics
  • fraud investigation
  • compliance
  • historical reporting
  • dispute resolution

But Immutable Systems Have a Cost

Immutability is not free.

If you store every transition forever, your database grows.

A simple CRUD table might contain:

1 million rows
Enter fullscreen mode Exit fullscreen mode

An event table might contain:

100 million events
Enter fullscreen mode Exit fullscreen mode

The difference is enormous.

Queries also become more complicated.

Instead of:

SELECT * FROM orders;
Enter fullscreen mode Exit fullscreen mode

you may need projections.

Instead of updating a record directly, you need event handling.

You may need:

events
   ↓
consumer
   ↓
projection
   ↓
read model
Enter fullscreen mode Exit fullscreen mode

Now your system has more moving pieces.

And more moving pieces mean more failure modes.

So the lesson is not:

Everything should be event sourced.

The lesson is:

Preserve immutability where historical truth matters.


Not Everything Needs Event Sourcing

A user's temporary UI preference probably doesn't need a sophisticated event log.

A cache definitely doesn't.

A table containing a materialized search index probably doesn't.

Sometimes:

UPDATE settings
SET theme = 'dark'
WHERE user_id = 42;
Enter fullscreen mode Exit fullscreen mode

is exactly the right design.

The interesting architectural question is:

Which facts are important enough that losing their history would make the system harder to understand or trust?

For financial transactions, probably yes.

For audit logs, yes.

For security-sensitive actions, yes.

For order lifecycles, often yes.

For workflows, often yes.

For ephemeral caches, probably no.

Architecture is about choosing where complexity belongs.


Immutable State and APIs

The same idea changes API design.

A typical CRUD API might expose:

PUT /orders/1001
Enter fullscreen mode Exit fullscreen mode

with:

{
  "status": "shipped"
}
Enter fullscreen mode Exit fullscreen mode

This API allows the caller to directly mutate state.

A transition-oriented API might instead expose:

POST /orders/1001/ship
Enter fullscreen mode Exit fullscreen mode

The difference is subtle but important.

The second API expresses intent.

You aren't saying:

Make this row contain this value.

You're saying:

Perform the shipping transition.

That gives the server an opportunity to validate:

Is payment complete?
Are all items packed?
Is the order already shipped?
Does the caller have permission?
Is this transition legal?
Enter fullscreen mode Exit fullscreen mode

The API becomes a state machine interface.

And the database can preserve the resulting transition.


Commands Are Not State

This distinction is extremely useful.

A command says:

I want something to happen.

An event says:

Something happened.

For example:

Command:
ShipOrder
Enter fullscreen mode Exit fullscreen mode

After successful processing:

Event:
OrderShipped
Enter fullscreen mode Exit fullscreen mode

The command can fail.

The event represents a fact that has already occurred.

That difference helps create cleaner systems.

The command is mutable in the sense that it is an instruction.

The event should be immutable because it represents history.

This distinction also makes asynchronous architectures much easier to reason about.


The Event Is a Fact, Not a Database Operation

A common mistake is designing events around database mechanics.

For example:

UserRowUpdated
Enter fullscreen mode Exit fullscreen mode

is not particularly meaningful.

It describes an implementation detail.

A better event might be:

EmailAddressChanged
Enter fullscreen mode Exit fullscreen mode

The difference is domain-oriented.

The first says:

A row changed.

The second says:

A business fact occurred.

This matters because databases can change.

Services can change.

Table structures can change.

But domain facts often remain stable.

The event should represent the business reality, not the current implementation.


Schema Evolution Becomes Interesting

Once events are immutable, you cannot simply rewrite old events every time your schema changes.

Suppose version one stores:

{
  "email": "derek@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Later, version two expects:

{
  "old_email": "old@example.com",
  "new_email": "new@example.com"
}
Enter fullscreen mode Exit fullscreen mode

What happens to historical events?

You have options.

You can version events:

EmailChanged.v1
EmailChanged.v2
Enter fullscreen mode Exit fullscreen mode

You can maintain upcasters that transform old events into the current representation.

Or you can design event schemas carefully so that they remain useful over time.

This is one of the hidden costs of immutability.

Once you preserve history, you inherit responsibility for that history.


Storage Should Reflect Meaning

One of the biggest architectural lessons here is that database structure should follow domain meaning.

Instead of asking:

What columns do I need?

Ask:

What facts does this system need to remember?

Instead of:

What rows need updating?

Ask:

What transition just happened?

Instead of:

What is the current status?

Ask:

What sequence of events produced this status?

This leads to databases that model the business more explicitly.

A database stops being merely a persistence mechanism.

It becomes a record of reality.


Immutability and Auditability

There is another major advantage.

Trust.

If a database allows administrators to modify important records without preserving previous values, auditing becomes difficult.

Imagine:

role = admin
Enter fullscreen mode Exit fullscreen mode

Six months later:

role = user
Enter fullscreen mode Exit fullscreen mode

Who changed it?

When?

Why?

Was it authorized?

An immutable transition log could say:

RoleGranted
RoleRevoked
Enter fullscreen mode Exit fullscreen mode

along with:

actor
timestamp
request_id
reason
Enter fullscreen mode Exit fullscreen mode

Now the system can answer questions that a mutable row cannot.

This becomes especially important when software manages:

  • money
  • identity
  • permissions
  • healthcare workflows
  • legal records
  • enterprise approvals
  • security events

In these systems, history is not a luxury.

It is part of the product.


The Most Interesting Part: Current State Becomes a Cache

There is a philosophical shift hidden inside this architecture.

If the immutable history is authoritative, then the current state is technically derived.

Your orders table might be thought of as a cache of the latest interpretation of events.

That doesn't mean it is an unimportant cache.

It can be highly optimized and extremely important for performance.

But conceptually:

History
   ↓
Projection
   ↓
Current State
Enter fullscreen mode Exit fullscreen mode

rather than:

Current State
   ↓
Maybe some logs
Enter fullscreen mode Exit fullscreen mode

That difference changes how you design systems.


Rebuilding the World

One of the strongest tests for an immutable architecture is this:

If I deleted the current projection, could I rebuild it?

Suppose your orders table disappears.

You still have:

OrderCreated
PaymentReceived
OrderPacked
OrderShipped
Enter fullscreen mode Exit fullscreen mode

You can replay the history.

Then:

orders.status = shipped
Enter fullscreen mode Exit fullscreen mode

appears again.

That is a beautiful property.

It means your derived state is reproducible.

If your current database is corrupted, you have a source from which it can be reconstructed.

If you introduce a new reporting model, you can build it from historical events.

If you discover a bug in a projection, you can fix the projection and replay history.

The past becomes computationally useful.


Designing for Failure

This architecture also changes how we think about failures.

In a mutable system:

write failed
Enter fullscreen mode Exit fullscreen mode

might leave you wondering what happened.

In an immutable system:

event persisted
projection failed
Enter fullscreen mode Exit fullscreen mode

is a much clearer state.

The source of truth exists.

The derived view is temporarily behind.

That gives you the ability to retry projection.

You can build:

events
  ↓
queue
  ↓
projection worker
  ↓
read model
Enter fullscreen mode Exit fullscreen mode

If the worker crashes, the event remains.

If the read model becomes corrupted, rebuild it.

If a consumer goes offline for an hour, it can catch up.

This is one reason immutable architectures work particularly well with asynchronous systems.


The Database as a System of Memory

Software usually has state.

But state without memory is fragile.

If all you know is:

temperature = 32°C
Enter fullscreen mode Exit fullscreen mode

you don't know whether the temperature rose from 20°C or fell from 40°C.

Context matters.

The same is true for software.

A user being suspended is one piece of information.

Knowing that they were suspended after five failed authentication attempts is another.

An order being cancelled is one piece of information.

Knowing that it was cancelled after payment authorization expired is another.

A transaction having a particular balance is one piece of information.

Knowing how the balance was produced is much more valuable.

Immutable state gives software memory.

And memory changes what systems are capable of explaining.


The Design Principle

The deeper principle is not really “use event sourcing.”

It is this:

Do not destroy information unless you are certain the information has no future value.

Databases have historically been very good at storing current state.

Modern systems increasingly need to explain how that state came to exist.

That requires preserving transitions.

It means treating certain facts as immutable.

It means separating history from projection.

It means making illegal transitions difficult or impossible.

It means using versioning when multiple actors can modify state.

It means designing APIs around intent rather than arbitrary mutation.

And it means recognizing that the database is not merely where your application puts objects.

It is where your system remembers what happened.


Conclusion

Mutable state is convenient.

Immutable state is explanatory.

Mutable databases tell us:

What is true?
Enter fullscreen mode Exit fullscreen mode

Immutable systems can tell us:

What is true?
Why is it true?
When did it become true?
What was true before?
Who caused the transition?
What sequence produced the current state?
Enter fullscreen mode Exit fullscreen mode

That is a much more powerful model.

But it should not become dogma.

You don't need to turn every CRUD application into an event-sourced architecture.

You don't need an event log for every button click.

You don't need to preserve every temporary value forever.

The goal is not maximum immutability.

The goal is meaningful immutability.

When the history of a state matters, preserve it.

When transitions matter, model them explicitly.

When correctness depends on ordering, represent that ordering.

When multiple services can modify the same state, give the database enough information to detect stale writes.

And when a system must explain itself, don't make the database remember only the final answer.

Make it remember the journey.

Because the most interesting thing about state is rarely the state itself.

It is the transition that created it.

And once you start designing databases around immutable state, you stop thinking of your database as a box of records.

You start thinking of it as a memory of the system.

A timeline.

A source of truth.

A history of decisions.

A machine that doesn't merely tell you where the software is standing today, but how it got there.

That is when database design starts becoming architecture.

Top comments (0)