Chapter 21 · PostgreSQL, MySQL, and Cross-Provider Portability

Design a Provider-Abstraction Boundary Without Falling into Lowest-Common-Denominator Data Modeling

Place database-specific capabilities behind intentional adapters and capability contracts without erasing PostgreSQL/MySQL strengths or inventing a ceremonial repository layer that hides observable EF and SQL behavior.

Advanced170–220 minutescapability-boundary + contract-test labEF Core 10.0.11 · Npgsql EF provider 10.0.3 · Oracle MySql.EntityFrameworkCore 10.0.9 · PostgreSQL 18.6 · MySQL 8.4 LTS · .NET 10.0.11 · SDK 10.0.400Free local PostgreSQL/MySQL path · MariaDB/Pomelo EF9 observation onlyProvider compatibility reviewed: August 27, 2026

Learning outcomes

01

Define a provider boundary around business capabilities rather than hiding DbContext behind a lowest-common-denominator repository.

02

Keep portable query/write workflows in shared services while routing genuinely native search/storage operations through explicit provider adapters.

03

Use capabilities and feature probes only where deployment really supports multiple providers, not as ceremony around a single-database product.

04

Preserve observability by exposing generated SQL/provider identity and by testing adapters against their actual database engines.

05

Avoid leaking provider-specific CLR types into domain contracts unless the application intentionally becomes provider-specific.

06

Design acceptance tests that prove both portable core behavior and provider-native advantages after upgrades.

1. The problem: abstraction can either clarify ownership or hide the database

After four lessons it is clear that PostgreSQL and MySQL do not differ only in connection strings. A common reaction is to invent a giant IRepository<T> exposing FindAll, FindBy, Add, and generic specifications until every database feature is inaccessible. That is not portability—it is information loss. DbContext already supplies unit-of-work/repository-like capabilities; the useful abstraction is around the business capability that truly differs.

Do not abstract EF merely to avoid seeing EF

A boundary should make provider ownership explicit, simplify tests/deployment, or isolate native behavior. If it only wraps DbSet with another generic CRUD API, it adds indirection without creating portability.

2. Split portable workflow from native capability

ServiceHub’s ordinary work-order lifecycle—load by ID, validate, change summary/priority, save with the Revision token—can stay provider-neutral because Chapters 01–20 already define those EF semantics. Search, similarity, advanced JSON/range/array queries, and provider-specific bulk/migration features may deserve adapters.

csharp · portable command service keeps DbContext semantics visible
public sealed class WorkOrderService(ServiceHubContext db){    public async Task ReviseSummaryAsync(        int id, Guid expectedRevision, string summary, CancellationToken ct)    {        var workOrder = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);        db.Entry(workOrder).Property(x => x.Revision).OriginalValue = expectedRevision;        workOrder.ReviseSummary(summary);        workOrder.AdvanceRevision();        await db.SaveChangesAsync(ct);    }}

This service benefits from EF’s tracking/concurrency behavior on both providers. There is no need to replace it with generic CRUD just because two databases are supported.

3. Define capability interfaces at the point of real divergence

Suppose PostgreSQL deployments support regex/array search while MySQL deployments use full-text/generated-column or JSON-backed search. The portable application asks for a search result; the provider adapter owns query syntax and indexes.

csharp · business-shaped provider seam
public interface IWorkOrderSearch{    Task<IReadOnlyList<WorkOrderHit>> SearchAsync(        WorkOrderSearchRequest request,        CancellationToken ct);}public sealed record WorkOrderSearchRequest(    string Text,    int Limit,    string? RequiredToken = null);public sealed record WorkOrderHit(int Id, string WorkOrderNumber, double Score);

The interface does not mention ILIKE, regex operators, arrays, MySQL JSON functions or Npgsql types. Each implementation may use those features internally and can expose provider-specific diagnostics for tests/operations.

4. Prefer deployment-time capability selection to scattered provider conditionals

One anti-pattern checks Database.ProviderName in dozens of repositories. That spreads database knowledge across the application and makes unsupported combinations easy to create. Bind the adapter once during composition.

csharp · composition-root selection
if (configuration["Database:Provider"] == "Postgres"){    services.AddDbContext<ServiceHubContext>(o => o.UseNpgsql(pgConnection));    services.AddScoped<IWorkOrderSearch, PostgresWorkOrderSearch>();}else if (configuration["Database:Provider"] == "MySql"){    services.AddDbContext<ServiceHubContext>(o => o.UseMySQL(mySqlConnection));    services.AddScoped<IWorkOrderSearch, MySqlWorkOrderSearch>();}else{    throw new InvalidOperationException("Unsupported ServiceHub database provider.");}

Startup should also validate that the provider package/database version/migration assembly matches the configured product profile.

5. Capability flags are useful only when the product truly varies at runtime

If the same binary can legitimately run with multiple providers and some feature is optional, a small immutable capability descriptor can drive UI/feature selection. Do not probe arbitrary database features on every request.

csharp · explicit capability descriptor
public sealed record DatabaseCapabilities(    string Provider,    bool RegexSearch,    bool NativeArrays,    bool NativeRanges,    bool JsonPathIndexing,    bool ProviderSpecificBulkPath);// Construct once from the supported deployment profile and verified server version.// It is not authorization and it is not a substitute for integration tests.

