Schema changes and code changes are different PRs. Always.
Any change that involves modifying a data schema alongside application code. This applies to database columns, localStorage shapes, API response formats, config file structures — anywhere you're changing the shape of persisted or transmitted data.
The key insight is that schema changes and code changes have fundamentally different risk profiles and rollback characteristics. A schema change that breaks on deploy is much harder to undo than a code change. If you ship both in the same PR and the deploy fails, rolling back the code is a git revert; rolling back the schema might mean a multi-step migration in the opposite direction under production pressure.
Separating them lets you validate each independently:
Use this whenever you catch yourself writing a PR that touches both a type definition (or database schema) and the logic that uses it in a non-trivial way.
Each PR is independently deployable and independently revertable. If PR 4 has a bug, roll it back — you're back to reading from the old field, which still exists and is still being written.
How this looks in your git history:
The team needs to switch from storing user addresses as a single address text field to a structured addressData object with street, city, state, zip, and country fields. The app uses localStorage for persistence — a clean, self-contained example with no database migrations to coordinate.
The goal: migrate without losing any existing user data and without breaking the app for users who have data in the old format. At any point during the rollout, a user might have the old field only, both fields, or only the new field — the app must handle all three states correctly.
Extend the UserProfile type to include the new addressData field alongside the existing address text field. Define the StructuredAddress interface.
The new field is optional (?), which means TypeScript won't complain about existing localStorage data that doesn't have it. No code reads or writes the new field yet — this PR is purely additive to the type definition.
A reviewer can verify this PR in 30 seconds: the type grows, nothing else changes, nothing can break.
| line number | line content |
|---|
Add a migrateAddressData() function that reads the existing address text, parses it into a StructuredAddress, and writes addressData back to the profile if it's missing.
The parser handles the most common US address format: "123 Main St, Springfield, IL 62701, USA". It's deliberately simple — this is a one-time migration, not a production address parser. Any addresses that don't parse cleanly keep the text field and can be fixed by the user on their next profile edit.
Run migrateAddressData() in the app's initialization hook, before any components mount. After this PR ships, every active user's profile has addressData populated on their next page load.
| line number | line content |
|---|
Update the profile save function to populate both fields whenever the user saves. The UI still collects structured address fields; the save logic now serializes the structured data to the text field as well as storing it directly.
This is the dual-write phase. From this point on, any profile saved by the user has both fields in sync. Users who haven't saved yet still have only the old field — the migration from PR 2 handles them on their next page load.
No reads change in this PR. Components still read from address. Nothing breaks.
| line number | line content |
|---|
Switch all read sites to prefer addressData over address. The address display component now reads from the structured field and formats it for display. A fallback to the raw address text field is kept as a belt-and-suspenders safety net for any user whose profile wasn't caught by the backfill.
After this PR, the new field drives the UI. But the old field is still being written (PR 3 is in place), so if we need to roll this back, we can revert this PR and all users immediately go back to reading from the old field — which is still up-to-date.
| line number | line content |
|---|
Remove the dual-write — the save function now only writes addressData. The formatAddress() helper that was serializing structured data back to text is deleted.
The address field still exists in the type (it's not removed yet), but nothing writes to it anymore. Existing values in localStorage are stale but harmless — the read path from PR 4 prefers addressData, so the stale address values are never shown.
This is a one-way door: after this PR, rolling back to address as the source of truth would require reading from a field that's no longer being updated. Don't merge this until you're confident in the new field.
| line number | line content |
|---|
The final cleanup PR removes everything related to the old address field: the type definition, the backfill migration, and the fallback read logic in AddressDisplay. StructuredAddress is now required (not optional) on UserProfile.
This PR is purely subtractive — it deletes dead code. A reviewer can confirm nothing still references address with a quick grep. After merging, the schema is clean, the migration is gone, and there's no legacy code left to confuse the next engineer.
| line number | line content |
|---|
Combining schema migration with code deployment. If the deploy fails and you need to roll back, undoing a schema change is much more painful than reverting a code change. A git revert restores a code change in minutes. Rolling back a database column that has already had data written to it might require a counter-migration, a production data fix, and coordination with ops — under pressure, in the middle of an incident. Keeping them in separate PRs means each is independently revertable. If the code PR has a bug, revert the code. The schema is untouched. You can re-deploy a fixed version without touching the data layer at all.
Forgetting the backfill step. If you add a new field and start reading from it before backfilling, every existing record returns undefined or null for that field. Users who saved their profile a month ago suddenly see a blank address. The backfill (PR 2) must run and complete before any code switches to reading from the new field (PR 4). In a database context this means running the backfill migration and verifying row counts before merging the read-switch PR. In a localStorage context it means the migration runs on page load, before components mount. Don't skip it and don't merge PR 4 before PR 2 has been in production long enough to have covered your active users.
Not handling the coexistence state in code. During the transition, users will be in three different states: old-field-only (haven't loaded the app since PR 2), both fields (active users after the backfill), and new-field-only (after PR 6). Your code must handle all three correctly. The fallback read logic in PR 4's AddressDisplay exists precisely for this reason — it prefers addressData but falls back to address for users who somehow slip through. Don't remove the fallback until you're certain no user can be in the old-field-only state. In practice, that means waiting for PR 5 (which stops writing the old field) to be in production for a full usage cycle before cleaning up in PR 6.