Skip to main content

Event Migrations

Why Schema Evolution Matters​

In event sourcing, events are immutable — once written to the journal, they stay forever. But your domain evolves: you add new fields, rename concepts, split events into finer-grained variants. Without a migration strategy, you end up with a journal full of outdated formats that your current code can't deserialize.

The fundamental tension: Events must be immutable for correctness, but your domain model must be free to evolve. Edomata resolves this by letting you rewrite the journal in a controlled, tracked, and compile-time-verified way.

The Problem​

Imagine you have an event PriceUpdated(price: Long) stored in your journal. You now need to add a currency field. What happens?

  • Option A: Add Option[String] default — Works, but pollutes your domain model with optionality that only exists for historical reasons. Every handler must deal with None.
  • Option B: Keep both old and new event types — Your transition function grows with every schema change. After a few iterations, you have PriceUpdatedV1, PriceUpdatedV2, PriceUpdatedV3...
  • Option C: Migrate the journal — Transform old events to the new format in the database. Your code only ever sees the latest format. ✓

Edomata provides Option C with automatic tracking, compile-time safety, and Flyway-like idempotency.

Defining Migrations​

A migration transforms event payloads from one format to another. Use the typed factory to get compile-time exhaustivity checking:

import edomata.backend.EventMigration

// Old event type (kept as a sealed enum for compile-time safety)
enum EventV1:
case Created(name: String)
case PriceUpdated(price: Long)

// New event type
enum EventV2:
case Created(name: String)
case PriceUpdated(price: Long, currency: String)

val v1ToV2 = EventMigration[EventV1, EventV2](
"001",
"Add currency to PriceUpdated"
)(
decode = io.circe.jawn.decode[EventV1](_).leftMap(_.getMessage),
transform = {
case EventV1.Created(name) => EventV2.Created(name)
case EventV1.PriceUpdated(price) => EventV2.PriceUpdated(price, "USD")
},
encode = _.asJson.noSpaces
)

Chaining Migrations​

Define multiple migrations and pass them as an ordered list. Each migration is applied independently — if "001" is already applied, only "002" runs:

val v2ToV3 = EventMigration[EventV2, EventV3](
"002",
"Add description to Created"
)(
decode = io.circe.jawn.decode[EventV2](_).leftMap(_.getMessage),
transform = {
case EventV2.Created(name) => EventV3.Created(name, description = "")
case EventV2.PriceUpdated(price, currency) => EventV3.PriceUpdated(price, currency)
},
encode = _.asJson.noSpaces
)

val allMigrations = List(v1ToV2, v2ToV3)

You can also compose two migrations into one using andThen:

val v1ToV3 = v1ToV2.andThen(v2ToV3)

Running Migrations​

Call the migration runner before building your backend. It is idempotent and safe to call on every application startup:

With Skunk​

import edomata.skunk.SkunkMigrations

for
result <- SkunkMigrations.run(naming, pool, allMigrations)
_ <- IO.println(s"Applied: ${result.applied}, Skipped: ${result.skipped}")
driver <- SkunkDriver.from(naming, pool)
// Build backend with latest event codec only
yield driver

With Doobie​

import edomata.doobie.DoobieMigrations

for
result <- DoobieMigrations.run(naming, transactor, allMigrations)
_ <- IO.println(s"Applied: ${result.applied}, Skipped: ${result.skipped}")
driver <- DoobieDriver.from(naming, transactor)
yield driver

How It Works Internally​

Application Startup
│
├── CREATE TABLE IF NOT EXISTS migrations
│ (version, description, applied_at)
│
├── SELECT applied versions from migrations table
│
├── For each pending migration (not yet applied):
│ ├── BEGIN TRANSACTION
│ ├── SELECT id, payload FROM journal
│ ├── Apply transform to each payload
│ ├── UPDATE journal SET payload = new_payload
│ ├── INSERT INTO migrations (version, description)
│ ├── TRUNCATE snapshots (invalidate cached state)
│ └── COMMIT
│
└── Return MigrationResult(applied, skipped)

Each migration runs in its own transaction. If migration "002" fails, "001" is already committed and will not re-run. The migrations table tracks exactly which migrations have been applied, just like Flyway's flyway_schema_history.

Snapshots are truncated after each migration because they contain pre-computed state derived from the old event format. The backend will lazily rebuild snapshots from the migrated journal on the next read.

Compile-Time Safety: Exhaustive Migrations​

One of the biggest risks with event migration is forgetting to handle an event variant. If your old event type has 5 cases and your migration only transforms 4, the 5th will crash at runtime — potentially corrupting your journal or losing data.

Edomata prevents this at compile time. The typed migration factory takes a transform: A => B function — a total function over type A. When A is a sealed enum (as all well-designed event types should be), Scala 3's exhaustivity checker ensures every case is handled:

enum EventV1:
case Created(name: String)
case Updated(name: String)
case Deleted // ← added later

val migration = EventMigration[EventV1, EventV2]("001", "...")(
decode = ...,
transform = {
case EventV1.Created(n) => EventV2.Created(n, email = "")
case EventV1.Updated(n) => EventV2.Updated(n, email = None)
// COMPILE ERROR: match may not be exhaustive.
// It would fail on pattern case: EventV1.Deleted
},
encode = ...
)

With sbt-typelevel (used by Edomata), this warning is promoted to a fatal compilation error. Your project will not compile until every event variant is handled.

What this means in practice​

  • Incomplete migrations are impossible to deploy — the compiler catches them before they reach production
  • Old event types must remain as sealed enums in your codebase until the migration has run in all environments (dev, staging, production)
  • Once deployed everywhere, you can safely delete the old types

Tip: Keep old event types in a dedicated migrations package to make it clear they exist only for migration purposes and can be cleaned up later.

Without the typed factory​

If you use the raw EventMigration(version, description, run) constructor directly (operating on JSON strings), you lose this compile-time guarantee. Always prefer the typed factory EventMigration[A, B](version, description)(decode, transform, encode) to benefit from exhaustivity checking.

Best Practices​

  1. Use sequential version strings — "001", "002", "003". They are identifiers, not sorted — order comes from list position.

  2. One migration per schema change — Don't bundle unrelated changes. Each migration should have a clear, single purpose.

  3. Test migrations locally first — Run against a copy of your production data before deploying.

  4. Back up your database — Before running migrations in production, always take a backup. Migrations rewrite journal payloads in-place.

  5. Keep old event types until fully deployed — The compile-time exhaustivity guarantee requires the old sealed enum to exist. Delete it only after all environments have run the migration.

  6. Use the same serialization library — If your events are stored with Circe, decode with Circe. Don't mix serialization libraries in a single migration.

Full Example​

See examples/src/main/scala/MigrationExample.scala for a complete working example showing V1 → V2 → V3 event evolution with automatic migrations.