Keep security/tenant authorization separate. “Database supports regex” must never decide whether a user may see a row.

6. Do not leak Npgsql/MySQL-specific CLR types into shared domain contracts accidentally

If NpgsqlRange<DateTime> appears in the core application DTO, the application is no longer meaningfully provider-neutral. That may be acceptable for a PostgreSQL-only product, but it should be intentional. For multi-provider ServiceHub, expose MaintenanceWindow(StartUtc, EndUtc) in the domain and translate it inside the PostgreSQL adapter to a range query while MySQL uses two columns.

csharp · provider-neutral domain value
public readonly record struct MaintenanceWindow(    DateTime StartUtc,    DateTime EndUtc){    public bool Contains(DateTime instantUtc)        => instantUtc >= StartUtc && instantUtc < EndUtc;}

The provider-specific persistence model may differ radically while the business invariant remains stable.

7. Observability must survive the abstraction

A provider adapter that swallows SQL, timing, errors, migration version and provider name makes production diagnosis worse. Reuse Chapter 18’s logs/traces/query tags and expose stable operation names rather than raw sensitive SQL as metric labels.

csharp · stable query tagging inside an adapter
var query = db.WorkOrders    .TagWith("ServiceHub.Search.Postgres.v1")    .Where(w => EF.Functions.ILike(w.CustomerName, pattern))    .Select(/* bounded read model */)    .Take(request.Limit);logger.LogInformation(    "WorkOrder search provider {Provider} limit {Limit}",    db.Database.ProviderName,    request.Limit);

Keep raw SQL and parameter values out of high-cardinality production metrics. Correlate a trace exemplar to logs and database plan evidence when diagnosing a slow provider-specific operation.

8. Failure case: lowest-common-denominator repository blocks useful features and still is not portable

A generic repository forbids provider functions, JSON, arrays, range predicates, set-based writes and raw SQL. Teams reimplement them with in-memory filtering, N+1 loops, serialized blobs, or hidden casts. The app becomes slower and more fragile while migrations and collations still differ underneath.

Repair: keep EF Core directly available in persistence/application services for portable workflows, define focused adapters for real divergence, and require provider integration tests. Portability comes from controlled seams and evidence, not from banning the database.

9. Contract tests define what “supported provider” means

A release should not claim PostgreSQL/MySQL support because both contexts start. Encode support as tests:

Suite Runs on every provider? Purpose
Domain invariants yes pure business rules
Portable EF contract yes relationships, concurrency, transactions, common queries/writes
Migration lifecycle yes, provider-specific artifact empty→latest + previous→latest
Provider capability adapter only owning provider native search/types/index/JSON semantics
Performance budget per production topology plans, p95/p99, connection/pool behavior
Failure/recovery per provider timeouts, deadlocks/locks, retries, concurrency conflicts

10. Mandatory lab: design and prove the boundary

  1. Keep WorkOrderService provider-neutral and run its concurrency-aware update contract on PostgreSQL and MySQL.
  2. Define IWorkOrderSearch and implement PostgresWorkOrderSearch using one Npgsql-native feature (for example regex/ILIKE/array search).
  3. Implement MySqlWorkOrderSearch using a MySQL-native text/JSON/collation approach verified by current provider/database behavior.
  4. Bind the correct implementation once in the composition root and fail fast on an unsupported provider value.
  5. Add a DatabaseCapabilities descriptor only for a feature that genuinely varies; write a test that prevents it from being used as authorization.
  6. Run shared portable contract tests on PostgreSQL 18.6 and MySQL 8.4 LTS; run adapter-specific tests only on their owners.
  7. Capture query tags, generated SQL, parameters and one plan for each search adapter.
  8. Write an ADR-style decision: which semantics are portable, which features are provider-native, which migration stream owns each schema extension, and what must pass before a provider is marketed as supported.

11. Production judgment and bridge to Chapter 22

A healthy provider abstraction is narrow and evidence-backed. It preserves EF Core’s useful unit-of-work/query semantics, keeps native database strengths available, and localizes divergence where the product actually needs it. It also states when portability is not worth the cost. With provider boundaries explicit, Chapter 22 can introduce global query filters and multi-tenancy without accidentally confusing tenant isolation with a provider abstraction or assuming filters alone are authorization.

Check your understanding

  1. Why is a generic CRUD repository not automatically a portability layer?
  2. Where should provider-specific search syntax live?
  3. Should Database.ProviderName conditionals be spread across services?
  4. When is leaking an Npgsql type into a shared contract acceptable?
  5. What proves a provider is supported?
  6. What should observability expose across adapters?
Review the answers

1. It often hides EF/database capabilities without removing provider differences in SQL, types, migrations, collations or operations.

2. In a focused provider adapter/capability implementation selected at composition time.

3. No. Prefer one composition/boundary decision and isolated provider implementations.

4. When the product intentionally becomes PostgreSQL-specific; otherwise translate from a provider-neutral business value at the persistence boundary.

5. Passing shared semantic/write/migration/failure tests plus its provider-specific capability and performance tests on the declared database version/topology.

6. Stable operation names, provider identity, timing/errors/trace correlation and plan evidence without sensitive/high-cardinality labels.

Authoritative references

Portability architecture should build on EF’s existing DbContext capabilities and current provider behavior, not add abstraction for its own sake.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n