A schema migration framework is essential for managing database evolution in production systems, requiring careful design to handle versioning, rollback scenarios, and concurrent environment deployments while maintaining data integrity and system reliability.
1. Problem Statement & Requirements
Clarifying questions
-
What scale and growth expectations are we targeting?
- How many databases and tables are we likely to manage?
- What is the expected rate of schema changes (e.g., 100s of migrations per day)?
-
What consistency and durability requirements exist?
- Is there a need for atomicity across multiple databases or services?
- Should migrations be idempotent and retryable?
-
What are the key entities in the migration system?
- Are we managing migrations for a single database, or across multiple databases/services?
- What is the relationship between migration versions and application versions?
-
What is the read/write ratio for migration metadata?
- Is the system primarily used for writing new migrations or reading migration history?
-
What latency sensitivity is expected?
- How quickly must migrations be applied or rolled back?
- Are there SLA requirements for zero-downtime deployments?
-
What authentication and authorization model is in place?
- Who can trigger migrations (e.g., dev, CI/CD, admin users)?
- Is there a need for role-based access control?
-
What external systems or integrations are involved?
- Do migrations need to integrate with CI/CD pipelines?
- Are there dependencies on external services (e.g., database backup systems)?
Functional requirements
- Migration execution: Apply schema changes to target databases in a controlled and ordered fashion.
- Migration rollback: Support for reverting migrations to previous versions.
- Migration status tracking: Track which migrations have been successfully applied and their status (pending, applied, failed).
- Migration versioning: Maintain a versioned history of migrations with unique identifiers.
- Migration dependency resolution: Ensure that migrations are applied in the correct order based on dependencies.
- Migration dry-run capability: Validate migration scripts without applying them to the database.
- Migration conflict detection: Identify and prevent concurrent or conflicting migration operations.
- Migration metadata persistence: Store migration history in a reliable and durable way.
Non-functional requirements
-
Latency targets:
- p50: < 100ms for status queries
- p95: < 500ms for status queries
- p99: < 2s for status queries
-
Throughput:
- Support concurrent migration execution across multiple databases (e.g., 100+ migrations per second)
- Handle batch migration application up to 1000 migrations at once
-
Availability:
- 99.9% uptime for migration service
- Migration history should be highly available (e.g., replicated in multiple regions)
-
Reliability:
- Migration scripts must be idempotent or have rollback capability
- Failures should not leave the system in an inconsistent state
Constraints and assumptions
-
Assumptions:
- Migration scripts are written in SQL (or compatible dialects like PostgreSQL, MySQL)
- The system operates within a single deployment environment (e.g., one Kubernetes cluster or cloud region)
- Migration history is stored in a dedicated database or table within the same system
- Migration scripts are provided as part of the application deployment (e.g., via code or config files)
- Rollbacks are supported only for migrations that have a reverse script (or can be reversed by logic)
-
Constraints:
- Migration scripts must not contain destructive operations (e.g.,
DROP TABLE) without explicit rollback support - Migration execution must not block the application startup or deployment process (asynchronous or background)
- Migration history must be immutable once applied to prevent data corruption
- Migration service must not interfere with database connection pools or transaction management
- Migration scripts must not contain destructive operations (e.g.,
Out of scope
- Migration script generation: The system does not generate SQL scripts from code or ORM models.
- Cross-database migration coordination: Migration across multiple heterogeneous databases (e.g., PostgreSQL + MongoDB) is out of scope.
- Migration conflict resolution: Automatic conflict detection and resolution (e.g., merge strategies) is out of scope.
- Migration UI: A web-based dashboard or console for managing migrations is not part of this design.
- Database backup and restore: The system does not handle database backups or restores as part of migration.
- Cross-service migration coordination: Migrations across microservices or distributed systems are not covered.
- Migration script validation beyond syntax: The system does not validate migration logic (e.g., data integrity checks) or run-time behavior.
2. API Design
API Style Selection
REST is selected for this schema migration framework due to its simplicity, wide adoption, and ability to provide clear semantics for CRUD operations on migration resources. REST's stateless nature aligns well with the distributed nature of schema migration systems where each request should contain all necessary information for processing.
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /migrations | List all migrations with status and metadata | JWT |
| POST | /migrations | Create a new migration script | JWT |
| GET | /migrations/{id} | Get details of a specific migration | JWT |
| PUT | /migrations/{id}/status | Update migration status (pending, running, completed, failed) | JWT |
| POST | /migrations/{id}/execute | Execute a specific migration | JWT |
| DELETE | /migrations/{id} | Delete a migration (soft delete) | JWT |
| GET | /migrations/history | Get migration history with execution logs | JWT |
| POST | /migrations/validate | Validate a new migration script against DB schema | JWT |
Request and Response Schemas
Create Migration Request
{
"name": "add_user_email_index",
"description": "Add index on user email column for faster lookups",
"script": "CREATE INDEX idx_user_email ON users(email);",
"version": "1.2.3",
"author": "john.doe@example.com"
}
Create Migration Response
{
"id": "b2a3c4d5-e6f7-8901-a2b3-c4d5e6f78901",
"name": "add_user_email_index",
"description": "Add index on user email column for faster lookups",
"script": "CREATE INDEX idx_user_email ON users(email);",
"version": "1.2.3",
"author": "john.doe@example.com",
"status": "pending",
"createdAt": "2023-10-15T10:30:00Z",
"updatedAt": "2023-10-15T10:30:00Z"
}
Migration Status Update Request
{
"status": "running",
"details": "Migration started at 2023-10-15T10:45:00Z"
}
Migration History Response
{
"migrations": [
{
"id": "b2a3c4d5-e6f7-8901-a2b3-c4d5e6f78901",
"name": "add_user_email_index",
"status": "completed",
"version": "1.2.3",
"author": "john.doe@example.com",
"executedAt": "2023-10-15T10:45:00Z",
"durationMs": 2345,
"logs": [
{
"timestamp": "2023-10-15T10:45:00Z",
"message": "Starting migration execution"
},
{
"timestamp": "2023-10-15T10:47:00Z",
"message": "Index creation completed successfully"
}
]
}
],
"pagination": {
"cursor": "eyJpZCI6IjEyMzQ1Njc4LTk4NzYtNTQzMjEwOTgiLCJ0aW1lc3RhbXAiOjE2OTc1MzQwMDB9",
"hasNext": true
}
}
HTTP Status Codes and Error Response Format
| Status Code | Description |
|---|---|
| 200 | Success - GET, PUT, DELETE |
| 201 | Created - POST |
| 400 | Bad Request - Malformed request or validation error |
| 401 | Unauthorized - Missing or invalid auth token |
| 403 | Forbidden - Insufficient permissions |
| 404 | Not Found - Resource not found |
| 409 | Conflict - Migration already exists or in progress |
| 422 | Unprocessable Entity - Validation errors |
| 500 | Internal Server Error - Unexpected system error |
Standardized error response format:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Migration name is required",
"details": [
{
"field": "name",
"message": "Name is required"
}
]
}
}
API Versioning Strategy
API versioning will be handled through the Accept header using application/vnd.migration.v1+json format. This approach allows for clean separation of versions without URL changes and supports backward compatibility.
Pagination Design
Cursor-based pagination is selected over offset-based pagination because:
- It's more performant for large datasets
- It handles data changes during pagination gracefully
- It provides consistent results even when items are inserted or deleted
Cursor format: Base64 encoded JSON containing migration ID and timestamp.
Authentication and Authorization
- JWT tokens for authentication with expiration of 24 hours
- RBAC roles: ADMIN (full access), MIGRATOR (read/write to migrations), VIEWER (read-only)
- Token validation middleware that checks permissions before processing requests
Rate Limiting
Rate limiting per endpoint:
- POST /migrations: 10 requests/minute per user
- GET /migrations: 60 requests/minute per user
- POST /migrations/{id}/execute: 5 requests/minute per user
Implementation uses Redis-based token bucket algorithm with sliding window for precise rate limiting.
Idempotency Design
Mutation endpoints support idempotency keys:
- Header:
X-Idempotency-Keywith UUID value - For same key, return last successful response if operation completed
- Store idempotency keys in Redis with TTL of 24 hours
// Example usage in controller
@PostMapping("/migrations/{id}/execute")
public ResponseEntity<Migration> executeMigration(
@PathVariable String id,
@RequestHeader("X-Idempotency-Key") String idempotencyKey) {
// Check if this idempotency key has been used
String existingResult = idempotencyCache.get(idempotencyKey);
if (existingResult != null) {
return ResponseEntity.ok(existingResult);
}
// Execute migration logic here...
Migration result = migrationService.executeMigration(id);
// Store result for future idempotent calls
idempotencyCache.put(idempotencyKey, result);
return ResponseEntity.ok(result);
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
MIGRATION_SCHEMA {
string id PK
string name
string version
string status
string description
jsonb schema_json
timestamp created_at
timestamp updated_at
string created_by
string updated_by
}
MIGRATION_HISTORY {
string id PK
string migration_id FK
string status
timestamp executed_at
string executed_by
string error_message
jsonb result_data
timestamp created_at
}
MIGRATION_DEPENDENCY {
string migration_id FK
string depends_on_migration_id FK
}
MIGRATION_SCHEMA ||--o{ MIGRATION_HISTORY : "has"
MIGRATION_SCHEMA ||--o{ MIGRATION_DEPENDENCY : "depends_on"
MIGRATION_SCHEMA ||--o{ MIGRATION_DEPENDENCY : "dependent_on"
Table Schema Definitions
migration_schemas table
CREATE TABLE migration_schemas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
description TEXT,
schema_json JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(255) NOT NULL,
updated_by VARCHAR(255),
CONSTRAINT migration_unique_name_version UNIQUE (name, version),
CONSTRAINT migration_status_check CHECK (status IN ('PENDING', 'APPLIED', 'FAILED', 'ROLLBACK'))
);
migration_history table
CREATE TABLE migration_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
migration_id UUID NOT NULL REFERENCES migration_schemas(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
executed_by VARCHAR(255),
error_message TEXT,
result_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT history_status_check CHECK (status IN ('SUCCESS', 'FAILED', 'ROLLBACK'))
);
migration_dependencies table
CREATE TABLE migration_dependencies (
migration_id UUID NOT NULL REFERENCES migration_schemas(id) ON DELETE CASCADE,
depends_on_migration_id UUID NOT NULL REFERENCES migration_schemas(id) ON DELETE CASCADE,
PRIMARY KEY (migration_id, depends_on_migration_id),
CONSTRAINT self_dependency_check CHECK (migration_id != depends_on_migration_id)
);
Primary Key Strategy
UUIDs chosen for primary keys with the following justification:
- Global uniqueness: UUIDs ensure no collisions across distributed systems and deployments
- No sequential exposure: Unlike auto-increment IDs, UUIDs don't reveal system information or order
- Distributed system compatibility: Enables seamless migration between systems without ID conflicts
- No database round-trips: UUIDs can be generated client-side, avoiding database round-trips for ID generation
Index Design
| Index Name | Columns | Type | Purpose |
|---|---|---|---|
| idx_migration_schemas_name_version | name, version | Unique | Fast lookup by name and version |
| idx_migration_schemas_status | status | Standard | Efficient filtering by migration status |
| idx_migration_history_migration_id | migration_id | Standard | Fast lookup of history records per migration |
| idx_migration_history_executed_at | executed_at | Standard | Efficient chronological queries |
| idx_migration_dependencies_mig_id | migration_id | Standard | Fast lookup of dependencies for a migration |
| idx_migration_dependencies_depends_on | depends_on_migration_id | Standard | Fast lookup of dependents |
Data Partitioning / Sharding Strategy
Sharding by migration name prefix:
- Strategy: Hash-based sharding on the first 2 characters of migration name
- Rationale: Even distribution across shards, predictable sharding key, minimal cross-shard dependencies
- Implementation:
-- Partitioning key: hash(name) % NUMBER_OF_SHARDS CREATE TABLE migration_schemas_shard_0 (...) INHERITS (migration_schemas); CREATE TABLE migration_schemas_shard_1 (...) INHERITS (migration_schemas); -- ... etc
Soft Delete vs. Hard Delete Decision
Soft delete chosen with the following justification:
- Audit and compliance: Historical migration records must be preserved for audit trails
- Recovery capability: Ability to rollback or reapply migrations without data loss
- Dependency tracking: Migration dependencies must remain intact for proper execution order
- Operational safety: Prevents accidental data loss during migration operations
Audit Trail Design
Audit fields included in all tables:
created_at/updated_at: Timestamps for creation and modificationcreated_by/updated_by: User or system identifier for audit trailstatusfield: Migration lifecycle tracking
Data Archival Strategy and Retention Policy
Archival strategy:
- Active migrations: Keep in primary storage (30 days retention)
- Historical migrations: Archive completed migrations to cold storage after 90 days
- Failed migrations: Retain indefinitely for debugging and rollback capability
Retention policy:
-- Archive completed migrations older than 90 days to cold storage
-- Keep failed migrations indefinitely in primary storage
Schema Versioning Strategy
Semantic versioning with schema evolution:
- Major version: Breaking changes (schema structure change)
- Minor version: Non-breaking additions (new fields, new tables)
- Patch version: Bug fixes and internal changes
Versioning constraints:
-- Enforce that version follows semantic versioning pattern
ALTER TABLE migration_schemas ADD CONSTRAINT valid_version_format
CHECK (version ~ '^[0-9]+\.[0-9]+\.[0-9]+(\-[a-zA-Z0-9\.\-\+]+)?$');
Migration Status Lifecycle
Status transitions:
PENDING→APPLIED(successful execution)PENDING→FAILED(execution error)APPLIED→ROLLBACK(manual or automatic rollback)FAILED→APPLIED(retry after fix)ROLLBACK→PENDING(reapplication)
-- Status transition validation
ALTER TABLE migration_schemas ADD CONSTRAINT valid_status_transitions
CHECK (
(status = 'PENDING' AND version IS NOT NULL) OR
(status IN ('APPLIED', 'FAILED', 'ROLLBACK') AND version IS NOT NULL)
);
4. Class Design & Service Layer
UML Class Diagram
classDiagram
class MigrationFramework {
+migrate()
+rollback()
+getStatus()
+validate()
}
class Migration {
+id: UUID
+name: String
+version: Integer
+status: MigrationStatus
+createdAt: LocalDateTime
+executedAt: LocalDateTime
+script: String
+checksum: String
}
class MigrationRunner {
+executeMigration()
+rollbackMigration()
+validateMigration()
}
class MigrationRepository {
+save(Migration)
+findById(UUID)
+findAll()
+findByStatus(MigrationStatus)
+update(Migration)
}
class MigrationValidator {
+validate(Migration)
+validateSchema()
}
class MigrationContext {
+migration: Migration
+connection: Connection
+logger: Logger
+transactionManager: TransactionManager
}
class MigrationService {
+migrate()
+rollback()
+getStatus()
+validate()
}
class MigrationLock {
+acquire()
+release()
+isLocked()
}
class TransactionManager {
+beginTransaction()
+commit()
+rollback()
}
class MigrationEventPublisher {
+publish(MigrationEvent)
}
class MigrationEvent {
+type: EventType
+migrationId: UUID
+status: MigrationStatus
+timestamp: LocalDateTime
}
class MigrationConfig {
+migrationTable: String
+lockTable: String
+autoMigrate: boolean
+maxRetries: int
}
MigrationFramework --> MigrationService : uses
MigrationFramework --> MigrationRepository : delegates to
MigrationFramework --> MigrationValidator : validates with
MigrationFramework --> MigrationLock : manages lock
MigrationFramework --> TransactionManager : manages tx
MigrationFramework --> MigrationEventPublisher : publishes events
MigrationService --> MigrationRunner : executes migrations
MigrationService --> MigrationRepository : persists state
MigrationService --> MigrationValidator : validates migrations
MigrationService --> MigrationEventPublisher : publishes events
MigrationRunner --> MigrationContext : executes with context
MigrationRunner --> TransactionManager : manages tx
MigrationRepository --> Migration : stores entities
MigrationContext --> Migration : contains migration
MigrationContext --> TransactionManager : manages tx
MigrationEventPublisher --> MigrationEvent : publishes events
class MigrationStatus {
<<enumeration>>
PENDING
EXECUTING
SUCCESS
FAILED
ROLLED_BACK
}
class EventType {
<<enumeration>>
MIGRATION_STARTED
MIGRATION_COMPLETED
MIGRATION_FAILED
MIGRATION_ROLLED_BACK
}
State Machine Diagram
stateDiagram-v2
[*] --> PENDING
PENDING --> EXECUTING : migrate()
EXECUTING --> SUCCESS : success
EXECUTING --> FAILED : failure
FAILED --> PENDING : retry
SUCCESS --> ROLLED_BACK : rollback()
ROLLED_BACK --> PENDING : rollback completed
Service Class Structure and Responsibilities
The migration framework follows a layered architecture with clear separation of concerns:
- MigrationFramework - The main entry point that orchestrates the migration lifecycle
- MigrationService - Core business logic for migration operations and state management
- MigrationRunner - Executes individual migration scripts with proper transaction handling
- MigrationRepository - Data persistence layer for migration metadata
- MigrationValidator - Validates migration scripts and schema integrity
- MigrationLock - Provides distributed locking to prevent concurrent migrations
- TransactionManager - Manages database transactions for migration operations
Interface / Abstract Class Design
public interface MigrationRepository {
void save(Migration migration);
Migration findById(UUID id);
List<Migration> findAll();
List<Migration> findByStatus(MigrationStatus status);
void update(Migration migration);
}
public interface MigrationValidator {
ValidationResult validate(Migration migration);
ValidationResult validateSchema();
}
public interface MigrationRunner {
MigrationResult execute(MigrationContext context);
MigrationResult rollback(MigrationContext context);
}
public enum MigrationStatus {
PENDING, EXECUTING, SUCCESS, FAILED, ROLLED_BACK
}
public enum EventType {
MIGRATION_STARTED, MIGRATION_COMPLETED, MIGRATION_FAILED, MIGRATION_ROLLED_BACK
}
Business Logic Encapsulation Patterns
The framework uses the Strategy Pattern for migration execution, allowing different strategies for various database systems:
public interface MigrationStrategy {
MigrationResult execute(MigrationContext context);
MigrationResult rollback(MigrationContext context);
}
public class PostgreSQLMigrationStrategy implements MigrationStrategy {
@Override
public MigrationResult execute(MigrationContext context) {
try {
// PostgreSQL-specific migration logic
Connection conn = context.getConnection();
Statement stmt = conn.createStatement();
stmt.execute(context.getMigration().getScript());
return MigrationResult.success();
} catch (SQLException e) {
return MigrationResult.failure(e.getMessage());
}
}
@Override
public MigrationResult rollback(MigrationContext context) {
// PostgreSQL-specific rollback logic
return MigrationResult.success();
}
}
Critical-Path Method Implementation
public MigrationResult migrate(MigrationContext context) {
Migration migration = context.getMigration();
// 1. Acquire distributed lock
MigrationLock lock = new MigrationLock(migration.getId());
try {
if (!lock.acquire()) {
throw new MigrationException("Migration lock already acquired");
}
// 2. Validate migration
ValidationResult validation = validator.validate(migration);
if (!validation.isValid()) {
throw new MigrationException("Migration validation failed: " + validation.getErrors());
}
// 3. Update migration status to EXECUTING
migration.setStatus(MigrationStatus.EXECUTING);
repository.update(migration);
// 4. Start transaction
TransactionManager tx = context.getTransactionManager();
tx.beginTransaction();
try {
// 5. Execute migration script
MigrationResult result = runner.execute(context);
if (result.isSuccess()) {
// 6. Update status to SUCCESS
migration.setStatus(MigrationStatus.SUCCESS);
migration.setExecutedAt(LocalDateTime.now());
repository.update(migration);
// 7. Publish success event
MigrationEvent event = new MigrationEvent(
EventType.MIGRATION_COMPLETED,
migration.getId(),
MigrationStatus.SUCCESS,
LocalDateTime.now()
);
eventPublisher.publish(event);
tx.commit();
} else {
// 8. Handle failure
migration.setStatus(MigrationStatus.FAILED);
repository.update(migration);
MigrationEvent event = new MigrationEvent(
EventType.MIGRATION_FAILED,
migration.getId(),
MigrationStatus.FAILED,
LocalDateTime.now()
);
eventPublisher.publish(event);
tx.rollback();
throw new MigrationException("Migration failed: " + result.getErrorMessage());
}
} catch (Exception e) {
tx.rollback();
throw new MigrationException("Migration execution failed", e);
}
} finally {
lock.release();
}
return MigrationResult.success();
}
Transaction Boundary Design
Each migration operation constitutes a single transaction boundary:
- The entire migration script execution happens within one database transaction
- If any part of the script fails, the entire transaction is rolled back
- The status update happens within the same transaction to maintain consistency
- Lock acquisition and release are handled outside of database transactions
Service Interface Contracts
public interface MigrationService {
MigrationResult migrate(MigrationContext context);
MigrationResult rollback(MigrationContext context);
List<Migration> getStatus();
ValidationResult validate(List<Migration> migrations);
void initialize();
}
Saga / Orchestration Pattern
The migration framework uses a simple orchestration pattern rather than complex saga management since:
- Migrations are typically single database operations
- Rollbacks can be handled within the same transaction scope
- Concurrent migration execution is prevented by distributed locking
Domain Object / Value Object Design
public class Migration {
private final UUID id;
private final String name;
private final Integer version;
private MigrationStatus status;
private final LocalDateTime createdAt;
private LocalDateTime executedAt;
private final String script;
private final String checksum;
public Migration(UUID id, String name, Integer version, MigrationStatus status,
LocalDateTime createdAt, String script, String checksum) {
this.id = id;
this.name = name;
this.version = version;
this.status = status;
this.createdAt = createdAt;
this.script = script;
this.checksum = checksum;
}
// Getters and immutable setters for status only
}
public class MigrationContext {
private final Migration migration;
private final Connection connection;
private final Logger logger;
private final TransactionManager transactionManager;
public MigrationContext(Migration migration, Connection connection,
Logger logger, TransactionManager transactionManager) {
this.migration = migration;
this.connection = connection;
this.logger = logger;
this.transactionManager = transactionManager;
}
// Getters
}
public class MigrationResult {
private final boolean success;
private final String errorMessage;
public static MigrationResult success() {
return new MigrationResult(true, null);
}
public static MigrationResult failure(String error) {
return new MigrationResult(false, error);
}
// Constructor and getters
}
5. Concurrency & Thread Safety
Concurrency Model
The schema migration framework will operate in a multi-threaded environment where multiple migration tasks can be executed concurrently. The concurrency model will leverage:
- Thread pools for managing migration execution threads
- Async/Await pattern for non-blocking operations where appropriate
- Java's ExecutorService with configurable thread pool sizes
- Reactive programming model for handling asynchronous migration workflows
Race Condition Prevention Strategy
Race conditions in schema migration can occur when:
- Multiple processes attempt to apply migrations simultaneously
- Migration state is updated concurrently by different threads
- Migration locks are not properly acquired or released
Prevention mechanisms:
- Migration lock acquisition using database-level advisory locks or distributed mutex
- State consistency checks before applying migrations
- Atomic state transitions using compare-and-swap operations where applicable
- Thread-local migration tracking to prevent duplicate execution in same thread
Optimistic vs. Pessimistic Locking Selection
Optimistic locking is selected for the following reasons:
- Migration operations are typically short-lived and idempotent
- High concurrency scenarios with low conflict probability
- Better performance under normal conditions (no blocking)
- Migration state updates are infrequent and atomic
Implementation approach:
// Example optimistic locking pattern for migration state updates
public boolean updateMigrationState(String migrationId, MigrationStatus newStatus) {
int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++) {
MigrationState current = migrationStateRepository.load(migrationId);
if (current.getStatus() != newStatus) {
// Validate that we can transition to new status
if (canTransition(current.getStatus(), newStatus)) {
MigrationState updated = current.withStatus(newStatus);
if (migrationStateRepository.update(current, updated)) {
return true;
}
}
}
if (attempt < maxRetries - 1) {
Thread.sleep(100 * (1 << attempt)); // Exponential backoff
}
}
return false;
}
Deadlock Prevention Strategy
Deadlock prevention in migration framework:
- Consistent lock ordering - All migrations acquire locks in the same order (by migration ID hash)
- Lock timeout configuration - 30-second timeout on advisory locks
- Migration dependency tracking - Prevent circular dependencies between migrations
- Resource pool limits - Configurable maximum concurrent migrations to prevent resource exhaustion
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is ensured through:
- Migration ID uniqueness - Each migration has a unique identifier (UUID)
- State tracking - Migration status is stored and checked before execution
- Idempotency keys - For external migration triggers, use idempotency keys to prevent duplicate processing
- Database constraints - Unique constraints on migration IDs in the migration registry table
Implementation example:
public class MigrationProcessor {
private final Set<String> activeMigrations = new HashSet<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public MigrationResult processMigration(Migration migration) {
String migrationId = migration.getId();
// Check if already processed
if (migrationStateRepository.isMigrationApplied(migrationId)) {
return MigrationResult.alreadyApplied();
}
// Acquire exclusive lock for this migration
try (LockResource lock = new MigrationLock(migrationId)) {
// Double-check after acquiring lock
if (migrationStateRepository.isMigrationApplied(migrationId)) {
return MigrationResult.alreadyApplied();
}
// Execute migration
MigrationResult result = executeMigration(migration);
// Store success state
migrationStateRepository.recordSuccess(migrationId, result);
return result;
}
}
}
Thread Safety Patterns
- Immutable migration objects - Once created, migration metadata cannot be changed
- Thread-safe state management - Using concurrent collections and atomic operations
- Synchronized access to shared resources - Database connections, migration registry
- Thread-local storage for context - Migration execution context per thread
Concurrency Control in Migration Execution
The framework uses a combination of:
- Semaphore-based concurrency control to limit concurrent migration execution
- Migration queue with priority handling for ordered execution
- Thread-safe migration status tracking using atomic operations or database transactions
Sample implementation:
public class MigrationExecutor {
private final Semaphore concurrentMigrationSemaphore;
private final ExecutorService migrationExecutor;
public MigrationResult executeMigration(Migration migration) {
try {
// Acquire semaphore for concurrent limit
concurrentMigrationSemaphore.acquire();
// Submit to executor pool
Future<MigrationResult> future = migrationExecutor.submit(() -> {
try {
return runMigration(migration);
} finally {
concurrentMigrationSemaphore.release();
}
});
return future.get(); // Wait for completion
} catch (InterruptedException | ExecutionException e) {
throw new MigrationException("Migration execution failed", e);
}
}
}
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant U as User/Operator
participant M as MigrationManager
participant D as Database
participant L as MigrationLog
participant E as EventPublisher
U->>M: Trigger migration (version X)
M->>L: Check if migration already applied
L-->>M: Migration not found
M->>D: Acquire migration lock
D-->>M: Lock acquired
M->>D: Execute migration SQL
D-->>M: SQL executed successfully
M->>L: Record migration completion
L-->>M: Log updated
M->>E: Publish migration event
E-->>M: Event published
M-->>U: Return success response
sequenceDiagram
participant U as User/Operator
participant M as MigrationManager
participant D as Database
participant L as MigrationLog
participant E as EventPublisher
U->>M: Trigger migration (version X)
M->>L: Check if migration already applied
L-->>M: Migration already exists
M-->>U: Return already applied response
sequenceDiagram
participant U as User/Operator
participant M as MigrationManager
participant D as Database
participant L as MigrationLog
participant E as EventPublisher
U->>M: Trigger migration (version X)
M->>L: Check if migration already applied
L-->>M: Migration not found
M->>D: Acquire migration lock
D-->>M: Lock acquired
M->>D: Execute migration SQL
D-->>M: SQL execution failed (timeout)
M->>L: Record migration failure
L-->>M: Log updated
M-->>U: Return error response with retry info
7. Caching Design
Cache Strategy Selection
For schema migration framework, we'll use cache-aside pattern with write-through semantics for migration state tracking. This approach provides:
- Explicit control over cache population and invalidation
- Consistency guarantees with database state
- Predictable performance characteristics for migration operations
- Simpler debugging and monitoring compared to write-behind
Cache Key Design
Cache keys will follow a structured namespace pattern:
migration:{migrationId}:{entityType}:{entityId}
migration:status:{migrationId}
migration:history:{migrationId}
Example key structure:
// Migration state cache key
String cacheKey = String.format("migration:%s:%s:%s",
migrationId, entityType, entityId);
// Migration status cache key
String statusKey = String.format("migration:status:%s", migrationId);
// Migration history cache key
String historyKey = String.format("migration:history:%s", migrationId);
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Migration state | 5 minutes | Short-lived, frequently accessed during migration |
| Migration status | 1 hour | Long-lived, less frequent updates |
| Migration history | 7 days | Historical tracking for audit and debugging |
| Migration metadata | 1 day | Configuration data that rarely changes |
Cache Eviction Policy
LRU (Least Recently Used) with TTL-based expiration as primary policy. For high-traffic scenarios, we'll implement LFU (Least Frequently Used) for migration state entries to prioritize frequently accessed migrations.
Cache Stampede Prevention
Implement probabilistic early expiry with jitter:
public class CacheStampedePrevention {
private static final double STAMPED_EXPIRY_JITTER = 0.2;
public String getCacheKeyWithJitter(String baseKey, long ttlSeconds) {
long jitter = (long) (Math.random() * STAMPED_EXPIRY_JITTER * ttlSeconds);
return baseKey + ":" + (ttlSeconds - jitter);
}
}
Distributed Cache Architecture
We'll use Redis Cluster with:
- 3 master nodes for write availability
- 3 replica nodes for read scalability
- Automatic failover with Redis Sentinel
- Cross-zone replication for high availability
Cache Invalidation Strategy
Event-driven invalidation using Redis Pub/Sub:
@Component
public class MigrationCacheInvalidator {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@EventListener
public void handleMigrationEvent(MigrationEvent event) {
String migrationId = event.getMigrationId();
// Invalidate all related cache entries
Set<String> keysToInvalidate = new HashSet<>();
keysToInvalidate.add("migration:status:" + migrationId);
keysToInvalidate.add("migration:history:" + migrationId);
// For state entries, use pattern matching
Set<String> stateKeys = redisTemplate.keys("migration:" + migrationId + ":*:*");
keysToInvalidate.addAll(stateKeys);
// Batch delete
redisTemplate.delete(keysToInvalidate);
}
}
Cache Read-Through Pattern
@Service
public class MigrationStateCache {
@Autowired
private RedisTemplate<String, MigrationState> redisTemplate;
@Autowired
private MigrationStateRepository migrationStateRepository;
public MigrationState getMigrationState(String migrationId, String entityType, String entityId) {
String cacheKey = buildCacheKey(migrationId, entityType, entityId);
// Try cache first
MigrationState state = redisTemplate.opsForValue().get(cacheKey);
if (state != null) {
return state;
}
// Cache miss - read from database
state = migrationStateRepository.findByMigrationIdAndEntity(migrationId, entityType, entityId);
if (state != null) {
// Cache with TTL
redisTemplate.opsForValue().set(cacheKey, state, 5, TimeUnit.MINUTES);
}
return state;
}
private String buildCacheKey(String migrationId, String entityType, String entityId) {
return "migration:" + migrationId + ":" + entityType + ":" + entityId;
}
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern is implemented to abstract database operations and provide a clean separation between domain logic and data access concerns. Each entity has its own repository interface and implementation.
public interface MigrationRepository {
Migration save(Migration migration);
Optional<Migration> findById(UUID id);
List<Migration> findAll();
List<Migration> findByStatus(MigrationStatus status);
void delete(UUID id);
boolean existsById(UUID id);
}
public class JdbcMigrationRepository implements MigrationRepository {
private final DataSource dataSource;
private final ObjectMapper objectMapper;
public JdbcMigrationRepository(DataSource dataSource, ObjectMapper objectMapper) {
this.dataSource = dataSource;
this.objectMapper = objectMapper;
}
@Override
public Migration save(Migration migration) {
try (Connection conn = dataSource.getConnection()) {
String sql = """
INSERT INTO migrations (id, name, version, status, script, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
version = EXCLUDED.version,
status = EXCLUDED.status,
script = EXCLUDED.script,
updated_at = EXCLUDED.updated_at
""";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setObject(1, migration.getId());
stmt.setString(2, migration.getName());
stmt.setString(3, migration.getVersion());
stmt.setString(4, migration.getStatus().name());
stmt.setString(5, migration.getScript());
stmt.setTimestamp(6, Timestamp.from(migration.getCreatedAt()));
stmt.setTimestamp(7, Timestamp.from(migration.getUpdatedAt()));
stmt.executeUpdate();
return migration;
}
} catch (SQLException e) {
throw new DataAccessException("Failed to save migration", e);
}
}
@Override
public Optional<Migration> findById(UUID id) {
try (Connection conn = dataSource.getConnection()) {
String sql = "SELECT * FROM migrations WHERE id = ?";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setObject(1, id);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return Optional.of(mapResultSetToMigration(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new DataAccessException("Failed to find migration by ID", e);
}
}
private Migration mapResultSetToMigration(ResultSet rs) throws SQLException {
return Migration.builder()
.id((UUID) rs.getObject("id"))
.name(rs.getString("name"))
.version(rs.getString("version"))
.status(MigrationStatus.valueOf(rs.getString("status")))
.script(rs.getString("script"))
.createdAt(Instant.ofEpochMilli(rs.getTimestamp("created_at").getTime()))
.updatedAt(Instant.ofEpochMilli(rs.getTimestamp("updated_at").getTime()))
.build();
}
}
ORM vs. Raw Query Decisions
For the migration framework, raw SQL queries are preferred over ORM for several reasons:
- Migration Script Control: Raw queries give complete control over migration scripts and their execution context
- Performance: Avoids ORM overhead during migration execution
- Database Compatibility: Allows direct database-specific optimizations and syntax
- Debugging: Easier to debug and understand exactly what SQL is being executed
Read vs. Write Separation
The framework implements a read-write separation strategy using CQRS principles:
public interface MigrationReadRepository {
List<Migration> findAll();
Optional<Migration> findById(UUID id);
List<Migration> findByStatus(MigrationStatus status);
MigrationSummary getMigrationSummary();
}
public interface MigrationWriteRepository {
Migration save(Migration migration);
void delete(UUID id);
Migration updateStatus(UUID id, MigrationStatus status);
Migration updateScript(UUID id, String script);
}
Connection Pool Configuration
Connection pool is configured with optimal settings for migration operations:
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
validation-timeout: 5000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
N+1 Query Prevention Strategy
The framework prevents N+1 queries by implementing eager loading and batch operations:
public class MigrationBatchService {
public List<Migration> getMigrationsWithDetails(List<UUID> migrationIds) {
// Single query to fetch all migrations
String sql = """
SELECT m.*, md.details
FROM migrations m
LEFT JOIN migration_details md ON m.id = md.migration_id
WHERE m.id IN (%s)
""";
// Format placeholders for the IN clause
String placeholders = migrationIds.stream()
.map(id -> "?")
.collect(Collectors.joining(","));
String finalSql = sql.replace("%s", placeholders);
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(finalSql)) {
for (int i = 0; i < migrationIds.size(); i++) {
stmt.setObject(i + 1, migrationIds.get(i));
}
ResultSet rs = stmt.executeQuery();
return mapResultSetToMigrations(rs);
} catch (SQLException e) {
throw new DataAccessException("Failed to fetch migrations with details", e);
}
}
}
Batch Operation Design
Batch operations are implemented for bulk migration operations:
public class MigrationBatchRepository {
public void saveMigrationsInBatch(List<Migration> migrations) {
String sql = """
INSERT INTO migrations (id, name, version, status, script, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
version = EXCLUDED.version,
status = EXCLUDED.status,
script = EXCLUDED.script,
updated_at = EXCLUDED.updated_at
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
for (Migration migration : migrations) {
stmt.setObject(1, migration.getId());
stmt.setString(2, migration.getName());
stmt.setString(3, migration.getVersion());
stmt.setString(4, migration.getStatus().name());
stmt.setString(5, migration.getScript());
stmt.setTimestamp(6, Timestamp.from(migration.getCreatedAt()));
stmt.setTimestamp(7, Timestamp.from(migration.getUpdatedAt()));
stmt.addBatch();
}
stmt.executeBatch();
} catch (SQLException e) {
throw new DataAccessException("Failed to save migrations in batch", e);
}
}
}
Transaction Management
The repository layer handles transaction boundaries carefully:
public class MigrationTransactionalRepository {
private final DataSource dataSource;
@Transactional
public Migration executeMigration(Migration migration, String script) {
try (Connection conn = dataSource.getConnection()) {
// Begin transaction
conn.setAutoCommit(false);
try {
Migration updatedMigration = saveMigration(conn, migration);
executeScript(conn, script);
// Commit transaction
conn.commit();
return updatedMigration;
} catch (Exception e) {
// Rollback transaction
conn.rollback();
throw new MigrationExecutionException("Migration failed", e);
}
} catch (SQLException e) {
throw new DataAccessException("Database transaction error", e);
}
}
private Migration saveMigration(Connection conn, Migration migration) throws SQLException {
String sql = """
INSERT INTO migrations (id, name, version, status, script, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
version = EXCLUDED.version,
status = EXCLUDED.status,
script = EXCLUDED.script,
updated_at = EXCLUDED.updated_at
""";
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setObject(1, migration.getId());
stmt.setString(2, migration.getName());
stmt.setString(3, migration.getVersion());
stmt.setString(4, migration.getStatus().name());
stmt.setString(5, migration.getScript());
stmt.setTimestamp(6, Timestamp.from(migration.getCreatedAt()));
stmt.setTimestamp(7, Timestamp.from(migration.getUpdatedAt()));
stmt.executeUpdate();
return migration;
}
}
private void executeScript(Connection conn, String script) throws SQLException {
try (Statement stmt = conn.createStatement()) {
stmt.execute(script);
}
}
}
Query Optimization Strategy
The repository layer implements query optimization techniques:
- Prepared Statements: All queries use prepared statements to prevent SQL injection
- Index Usage: Queries are optimized to leverage database indexes
- Result Set Streaming: Large result sets are streamed for memory efficiency
public class OptimizedMigrationRepository {
public Stream<Migration> streamMigrationsByStatus(MigrationStatus status) {
String sql = "SELECT * FROM migrations WHERE status = ? ORDER BY created_at ASC";
try {
Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setString(1, status.name());
// Enable streaming for large result sets
stmt.setFetchSize(Integer.MIN_VALUE);
ResultSet rs = stmt.executeQuery();
return StreamSupport.stream(
Spliterators.spliteratorUnknownSize(
new MigrationResultSetIterator(rs),
Spliterator.ORDERED
), false
);
} catch (SQLException e) {
throw new DataAccessException("Failed to stream migrations", e);
}
}
private static class MigrationResultSetIterator implements Iterator<Migration> {
private final ResultSet rs;
private boolean hasNext = true;
public MigrationResultSetIterator(ResultSet rs) {
this.rs = rs;
}
@Override
public boolean hasNext() {
return hasNext;
}
@Override
public Migration next() {
try {
if (rs.next()) {
return mapResultSetToMigration(rs);
}
hasNext = false;
return null;
} catch (SQLException e) {
throw new DataAccessException("Failed to iterate over results", e);
}
}
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature — what triggers async vs. what stays synchronous, and why
The schema migration framework should process migrations synchronously for immediate feedback during development and testing phases. However, in production environments with large datasets or complex migrations, certain operations should be offloaded asynchronously to prevent blocking the main application thread and ensure system responsiveness.
Key async triggers:
- Large data transformations that could take minutes to hours
- Migrations that require external system coordination (e.g., data warehouse sync)
- Rollback operations that may span multiple services
- Batch migrations for historical data
Synchronous operations remain for:
- Validation and pre-flight checks
- Migration status updates to tracking systems
- Immediate error reporting and rollback capability
Message schema design with a sample payload (Avro / Protobuf / JSON Schema — with versioning strategy)
{
"schema": {
"type": "object",
"version": "1.0.0",
"properties": {
"migrationId": {
"type": "string",
"description": "Unique identifier for the migration"
},
"status": {
"type": "string",
"enum": ["PENDING", "IN_PROGRESS", "SUCCESS", "FAILED", "ROLLBACK"]
},
"message": {
"type": "string",
"description": "Human-readable status message"
},
"progress": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"errorDetails": {
"type": "object",
"properties": {
"errorCode": { "type": "string" },
"errorMessage": { "type": "string" }
}
}
},
"required": ["migrationId", "status", "timestamp"]
}
}
Versioning strategy:
- Major version bump when breaking changes occur (e.g., field removal)
- Minor version bump for backward-compatible additions
- Patch version for bug fixes and non-breaking changes
Dead letter queue strategy and poison message handling
Poison messages will be routed to a dedicated DLQ topic with a 24-hour TTL. Messages that fail more than 5 times will be flagged for manual intervention.
Poison message handling:
- Messages exceeding retry threshold are logged with full context
- Alerting system triggers for repeated failures
- Manual intervention workflow via admin dashboard
At-least-once vs. exactly-once delivery — decision with justification for this feature
At-least-once delivery is chosen because:
- Migration status updates are idempotent operations
- The system can safely reprocess status updates without data corruption
- Simpler implementation and better performance characteristics
Retry policy for consumers (max attempts, backoff formula, non-retryable error classification)
Retry policy:
- Max attempts: 5
- Backoff formula: exponential with jitter (1s to 30s)
- Retryable errors: network timeouts, database connection failures, transient service unavailability
- Non-retryable errors: invalid migration IDs, malformed payloads, permanent data inconsistencies
Outbox pattern for reliable event publishing — include or explicitly justify omission
Outbox pattern is included because:
- Ensures migration status events are published atomically with database writes
- Prevents event loss during system failures
- Provides eventual consistency guarantees across distributed systems
Implementation approach:
- Events are written to an
outbox_eventstable before being published - A background worker periodically flushes pending events to the message broker
- Events are marked as published once successfully delivered
Sequence diagram for all async flows (Mermaid)
sequenceDiagram
participant Migrator as SchemaMigrator
participant DB as Database
participant Outbox as EventOutbox
participant Broker as MessageBroker
participant Consumer as MigrationStatusConsumer
Migrator->>DB: Execute migration script
DB->>Outbox: Insert event record (status=IN_PROGRESS)
Outbox->>Broker: Publish migration status event
Broker->>Consumer: Deliver event to consumer
Consumer->>DB: Update migration status (status=SUCCESS)
Note over Migrator,Consumer: Async processing completes
10. Error Handling Design
Error Classification Hierarchy
The migration framework implements a three-tier error classification system:
public enum ErrorType {
VALIDATION_ERROR, // Input validation failures
MIGRATION_ERROR, // Migration execution failures
INFRASTRUCTURE_ERROR, // Database connectivity, resource exhaustion
SYSTEM_ERROR // Unexpected runtime errors
}
Error Propagation Strategy
Errors are handled at the appropriate layer with specific propagation rules:
- Validation errors - Caught and converted to
MigrationValidationExceptionat API boundary - Migration execution errors - Caught and wrapped in
MigrationExecutionExceptionat service layer - Infrastructure errors - Bubble up to callers with retry logic where appropriate
- System errors - Logged and re-thrown as
SystemExceptionfor monitoring
Retry Strategy with Exponential Backoff
Retry logic is implemented using exponential backoff with jitter:
public class RetryStrategy {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public <T> T executeWithRetry(Supplier<T> operation, Predicate<Exception> shouldRetry) {
int attempts = 0;
Exception lastException = null;
while (attempts < MAX_RETRIES) {
try {
return operation.get();
} catch (Exception e) {
lastException = e;
if (!shouldRetry.test(e) || attempts >= MAX_RETRIES - 1) {
throw new MigrationExecutionException("Migration failed after " + attempts + " retries", e);
}
long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempts) +
(Math.random() - 0.5) * JITTER_FACTOR * BASE_DELAY_MS);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new MigrationExecutionException("Migration interrupted during retry", ie);
}
attempts++;
}
}
throw new MigrationExecutionException("Migration failed after " + MAX_RETRIES + " attempts", lastException);
}
}
Circuit Breaker Design
For external dependencies like database connections, a circuit breaker pattern is implemented:
public class CircuitBreaker {
private final int failureThreshold;
private final long timeoutMs;
private final AtomicInteger failureCount = new AtomicInteger(0);
private final AtomicLong lastFailureTime = new AtomicLong(0);
private volatile CircuitState state = CircuitState.CLOSED;
enum CircuitState {
CLOSED, OPEN, HALF_OPEN
}
public boolean canExecute() {
if (state == CircuitState.CLOSED) {
return true;
} else if (state == CircuitState.OPEN) {
if (System.currentTimeMillis() - lastFailureTime.get() > timeoutMs) {
state = CircuitState.HALF_OPEN;
return true;
}
return false;
} else { // HALF_OPEN
return true; // Allow one request through to test
}
}
public void recordSuccess() {
if (state == CircuitState.HALF_OPEN) {
state = CircuitState.CLOSED;
failureCount.set(0);
}
}
public void recordFailure() {
if (state == CircuitState.CLOSED) {
int failures = failureCount.incrementAndGet();
if (failures >= failureThreshold) {
state = CircuitState.OPEN;
lastFailureTime.set(System.currentTimeMillis());
}
} else if (state == CircuitState.HALF_OPEN) {
state = CircuitState.OPEN;
lastFailureTime.set(System.currentTimeMillis());
}
}
}
Fallback Behavior Design
Fallbacks are implemented for critical migration operations:
public class MigrationService {
private final CircuitBreaker circuitBreaker = new CircuitBreaker();
public MigrationResult executeMigration(Migration migration) {
return circuitBreaker.canExecute() ?
executeWithFallback(migration) :
handleCircuitOpen(migration);
}
private MigrationResult executeWithFallback(Migration migration) {
try {
return migrateWithRetry(migration);
} catch (MigrationExecutionException e) {
if (canRecoverFromError(migration, e)) {
return attemptRecovery(migration, e);
}
throw new MigrationExecutionException("Migration failed and cannot recover", e);
}
}
private MigrationResult handleCircuitOpen(Migration migration) {
return new MigrationResult(
migration.getId(),
MigrationStatus.FAILED,
"Database circuit breaker open - migration failed",
new MigrationError("CIRCUIT_OPEN", "Database connection unavailable")
);
}
}
Error Logging Format and Alerting
Structured logging is implemented with consistent error format:
public class MigrationErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(MigrationErrorLogger.class);
public void logMigrationError(String migrationId, String operation, Exception error) {
logger.error(
"MIGRATION_ERROR: migrationId={}, operation={}, errorType={}, errorMessage={}, stackTrace={}",
migrationId,
operation,
error.getClass().getSimpleName(),
error.getMessage(),
Arrays.toString(error.getStackTrace())
);
}
public void logMigrationWarning(String migrationId, String operation, String warning) {
logger.warn(
"MIGRATION_WARNING: migrationId={}, operation={}, warning={}",
migrationId,
operation,
warning
);
}
}
Alerting thresholds:
- Critical: Migration failures > 5 in 5 minutes → On-call alert
- Warning: Validation errors > 10 in 1 hour → Slack notification
- Info: Migration rollback operations > 3 in 24 hours → Weekly report
11. Security Design
Input Validation and Sanitization Strategy
All inputs must undergo strict validation using a combination of:
- Whitelist-based validation for schema fields and values
- Length constraints to prevent buffer overflow attacks
- Type checking to ensure expected data types
- Regular expression validation for structured fields (e.g., email, phone)
- SQL injection prevention through parameterized queries and ORM usage
public class SchemaMigrationValidator {
private static final Pattern VALID_SCHEMA_NAME = Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$");
private static final Pattern VALID_TABLE_NAME = Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$");
public void validateMigrationRequest(SchemaMigrationRequest request) {
if (!VALID_SCHEMA_NAME.matcher(request.getSchemaName()).matches()) {
throw new ValidationException("Invalid schema name format");
}
if (request.getMigrationSteps() == null || request.getMigrationSteps().isEmpty()) {
throw new ValidationException("Migration steps cannot be empty");
}
for (MigrationStep step : request.getMigrationSteps()) {
validateMigrationStep(step);
}
}
}
SQL Injection Prevention
All database interactions must use parameterized queries and ORM frameworks to prevent injection attacks:
- Never construct SQL strings with user input
- Use JPA/Hibernate or MyBatis with parameterized queries
- Validate and sanitize all schema/table names before dynamic query construction
Authentication Middleware Design
Authentication middleware will use JWT tokens with the following security properties:
- Token expiration: 24 hours for access tokens, 7 days for refresh tokens
- Token revocation: Implement token blacklisting or short-lived tokens with refresh mechanism
- Secure cookie flags: HttpOnly, Secure, SameSite=Strict for session cookies
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && tokenProvider.validateToken(token)) {
Authentication auth = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
Authorization Enforcement
Role-based access control (RBAC) will be implemented with the following permissions:
- MIGRATION_ADMIN: Full CRUD access to all migration operations
- MIGRATION_DEVELOPER: Read and write access to migrations in their namespace
- MIGRATION_VIEWER: Read-only access to migration history
@PreAuthorize("hasRole('MIGRATION_ADMIN') or hasPermission(#migrationId, 'migrate')")
public void executeMigration(String migrationId) {
// Implementation
}
@PreAuthorize("hasRole('MIGRATION_ADMIN') or hasPermission(#schemaName, 'write')")
public void createSchema(String schemaName) {
// Implementation
}
Sensitive Data Encryption
At Rest: All sensitive data (migration scripts, credentials) will be encrypted using AES-256:
@Service
public class EncryptionService {
private final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
public String encrypt(String plainText, SecretKey key) throws Exception {
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encrypted = cipher.doFinal(plainText.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
}
In Transit: All API communications must use TLS 1.3 with:
- HSTS enabled (max-age=31536000)
- Certificate pinning for critical endpoints
- Forward secrecy using ECDHE ciphers
PII Handling and Masking Strategy
All personal identifiable information in migration logs will be:
- Masked using tokenization or redaction
- Logged only when necessary for debugging and audit purposes
- Compliant with GDPR/CCPA requirements
public class PiiMaskingService {
public String maskEmail(String email) {
if (email == null || email.isEmpty()) return email;
int atIndex = email.indexOf('@');
if (atIndex <= 0) return email;
String localPart = email.substring(0, atIndex);
String domain = email.substring(atIndex);
return localPart.charAt(0) + "****" + localPart.charAt(localPart.length() - 1) + domain;
}
}
Secrets Management
Secrets will be managed using AWS Secrets Manager with:
- Automatic rotation every 90 days
- IAM-based access control with least privilege principle
- Encrypted storage using KMS keys
@Service
public class SecretsManagerService {
private final AWSSecretsManager secretsManager;
public String getDatabaseCredentials(String secretName) {
GetSecretValueRequest request = GetSecretValueRequest.builder()
.secretId(secretName)
.build();
return secretsManager.getSecretValue(request).secretString();
}
}
Audit Logging Design
All security-relevant operations will be logged with:
- Timestamp in UTC
- User identity (principal, session ID)
- Operation type and target resource
- Success/failure status
- IP address of the request origin
@Component
public class SecurityAuditLogger {
private static final Logger logger = LoggerFactory.getLogger(SecurityAuditLogger.class);
public void logMigrationAttempt(String userId, String operation,
String resource, boolean success) {
Map<String, Object> auditEntry = new HashMap<>();
auditEntry.put("timestamp", Instant.now().toString());
auditEntry.put("userId", userId);
auditEntry.put("operation", operation);
auditEntry.put("resource", resource);
auditEntry.put("success", success);
auditEntry.put("ipAddress", getClientIpAddress());
logger.info("SECURITY_AUDIT: {}", auditEntry);
}
}
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85%+ code coverage with focus on business logic, validation rules, and error handling paths
- Repository Layer: 90%+ coverage for all data access methods with mocked database interactions
- Domain Objects: 100% coverage for value objects, domain events, and state transitions
- Core Migration Logic: 95%+ coverage including migration plan generation, execution state tracking, and rollback scenarios
- Configuration Parsing: 100% coverage for all configuration formats (YAML, JSON) and validation rules
Integration Testing Strategy
- Database Integration Tests:
- Use Testcontainers with PostgreSQL/MySQL to test actual migration execution against real databases
- Validate schema changes, data transformations, and constraint enforcement
- Test concurrent migration scenarios with multiple database instances
- Event System Integration:
- Use embedded Kafka or RabbitMQ for testing event publishing and consumption
- Validate migration state change events are properly emitted and consumed
- External Service Mocks:
- WireMock for external API dependencies (e.g., version control systems, cloud storage)
- Mockito for dependency injection testing of external services
Contract Testing
- Pact Broker Integration:
- Define consumer-driven contracts for migration service endpoints
- Test contract compatibility between migration service and external systems (e.g., CI/CD pipelines, monitoring tools)
- Automate contract verification in CI pipeline with Pact Broker
- API Contract Validation:
- Validate OpenAPI/Swagger specifications for migration endpoints
- Ensure all migration operations are properly documented and versioned
Performance Testing Tool and Key Scenarios
- Tool: k6 (JavaScript-based load testing framework) for performance validation
- Key Scenarios:
- Concurrent migration execution under load (100+ concurrent migrations)
- Large dataset migration performance (1M+ records)
- Migration rollback performance under stress
- Database connection pool utilization during migration bursts
- Metrics Tracked:
- p95/p99 response times for migration operations (target <200ms)
- Throughput (migrations/sec) under concurrent load
- Memory usage and GC pressure during long-running migrations
Test Data Management Strategy
- Fixtures:
- YAML/JSON fixtures for migration scripts and configuration files
- Database fixture data for testing data transformation logic (e.g., user data, audit logs)
- Factories:
- Factory classes for generating test migration objects with various states (pending, running, completed, failed)
- Factory methods for creating migration plan scenarios with different complexity levels
- Seeding:
- Database seeding scripts to populate test environments with realistic data volumes
- Automated seed generation for testing edge cases (empty migrations, circular dependencies)
- Test Data Isolation:
- Per-test database schema isolation using unique prefixes or dedicated test databases
- Transaction rollback after each test to maintain clean state
// Example unit test for migration execution logic with coverage focus
@Test
public void testMigrationExecutionWithRollback() {
// Given: Valid migration configuration and database state
MigrationConfig config = createValidMigrationConfig();
DatabaseState initialState = databaseService.getCurrentState();
// When: Execute migration with rollback capability
MigrationResult result = migrationService.executeMigration(config);
// Then: Verify successful execution and rollback capability
assertThat(result.getStatus()).isEqualTo(MigrationStatus.COMPLETED);
assertThat(result.getRollbackAvailable()).isTrue();
// Verify data integrity post-migration
assertThat(databaseService.getCurrentState().getSchemaVersion())
.isEqualTo(config.getTargetVersion());
}
13. Observability Design
Structured Logging Schema
The logging system follows a structured JSON schema to ensure consistent parsing and filtering across services:
{
"timestamp": "2023-10-15T14:30:45.123Z",
"level": "INFO",
"service": "schema-migration-service",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user_12345",
"request_id": "req_abcde12345",
"operation": "migrate",
"entity_id": "migration_001",
"message": "Migration started for schema version 2.3.1",
"duration_ms": 150,
"error_code": null,
"error_message": null,
"stack_trace": null,
"migration_details": {
"version": "2.3.1",
"status": "running",
"target_tables": ["users", "orders"]
}
}
PII masking rules:
- User IDs are masked in logs
- Email addresses and phone numbers are redacted using regex patterns
- Sensitive fields like passwords or tokens are completely omitted from logs
Distributed Tracing Instrumentation
OpenTelemetry is used for distributed tracing with custom spans:
Key Spans:
migration.start- Captures migration initiation and version infomigration.validate- Validates schema compatibilitymigration.execute- Tracks individual migration execution stepsmigration.rollback- Logs rollback operations if needed
Span Attributes:
// Example span attribute capture in migration execution
span.setAttribute("migration.version", migration.getVersion());
span.setAttribute("migration.target_tables", String.join(",", targetTables));
span.setAttribute("migration.status", "success");
Metrics Design
RED Method Metrics:
| Metric Name | Type | Labels | Description |
|---|---|---|---|
schema_migration_rate | Counter | version, status, operation | Migration rate per version |
schema_migration_errors | Counter | version, error_type | Error counts by migration version |
schema_migration_duration | Histogram | version, status | Execution time per migration |
schema_migration_active | Gauge | version | Currently running migrations |
Example Histogram Configuration:
Histogram migrationDuration = OpenTelemetry.get().getMeter("schema-migration")
.histogramBuilder("schema_migration_duration")
.setDescription("Migration execution duration in milliseconds")
.setUnit("ms")
.build();
// Sample histogram with labels
migrationDuration.record(150,
AttributeKey.stringKey("version").string("2.3.1"),
AttributeKey.stringKey("status").string("success"));
Health Check Endpoint Design
@RestController
@RequestMapping("/health")
public class HealthController {
@GetMapping("/live")
public ResponseEntity<Map<String, Object>> live() {
Map<String, Object> response = new HashMap<>();
response.put("status", "healthy");
response.put("timestamp", Instant.now());
return ResponseEntity.ok(response);
}
@GetMapping("/ready")
public ResponseEntity<Map<String, Object>> ready() {
Map<String, Object> response = new HashMap<>();
response.put("status", "ready");
response.put("database_connection", dbHealthCheck());
response.put("migration_engine", migrationEngineHealth());
return ResponseEntity.ok(response);
}
@GetMapping("/startup")
public ResponseEntity<Map<String, Object>> startup() {
Map<String, Object> response = new HashMap<>();
response.put("status", "startup_complete");
response.put("components", Arrays.asList("database", "migration_engine"));
return ResponseEntity.ok(response);
}
}
Alerting Thresholds
| Metric | Warning Threshold | Critical Threshold | Alert Channel |
|---|---|---|---|
schema_migration_errors (per minute) | > 5 errors | > 20 errors | On-call |
schema_migration_duration (p95) | > 30s | > 60s | On-call |
schema_migration_rate (per hour) | < 1 migration/hour | < 0.5 migration/hour | Team |
database_connection_failure | > 3 consecutive failures | > 10 consecutive failures | On-call |
Alerting Logic:
// Example alerting logic for migration errors
if (migrationErrors.get(1, TimeUnit.MINUTES) > 20) {
alertService.sendCriticalAlert("High migration error rate detected");
}
On-call Escalation Path:
- First alert → Slack notification to team
- Second alert within 5 minutes → SMS to on-call engineer
- Third alert within 10 minutes → PagerDuty escalation to primary on-call
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Migration rollback strategy: Should rollbacks be automatic or manual? What's the acceptable window for rollback operations in production?
- Migration execution window: When can migrations run? Should they be restricted to maintenance windows or allow for hot-swapping during peak hours?
- Version compatibility policy: How do we handle migration versioning when multiple services depend on the same schema? Should we enforce strict version alignment or allow flexible compatibility?
- Backward compatibility guarantee: What level of backward compatibility do we need to maintain for older versions during migration?
Known trade-offs and justifications
- Atomicity vs. Performance: The framework supports atomic migrations but at cost of longer execution times and potential write locks on tables. For large datasets, we might prefer batched migrations that trade atomicity for performance.
- Flexibility vs. Safety: Providing extensive customization options (e.g., custom migration scripts) increases flexibility but also increases risk of human error. We've chosen to balance this with strict validation and sandboxed execution where possible.
- Self-hosted vs. Managed Solution: Building a self-hosted migration framework provides full control but requires more operational overhead than using managed services like Flyway or Liquibase.
- Real-time vs. Batch Migration: Supporting real-time schema changes (e.g., online DDL in MySQL) provides better availability but can increase complexity and potential for data inconsistency during migration.
Risks and mitigation strategies
- Data Loss Risk: Risk of data corruption or loss during migration. Mitigation includes: pre-migration backups, validation checks, and rollback capability.
- Migration Timeout Risk: Long-running migrations can timeout or block database operations. Mitigation includes: batch processing, progress tracking, and configurable timeouts.
- Race Condition Risk: Concurrent migration execution across multiple instances. Mitigation includes: distributed locking, optimistic concurrency control, and instance-level migration locks.
- Version Conflict Risk: Multiple services trying to apply the same migration in different orders. Mitigation includes: versioned migration IDs, dependency tracking, and centralized migration coordination.
- Performance Degradation Risk: Migration operations can impact production database performance. Mitigation includes: read-only migration mode, background processing, and resource throttling.
Technical debt acknowledged
- Migration Script Complexity: Complex migration scripts with multiple dependencies and conditional logic increase maintenance burden and testing complexity.
- Legacy Migration Support: Supporting older migration formats (e.g., Flyway-style) introduces technical debt through code duplication and compatibility layers.
- Custom Migration Logic: Extensibility points for custom migration logic may lead to inconsistent patterns and reduced maintainability if not properly documented and enforced.
- Cross-Database Compatibility: Supporting multiple database engines (PostgreSQL, MySQL, Oracle) introduces complexity in migration script generation and execution, leading to potential inconsistencies.
- Migration State Management: The framework's internal state tracking (migration status, version history) introduces additional complexity and potential for state synchronization issues across distributed systems.