Advanced Topics
Writing a Custom Provider
To add support for a new database, implement the following:
- IMigrationRecordStore – 5 methods that manage migration state and locking.
- Provider-specific MigrationOptions – extend
MigrationOptionsto add connection and lock settings for your database. - ServiceCollectionExtensions.AddXxxMigrations() – register the record store, options, runner, and resource runner with DI.
- Optionally, a ResourceRunner<T> for resource-based migrations.
IMigrationRecordStore Interface
public interface IMigrationRecordStore
{
// Lifecycle
Task InitializeAsync(CancellationToken cancellationToken = default);
Task<IDisposable> CreateLockAsync();
// Per-record CRUD
Task<bool> ExistsAsync(string recordId);
Task<MigrationRecord> ReadAsync(string recordId);
Task DeleteAsync(string recordId);
Task WriteAsync(string recordId);
// Squash-aware write: persists Checksum + Kind + Replaces.
// Default-interface-method delegates to the legacy WriteAsync(string)
// for backward compatibility; shipped providers override.
Task<WriteOutcome> WriteAsync(
MigrationRecord record,
WritePrecondition precondition = WritePrecondition.None,
CancellationToken cancellationToken = default);
// Bulk reads used during reconciliation.
Task<IReadOnlySet<string>> IntersectWithAppliedAsync(
IEnumerable<string> candidateIds,
CancellationToken cancellationToken = default);
Task<IReadOnlySet<long>> IntersectWithSquashedAsync(
IEnumerable<long> versions,
CancellationToken cancellationToken = default);
}
Lifecycle
- InitializeAsync – create tables, collections, or sets needed for tracking.
- CreateLockAsync – acquire a distributed lock; return an
IDisposablethat releases it. Provider-native locking (each provider uses its own store’s primitives, not a shared lock service).
Per-record CRUD
- ExistsAsync – realtime point-lookup; checks whether a migration record has already been applied. Used in the runner’s discover loop.
- ReadAsync – realtime read returning the full
MigrationRecord(id, runOn, checksum, kind, replaces). Used for ledger inspection and integrity checks. - WriteAsync(string) – legacy v2 overload; persists only the record id. Kept for source compatibility with v2 record-store implementations.
- DeleteAsync – remove a migration record (used during down migrations).
Squash-aware write (v3)
-
WriteAsync(MigrationRecord, …) – the v3 write path. Persists the record id along with its
Checksum,Kind(Migration/Squash/Baseline), andReplacesarray. The optionalWritePreconditionensures concurrent runners don’t double-write a row. Returns aWriteOutcomedistinguishingCreated,AlreadyExistsBenign(the row exists with matching content – treated as no-op success), andPreconditionFailed(the row exists with a different checksum – hard error).The default-interface-method implementation delegates to the legacy
WriteAsync(string)so v2 record-store implementations compile unchanged. Shipped providers override with a single-round-trip persist that captures all three fields.
Bulk reads (v3, squash reconciliation)
- IntersectWithAppliedAsync – given a candidate set of record ids, returns the subset already in the ledger. Single round trip per reconciliation pass. Default implementation falls back to a per-id
ExistsAsyncloop – adequate but slow for large migration sets. - IntersectWithSquashedAsync – given a candidate set of migration versions, returns the subset transitively covered by some applied squash row’s
Replacesarray. Default returns an empty set; custom implementations must walk the squash graph to support fresh-install reconciliation against squashed history.
Custom Conventions
IMigrationConventions controls how record IDs are generated for each migration.
- Default format:
Record.{version}.{normalized-class-name} - Override by implementing
IMigrationConventionsand assigning it tooptions.Conventionsduring registration.
Custom Migration Activator
IMigrationActivator controls how migration instances are created.
- The default uses
ActivatorUtilities.CreateInstance(standard DI). - Override for custom instantiation logic, such as pulling migrations from a container scope or applying cross-cutting concerns.
Retry Strategies
Two built-in retry strategies are available for polling operations:
- BackoffRetryStrategy – exponential backoff with jitter. Default: 100ms initial delay, 120s maximum delay.
- PauseRetryStrategy – fixed delay between retries. Default: 1s delay.
These are used by WaitHelper for polling operations such as waiting for Aerospike secondary index readiness.
Distributed Locking Details
Each provider implements locking at the database layer using native primitives:
- Locks have a maximum lifetime to prevent orphaned locks from blocking future runs.
- The lock is acquired in a
usingblock and released automatically when disposed. - If lock acquisition fails, a
MigrationLockUnavailableExceptionis thrown and the runner skips execution.
Provider Lock Options
| Option | Aerospike | Couchbase | MongoDB | OpenSearch | PostgreSQL |
|---|---|---|---|---|---|
| LockName | Yes | Yes | Yes | Yes | Yes |
| LockMaxLifetime | Yes | Yes | Yes | Yes | Yes |
| LockExpireInterval | No | Yes | No | No | No |
| LockRenewInterval | No | Yes | No | Yes | No |
| LockStaleAfter | No | No | No | Yes | No |
Couchbase + OpenSearch support additional lock options because their lock implementations use renewal loops to extend the lock during long-running migrations. OpenSearch additionally exposes LockStaleAfter for forensic recovery from crashed runners (it uses a split lock index).
Error Handling
The library defines a hierarchy of exceptions for migration failures:
| Exception | Description |
|---|---|
MigrationException | Base exception for all migration errors |
DuplicateMigrationException | Two migrations share the same version number |
MigrationLockUnavailableException | Distributed lock could not be acquired |
MigrationTimeoutException | A resource operation exceeded its timeout |
RetryTimeoutException | Polling via WaitHelper exceeded its timeout |
All exceptions derive from MigrationException, so a single catch block can handle the full range of migration failures when fine-grained handling is not needed.