Nobody Moved the Data
The extracted Customers service ships one EF migration that creates three empty tables in a brand-new database. Nothing dual-writes, nothing backfills, nothing reconciles - and the only path that repopulates the service is events that have not happened yet.
Every talk about extracting a service spends forty minutes on code and four on data, and the ratio is backwards. Moving the handlers is the part you can do on a Tuesday. Moving the rows — while both systems are live, without losing writes, with a way back if it goes wrong — is the part that takes a quarter and gets people paged.
Part 10 was about the switch that was never fitted. This part is about the rows, and it is short in the way the subject is short.
The whole migration
src/Services/Customers/Inflow.Services.Customers.Core/DAL/Migrations/20211229214939_Customers_Init.cs, in full outline:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Customers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
// ... FullName, Address, Nationality, Identity, Notes, IsActive,
// CreatedAt, CompletedAt, VerifiedAt
},
constraints: table => { table.PrimaryKey("PK_Customers", x => x.Id); });
migrationBuilder.CreateTable(name: "Inbox", /* Id, Name, ReceivedAt, ProcessedAt */ );
migrationBuilder.CreateTable(name: "Outbox", /* Id, CorrelationId, UserId, Name, Type, Data, TraceId, CreatedAt, SentAt */ );
migrationBuilder.CreateIndex("IX_Customers_Email", "Customers", "Email", unique: true);
migrationBuilder.CreateIndex("IX_Customers_Name", "Customers", "Name", unique: true);
}
Three CreateTable calls and two unique indexes. No InsertData, no Sql(...), no seeding, no copy. Down drops the three tables. That is the entire data story of the extraction, and it is worth saying plainly: the migration is correct, and it creates a schema with nothing in it.
(A small forensic note, since Part 2 made a thing of provenance: the transition commit's original migration was 20211031055036_Customers_Init. The branch's .NET 6 port regenerated it as 20211229214939_Customers_Init. Same three tables; the timestamp is just when EF was last run.)
A new database, not a new schema
The module and the service do not merely have different tables. They have different databases, and the difference is one line in each DbContext.
// Inflow.Modules.Customers.Core — schema-per-module inside one database
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("customers");
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
// Inflow.Services.Customers.Core — no default schema at all
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
The Bootstrapper's connection string points at Database=inflow, where every module lives in its own PostgreSQL schema. The service's points at Database=customers-service, where its three tables sit in public. So the extraction is also the moment the estate stops being schema-per-module and starts being database-per-service — a genuinely important architectural step, executed by deleting one method call and changing one connection string.
Both hosts run DbContextAppInitializer, which calls MigrateAsync on every discovered DbContext at startup. Start the service against an empty PostgreSQL and it creates its own database and applies the migration. Start it against a production one and it does the same thing: creates the tables, and stops.
Nothing copies anything
I went looking for the other half. There isn't one. There is no seed method, no InsertData, no raw Sql block, no IInitializer implementation for the service, no import script anywhere in src/Services/, no .sql file on the branch, no dual-write in the module (which is switched off and therefore writes nothing), no shadow-read, no reconciliation job, and no comment acknowledging any of it.
Follow the cutover as the README describes it — docker-compose up, run the gateway, run the Bootstrapper, run the service. If that Bootstrapper had been running with the Customers module enabled and had real rows in inflow.customers.Customers, then at the instant you flip "enabled": false and start the service:
- The gateway routes
customers-service/*to a service whoseCustomerstable is empty. GET customers-service/customersreturns an empty page. Not an error — a successful, empty, entirely plausible result.GET customers-service/customers/{id}returns 404 for every customer that exists.- Payments and Wallets keep their own copies of the customer data they cached from earlier events, so half the estate still believes in customers the customer service has never heard of.
The rows are not lost — they are still sitting in the customers schema of the inflow database, in a module that no longer loads. They are simply unreachable, and nothing in the system says so.
The only repopulation path points forwards
There is a mechanism that fills the service's table, and it is the most interesting thing here, because it is nearly the right answer.
The service consumes two external events from the monolith. SignedUpHandler does this:
public async Task HandleAsync(SignedUp @event, CancellationToken cancellationToken = default)
{
if (@event.Role is not ValidRole)
{
return;
}
var customer = new Customer(@event.UserId, @event.Email, _clock.CurrentDate());
await _customerRepository.AddAsync(customer);
_logger.LogInformation($"Created a new customer based on user with ID: '{@event.UserId}'.");
await _messageBroker.PublishAsync(new CustomerCreated(customer.Id), cancellationToken);
}
Every new user registration in the monolith's Users module creates a customer in the service. That is a correct, event-driven repopulation path — for the future. It replays nothing. If the Users module's own events were durable and replayable, pointing the service's queue at the start of the stream would rebuild the table exactly; nothing on this branch is durable in that sense, because Part 4's broker has no persisted log and the outbox is switched off.
And the second handler shows what “empty table” costs downstream. UserStateUpdatedHandler locks or unlocks a customer when a user's state changes:
var customer = await _customerRepository.GetAsync(@event.UserId);
if (customer is null)
{
return;
}
A silent return. Lock a pre-existing user in the monolith after cutover and the customer service does nothing at all — no exception, no warning log, no dead letter. The guard is correct defensive code and, in this topology, it is also the mechanism by which a data gap becomes permanently invisible. Every row that was not migrated turns into a no-op on the path that would have told you.
Two tables named Customers, two unique indexes, one truth
The uniqueness constraints make the split concrete. Both copies declare IX_Customers_Email and IX_Customers_Name as unique. Before extraction there was one index, and “email addresses are unique across customers” was a fact the database enforced. After, there are two indexes over two disjoint row sets in two databases, and the invariant is no longer enforced anywhere — the module's copy governs rows nobody reads, the service's copy governs rows created after the cutover, and nothing compares them.
The Inbox and Outbox tables are the same story in miniature. The service's AddCore calls AddOutbox() and AddOutbox<CustomersDbContext>(), its DbContext declares both DbSets, and the migration creates both tables. But the service's appsettings.json has no outbox section at all, so the options bind to a default with Enabled == false, and AddOutbox returns before registering the decorator or the processors. Two tables, created on every deploy, written to by nothing.
What a cutover actually needs
For contrast, the shortest honest checklist I would want before flipping that boolean on a system with real customers:
- Backfill — copy the existing rows, with the new schema's constraints applied, and record how many.
- Dual write or change capture — while the backfill runs, every write to the old store reaches the new one, or you accept a freeze window and say so.
- Shadow reads — the new service answers alongside the old module, and a comparator logs disagreements, for as long as it takes to stop finding any.
- Reconciliation — a scheduled count-and-checksum between the two stores that alarms on drift, kept running through the cutover and for a while after.
- A rollback that includes data — Part 8 praised copy-and-disable for making the code rollback a config flip. That is only true while the new store has taken no writes. The moment it has, flipping back loses them, and nothing on this branch would tell you.
In fairness
None of this belongs in a course branch, and I do not think its absence is an oversight so much as a scope decision. Inflow is teaching the shape of an extraction — the seam, the transport, the topology — and it does that unusually well. The data migration is a different subject, it is mostly not C#, and demonstrating it properly would need a populated database, a comparator and a load generator, none of which a sample repository ships.
But the branch is named microservices, the README calls it “the sample module to microservice transition”, and this series has been reading it as the document it presents itself as. On the evidence in the repository, the transition is a code transition. The data does not move, and the system does not notice. That gap is not a defect in the code; it is the thing the code does not talk about, and it is the part that in my experience decides whether an extraction is a Tuesday or a quarter.
Next, the retrospective: the seam held, the envelope did not.