Event Sourcing and Akka Persistence
By lesson thirteen, the Akka course has reached a more serious design boundary. Up to now, we have talked about actors, streams, clusters, and sharding mostly in terms of concurrency, workflow boundaries, and distribution. The next step is about truth over time.
That question matters because some systems are not just trying to know the current state. They also need to know how that state came to be.
If you are building a cache, a session timeout tracker, or a short-lived coordination component, the latest state may be enough. But if you are building a ledger, an audit-heavy workflow, a customer account timeline, a fraud investigation system, or a long-running business process, "what is true now" is only part of the story. You also need "what happened," in order, and in a form that can be trusted after a crash, a restart, or an investigation months later.
That is where event sourcing starts to make sense, and where Akka Persistence becomes useful.
This lesson is not about pretending event sourcing is the default answer for all stateful actors. It is about understanding when historical truth is valuable enough to justify the extra design discipline, and how Akka Persistence supports that model in production systems.
Why Event Sourcing Exists
Many backend systems are built around the idea of storing current state.
For example:
- an
accountstable stores the latest balance - an
orderstable stores the latest status - a
shipmentstable stores the latest delivery state - a
fraud_casestable stores the latest investigation result
That model is often perfectly reasonable. The current state is what the application needs most of the time, and storing it directly is simple.
But some domains care deeply about the sequence of changes:
- a payment platform must explain why an account balance changed
- a trading platform must reconstruct how an order moved from received to matched to settled
- an insurance workflow must show who approved what and when
- a compliance-heavy system must retain a defensible business history
- a customer-support tool may need a complete timeline instead of a final status snapshot
In those systems, storing only the latest row creates pressure elsewhere. Teams start adding audit tables, change logs, compensating metadata, and side channels that attempt to reconstruct the past after the fact.
Event sourcing changes the model. Instead of storing only the latest state, the system stores the events that produced that state.
For an account, that might be:
AccountOpenedFundsDepositedFundsReservedReservationReleasedFundsWithdrawn
Current state still matters, but it becomes the result of replaying those events rather than the only thing that was stored.
That is the core idea.
Event Sourcing Is About Historical Truth, Not Style
It is important to stay grounded here. Event sourcing is not valuable because it sounds more advanced than CRUD. It is valuable when the event history is itself part of the product or the operational truth.
The clearest signs that event sourcing may be a good fit are usually these:
- you need a reliable history of business decisions
- state transitions have audit or compliance significance
- the order of changes matters, not just the final value
- recovery after failure must preserve domain history accurately
- you expect other downstream models to be built from the same stream of facts
The clearest signs that it may be the wrong fit are also worth stating:
- the domain mostly needs the latest state
- event definitions would be artificial rather than meaningful
- the team does not want the operational cost of evolving event schemas carefully
- rebuilding state from history would add complexity without real business value
That last point matters more than many teams admit. Event sourcing raises the engineering bar. It forces you to model state transitions explicitly, think about versioning, and treat your event history as a durable contract rather than an implementation detail.
If the domain does not benefit from that, the complexity is hard to justify.
Commands, Events, and State Are Different Things
One of the most important mental models in Akka Persistence is the distinction between commands, events, and state.
- a command is an intent directed at the entity
- an event is a fact that the entity decided happened
- state is the current in-memory interpretation of all previous events
This separation matters because teams new to event sourcing often blur these categories.
For example, in a wallet or ledger domain:
ReserveFunds(transferId, amount)is a commandFundsReserved(transferId, amount)is an eventState(balance = 1000, reserved = 300)is current state
The command expresses a request.
The event expresses a durable fact.
The state expresses where the entity stands after applying all accepted facts.
That distinction gives you several benefits at once:
- validation logic stays at the command boundary
- persistence captures domain facts instead of mutable object dumps
- recovery becomes deterministic because state is rebuilt from the same events every time
- downstream consumers can reason about domain changes rather than hidden internal mutations
In other words, commands are about intent, events are about truth, and state is about interpretation.
A Concrete Scenario: A Merchant Settlement Account
Suppose you are building part of a payment platform. Each merchant has a settlement account. The system needs to:
- accept deposits from processed payments
- reserve funds for pending payouts
- release reservations when payouts fail
- withdraw funds when payouts complete
- reconstruct account history during disputes or reconciliation
This is a much better event-sourcing candidate than, say, a temporary request-rate counter.
Why?
Because the history matters.
If a merchant disputes a payout, operators need more than the current balance. They need to know:
- when the funds arrived
- when they were reserved
- whether a reservation was released
- whether the payout completed or was retried
- whether the current state is consistent with the recorded flow of money
An event-sourced entity is a natural fit for that kind of problem.
What Akka Persistence Gives You
Akka Persistence gives an actor a way to persist domain events and rebuild its state from those events when it restarts.
At a practical level, that means a persistent actor can:
- receive a command
- validate it against current state
- persist one or more events if the command is accepted
- update its state by applying those events
- recover the same state later by replaying the persisted history
This model fits Akka especially well because it keeps state ownership local to the entity. The actor already owns its sequential message handling. Persistence adds durable history to that ownership model.
The important shift is that the actor is no longer just a runtime object with in-memory state. It becomes the owner of a durable event log for one domain entity.
A Simplified Persistent Account Entity
Here is a realistic Akka Typed example for a merchant settlement account. The exact API details can vary by Akka version and plugin setup, but the model is the important part.
import akka.actor.typed.ActorRef
import akka.actor.typed.Behavior
import akka.persistence.typed.PersistenceId
import akka.persistence.typed.scaladsl.{Effect, EventSourcedBehavior, ReplyEffect}
object MerchantAccount {
sealed trait Command
final case class Deposit(amount: BigDecimal, replyTo: ActorRef[Confirmation]) extends Command
final case class ReservePayout(payoutId: String, amount: BigDecimal, replyTo: ActorRef[Confirmation])
extends Command
final case class CompletePayout(payoutId: String, replyTo: ActorRef[Confirmation]) extends Command
final case class ReleasePayout(payoutId: String, replyTo: ActorRef[Confirmation]) extends Command
final case class GetAccount(replyTo: ActorRef[Summary]) extends Command
sealed trait Confirmation
final case class Accepted(message: String) extends Confirmation
final case class Rejected(reason: String) extends Confirmation
final case class Summary(balance: BigDecimal, reserved: BigDecimal, openPayouts: Set[String])
sealed trait Event
final case class FundsDeposited(amount: BigDecimal) extends Event
final case class PayoutReserved(payoutId: String, amount: BigDecimal) extends Event
final case class PayoutCompleted(payoutId: String, amount: BigDecimal) extends Event
final case class PayoutReleased(payoutId: String, amount: BigDecimal) extends Event
final case class State(
balance: BigDecimal,
reserved: BigDecimal,
reservedPayouts: Map[String, BigDecimal]
) {
def available: BigDecimal = balance - reserved
def toSummary: Summary =
Summary(balance = balance, reserved = reserved, openPayouts = reservedPayouts.keySet)
}
object State {
val empty: State = State(balance = 0, reserved = 0, reservedPayouts = Map.empty)
}
def apply(accountId: String): Behavior[Command] =
EventSourcedBehavior.withEnforcedReplies[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId(s"merchant-account-$accountId"),
emptyState = State.empty,
commandHandler = commandHandler,
eventHandler = eventHandler
)
private def commandHandler(state: State, command: Command): ReplyEffect[Event, State] =
command match {
case Deposit(amount, replyTo) if amount <= 0 =>
Effect.reply(replyTo)(Rejected("Deposit amount must be positive"))
case Deposit(amount, replyTo) =>
Effect
.persist(FundsDeposited(amount))
.thenReply(replyTo)(_ => Accepted(s"Deposited $amount"))
case ReservePayout(payoutId, _, replyTo) if state.reservedPayouts.contains(payoutId) =>
Effect.reply(replyTo)(Rejected("Payout already reserved"))
case ReservePayout(_, amount, replyTo) if amount <= 0 =>
Effect.reply(replyTo)(Rejected("Reservation amount must be positive"))
case ReservePayout(payoutId, amount, replyTo) if state.available < amount =>
Effect.reply(replyTo)(Rejected("Insufficient available balance"))
case ReservePayout(payoutId, amount, replyTo) =>
Effect
.persist(PayoutReserved(payoutId, amount))
.thenReply(replyTo)(_ => Accepted(s"Reserved payout $payoutId"))
case CompletePayout(payoutId, replyTo) =>
state.reservedPayouts.get(payoutId) match {
case None =>
Effect.reply(replyTo)(Rejected("Unknown payout"))
case Some(amount) =>
Effect
.persist(PayoutCompleted(payoutId, amount))
.thenReply(replyTo)(_ => Accepted(s"Completed payout $payoutId"))
}
case ReleasePayout(payoutId, replyTo) =>
state.reservedPayouts.get(payoutId) match {
case None =>
Effect.reply(replyTo)(Rejected("Unknown payout"))
case Some(amount) =>
Effect
.persist(PayoutReleased(payoutId, amount))
.thenReply(replyTo)(_ => Accepted(s"Released payout $payoutId"))
}
case GetAccount(replyTo) =>
Effect.reply(replyTo)(state.toSummary)
}
private def eventHandler(state: State, event: Event): State =
event match {
case FundsDeposited(amount) =>
state.copy(balance = state.balance + amount)
case PayoutReserved(payoutId, amount) =>
state.copy(
reserved = state.reserved + amount,
reservedPayouts = state.reservedPayouts.updated(payoutId, amount)
)
case PayoutCompleted(payoutId, amount) =>
state.copy(
balance = state.balance - amount,
reserved = state.reserved - amount,
reservedPayouts = state.reservedPayouts - payoutId
)
case PayoutReleased(payoutId, amount) =>
state.copy(
reserved = state.reserved - amount,
reservedPayouts = state.reservedPayouts - payoutId
)
}
}
There are several important design points in this example.
First, the actor does not persist arbitrary state snapshots as its primary truth. It persists domain events.
Second, validation happens in the command handler, before persistence.
Third, state changes only through the event handler. That means recovery and live operation use the same transition logic.
That last property is one of the strongest aspects of event sourcing. If the state can be rebuilt by replaying persisted events through the same event handler, your recovery path is usually much less mysterious than a hand-written restart sequence.
Recovery Means Replaying Facts
Suppose the node crashes after a merchant has accumulated months of deposits and payout activity. When the persistent actor starts again, Akka Persistence loads the stored event stream for that persistence ID and replays it into the event handler.
That means state is reconstructed from facts such as:
FundsDeposited(500)PayoutReserved("payout-101", 200)PayoutReleased("payout-101", 200)PayoutReserved("payout-102", 150)PayoutCompleted("payout-102", 150)
After replay, the actor ends up with the same derived balance and reservation state it had before failure.
This matters because the recovery model is explicit.
You are not relying on hidden in-memory assumptions or on trying to infer what happened from partial side effects. You are reconstructing state from the durable business history.
That is exactly why event sourcing becomes attractive in audit-sensitive systems. Recovery is not just about surviving a crash. It is about restoring a defensible domain truth.
Why This Is Stronger Than "Update Row In Place"
A traditional design might keep a single balance row and update it transactionally. That can work, and in many systems it is the right answer.
But when historical detail matters, update-in-place models often create awkward gaps:
- the latest state exists, but the path to it is incomplete
- business investigations depend on separate audit plumbing
- downstream consumers need custom logic to detect what changed
- correcting bad state becomes harder because the change history is fragmented
With event sourcing, the model starts from the opposite direction. The history is primary. Current state is derived.
That does not make event sourcing universally better. It makes it better for domains where history is part of the business meaning.
Snapshots Help When Histories Get Long
One obvious question appears quickly: if recovery replays all events, what happens when an entity has a very long history?
That is where snapshots help.
A snapshot stores the derived state at a point in time so recovery can start from that snapshot and replay only the newer events after it.
This is an optimization, not a replacement for events.
The event log remains the source of truth. Snapshots just reduce recovery cost.
In Akka Persistence, snapshotting can be configured directly on the behavior:
import akka.persistence.typed.scaladsl.RetentionCriteria
def apply(accountId: String): Behavior[Command] =
EventSourcedBehavior.withEnforcedReplies[Command, Event, State](
persistenceId = PersistenceId.ofUniqueId(s"merchant-account-$accountId"),
emptyState = State.empty,
commandHandler = commandHandler,
eventHandler = eventHandler
).withRetention(
RetentionCriteria.snapshotEvery(numberOfEvents = 100, keepNSnapshots = 2)
)
The exact thresholds depend on workload and recovery expectations.
For example:
- a rarely used entity may not need snapshots often
- a hot ledger or account entity with years of activity may benefit from regular snapshotting
- a system with strict restart-time requirements may snapshot more aggressively
The key design principle is simple: snapshots improve operational performance, but they should not distort the domain model.
Events Need To Be Meaningful and Stable
Event sourcing works well only if events are designed carefully.
Bad event design creates long-term pain because persisted events are not just local implementation details. They become durable business records.
Good events are usually:
- specific to the domain
- easy to explain to another engineer or operator
- stable enough to survive version changes
- precise enough to rebuild state deterministically
Weak events are often too technical or too vague:
AccountUpdatedStateChangedProcessFinishedHandlerRan
Those names do not describe domain truth. They describe implementation movement.
By contrast, events like PayoutReserved, PayoutCompleted, and PayoutReleased explain exactly what happened. That makes them more useful for recovery, auditing, and downstream projections later.
This also means schema evolution deserves respect. Once persisted, events are part of your durable contract. Changing them casually is much riskier than renaming an internal method or refactoring a private class.
Event Sourcing Does Not Remove the Need for Good Boundaries
One common failure mode is trying to make a persistent actor do too much.
The event-sourced entity should own one clear consistency boundary.
For example:
- one persistent actor per merchant account
- one persistent actor per cart
- one persistent actor per invoice
- one persistent actor per fraud case
That is usually a better design than trying to make one giant persistent actor represent an entire subsystem.
Why?
Because the actor model and event sourcing both work best when ownership is explicit and bounded. If the entity boundary is vague, the event model becomes vague too.
You can see the relationship to earlier lessons here:
- actors provide sequential message handling
- sharding distributes many entity instances across the cluster
- persistence gives each entity a durable history
These pieces fit together well when the entity boundary is clear.
Side Effects Must Stay Deliberate
Another common mistake is mixing persistence with uncontrolled side effects.
Suppose a command leads to an event and also triggers an external email, HTTP call, or Kafka publication. You need to think carefully about ordering and failure.
Questions like these matter immediately:
- should the event be persisted before the side effect happens?
- what if persistence succeeds but the external publish fails?
- what if the actor restarts and a side effect is attempted twice?
- which actions are part of the entity decision versus downstream reactions?
The safest mental model is usually this:
- persist domain facts first
- treat side effects as reactions to those facts
- design downstream consumers for retries and idempotency
This reduces the risk of building a workflow where external systems and local persistent truth drift apart silently.
It also prepares the system for the next lesson, where persisted events become read models, projections, and integration pipelines.
What Event Sourcing Costs
This lesson would be incomplete if it only sold the benefits.
Event sourcing brings real costs:
- the event model must be designed carefully from the start
- schema evolution becomes a durable concern
- debugging often requires understanding both current state and event history
- storage grows with activity rather than only with final rows
- teams must reason clearly about replay, recovery, and idempotent side effects
That is why event sourcing should be chosen because the domain benefits from it, not because it feels architecturally sophisticated.
If the business only cares about the latest mutable status, and no one needs a durable event history, a simpler state-storage model may be the better engineering decision.
When Akka Persistence Is a Strong Fit
Akka Persistence tends to be a strong fit when all of these conditions are true:
- the domain has entity-like boundaries that already map well to actors
- state changes matter over time, not just at the latest instant
- failures must not erase the business history
- recovery should rebuild truth from durable facts
- the team is comfortable modeling events explicitly
That combination appears in systems such as:
- payment and settlement ledgers
- order and fulfillment workflows
- long-running approval processes
- fraud case lifecycles
- device twins or account entities with important timelines
It is less compelling for ephemeral coordination, caches, or low-value state that can be recomputed cheaply without preserving a business narrative.
Summary
Event sourcing changes the storage model from "save the latest mutable state" to "persist the facts that produced that state." In Akka Persistence, that model fits naturally with actors because each entity already owns its commands, state transitions, and sequential message handling.
The practical payoff is not abstraction for its own sake. It is the ability to recover accurately, explain how state evolved, and treat business history as a first-class part of the system.
That power comes with cost. Events must be meaningful, boundaries must be clear, and side effects must be designed carefully. When the domain genuinely needs historical truth, those costs are often worth paying. When it does not, simpler storage models are usually better.
In the next lesson, we will look at what happens after events are persisted: how to turn them into read models, projections, and integration pipelines that make the write-side history useful to the rest of the platform.
Comments
Be the first to comment on this lesson!