Most articles about database migrations assume you know the final model before you start. In real products, you often don't. You ship a design, it goes to production, and then a customer conversation tells you the design was wrong.
In 2026, I designed and built a speaker roles feature for an event platform, end to end: database schema, GraphQL API, and changes to both the organizer dashboard and the attendee-facing event app. I shipped it as a one-to-one relationship. After it was live, the requirement changed to many-to-many. This is how I evolved the data model twice without breaking a single existing event, and what I'd ask earlier next time.
The problem: a custom field doing a feature's job
Organizers wanted to label what each speaker does in a session: moderator, host, interviewer. The platform had no concept for this, so organizers worked around it with a generic custom field on the speaker profile. It was free text, it was manual, and nothing in the product understood it.
The goal was to replace that workaround with a real feature: organizers define roles per event and assign them to speakers.
The constraint was the hard part. Existing events were already live, with speakers, integrations, and API clients built on the old data model. None of them could break while the new feature rolled out incrementally.
Version 1: one role per speaker, zero breaking changes
The first design was simple. I added a speaker_role table, scoped to an event, and linked each speaker to one role through a foreign key.
A foreign key creates an obvious problem: every existing speaker now needs a valid role, and so does every speaker created by a client that has never heard of roles. Three decisions solved it:
- Every event gets a default role, automatically. When an event is created, the backend creates its default role in the same flow. No event can exist without one.
- Existing speakers were migrated to their event's default role. A migration script assigned them before anything depended on the new relationship.
roleIdis optional in the API. If a client doesn't send it, the backend assigns the event's default role.
The third one is what kept existing integrations working. In the GraphQL schema, it looks like this:
type SpeakerRole {
id: ID!
name: String!
isDefault: Boolean!
}
input CreateSpeakerInput {
eventId: ID!
name: String!
# Optional on purpose: older clients don't know roles exist
roleId: ID
}
And in the resolver:
const roleId =
input.roleId ?? (await speakerRoles.getDefaultRoleId(input.eventId));
One line, but it carries the whole compatibility strategy. Clients that know about roles send one. Clients that don't keep working, and their speakers still end up in a valid state. Nobody had to coordinate a release with us.
The data question: migrate the old field or not?
During implementation, a product question came up. Organizers already had speaker types in that custom field. Should the event app keep showing it, or should the new roles replace it? Showing both would display the same information twice.
The tempting answer was to migrate the custom field into the new roles. Before proposing that, I checked production data. Only a small minority of speakers had the custom field filled in. And because it was free text, the values varied too much to map reliably to roles. An automatic migration would have produced wrong roles for real events.
So instead of migrating, I proposed a toggle. Each organizer chooses whether the event app displays the old custom field or the new speaker roles. Organizers who depended on the old field lost nothing. Everyone else got the new feature.
The lesson: a migration is only as good as the data you migrate. When the source data is unreliable, giving users a choice can be safer than guessing for them.
Version 2: the requirement changed after launch
The backend was fully in production when a meeting with the integrations team surfaced something new: a customer needed speakers to hold multiple roles in the same session. A speaker could be both host and interviewer in the same session.
The one-to-one design couldn't express that. A speaker pointed to exactly one role.
The quick fix would have been to duplicate the speaker, one row per role. That didn't work either. The speaker table had a unique index, and other features relied on a speaker existing once. Breaking that assumption would have spread the change far beyond roles.
The honest conclusion was that the model needed a redesign: a pivot table between speakers and roles, a many-to-many relationship. The real question was how to get there while Version 1 was live and in use.

Migrating to many-to-many without changing the API
I didn't replace the old relationship. I added the new one next to it and moved traffic over in steps:
- Add the pivot table.
speaker_role_assignmentlinks a speaker to a role. The old foreign key stays in place. - Start writing to the new model. The organizer dashboard populates the pivot table on every role change.
- Backfill existing data. A migration script copies every existing speaker-role link into the pivot table.
- Read new first, fall back to old. A dataloader resolves roles from the pivot table and falls back to the old relationship when no assignment exists yet.
- Switch the event app. It reads the new relationship, and the API response shape stays the same.

The old foreign key stays in Version 2, so reads can fall back to it for any speaker not yet in the pivot table.
Because of that, the backfill didn't need to finish before deploying, and no event ever had a window where its speakers lost their roles.
What I ask before designing a relationship now
Looking back, Version 1 wasn't a mistake. It solved the problem as it was described at the time. But a few questions, asked earlier, would have saved a redesign:
- "Can this ever be more than one?" Ask it about every one-to-one relationship, and ask the people closest to customers, not only product. The integrations team knew before anyone else.
- What happens to clients that don't know this feature exists? Make new inputs optional and give them a safe default.
- Can every existing record satisfy the new constraint? If not, create the defaults and migrate before you enforce it.
- Is the data I'm about to migrate trustworthy? Check production before promising a migration.
- Can old and new models coexist? If reads fall back gracefully, the backfill stops being a deadline.
Conclusion
The hardest part of this feature wasn't the schema or the GraphQL types. It was that production never pauses while you change your mind. Existing events, old integrations, and real organizers were using Version 1 the whole time I built Version 2.
What made it work was treating every change as a transition instead of a replacement: defaults for clients that don't know about roles, a toggle instead of an unreliable migration, and a dataloader that could read both models at once.
The question I ask now isn't only "Does the new feature work?" It's "Can the system move safely from where it is to where it needs to be, even if where it needs to be changes halfway through?"
