#Abstract
Portable object-relational mappers express queries in the intersection of what their backends support, and applications escape that intersection at the first query with real requirements. This note argues the module boundary belongs below query construction rather than above it, reports the cost of eliminating reflection from the query path as measured on a single insert microbenchmark, and states the conditions under which the portable design is the correct one.
#The intersection problem
Each supported backend contributes constraints, and the expressible language is their intersection. Distinct-on, containment operators over semi-structured columns and skip-locked selection exist in some engines and not others. Column-oriented engines have pre-filtering and sampling constructs with no row-store equivalent. Document stores have aggregation pipelines that do not translate.
The observed consequence is that applications use the mapper for simple access and drop to raw queries for the rest, producing two access patterns where the mapper covers the half that needed the least help.
The distribution of that split is what makes the situation frustrating rather than merely imperfect. The queries that escape the mapper are the ones carrying business logic, the ones that are performance sensitive, and the ones most likely to be wrong. They are therefore the queries that would benefit most from a typed, composable construction interface, and they are precisely the ones for which the abstraction has nothing to offer. The mapper is present for the queries a competent developer would get right by hand and absent for the ones they would not.
#Where the boundary belongs
Parnas asks which decision a module hides . Connection management, metadata caching, migration ordering, hooks and result streaming are genuinely common across backends, and a module hiding them serves its purpose. Query construction is not common, and a module claiming to hide it is hiding a decision that differs materially between engines.
There is a second way to see the same point. A portable mapper's interface asserts that its backends are substitutable: that a program written against it behaves correctly with any of them. Behavioural subtyping makes that assertion precise, requiring that a substituted implementation preserve the properties clients depend on . Database engines do not satisfy it. They differ in isolation behaviour, in null ordering, in collation, in how they handle concurrent writes to the same row, and a program that is correct under one may be subtly wrong under another while compiling and passing its tests. An interface that claims substitutability where none exists does not remove the differences, it conceals them until they surface as defects in production.
The proposal is a shared core with a per-driver query builder reached by an explicit unwrap. Portability is surrendered deliberately and visibly, at a point in the code a reader can see.
1// Portable core: connection, mapping, migrations, hooks, streaming.2repo := grove.For[Asset](db)3one, err := repo.ByID(ctx, id)45// Explicit descent. The type is dialect-specific; there is no pretence6// that this compiles against another engine.7pg := grove.Unwrap[postgres.Builder](repo)8rows, err := pg.Select().9 DistinctOn("site_id").10 Where(pg.JSONContains("attrs", filter)).11 OrderBy("site_id", "recorded_at DESC").12 SkipLocked().13 All(ctx)
The value of the unwrap being explicit is that portability becomes greppable. A team that wants to know what it would cost to change engines can find every site that would need rewriting, rather than discovering them by attempting the migration. Concealed coupling is not less coupling, it is coupling nobody has counted .
Portability across engines is a property most applications never exercise and every portable mapper charges for.
#What the shared core actually contains
An argument that query construction should not be portable invites the question of what is left, and the answer is most of the library by volume. Listing it matters because the design is easily misread as abandoning abstraction, which it does not.
- Connection and pool management. Acquisition, health checking, retry on transient failure and lifecycle. Engines differ in their wire protocols and not in what a pool must do.
- Struct metadata. Field to column mapping, tags, embedded structs, nullability and the prepared descriptions that make the measured performance possible. This is one implementation serving every driver.
- Scanning and materialisation. Turning driver rows into typed values, including nested and slice results. Genuinely common, tedious, and exactly the sort of thing an application should not write.
- Migrations. Ordering, checksumming, locking so that concurrent instances do not apply the same migration twice, and recording what has run. The migration bodies are dialect-specific; everything around them is not.
- Hooks and instrumentation. Before and after callbacks, context propagation, tracing spans and slow query reporting.
- Transactions and savepoints. Nesting, propagation semantics and rollback, with the isolation level chosen explicitly rather than defaulted, since that is one of the places engines differ.
By line count this is the large majority of the library. What the design gives up is a portable expression language for the twenty per cent of queries that carry real requirements, which is precisely the part a portable mapper does worst.
Worth noting that the boundary is not perfectly clean. Migrations sit awkwardly across it, since the machinery is common and the statements are not, and result streaming behaves differently enough between engines that the common interface is a slight abstraction over genuinely different mechanisms. A design that claimed a crisp line would be overstating.
#Measured cost of reflection
Insert benchmark, in-memory embedded engine, Go 1.25.7 on arm64, five runs averaged. Absolute values are less interesting than the ratios.
- Raw driver interface: 4,015 ns per operation, 880 bytes, 20 allocations.
- The described implementation: 4,381 ns, 1,283 bytes, 28 allocations. Approximately 9 per cent over raw.
- Comparison mapper A: 8,459 ns, 5,470 bytes, 27 allocations. Approximately 111 per cent over raw.
- Comparison mapper B: 10,265 ns, 4,954 bytes, 66 allocations. Approximately 156 per cent over raw.
The mechanism is that struct metadata is resolved once at registration and cached, so the query path walks a prepared description rather than the reflection API, with pooled buffers. This is available to any mapper willing to give up runtime schema flexibility, and the trade is stated rather than claimed as a free improvement.
#Interpretation of the numbers
This is one workload against an in-memory engine, which maximises the proportion of time spent in the driver. Across a network round trip to a server, all four figures are within noise of each other, and any claim of a general performance advantage would be unsupported.
Where it matters is bulk insert paths and write paths that multiply per-command overhead by a fan-out factor, and those are the cases the design targets.
Three further caveats belong with the numbers rather than in a limitations section, because reading them without these is reading them wrong. Five runs were averaged and no variance is reported, so the differences between the two comparison mappers should not be treated as meaningful; only the gap between the reflective and non-reflective groups is large enough to survive any plausible spread. The benchmark measures a single insert of a small struct, and allocation counts in particular are sensitive to struct width in ways this does not capture. And all four were measured on one machine and one Go version, so the absolute figures date quickly even where the ratios do not.
It is also worth stating what the measurement does not show. It does not show that this design is faster because of where it puts the module boundary. Metadata caching is available to a portable mapper too, and at least one of the comparisons could adopt it without changing its interface. The boundary argument and the performance result are independent, and presenting them together invites a reader to treat one as evidence for the other. It is not.
#Adoption
An unexpected finding. The feature that most affected adoption was reading a competing mapper's struct tags as a fallback, allowing an existing codebase to adopt the library without modifying its models. This took an afternoon and outperformed several features that took weeks.
The generalisable point is that the cost of the first step dominates adoption decisions, and reducing it is often cheaper than improving the destination .
#When the portable design is correct
Where a team wants one query language and is indifferent to the engine underneath, the portable mapper serves that preference well and this design does not. The argument here applies where an engine was chosen deliberately for properties the application depends on, including its isolation behaviour and its execution model .
Two further cases favour the portable design and are worth conceding plainly. A product shipped to customers who supply their own database has portability as a requirement rather than a preference, and the intersection is the point rather than a compromise. And a team with no database specialist is better served by an interface that prevents engine-specific constructs than by one that invites them, since an unwrap that nobody on the team can evaluate is a hazard rather than an escape hatch.
#Limitations
A single microbenchmark on a single workload, with the caveats given above. No application-level benchmark is reported, and an application-level comparison is what would establish whether the difference is ever material outside bulk paths. My expectation is that it usually is not, which is an odd thing for the author of the faster library to say and is what the numbers support.
No measurement of the maintenance cost of per-driver builders is offered, and that cost is real: each engine's builder is code that must be tested against that engine, and the total grows linearly with the number of engines supported where a portable mapper's does not. For a library supporting many backends this could dominate, and the design is therefore more defensible for a small number of well supported engines than as a general architecture.
The substitutability argument is made from the standard behavioural notion and is not formalised for this setting. Stating precisely which properties a portable mapper implicitly promises, and demonstrating a concrete program correct under one engine and incorrect under another through that interface, would turn an argument into a demonstration. It would not be hard to construct and it has not been done here.
References
- [1]D. L. Parnas, “On the Criteria To Be Used in Decomposing Systems into Modules”, Communications of the ACM, vol. 15, no. 12, pp. 1053-1058, 1972doi:10.1145/361598.361623 ↗
- [2]Barbara H. Liskov, Jeannette M. Wing, “A Behavioral Notion of Subtyping”, ACM Transactions on Programming Languages and Systems, vol. 16, no. 6, pp. 1811-1841, 1994doi:10.1145/197320.197383 ↗
- [3]John Ousterhout, “A Philosophy of Software Design”, Yaknyam Press, 2018
- [4]Frederick P. Brooks Jr., “No Silver Bullet: Essence and Accidents of Software Engineering”, Computer, vol. 20, no. 4, pp. 10-19, 1987doi:10.1109/MC.1987.1663532 ↗
- [5]Hal Berenson et al., “A Critique of ANSI SQL Isolation Levels”, ACM SIGMOD International Conference on Management of Data, 1995doi:10.1145/223784.223785 ↗
- [6]Martin Kleppmann, “Designing Data-Intensive Applications”, O'Reilly Media, 2017