Architecture case study · open-source library

Evolving Data Access Without Coupling the Domain to a Legacy Database

A CUSTOMER table exposes CUST_NO and LEGAL_NM; the domain should speak in Customer.Id and Customer.Name. FluentMap makes that translation explicit without persistence attributes on the entity.

The enterprise scenario is illustrative. It demonstrates public library contracts; it is not a customer, deployment, or production performance claim.

Context and motivation

The database vocabulary does not have to become the domain vocabulary.

Legacy schemas carry abbreviations, historical conventions, and names shared by systems that cannot all be renamed in one delivery. Repeating those names in the .NET model transfers persistence cost into business rules, APIs, and tests.

The goal is not to hide SQL or create a full ORM. Dapper remains responsible for queries and connections, while a centralized, strongly typed configuration translates physical names into properties that communicate intent.

Constraints and quality attributes

Modernize the boundary without breaking historical contracts.

01

Stable schema

Tables and columns are shared and cannot immediately adopt domain terminology.

02

Clean domain

Entities receive no attributes that make them aware of persistence mechanics.

03

Compatibility

The 3.x line preserves primary 2.x APIs, with documented differences and opt-in additions.

04

Layered evidence

Dapper compatibility, Core certification, and Dommel integration are separate claims.

MaintainabilityTestabilityCompatibilityReadabilityIsolationEvolvability
Flow from CUSTOMER columns CUST_NO and LEGAL_NM through Dapper and an EntityMap to Customer properties Id and Name.
The result still comes from SQL executed by Dapper; the map controls how columns materialize the object.

Architecture decisions

Mapping is an explicit boundary, not an abstraction over all data access.

Typed maps

EntityMap<T> connects property expressions to columns. Property renames go through the compiler, and configuration can be validated at startup.

Property mapping ↗

Conventions with precedence

Conventions reduce repetition when schemas follow patterns. Explicit maps remain suitable for exceptions and follow documented precedence.

Mapping precedence ↗

Immutable configuration

Global configuration is built before concurrent queries and then treated as read-only. FluentMapRuntime provides isolated configuration for QueryMapped* APIs.

Runtime isolation ↗

Optional Dommel

The Dommel package adds metadata and persistence integration, but its resolvers and builders remain process-global. Isolated FluentMap runtimes do not isolate Dommel.

Dommel integration ↗

Opt-in generation

Source generators register eligible maps and materializers for supported shapes. They do not parse SQL or cover every materialization shape; unsupported cases can fall back.

Source generators ↗

Static diagnostics

Analyzers detect configuration problems visible at compilation. Dynamic registration, reflection, and runtime-dependent behavior remain outside that proof.

Roslyn analyzers ↗

Scenario implementation

An attribute-free entity, an infrastructure map, and a normal Dapper query.

using Dapper;
using Dapper.FluentMap;
using Dapper.FluentMap.Mapping;

FluentMapper.Initialize(config => config.AddMap<CustomerMap>());
FluentMapper.Validate();

const string sql = """
    SELECT CUST_NO, LEGAL_NM
    FROM CUSTOMER
    WHERE CUST_NO = @id
    """;

Customer customer = connection.QuerySingle<Customer>(sql, new { id = 42 });

public sealed class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

public sealed class CustomerMap : EntityMap<Customer>
{
    public CustomerMap()
    {
        Map(customer => customer.Id).ToColumn("CUST_NO");
        Map(customer => customer.Name).ToColumn("LEGAL_NM");
    }
}

The example uses the current documented API. The application and Dapper still own the connection, transaction, parameters, query, and migrations.

Materialization flow

The ADO.NET provider gives Dapper a reader containing CUST_NO and LEGAL_NM. FluentMap's installed type map resolves those names to Id and Name. Dapper creates and populates Customer; FluentMap neither tracks the entity nor rewrites the SQL.

Alternatives and trade-offs

Three valid approaches place cost in different locations.

ApproachExampleAdvantageCost and best fit
SQL aliasesSELECT CUST_NO AS Id, LEGAL_NM AS NameNo extra configuration; intent is visible in the query.Repeats aliases across queries. Good for a one-off read or SQL owned by one operation.
Persistence DTOCustomerRow.CUST_NO → Customer.IdFully explicit boundary and transformation; DTOs can model joins and projections.More types and conversion code. Good when the persistence shape differs semantically from the aggregate.
Explicit FluentMapMap(x => x.Id).ToColumn("CUST_NO")Reuses a typed map and leaves the entity free of attributes.Global configuration requires discipline and does not remove SQL. Good when entities recur across queries with stable physical names.

Evolution and maintenance

Add capabilities without erasing the surface applications already use.

From 2.x to 3.x

EntityMap<T>, FluentMapper.Initialize(...), and the Dapper type-map bridge remain. For the common historical path, migration is usually a coordinated package update followed by validation and tests.

Migration guide ↗

Bounded global state

Normal Dapper.Query<T>() uses Dapper's global type map, and Dommel is also global. FluentMapRuntime isolation applies to mapped APIs that accept or belong to that runtime.

Global-state limits ↗

Provider matrix

SQLite, SQL Server, PostgreSQL, MySQL, and MariaDB have real Core and Dommel evidence in the current matrix. Oracle and Firebird have Core evidence but no Dommel certification.

Compatibility matrix ↗

Verifiable contracts

The documented ranges are Dapper [2.1.79,3.0.0) and Dommel [3.5.3,4.0.0). Experimental canaries do not expand the matrix until it is intentionally updated and reviewed.

Machine-readable contract ↗

Limitations and risks

FluentMap complements Dapper; it is neither a replacement nor a full ORM.

The library does not manage connections, transactions, migrations, tracking, or query design. Late global configuration can conflict with concurrent queries; initialize before use and then treat configuration as immutable.

Expected Dapper/ADO.NET compatibility does not equal FluentMap certification. Core certification does not imply Dommel. Full Native AOT support is also not claimed: the strict generated path has bounded coverage, and incompatible shapes can use runtime fallback.

Benchmarks use BenchmarkDotNet and SQLite in repository-defined scenarios. The committed baseline contains "reportOnly": true; it is exploratory evidence and a trend signal, not a general performance promise across environments, providers, or workloads.

Lessons

Compatibility is sustained through boundaries, documentation, and tests.

A map's value is not hiding the database but locating the translation. Aliases, DTOs, and FluentMap remain architectural options; reuse frequency, semantic distance, and isolation needs indicate which one costs less.

In library evolution, opt-in capabilities expand materialization, diagnostics, and integration without forcing historical consumers to adopt everything. Separate matrices and precise language keep test evidence from being generalized beyond its actual coverage.