Skip to Content
Course content

155: Event Sourcing for Audit Trails in Scala

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of time reviewing architectures for financial systems, and there is one mistake I see almost every single time someone mentions "audit trails." They tell me, "We've got it covered; we just have an audit_logs table where we insert a row every time a user updates a record."

Audit Tables are not Event Stores

Here is why that approach is fundamentally flawed: it treats the audit trail as a side effect. In a traditional CRUD application, your "Source of Truth" is the current state (the accounts table). The audit log is just a diary of what happened to that state. The problem is that the state and the log can diverge. If a bug in your service updates the balance but fails to write to the audit table, or if a DBA manually tweaks a value in the database to fix a production issue, your audit trail is now a lie.

When we talk about Event Sourcing, we aren't talking about logging what happened. We are talking about making the sequence of events the only source of truth. You don't update a row in a table; you append a fact to a log. If you want to know the current state, you calculate it by replaying those facts.

The Log as the Single Source of Truth

Let's look at this through the lens of a bank account. In a CRUD world, you have a balance column. In an Event Sourced world, you have a stream of events. I prefer this because it's mathematically honest—you don't actually "have" a balance; you have a history of transactions that results in a balance.

case class AccountState(balance: BigDecimal, accountStatus: String)

sealed trait AccountEvent
case class AccountOpened(accountId: String, owner: String) extends AccountEvent
case class MoneyDeposited(amount: BigDecimal) extends AccountEvent
case class MoneyWithdrawn(amount: BigDecimal) extends AccountEvent
case class AccountClosed(reason: String) extends AccountEvent

The magic happens in the "evolve" or "apply" function. This is a pure function that takes the current state and an event, and returns a new state. It's essentially a foldLeft over your event stream.

Implementing the State Fold in Scala

Since we've already covered functional patterns in previous lessons, you'll recognize this as a classic reducer pattern. I usually implement this inside the state object as a method to keep the logic encapsulated.

case class AccountState(balance: BigDecimal = 0, accountStatus: String = "Unknown") {
  def applyEvent(event: AccountEvent): AccountState = event match {
    case AccountOpened(_, _)            => copy(accountStatus = "Open")
    case MoneyDeposited(amount)         => copy(balance = balance + amount)
    case MoneyWithdrawn(amount)         => copy(balance = balance - amount)
    case AccountClosed(_)               => copy(accountStatus = "Closed")
  }
}

// To reconstruct the state:
val history = List(
  AccountOpened("ACC-123", "Alice"),
  MoneyDeposited(100.00),
  MoneyWithdrawn(20.00),
  MoneyDeposited(50.00)
)

val finalState = history.foldLeft(AccountState()) { (state, event) => 
  state.applyEvent(event) 
}
// finalState is AccountState(130.0, "Open")

I love this approach because it gives you "time travel" for free. If a client claims their balance was wrong last Tuesday, you don't have to guess based on a vague audit log. You simply replay the events up until last Tuesday and see exactly what the state was. It is the ultimate audit trail because the trail is the data.

Handling the Performance Hit with Snapshots

Now, you're probably thinking: "I can't replay ten thousand events every time a user refreshes their page." You're right; that would be insane. This is where snapshots come in.

A snapshot is simply a cached version of the state at event N. When you need the current state, you load the latest snapshot (say, at event 500) and only replay the events that occurred from 501 onwards. I usually recommend snapshotting every 100 or 1,000 events depending on the event volume. It turns an O(N) operation into an O(1) (or close to it) without sacrificing the integrity of the event log.




📋 Practical Task

Building a Version-Controlled Document Revision System

You are tasked with building a simplified version of a collaborative text editor. Instead of storing the current text of a document, you must implement an Event Sourced system that reconstructs the document from a series of edits.

Requirements:

  • Define a sealed trait DocEvent with three types of events:
    • TextAdded(position: Int, text: String)
    • TextDeleted(position: Int, length: Int)
    • TitleChanged(newTitle: String)
  • Create a DocumentState case class that holds the current title: String and the content: String.
  • Implement a method applyEvent(event: DocEvent): DocumentState inside DocumentState.
    • Hint: Use StringBuilder or string slicing to handle the position and length for text edits.
  • Write a test case that takes a List[DocEvent] and uses foldLeft to produce the final DocumentState.

Verification: If you start with an empty document and apply: TitleChanged("Notes"), TextAdded(0, "Hello "), TextAdded(6, "World"), TextDeleted(0, 1), your final state should have the title "Notes" and the content "ello World".

Rating
0 0

There are no comments for now.

to be the first to leave a comment.