Exercise 07 · IncomeChanged v1 → v2 and upcasting

Event schema evolution

  • event versioning
  • upcaster
  • backward compatibility

Principle

Events in the event store are not rewritten, but the language evolves. IncomeChanged v1 carried annual income (`income`), v2 carries monthly income (`monthlyIncome`).

New code must read old events. Solution: an upcaster converts v1 to v2 on load; the stored history stays unchanged.

What to watch

  • Income state after replay: NaN vs. 100,000.
  • The event detail shows schemaVersion 1.

Without the solution / With the solution

Without the solution

Run without upcaster

An IncomeChanged v1 event arrives in the stream (annual income 1.2 M). The new code expects the `monthlyIncome` field.

Run in the simulator without the solution

With the solution

Run with upcaster

The upcaster converts annual income to monthly (1,200,000 / 12). The stored event remained in v1.

Run in the simulator with the solution

What to do: Click “Run without upcaster”. What to look at: Income in the old shape turns into a meaningless value (NaN). The simulator needs JavaScript and runs as a separate application.

Try it in your own project

Copy the prompt into your coding agent (Claude Code, Copilot, Codex…) and run it in your project's repository.

Change the shape of an existing event without rewriting stored data and without breaking replay.

Language
Variant
# Task for a coding agent: Event schema evolution

Stack: Java. Mode: guided.
Goal: Change the shape of an existing event without rewriting stored data and without breaking replay.

## Context of the existing code

