Everybody Knows That Drizzle is the Word!
Source: Dev.to
Introduction
I’ve worked with plenty of ORMs and database tools over the years—JPA, Hibernate, Knex, Mongoose, Prisma. The list is long. There’s always a new “revolutionary” tool. So when I first heard about Drizzle, I was skeptical.
I was happy with Prisma, until I had to integrate with a Supabase Postgres database. After a week fighting the client‑generation workflow, I gave Drizzle a shot. The integration felt boring in the best way: it was as smooth as working with native Postgres.
Then I plugged it into NestJS, and the experience changed. Supabase felt easy. Nest introduced friction—because the real problem isn’t Drizzle or Nest. It’s the glue code in between.
In this post, we’ll make Drizzle feel Nest‑native using providers.
The real problem: your DB client becomes a hidden dependency
Drizzle is minimal by design. NestJS is modular by design. The friction starts the moment you try to make “minimal” fit inside a DI container.
It usually begins innocently: one file creates the client, another reads DATABASE_URL, and a service imports a singleton from some db.ts file in a random folder.
It works—until it doesn’t.
Soon the database client becomes a hidden dependency. Tests that should be pure unit tests suddenly require wiring. A simple refactor turns into a scavenger hunt for imports.
We’ll get there with a few small building blocks:
- Injection tokens (stable DI keys)
- A Postgres provider (creates and owns the connection)
- A Drizzle provider (builds the typed DB instance)
- A thin DatabaseModule (Nest wiring only)
The plan: a thin DatabaseModule
Here’s the blueprint we’re aiming for. If we do this right, two things become true:
- No service imports a database singleton from a file.
- All database access goes through one DI token.
We’ll get there with one module boundary and two providers, each with a single responsibility:
PostgresProvider
- Input: validated `database.url` from `ConfigService`.
- Output: a single Postgres client wrapper that owns shutdown.
DrizzleProvider
- Input: the Postgres client and app env.
- Output: the Drizzle DB instance bound to **one DI token**.
DatabaseModule (Nest wiring)
- Guarantee: modules and services only depend on DI tokens, not on a file path.
That separation keeps Nest concerns (modules, DI, lifecycle) out of your Drizzle initialization logic.
Next, we’ll build it in five steps.
Step 1: Create dedicated injection tokens
Before we write any modules, we need stable injection tokens. These tokens are the only thing services should ever inject.
First, install Drizzle and the postgres driver:
pnpm add drizzle-orm@beta postgres
💡 Note: I’m using @beta here. If you prefer fewer breaking changes, pin a stable version instead.
Now define a symbol token in a single place, and don’t redefine it anywhere else:
// database/database.constants.ts
export const DRIZZLE = Symbol('DRIZZLE');
export const POSTGRES_CLIENT = Symbol('POSTGRES_CLIENT');
With tokens in place, we can implement the Postgres and Drizzle providers.
Step 2: Implement the Postgres provider
// database/providers/postgres.provider.ts
import { Logger, OnApplicationShutdown } from '@nestjs/common';
import postgres from 'postgres';
import { POSTGRES_CLIENT } from '../constants/database.constants';
import { ConfigService } from '@nestjs/config';
import { DatabaseConfig } from '@app/config/types/database-config.type';
export class PostgresClientProvider implements OnApplicationShutdown {
private readonly logger = new Logger(PostgresClientProvider.name);
constructor(public readonly client: postgres.Sql) {}
async onApplicationShutdown(signal?: string) {
this.logger.log(`Closing database connection (signal: ${signal})`);
await this.client.end();
}
}
export const PostgresProvider = {
provide: POSTGRES_CLIENT,
inject: [ConfigService],
useFactory: (
configService: ConfigService,
): PostgresClientProvider => {
const { url } = configService.get('database', {
infer: true,
}) as DatabaseConfig;
const client = postgres(url, { prepare: false });
return new PostgresClientProvider(client);
},
};
Step 3: Implement the Drizzle provider
// database/providers/drizzle.provider.ts
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import { DRIZZLE, POSTGRES_CLIENT } from '../constants/database.constants';
import { DrizzleDB } from '../types/database.type';
import { PostgresClientProvider } from './postgres.provider';
import { AppConfig } from '@app/config/types/app-config.types';
import * as schema from '../schema';
export const DrizzleProvider = {
provide: DRIZZLE,
inject: [POSTGRES_CLIENT, ConfigService],
useFactory: (
postgresProvider: PostgresClientProvider,
configService: ConfigService,
): DrizzleDB => {
const { nodeEnv } = configService.get('app', {
infer: true,
}) as AppConfig;
return drizzle({
client: postgresProvider.client,
schema,
logger: nodeEnv === 'development',
});
},
};
In the next steps we’ll define a single DrizzleDB type (derived from your schema) and reuse it everywhere.
Step 4: Wire it up in DatabaseModule (Nest‑only concerns)
// database/database.module.ts
import { Module } from '@nestjs/common';
import { PostgresProvider } from './providers/postgres.provider';
import { DrizzleProvider } from './providers/drizzle.provider';
import { DRIZZLE, POSTGRES_CLIENT } from './constants/database.constants';
@Module({
providers: [PostgresProvider, DrizzleProvider],
exports: [DRIZZLE, POSTGRES_CLIENT],
})
export class DatabaseModule {}
Now any other module can simply import DatabaseModule and inject the tokens DRIZZLE or POSTGRES_CLIENT without ever touching a concrete file path.
Step 5: Use the injected DB instance in a service
// users/users.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { DRIZZLE } from '../database/constants/database.constants';
import type { DrizzleDB } from '../database/types/database.type';
@Injectable()
export class UsersService {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async findAll() {
return this.db.select().from(this.db.users).all();
}
}
All database access now flows through a single, well‑typed DI token, keeping your codebase clean, testable, and fully Nest‑native.
Keep Your Database Initialization Boring and Isolated
A few rules to keep it simple:
- The module wires providers that read config.
- The module creates the Drizzle instance once.
- Services only inject
DRIZZLE.
// database/database.module.ts
import { Global, Module } from '@nestjs/common';
import { DRIZZLE } from './constants/database.constants';
import { DrizzleProvider } from './providers/drizzle.provider';
import { PostgresProvider } from './providers/postgres.provider';
@Global()
@Module({
providers: [PostgresProvider, DrizzleProvider],
exports: [DRIZZLE],
})
export class DatabaseModule {}
At this point, your database client is no longer a hidden dependency—it’s a first‑class provider with a stable token.
Step 5 – Inject Drizzle Anywhere (No “random file” imports)
Now the payoff: modules import a module, and services inject a token.
-
Define a strongly‑typed DB type derived from your schema
// database/types/database.type.ts import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import * as schema from '../schema'; export type DrizzleDB = PostgresJsDatabase; -
Import the
DatabaseModulewherever you need the client// any.module.ts import { Module } from '@nestjs/common'; import { DatabaseModule } from '@database/database.module'; @Module({ imports: [DatabaseModule], }) export class AnyModule {} -
Inject the token with the typed DB interface
// example.service.ts import { Inject, Injectable } from '@nestjs/common'; import { DRIZZLE } from '@database/database.constants'; import { DrizzleDB } from '@database/types/database.type'; @Injectable() export class ExampleService { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} }
Because the provider was created with schema, DrizzleDB stays in sync with your tables and queries.
Takeaway – Keep Database Initialization Boring and Isolated
Treat your database client as infrastructure and give it a dedicated module boundary. Three things happen:
- Config stays centralized (and validated).
- Services stay clean (they inject a token, not a file import).
- Future work gets easier (schema, migrations, and tests build on a stable foundation).
The goal isn’t cleverness; it’s a database client that feels boring to use.
💡 Next post: Defining a typed Drizzle schema.
🔗 Code so far: