How code talks to Postgres once a module is ported off EF Core. Decision and migration path:
ADR-028. Schema: database/ (ADR-026).
Domain/ entities, value objects — no Dapper, no SQL
Application/ use-case services; repository interfaces (ports), e.g. IInsightRepository
Infrastructure/Persistence/
DataSource.cs the one NpgsqlDataSource (+ Dapper global setup)
Repositories/ one per aggregate; SQL next to the method that runs it
Api/Endpoints/ map HTTP → service or repository port; no SQL
database/ schema (up/down SQL)
tests/TextStack.Persistence.Tests repositories against real Postgres (Testcontainers + migrate.sh)
Dependency rule unchanged: Api → Application → Domain ← Infrastructure.
- One repository per aggregate, explicit methods. No generic
IRepository<T>. A method's name says what it does (DeleteAsync(userId, id), notRemove(entity)). - Explicit column lists. Never
SELECT *— a new column must not silently change what a query returns or what a DTO maps. - Parameters only. Never build SQL from input. Where a fragment must vary (a sort column, an
ON CONFLICTtarget), pick it from a closedswitchover constants. - Async with cancellation. Every call is
await conn.XxxAsync(new CommandDefinition(sql, args, transaction, cancellationToken: ct)). - One
NpgsqlDataSource, a singleton, shared with EF Core. Per operation:await using var conn = await dataSource.OpenConnectionAsync(ct); it returns to the pool on dispose. Never hold a connection across awaits you don't own. V2 repositories useV2DataSourceinstead, the pool totextstack_v2(one data source per database, ADR-030 §2). Building it opens nothing, so nothing at startup touchestextstack_v2and V1 keeps serving while V2 is broken or unconfigured (ADR-030 decision 1, amended; postmortem). WithoutConnectionStrings:V2every V2 service still resolves; the first open fails naming that key. - Transactions. A use case that writes EF tables and a ported table together opens the
transaction on EF and passes the
IDbContextTransactionto the repository; the repository runs on that transaction's connection (GetDbTransaction()), never its own. A method whose write must be atomic with EF writes takes a requiredIDbContextTransaction tx; one that may run either way takesIDbContextTransaction? tx = null. On V2 a use case that writes two aggregates atomically begins one (await using var tx = await unitOfWork.BeginAsync(ct),IV2UnitOfWork), passestx.Transactionto each method that takesNpgsqlTransaction? tx(null: the method runs on its own), and commits. First use: the file replacement and its reading-state delete (#1067). - Ownership in SQL. Every user-owned method takes
userIdand puts it in theWHERE(and in theON CONFLICTkey /INSERT). Cross-user system operations are separate, clearly named methods. Each repository has an isolation test: user B never reads, changes or deletes user A's rows. - Not found =
null(orfalsefor a delete). Exceptions are for broken invariants, not for "no such row". - Postgres idioms over round trips.
INSERT … ON CONFLICT … DO UPDATE … RETURNINGfor upserts,= ANY(@ids)for sets,RETURNINGinstead of re-reading. - Naming. Columns are snake_case, properties PascalCase;
DefaultTypeMap.MatchNamesWithUnderscoresmaps between them, so noASaliases for plain renames. - Types.
jsonbparameters are cast in SQL (CAST(@x AS jsonb)) and read back asstring;timestamptzmaps toDateTimeOffset(UTC). Add a type handler only when a mapping actually fails. One is registered (DataSource.ConfigureDapper):DateTimeOffsetparameters are written as UTC, because Npgsql throws on a non-zero offset and client times arrive with one. It covers scalar parameters, not arrays: convert array elements yourself (asReplaceForInsightAsyncdoes). One clock per column: what the database stamps (created_at,started_at,revoked_at,denied_at,consumed_at,last_used_at, …) is writtennow()and compared withnow() - @interval; a deadline the application wrote (expires_at) is compared with the caller's@now. Exception: a stamp that must post-date a lock wait isclock_timestamp()(users.credentials_reset_at:now()is the transaction start, before the wait, and a credential minted during the wait would pass). - No EF navigation into a ported table. A join through one becomes a second query through the repository.
- SQL stays inline, next to the method that runs it (decided 2026-10-30; the owner considered
stored procedures and
.sqlresource files and rejected both). The golden path is one file read top to bottom: method → its SQL → its parameters → its result. Rules that keep that readable:- each statement is a
const stringnamed by what it does (InsertFile,AdvanceGuarded,UnlinkCheckpoints), declared at the top of the class or directly above its one method — never built inline in a call; - raw string literals (
"""…"""), one statement per concern, a-- whyline above anything non-obvious, no comment that repeats the SQL; - a repository stays under about 250 lines. Longer means the aggregate is cut wrong, not that the
SQL should move to a file. No stored procedures, no functions holding behaviour (schema-level
functions such as
is_locatorare schema, not behaviour), no.sqlembedded resources.
- each statement is a