Skip to main content

Schema Migration Framework Design and Implementation

Comprehensive low-level design for a robust database schema migration system supporting version control, rollback capabilities, and concurrent deployment across environments.

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

  1. 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)?
  2. What consistency and durability requirements exist?

    • Is there a need for atomicity across multiple databases or services?
    • Should migrations be idempotent and retryable?
  3. 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?
  4. What is the read/write ratio for migration metadata?

    • Is the system primarily used for writing new migrations or reading migration history?
  5. What latency sensitivity is expected?

    • How quickly must migrations be applied or rolled back?
    • Are there SLA requirements for zero-downtime deployments?
  6. 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?
  7. 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

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

MethodPathDescriptionAuth
GET/migrationsList all migrations with status and metadataJWT
POST/migrationsCreate a new migration scriptJWT
GET/migrations/{id}Get details of a specific migrationJWT
PUT/migrations/{id}/statusUpdate migration status (pending, running, completed, failed)JWT
POST/migrations/{id}/executeExecute a specific migrationJWT
DELETE/migrations/{id}Delete a migration (soft delete)JWT
GET/migrations/historyGet migration history with execution logsJWT
POST/migrations/validateValidate a new migration script against DB schemaJWT

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 CodeDescription
200Success - GET, PUT, DELETE
201Created - POST
400Bad Request - Malformed request or validation error
401Unauthorized - Missing or invalid auth token
403Forbidden - Insufficient permissions
404Not Found - Resource not found
409Conflict - Migration already exists or in progress
422Unprocessable Entity - Validation errors
500Internal 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:

  1. It's more performant for large datasets
  2. It handles data changes during pagination gracefully
  3. 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-Key with 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 NameColumnsTypePurpose
idx_migration_schemas_name_versionname, versionUniqueFast lookup by name and version
idx_migration_schemas_statusstatusStandardEfficient filtering by migration status
idx_migration_history_migration_idmigration_idStandardFast lookup of history records per migration
idx_migration_history_executed_atexecuted_atStandardEfficient chronological queries
idx_migration_dependencies_mig_idmigration_idStandardFast lookup of dependencies for a migration
idx_migration_dependencies_depends_ondepends_on_migration_idStandardFast 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 modification
  • created_by / updated_by: User or system identifier for audit trail
  • status field: 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:

  1. PENDING → APPLIED (successful execution)
  2. PENDING → FAILED (execution error)
  3. APPLIED → ROLLBACK (manual or automatic rollback)
  4. FAILED → APPLIED (retry after fix)
  5. 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:

  1. MigrationFramework - The main entry point that orchestrates the migration lifecycle
  2. MigrationService - Core business logic for migration operations and state management
  3. MigrationRunner - Executes individual migration scripts with proper transaction handling
  4. MigrationRepository - Data persistence layer for migration metadata
  5. MigrationValidator - Validates migration scripts and schema integrity
  6. MigrationLock - Provides distributed locking to prevent concurrent migrations
  7. 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:

  1. Migrations are typically single database operations
  2. Rollbacks can be handled within the same transaction scope
  3. 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:

  1. Multiple processes attempt to apply migrations simultaneously
  2. Migration state is updated concurrently by different threads
  3. 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:

  1. Consistent lock ordering - All migrations acquire locks in the same order (by migration ID hash)
  2. Lock timeout configuration - 30-second timeout on advisory locks
  3. Migration dependency tracking - Prevent circular dependencies between migrations
  4. Resource pool limits - Configurable maximum concurrent migrations to prevent resource exhaustion

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is ensured through:

  1. Migration ID uniqueness - Each migration has a unique identifier (UUID)
  2. State tracking - Migration status is stored and checked before execution
  3. Idempotency keys - For external migration triggers, use idempotency keys to prevent duplicate processing
  4. 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

  1. Immutable migration objects - Once created, migration metadata cannot be changed
  2. Thread-safe state management - Using concurrent collections and atomic operations
  3. Synchronized access to shared resources - Database connections, migration registry
  4. 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:

  1. Explicit control over cache population and invalidation
  2. Consistency guarantees with database state
  3. Predictable performance characteristics for migration operations
  4. 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 TypeTTLReason
Migration state5 minutesShort-lived, frequently accessed during migration
Migration status1 hourLong-lived, less frequent updates
Migration history7 daysHistorical tracking for audit and debugging
Migration metadata1 dayConfiguration 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:

  1. Migration Script Control: Raw queries give complete control over migration scripts and their execution context
  2. Performance: Avoids ORM overhead during migration execution
  3. Database Compatibility: Allows direct database-specific optimizations and syntax
  4. 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:

  1. Prepared Statements: All queries use prepared statements to prevent SQL injection
  2. Index Usage: Queries are optimized to leverage database indexes
  3. 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:

  1. Migration status updates are idempotent operations
  2. The system can safely reprocess status updates without data corruption
  3. 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:

  1. Ensures migration status events are published atomically with database writes
  2. Prevents event loss during system failures
  3. Provides eventual consistency guarantees across distributed systems

Implementation approach:

  • Events are written to an outbox_events table 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:

  1. Validation errors - Caught and converted to MigrationValidationException at API boundary
  2. Migration execution errors - Caught and wrapped in MigrationExecutionException at service layer
  3. Infrastructure errors - Bubble up to callers with retry logic where appropriate
  4. System errors - Logged and re-thrown as SystemException for 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:

  1. migration.start - Captures migration initiation and version info
  2. migration.validate - Validates schema compatibility
  3. migration.execute - Tracks individual migration execution steps
  4. migration.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 NameTypeLabelsDescription
schema_migration_rateCounterversion, status, operationMigration rate per version
schema_migration_errorsCounterversion, error_typeError counts by migration version
schema_migration_durationHistogramversion, statusExecution time per migration
schema_migration_activeGaugeversionCurrently 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

MetricWarning ThresholdCritical ThresholdAlert Channel
schema_migration_errors (per minute)> 5 errors> 20 errorsOn-call
schema_migration_duration (p95)> 30s> 60sOn-call
schema_migration_rate (per hour)< 1 migration/hour< 0.5 migration/hourTeam
database_connection_failure> 3 consecutive failures> 10 consecutive failuresOn-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:

  1. First alert → Slack notification to team
  2. Second alert within 5 minutes → SMS to on-call engineer
  3. 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.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend LLD

    Document Management System LLD Design

    Comprehensive low-level design for a scalable document management system with version control, search, and access management capabilities.

  • Backend LLD

    File Versioning System Design

    Comprehensive low-level design for a scalable file versioning system with snapshot, rollback, and storage optimization capabilities.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.