Release Management and Database Migrations
(We'll explain why in Part 2)
Deploy whatever accumulated on main
Create tagged releases (v1.2.3)
Release: v2.1.0
Type: MINOR
Changelog:
Features:
- "[#123] Add user email preferences"
- "[#456] Support bulk export from dashboard"
Fixes:
- "[#789] Fix password validation edge case"
Database Changes:
Migrations:
- "Added user_preferences table"
- "Added email_verified column to users (nullable)"
Risk Level: LOW (additive only)
Rollback Impact: "New data would be lost"
API Changes:
New Endpoints:
- "POST /api/user/preferences"
Breaking Changes: None
Service Dependencies:
Requires: "frontend >= v2.1.0"
Compatible With: "All existing API consumers"
Rollback Strategy:
Code: "Safe to roll back to v2.0.x"
Database: "New tables/columns left in place"
Data Impact: "User preferences would be orphaned"
up has a downdown to rollbackup has a downdown to rollback✅ Works in development
❌ Fails in production
-- up: Add column
ALTER TABLE users ADD COLUMN preferences JSONB;
-- down: Remove column
ALTER TABLE users DROP COLUMN preferences;
After deployment, users write data. Rollback = permanent data loss.
v2.0 adds enum values ("paused", "archived")
Production runs for 2 hours, creates records
Rollback to v1.5 → v1.5 doesn't understand these values
Step 1: ALTER TABLE users ADD COLUMN email_verified BOOLEAN; ✓
Step 2: UPDATE users SET email_verified = FALSE; ✓
Step 3: ALTER TABLE ... SET NOT NULL; ✗
Step 4: CREATE INDEX ... (never runs)
Down migration assumes full completion → creates more problems
down first, then deploy old codeMake breaking changes through backward-compatible steps
Expand
Add new schema, dual-write, read from oldBackfill
Populate new schema with existing dataSwitch Reads
Read from new, keep dual-writeStop Old Writes
Write only to new schemaContract
Remove old schemaEach step is independently deployable and code-rollback-safe
users table: full_name VARCHAR(255) -- "First Last"
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
Add new columns, dual-write to both, continue reading from old
All phases are MINOR (additive) until final DROP (MAJOR)
Application keeps both columns synchronized:
full_name changes → split and write to first_name / last_namefirst_name / last_name change → combine to full_name
class User < ApplicationRecord
before_save :sync_name_fields
def sync_name_fields
# Write to both old and new columns
if will_save_change_to_full_name?
parts = full_name.to_s.split(' ', 2)
self.first_name = parts[0] || ''
self.last_name = parts[1] || ''
elsif will_save_change_to_first_name? || will_save_change_to_last_name?
self.full_name = "#{first_name} #{last_name}".strip
end
end
end
Reads prioritize new fields with fallback to old
UPDATE users
SET
first_name = SPLIT_PART(full_name, ' ', 1),
last_name = COALESCE(NULLIF(SPLIT_PART(full_name, ' ', 2), ''), '')
WHERE
full_name IS NOT NULL
AND (first_name IS NULL OR last_name IS NULL);
# Code now READS from first_name/last_name
# But still WRITES to both (dual-write continues)
def full_name
"#{first_name} #{last_name}".strip
end
class User < ApplicationRecord
# sync_name_fields callback REMOVED
# full_name column is now ignored (becomes stale)
# All writes go exclusively to new columns
def update_name(first, last)
update!(first_name: first, last_name: last)
# full_name is NOT updated anymore
end
end
sync_name_fields callback from Phase 1first_name / last_namefull_name becomes stale — safe to drop in next phase
ALTER TABLE users DROP COLUMN full_name;
Ensure new code works before removing safety net
Decision:
Breaking change? → Expand-Contract
Additive only? → Traditional