One plugin owns the connection. Everything else injects it.
import { InjectDatabase, type Database } from '@apograph/database';
@Injectable()
export class ReviewsService {
constructor(@InjectDatabase() private readonly db: Database) {}
}The database plugin opens a single Drizzle client over the pg driver in its
init hook — which is why it is first in the plugin list — and provides it from a
global module. There is also a plain getDatabase() / getPool() pair for code
outside dependency injection.
Annotate with Database, not the dialect-specific type. That is what makes a
dialect change a one-line edit in one package.
Transactions
import { UnitOfWork } from '@apograph/database';
await this.uow.run(async () => {
await this.reviews.insert(review);
await this.outbox.append([reviewCreated(review)]);
});run(fn) executes fn inside one transaction. A nested run joins the outer
transaction rather than opening a new one, so a use case that calls another use
case still commits once.
Repositories call current() to get the executor, so every query in the tree
joins the ambient transaction implicitly — there is no tx parameter to thread
through every function.
Outside a run, current() falls back to the pool, because a read outside a
unit of work is an ordinary read.
The rules that matter
Lock a contended invariant — never count then write
Reading a count and then inserting based on it is a race: two requests both read “three of five used” and both insert. Take a row lock, or express the invariant as a constraint and handle the violation.
This is the single most common correctness bug in this kind of code, and the codebase’s own review checklist calls it out by name.
No foreign keys across plugin boundaries
Your plugin’s tables may not reference another plugin’s. A workspace_id is a
plain uuid and the relationship is enforced in the application layer.
The upside is that a plugin can be removed, and that the schema does not become one interlocked graph. The cost is that referential integrity for those columns is your code’s responsibility, not Postgres’s.
Owning schema
Tables go in your package, with a drizzle.config.ts alongside them. The
migration targets appear automatically:
npx nx run reviews-server:db:generate --name=create_reviews
npx nx run server:db:migrateEach plugin’s migrations are tracked in its own table, named in the plugin’s
migrations descriptor, so histories stay independent. See
migrations.
Reads that outlive a request
Nothing in Apograph caches database reads across requests. There is no second-level cache, no query cache and no Redis. A read is a query.
If you need caching, it belongs in front of the API or inside your plugin, and it is yours to invalidate.
What is not here
One database, one dialect, no read replicas
There is one connection string and one pool. There is no read-replica routing, no sharding, no multi-tenant database-per-workspace, and PostgreSQL is the only dialect — Drizzle abstracts the query building, not the schema, and the generated content tables use Postgres types directly.