devto 2026-10-25 원문 보기 ↗
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.
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.
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:
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.
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.
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.
I didn't replace the old relationship. I added the new one next to it and moved traffic over in steps:
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.
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:
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?"