You are working in an existing project (Java backend (typically Java 21, Spring Boot, JPA or JDBC). Use the build tool and style the project already has (Maven/Gradle, JUnit 5).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has an event store and projections. If not, first create a minimal event store (append-only events, stream loading via `apply`).

Read first, change nothing. Find and briefly summarise:
- Event definitions and their serialisation.
- Where events are read from the event store (stream, snapshot, projection feed).
- Whether events carry a schema version.
- Which of these parts already exist in the project and which are missing. Before you write your own infrastructure (broker client, outbox, retry), check whether the project or organisation already has it as a ready-made library, and use it.

## Expected behaviour

Stored events are not rewritten, but the language evolves: `IncomeChanged` v1 carried annual income, v2 monthly. Without an upcast, replay yields income `NaN`; with an upcast, 100,000; the stored event stays in v1. The stream and projections both read through the same upcast, which rejects a version newer than the code knows. Default change: `LoanTermEntered { termYears }` → `{ termMonths }` (different unit, lossless).

## Specific task

Technical specifics (Java): Upcaster as a separate class in deserialisation (Jackson `JsonNode` transformation).

Proceed step by step. After point 1 (plan), write what you found and what you propose, and wait for confirmation:
1. Plan: describe the v1 → v2 change, whether it is semantic (units) or merely structural, and whether it is lossless (rounding changes stored facts).
2. Add `schemaVersion` to stored events if it is missing (default 1 for old data). Bump it only for the changed type.
3. Implement a `v1 -> v2` upcaster in the read path before `apply`.
4. Test: a fixture with a stream written by the old code replays to the correct state; a new event is written in v2.
5. Verify that other readers (projections) already receive the upcast form.

Watch out for:
- You can't “fix” history retroactively by changing apply: you need an event version.
- The upcaster must be deterministic and free of I/O.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- A fixture with an old event replays to the expected state.
- No script rewrites stored events.
- New events of the changed type are written as v2.
- Projections receive only v2; an unknown newer version ends in an error.
- JUnit 5 tests in the existing `src/test/java` structure pass with the project's build tool.
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Event schema evolution

Stack: Java. Mode: challenge.
Goal: Change the shape of an existing event without rewriting stored data and without breaking replay.

## Context of the existing code

You are working in an existing project (Java backend (typically Java 21, Spring Boot, JPA or JDBC). Use the build tool and style the project already has (Maven/Gradle, JUnit 5).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has an event store and projections. If not, first create a minimal event store (append-only events, stream loading via `apply`).

Read first, change nothing. Find and briefly summarise:
- Event definitions and their serialisation.
- Where events are read from the event store (stream, snapshot, projection feed).
- Whether events carry a schema version.
- Which of these parts already exist in the project and which are missing. Before you write your own infrastructure (broker client, outbox, retry), check whether the project or organisation already has it as a ready-made library, and use it.

## Expected behaviour

Stored events are not rewritten, but the language evolves: `IncomeChanged` v1 carried annual income, v2 monthly. Without an upcast, replay yields income `NaN`; with an upcast, 100,000; the stored event stays in v1. The stream and projections both read through the same upcast, which rejects a version newer than the code knows. Default change: `LoanTermEntered { termYears }` → `{ termMonths }` (different unit, lossless).

## Brief (Challenge)

Change the meaning of the IncomeChanged field (annual → monthly) so that the old history replays correctly and nothing is rewritten in the database.

Technical specifics (Java): Upcaster as a separate class in deserialisation (Jackson `JsonNode` transformation).

Choose your own approach. The pitfalls in this topic are not listed: find them (concurrency, redelivery, ordering, failure midway) and for each write a test that would expose it. At the end, list which pitfalls you found and which you deliberately left open.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- A fixture with an old event replays to the expected state.
- No script rewrites stored events.
- New events of the changed type are written as v2.
- Projections receive only v2; an unknown newer version ends in an error.
- JUnit 5 tests in the existing `src/test/java` structure pass with the project's build tool.
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Event schema evolution

Stack: C#. Mode: guided.
Goal: Change the shape of an existing event without rewriting stored data and without breaking replay.

## Context of the existing code

You are working in an existing project (C# backend (.NET, ASP.NET Core, EF Core or Dapper). Use the solution, style and DI conventions the project already has (xUnit/NUnit).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has an event store and projections. If not, first create a minimal event store (append-only events, stream loading via `apply`).

Read first, change nothing. Find and briefly summarise:
- Event definitions and their serialisation.
- Where events are read from the event store (stream, snapshot, projection feed).
- Whether events carry a schema version.
- Which of these parts already exist in the project and which are missing. Before you write your own infrastructure (broker client, outbox, retry), check whether the project or organisation already has it as a ready-made library, and use it.

## Expected behaviour

Stored events are not rewritten, but the language evolves: `IncomeChanged` v1 carried annual income, v2 monthly. Without an upcast, replay yields income `NaN`; with an upcast, 100,000; the stored event stays in v1. The stream and projections both read through the same upcast, which rejects a version newer than the code knows. Default change: `LoanTermEntered { termYears }` → `{ termMonths }` (different unit, lossless).

## Specific task

Technical specifics (C#): Upcaster as a service in DI, transformation via `JsonNode` before deserialisation.

Proceed step by step. After point 1 (plan), write what you found and what you propose, and wait for confirmation:
1. Plan: describe the v1 → v2 change, whether it is semantic (units) or merely structural, and whether it is lossless (rounding changes stored facts).
2. Add `schemaVersion` to stored events if it is missing (default 1 for old data). Bump it only for the changed type.
3. Implement a `v1 -> v2` upcaster in the read path before `apply`.
4. Test: a fixture with a stream written by the old code replays to the correct state; a new event is written in v2.
5. Verify that other readers (projections) already receive the upcast form.

Watch out for:
- You can't “fix” history retroactively by changing apply: you need an event version.
- The upcaster must be deterministic and free of I/O.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- A fixture with an old event replays to the expected state.
- No script rewrites stored events.
- New events of the changed type are written as v2.
- Projections receive only v2; an unknown newer version ends in an error.
- Tests in the existing test project pass (`dotnet test`).
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Event schema evolution

Stack: C#. Mode: challenge.
Goal: Change the shape of an existing event without rewriting stored data and without breaking replay.

## Context of the existing code

You are working in an existing project (C# backend (.NET, ASP.NET Core, EF Core or Dapper). Use the solution, style and DI conventions the project already has (xUnit/NUnit).).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has an event store and projections. If not, first create a minimal event store (append-only events, stream loading via `apply`).

Read first, change nothing. Find and briefly summarise:
- Event definitions and their serialisation.
- Where events are read from the event store (stream, snapshot, projection feed).
- Whether events carry a schema version.
- Which of these parts already exist in the project and which are missing. Before you write your own infrastructure (broker client, outbox, retry), check whether the project or organisation already has it as a ready-made library, and use it.

## Expected behaviour

Stored events are not rewritten, but the language evolves: `IncomeChanged` v1 carried annual income, v2 monthly. Without an upcast, replay yields income `NaN`; with an upcast, 100,000; the stored event stays in v1. The stream and projections both read through the same upcast, which rejects a version newer than the code knows. Default change: `LoanTermEntered { termYears }` → `{ termMonths }` (different unit, lossless).

## Brief (Challenge)

Change the meaning of the IncomeChanged field (annual → monthly) so that the old history replays correctly and nothing is rewritten in the database.

Technical specifics (C#): Upcaster as a service in DI, transformation via `JsonNode` before deserialisation.

Choose your own approach. The pitfalls in this topic are not listed: find them (concurrency, redelivery, ordering, failure midway) and for each write a test that would expose it. At the end, list which pitfalls you found and which you deliberately left open.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- A fixture with an old event replays to the expected state.
- No script rewrites stored events.
- New events of the changed type are written as v2.
- Projections receive only v2; an unknown newer version ends in an error.
- Tests in the existing test project pass (`dotnet test`).
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Event schema evolution

Stack: Web (HTML/JS or the app's stack). Mode: guided.
Goal: Change the shape of an existing event without rewriting stored data and without breaking replay.

## Context of the existing code

You are working in an existing project (web application: a browser frontend, possibly with its own backend. If the project has a backend, the event store, broker and services belong on the backend, and the points about endpoints and tables refer to it. If it has no backend, use IndexedDB in the browser and `fake-indexeddb` in tests. Keep the project's framework and test runner; add new dependencies only with a justification.).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has an event store and projections. If not, first create a minimal event store (append-only events, stream loading via `apply`).

Read first, change nothing. Find and briefly summarise:
- Event definitions and their serialisation.
- Where events are read from the event store (stream, snapshot, projection feed).
- Whether events carry a schema version.
- Which of these parts already exist in the project and which are missing. Before you write your own infrastructure (broker client, outbox, retry), check whether the project or organisation already has it as a ready-made library, and use it.

## Expected behaviour

Stored events are not rewritten, but the language evolves: `IncomeChanged` v1 carried annual income, v2 monthly. Without an upcast, replay yields income `NaN`; with an upcast, 100,000; the stored event stays in v1. The stream and projections both read through the same upcast, which rejects a version newer than the code knows. Default change: `LoanTermEntered { termYears }` → `{ termMonths }` (different unit, lossless).

## Specific task

Technical specifics (Web (HTML/JS or the app's stack)): A pure function `upcast(event)` in all read paths: stream loading (including from a snapshot) before `apply`, and the projection feed.

Proceed step by step. After point 1 (plan), write what you found and what you propose, and wait for confirmation:
1. Plan: describe the v1 → v2 change, whether it is semantic (units) or merely structural, and whether it is lossless (rounding changes stored facts).
2. Add `schemaVersion` to stored events if it is missing (default 1 for old data). Bump it only for the changed type.
3. Implement a `v1 -> v2` upcaster in the read path before `apply`.
4. Test: a fixture with a stream written by the old code replays to the correct state; a new event is written in v2.
5. Verify that other readers (projections) already receive the upcast form.

Watch out for:
- You can't “fix” history retroactively by changing apply: you need an event version.
- The upcaster must be deterministic and free of I/O.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- A fixture with an old event replays to the expected state.
- No script rewrites stored events.
- New events of the changed type are written as v2.
- Projections receive only v2; an unknown newer version ends in an error.
- Tests pass with the existing test runner; lint and build pass if the project has them.
- In the summary, state: what you changed, what you assumed, what remains.
# Task for a coding agent: Event schema evolution

Stack: Web (HTML/JS or the app's stack). Mode: challenge.
Goal: Change the shape of an existing event without rewriting stored data and without breaking replay.

## Context of the existing code

You are working in an existing project (web application: a browser frontend, possibly with its own backend. If the project has a backend, the event store, broker and services belong on the backend, and the points about endpoints and tables refer to it. If it has no backend, use IndexedDB in the browser and `fake-indexeddb` in tests. Keep the project's framework and test runner; add new dependencies only with a justification.).
Domain: a mortgage calculator that is gradually turning into an event-driven system (calculator, risk, approval, identity, account, documents, payout).
Prerequisite: the project has an event store and projections. If not, first create a minimal event store (append-only events, stream loading via `apply`).

Read first, change nothing. Find and briefly summarise:
- Event definitions and their serialisation.
- Where events are read from the event store (stream, snapshot, projection feed).
- Whether events carry a schema version.
- Which of these parts already exist in the project and which are missing. Before you write your own infrastructure (broker client, outbox, retry), check whether the project or organisation already has it as a ready-made library, and use it.

## Expected behaviour

Stored events are not rewritten, but the language evolves: `IncomeChanged` v1 carried annual income, v2 monthly. Without an upcast, replay yields income `NaN`; with an upcast, 100,000; the stored event stays in v1. The stream and projections both read through the same upcast, which rejects a version newer than the code knows. Default change: `LoanTermEntered { termYears }` → `{ termMonths }` (different unit, lossless).

## Brief (Challenge)

Change the meaning of the IncomeChanged field (annual → monthly) so that the old history replays correctly and nothing is rewritten in the database.

Technical specifics (Web (HTML/JS or the app's stack)): A pure function `upcast(event)` in all read paths: stream loading (including from a snapshot) before `apply`, and the projection feed.

Choose your own approach. The pitfalls in this topic are not listed: find them (concurrency, redelivery, ordering, failure midway) and for each write a test that would expose it. At the end, list which pitfalls you found and which you deliberately left open.

## What you must not change

- The existing public API and the behaviour of the calculator endpoints that currently work.
- The database schema of existing tables (new tables/migrations are fine; renaming and deleting are not).
- Other people's tests, seed data and existing values. If a change is necessary, put it in a separate commit and state it. If a change to an event's shape or a new consumer necessarily changes an existing test, update it in a separate commit and explain why in the summary.
- Public contracts (APIs, event schemas) without backward compatibility.

## Done when

- A fixture with an old event replays to the expected state.
- No script rewrites stored events.
- New events of the changed type are written as v2.
- Projections receive only v2; an unknown newer version ends in an error.
- Tests pass with the existing test runner; lint and build pass if the project has them.
- In the summary, state: what you changed, what you assumed, what remains.

Training for your team

Would you like this training for your team?

We run the training on your code and your domain problem. We agree the scope, dates and format with you.

Discuss the training