Drizzle ORM integration

This example shows how to integrate Drizzle ORM into a VextJS project to implement type-safe database operations by writing a database plug-in.

Why choose Drizzle ORM?

FeaturesDescription
Type SafetySchema is the type, and the query results automatically infer the TypeScript type
LightweightNo runtime code generation, zero abstraction overhead
SQL-likeAPI is close to SQL syntax, low learning cost
Multiple databasesSupport PostgreSQL, MySQL, SQLite
Migration ToolBuilt-in drizzle-kit for schema migration
PerformanceNo additional query layer, compiled directly into SQL

Project structure

drizzle-orm/
  ├── src/
  │ ├── config/
  │ │ ├── default.ts
  │ │ └── production.ts
  │ ├── db/
  │ │ ├── schema.ts ← Drizzle schema definition
  │ │ ├── index.ts ← Database connection factory
  │ │ └── migrate.ts ← Migration script
  │ ├── plugins/
  │ │ └── database.ts ← Database plug-in
  │ ├── routes/
  │ │ ├── index.ts
  │ │ └── users.ts
  │ ├── services/
  │ │ └── user.ts
  │ └── index.ts
  ├── drizzle/ ← Migration file (generated by drizzle-kit)
  ├── drizzle.config.ts ← drizzle-kit configuration
  ├── package.json
  └── tsconfig.json

1. Install dependencies

This example uses SQLite (via better-sqlite3) as the demo database. Can be replaced by PostgreSQL or MySQL in a production environment.

npx vextjs create drizzle-orm-demo
cd drizzle-orm-demo

# Drizzle ORM + SQLite driver
pnpm add drizzle-orm better-sqlite3

# Development dependencies
pnpm add -D drizzle-kit @types/better-sqlite3
Tip

Other database drivers:

  • PostgreSQL: pnpm add drizzle-orm postgres or pnpm add drizzle-orm @neondatabase/serverless
  • MySQL: pnpm add drizzle-orm mysql2

After replacing the driver, you only need to modify the connection logic in src/db/index.ts and the table definition import path in src/db/schema.ts, and there is almost no need to change the business code.

2. Database Schema

Define table structures using Drizzle's TypeScript-first schema. Schema is both a database table definition and the source of TypeScript types.

// src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

//──User table ───────────────────────────────────────────────

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  age: integer("age"),
  role: text("role", { enum: ["admin", "user", "editor"] })
    .notNull()
    .default("user"),
  bio: text("bio"),
  avatarUrl: text("avatar_url"),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
  updatedAt: text("updated_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});

//──Article table (demo related query)──────────────────────────────────

export const posts = sqliteTable("posts", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  content: text("content").notNull(),
  authorId: integer("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  published: integer("published", { mode: "boolean" }).notNull().default(false),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(datetime('now'))`),
  updatedAt: text("updated_at")
    .notNull()
    .default(sql`(datetime('now'))`),
});

//──Export inferred type ──────────────────────────────────────────

/** User complete type (query result) */
export type User = typeof users.$inferSelect;

/** User insertion type (used when creating) */
export type NewUser = typeof users.$inferInsert;

/** Complete article type */
export type Post = typeof posts.$inferSelect;

/** Article insertion type */
export type NewPost = typeof posts.$inferInsert;
Tip

Drizzle's $inferSelect and $inferInsert type tools are very powerful:- $inferSelect — Infer query result type from table definition (includes all fields, id, createdAt, etc. are required)

  • $inferInsert — infer the insertion type from the table definition (fields with default values become optional, such as role, createdAt)

These types can be used directly in the service layer's parameter and return value declarations, eliminating the need to manually maintain interface definitions.

3. Database connection factory

// src/db/index.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "./schema.js";

export interface DatabaseConfig {
  /** SQLite database file path */
  url: string;
  /** Whether to enable WAL mode (Write-Ahead Logging, to improve concurrency performance) */
  wal?: boolean;
  /** Whether to automatically create tables during connection (for development environment) */
  autoCreate?: boolean;
}

/**
 * Create Drizzle ORM database instance
 *
 * @param config database configuration
 * @returns { db, client }
 * - db: Drizzle ORM instance (for querying)
 * - client: better-sqlite3 original client (used to close the connection)
 */
export function createDatabase(config: DatabaseConfig) {
  const client = new Database(config.url);

  // WAL mode: improve concurrent read and write performance
  if (config.wal !== false) {
    client.pragma("journal_mode = WAL");
  }

  // Enable foreign key constraints
  client.pragma("foreign_keys = ON");

  const db = drizzle(client, { schema });

  //The development environment automatically creates tables
  if (config.autoCreate) {
    client.exec(`
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE,
        age INTEGER,
        role TEXT NOT NULL DEFAULT 'user',
        bioTEXT,
        avatar_url TEXT,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      );

      CREATE TABLE IF NOT EXISTS posts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        content TEXT NOT NULL,
        author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        published INTEGER NOT NULL DEFAULT 0,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      );
    `);
  }

  return { db, client };
}

/** Drizzle DB instance type */
export type DrizzleDB = ReturnType<typeof createDatabase>["db"];
Warning

Do not use autoCreate: true in a production environment. Database structure changes should be managed using the drizzle-kit migration tool. autoCreate is for quick development and demonstration purposes only.

4. Database migration configuration

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "sqlite",
  dbCredentials: {
    url: process.env.DATABASE_URL ?? "./data/app.db",
  },
});

Migration command:

# Generate migration file
pnpm drizzle-kit generate

# Execute migration
pnpm drizzle-kit migrate

# Open Drizzle Studio (visual database management)
pnpm drizzle-kit studio

5. Database plug-in

// src/plugins/database.ts
import { definePlugin } from "vextjs";
import { createDatabase } from "../db/index.js";
import type { DrizzleDB } from "../db/index.js";
import { users, posts } from "../db/schema.js";

export default definePlugin({
  name: "database",
  async setup(app) {
    const dbConfig = (app.config as any).database ?? {
      url: "./data/app.db",
      wal: true,
      autoCreate: true,
    };

    app.logger.info({ url: dbConfig.url }, "Connecting to database...");

    const { db, client } = createDatabase(dbConfig);

    //Mount to app
    app.extend("db", db);

    //Ready hook: verify connection + fill in seed data
    app.onReady(async () => {
      try {
        // Verify connection
        client.pragma("quick_check");
        app.logger.info("Database connection verification successful");// Check if seed data is required
        const userCount = db.select().from(users).all();
        if (userCount.length === 0) {
          app.logger.info("The database is empty, filling seed data...");
          await seedDatabase(db);
          app.logger.info("Seed data filling completed");
        }
      } catch (err) {
        app.logger.error({ error: err }, "Database initialization failed");
        throw err;
      }
    });

    //Close hook: disconnect
    app.onClose(() => {
      client.close();
      app.logger.info("Database connection has been closed");
    });
  },
});

/**
 * Fill in seed data (only executed when the database is empty)
 */
async function seedDatabase(db: DrizzleDB): Promise<void> {
  //Insert user
  const insertedUsers = db
    .insert(users)
    .values([
      {
        name: "Alice",
        email: "alice@example.com",
        age: 28,
        role: "admin",
        bio: "Full stack developer, loves open source",
      },
      {
        name: "Bob",
        email: "bob@example.com",
        age: 32,
        role: "user",
        bio: "front-end engineer",
      },
      {
        name: "Charlie",
        email: "charlie@example.com",
        age: 25,
        role: "editor",
        bio: "Technology Blogger",
      },
    ])
    .returning()
    .all();

  //Insert article
  db.insert(posts)
    .values([
      {
        title: "VextJS Getting Started Guide",
        content: "This article introduces how to use VextJS to build a high-performance RESTful API...",
        authorId: insertedUsers[0].id,
        published: true,
      },
      {
        title: "Drizzle ORM Best Practices",
        content: "Explore advanced query techniques of Drizzle ORM...",
        authorId: insertedUsers[0].id,
        published: true,
      },
      {
        title: "TypeScript type gymnastics",
        content: "In-depth understanding of TypeScript's advanced type system...",
        authorId: insertedUsers[2].id,
        published: false,
      },
    ])
    .run();
}

Add a type declaration for app.db:

// types/vext.d.ts
import type { DrizzleDB } from "../src/db/index.js";

declare module "vextjs" {
  interface VextApp {
    db: DrizzleDB;
  }

  interface VextConfig {
    database?: {
      url: string;
      wal?: boolean;
      autoCreate?: boolean;
    };
  }

  interface VextRequest {
    user?: {
      id: string;
      role: string;
    };
  }
}

6. Configuration

// src/config/default.ts
export default {
  port: 3000,
  adapter: "native",
  logger: {
    level: "debug",
    pretty: true,
  },
  cors: {
    enabled: true,
    origins: ["*"],
  },
  response: {
    wrap: true,
    hideInternalErrors: false,
  },
  openapi: {
    enabled: true,
    title: "Drizzle ORM Example",
    version: "1.0.0",
    description: "VextJS RESTful API integrated with Drizzle ORM",
    tags: [
      { name: "Basic", description: "Basic interface" },
      { name: "User", description: "User management interface" },
      { name: "Article", description: "Article Management Interface" },
    ],
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
      },
    },
    guardSecurityMap: {
      auth: "bearerAuth",
    },
  },
  middlewares: [{ name: "auth" }],
  //Database configuration
  database: {
    url: "./data/app.db",
    wal: true,
    autoCreate: true,
  },
};
// src/config/production.ts
export default {
  logger: {
    level: "info",
    pretty: false,
  },
  response: {
    hideInternalErrors: true,
  },
  openapi: {
    enabled: false,
  },
  database: {
    url: process.env.DATABASE_URL ?? "./data/production.db",
    autoCreate: false, // Use migration in production environment
  },
};

7. Service layer

// src/services/user.ts
import type { VextApp, VextLogger } from "vextjs";
import { users, posts } from "../db/schema.js";
import type { User, NewUser } from "../db/schema.js";
import { eq, like, or, sql, desc, asc, count } from "drizzle-orm";export default class UserService {
  private logger: VextLogger;

  constructor(private app: VextApp) {
    this.logger = app.logger.child({ service: "UserService" });
  }

  /**
   * Query user list by page
   *
   * Supports keyword search (fuzzy matching by name or email) and sorting.
   */
  async findAll(options: {
    page: number;
    limit: number;
    keyword?: string;
    sortBy?: "name" | "createdAt";
    sortOrder?: "asc" | "desc";
  }): Promise<{
    items: User[];
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  }> {
    this.logger.debug(options, "Query user list");

    const db = this.app.db;
    const offset = (options.page - 1) * options.limit;

    //Construct WHERE condition
    const whereCondition = options.keyword
      ? or(
          like(users.name, `%${options.keyword}%`),
          like(users.email, `%${options.keyword}%`),
        )
      : undefined;

    // Build sort
    const orderBy =
      options.sortBy === "name"
        ? options.sortOrder === "desc"
          ?desc(users.name)
          : asc(users.name)
        : options.sortOrder === "asc"
          ? asc(users.createdAt)
          : desc(users.createdAt);

    // Query data
    const items = db
      .select()
      .from(users)
      .where(whereCondition)
      .orderBy(orderBy)
      .limit(options.limit)
      .offset(offset)
      .all();

    //Total number of queries
    const [{ total }] = db
      .select({ total: count() })
      .from(users)
      .where(whereCondition)
      .all();

    return {
      items,
      total,
      page: options.page,
      limit: options.limit,
      totalPages: Math.ceil(total/options.limit),
    };
  }

  /**
   * Query users based on ID (including article list)
   */
  async findById(id: number): Promise<(User & { posts: any[] }) | null> {
    this.logger.debug({ userId: id }, "Query user details");

    const db = this.app.db;

    const user = db.select().from(users).where(eq(users.id, id)).get();

    if (!user) return null;

    // Query the user's articles
    const userPosts = db
      .select()
      .from(posts)
      .where(eq(posts.authorId, id))
      .orderBy(desc(posts.createdAt))
      .all();

    return {
      ...user,
      posts: userPosts,
    };
  }

  /**
   * Create user
   */
  async create(data: {
    name: string;
    email: string;
    age?: number;
    role?: "admin" | "user" | "editor";
    bio?: string;
    avatarUrl?: string;
  }): Promise<User> {
    this.logger.info({ email: data.email }, "Create user");

    const db = this.app.db;

    // Check email uniqueness
    const existing = db
      .select({ id: users.id })
      .from(users)
      .where(eq(users.email, data.email))
      .get();

    if (existing) {
      this.app.throw(409, "Email has been registered", 10001);
    }

    //Insert and return the complete record
    const user = db
      .insert(users)
      .values({
        name: data.name,
        email: data.email,
        age: data.age,
        role: data.role ?? "user",
        bio: data.bio,
        avatarUrl: data.avatarUrl,
      })
      .returning()
      .get();

    this.logger.info({ userId: user.id, email: user.email }, "User created successfully");

    return user;
  }

  /**
   * Update user
   */
  async update(
    ID: number,
    data: {
      name?: string;
      email?: string;
      age?: number;
      bio?: string;
      avatarUrl?: string;
    },
  ): Promise<User> {
    this.logger.info({ userId: id }, "Update user");

    const db = this.app.db;

    // Check if the user exists
    const existing = db.select().from(users).where(eq(users.id, id)).get();
    if (!existing) {
      this.app.throw(404, "User does not exist");
    }// If updating mailbox, check for uniqueness
    if (data.email && data.email !== existing.email) {
      const emailTaken = db
        .select({ id: users.id })
        .from(users)
        .where(eq(users.email, data.email))
        .get();

      if (emailTaken) {
        this.app.throw(409, "The mailbox is already used by another user", 10002);
      }
    }

    //Update record
    const updated = db
      .update(users)
      .set({
        ...data,
        updatedAt: sql`datetime('now')`,
      })
      .where(eq(users.id, id))
      .returning()
      .get();

    this.logger.info({ userId: id }, "User updated successfully");

    return updated;
  }

  /**
   * Delete user
   *
   * Since ON DELETE CASCADE is set, the associated articles will be automatically deleted.
   */
  async delete(id: number): Promise<void> {
    this.logger.info({ userId: id }, "Delete user");

    const db = this.app.db;

    const existing = db
      .select({ id: users.id })
      .from(users)
      .where(eq(users.id, id))
      .get();

    if (!existing) {
      this.app.throw(404, "User does not exist");
    }

    db.delete(users).where(eq(users.id, id)).run();

    this.logger.info({ userId: id }, "User deleted successfully");
  }

  /**
   * Count the number of users
   */
  async count(): Promise<number> {
    const [{ total }] = this.app.db
      .select({ total: count() })
      .from(users)
      .all();

    return total;
  }
}

8. Routing

Root route

// src/routes/index.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.get(
    "/",
    {
      docs: {
        summary: "Health Check",
        tags: ["Basics"],
      },
    },
    async (_req, res) => {
      const userCount = await app.services.user.count();
      res.json({
        status: "ok",
        uptime: Math.floor(process.uptime()),
        database: "connected",
        users: userCount,
        timestamp: new Date().toISOString(),
      });
    },
  );
});

User routing

// src/routes/users.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // GET /users/list — Query the user list in pages
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.get(
    "/list",
    {
      validate: {
        query: {
          page: "number:1-",
          limit: "number:1-100",
          keyword: "string?",
        },
      },
      docs: {
        summary: "User list",
        description: "Query the user list in pages. Search by name or email keywords is supported.",
        tags: ["user"],
        responses: {
          200: {
            description: "Query successful",
            example: {
              items: [
                {
                  id: 1,
                  name: "Alice",
                  email: "alice@example.com",
                  age: 28,
                  role: "admin",
                  bio: "Full stack developer",
                  createdAt: "2026-03-05 00:00:00",
                },
              ],
              total: 3,
              page: 1,
              limit: 10,
              totalPages: 1,
            },
          },
        },
      },
    },
    async (req, res) => {
      const { page, limit, keyword } = req.valid("query");
      const result = await app.services.user.findAll({ page, limit, keyword });
      res.json(result);
    },
  );// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // GET /users/:id — Query user details (including articles)
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.get(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
      },
      docs: {
        summary: "Get user details",
        description: "Query user details, including all articles published by this user.",
        tags: ["user"],
        responses: {
          200: { description: "Query successful" },
          404: { description: "User does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const user = await app.services.user.findById(id);

      if (!user) {
        app.throw(404, "User does not exist");
      }

      res.json(user);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // POST /users — Create users (authentication required)
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.post(
    "/",
    {
      validate: {
        body: {
          name: "string:1-50",
          email: "email",
          age: "number:0-200?",
          role: "enum:admin,user,editor?",
        },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Create user",
        description: "Create a new user. Bearer Token authentication is required. Email must be unique.",
        tags: ["user"],
        responses: {
          201: { description: "Created successfully" },
          422: { description: "Parameter verification failed" },
          401: { description: "Not authenticated" },
          409: { description: "Email has been registered" },
        },
      },
    },
    async (req, res) => {
      const body = req.valid("body");
      const user = await app.services.user.create(body);
      res.json(user, 201);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // PUT /users/:id — update user (authentication required)
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.put(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
        body: {
          name: "string:1-50?",
          email: "email?",
          age: "number:0-200?",
        },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Update user",
        description: "Update user information. Just pass in the fields that need to be updated.",
        tags: ["user"],
        responses: {
          200: { description: "Update successful" },
          422: { description: "Parameter verification failed" },
          401: { description: "Not authenticated" },
          404: { description: "User does not exist" },
          409: { description: "The mailbox has been occupied" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const body = req.valid("body");
      const user = await app.services.user.update(id, body);
      res.json(user);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // DELETE /users/:id — delete user (authentication required)
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.delete(
    "/:id",
    {
      validate: {
        param: { id: "number:1-" },
      },
      middlewares: ["auth"],
      docs: {
        summary: "Delete user",
        description: "Delete user and all articles (CASCADE). Irreversible operation.",
        tags: ["user"],
        responses: {
          204: { description: "Delete successfully" },
          401: { description: "Not authenticated" },
          404: { description: "User does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      await app.services.user.delete(id);
      res.status(204).json(null);
    },
  );
});

9. Entry file

// src/index.ts
import { bootstrap } from "vextjs";

bootstrap().catch((err) => {
  console.error("Startup failed:", err);
  process.exit(1);
});

10. Operation and verification

#Create data directory
mkdir -p data

# Start development server
pnpmdev

Interface verification

#HealthCheck
curl http://localhost:3000/
# → {"code":0,"data":{"status":"ok","database":"connected","users":3,...},"requestId":"..."}# Query user list
curl "http://localhost:3000/users/list?page=1&limit=10"
# → {"code":0,"data":{"items":[...],"total":3,"page":1,"limit":10,"totalPages":1},"requestId":"..."}

#Search for users
curl "http://localhost:3000/users/list?page=1&limit=10&keyword=alice"
# → Only return users matching "alice"

# Query user details (including articles)
curl http://localhost:3000/users/1
# → {"code":0,"data":{"id":1,"name":"Alice",...,"posts":[...]},"requestId":"..."}

#Create user
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"name":"Diana","email":"diana@example.com","age":26}'
# → 201 {"code":0,"data":{"id":4,"name":"Diana","email":"diana@example.com",...},"requestId":"..."}

# Update user
curl -X PUT http://localhost:3000/users/4 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"name":"Diana Updated","age":27}'
# → {"code":0,"data":{"id":4,"name":"Diana Updated","age":27,...},"requestId":"..."}

# Delete user
curl -X DELETE http://localhost:3000/users/4 \
  -H "Authorization: Bearer user-1-admin"
# → 204 No Content

# Email uniqueness verification
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"name":"Test","email":"alice@example.com"}'
# → 409 {"code":10001,"message":"Email has been registered","requestId":"..."}

11. Test

// test/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
import type { TestApp } from "vextjs";

describe("User CRUD (Drizzle)", () => {
  let testApp: TestApp;
  const AUTH = "Bearer user-1-admin";

  beforeEach(async () => {
    testApp = await createTestApp({
      plugins: true, // Load database plugins
      config: {
        database: {
          url: ":memory:", // Use in-memory database to isolate between tests
          autoCreate: true,
        },
      },
    });
  });

  afterEach(async () => {
    await testApp?.close();
  });

  it("GET /users/list should return seed data", async () => {
    const res = await testApp.request
      .get("/users/list")
      .query({ page: 1, limit: 10 });

    expect(res.status).toBe(200);
    expect(res.body.data.total).toBe(3); // Seed data 3 users
    expect(res.body.data.items[0]).toHaveProperty("id");
    expect(res.body.data.items[0]).toHaveProperty("name");
    expect(res.body.data.items[0]).toHaveProperty("email");
  });

  it("GET /users/:id should contain associated articles", async () => {
    const res = await testApp.request.get("/users/1");

    expect(res.status).toBe(200);
    expect(res.body.data.name).toBe("Alice");
    expect(Array.isArray(res.body.data.posts)).toBe(true);
    expect(res.body.data.posts.length).toBeGreaterThan(0);
  });

  it("POST /users should create users and return auto-incremented IDs", async () => {
    const res = await testApp.request
      .post("/users")
      .set("Authorization", AUTH)
      .send({
        name: "Diana",
        email: "diana@example.com",
        age: 26,
      });

    expect(res.status).toBe(201);
    expect(res.body.data.id).toBe(4); // Seed data accounts for 1-3
    expect(res.body.data.role).toBe("user"); //Default value
    expect(res.body.data.createdAt).toBeDefined();
  });

  it("DELETE /users/:id should cascade delete articles", async () => {
    // Alice (id=1) has 2 articles
    const deleteRes = await testApp.request
      .delete("/users/1")
      .set("Authorization", AUTH);

    expect(deleteRes.status).toBe(204);

    // Confirm that both users and articles have been deleted
    const getRes = await testApp.request.get("/users/1");
    expect(getRes.status).toBe(404);
  });
});

Drizzle Advanced Usage

Drizzle supports declarative related queries to avoid manual JOIN:

import { relations } from "drizzle-orm";
import { users, posts } from "./schema.js";

// Declare the relationship
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

// Use related query
const usersWithPosts = await db.query.users.findMany({
  with: {
    posts: {
      where: eq(posts.published, true),
      orderBy: desc(posts.createdAt),
      limit: 5,
    },
  },
});

Transaction

import { eq, sql } from "drizzle-orm";

//Transaction: transfer operation
async function transfer(fromId: number, toId: number, amount: number) {
  await db.transaction(async (tx) => {
    //deduction
    tx.update(accounts)
      .set({ balance: sql`balance - ${amount}` })
      .where(eq(accounts.id, fromId))
      .run();

    // increase
    tx.update(accounts)
      .set({ balance: sql`balance + ${amount}` })
      .where(eq(accounts.id, toId))
      .run();

    // record transaction
    tx.insert(transactions)
      .values({ fromId, toId, amount, type: "transfer" })
      .run();
  });
}

Subquery and aggregation

import { eq, count, avg, gt, sql } from "drizzle-orm";

// Count the number of articles for each user
const userPostCounts = db
  .select({
    userId: users.id,
    name: users.name,
    postCount: count(posts.id),
  })
  .from(users)
  .leftJoin(posts, eq(users.id, posts.authorId))
  .groupBy(users.id)
  .having(gt(count(posts.id), 0))
  .all();

//Subquery: Query users whose number of articles is greater than the average
const avgPostCount = db
  .select({ avg: avg(count(posts.id)) })
  .from(posts)
  .groupBy(posts.authorId);

const activeAuthors = db
  .select()
  .from(users)
  .where(
    gt(
      db
        .select({ count: count() })
        .from(posts)
        .where(eq(posts.authorId, users.id)),
      sql`(SELECT AVG(cnt) FROM (SELECT COUNT(*) as cnt FROM posts GROUP BY author_id))`,
    ),
  )
  .all();

Dynamic condition construction

import { and, or, eq, like, gte, lte, sql } from "drizzle-orm";
import type { SQL } from "drizzle-orm";

interface UserFilter {
  keyword?: string;
  role?: string;
  minAge?: number;
  maxAge?: number;
}

function buildUserFilter(filter: UserFilter): SQL | undefined {
  const conditions: SQL[] = [];

  if (filter.keyword) {
    conditions.push(
      or(
        like(users.name, `%${filter.keyword}%`),
        like(users.email, `%${filter.keyword}%`),
      )!,
    );
  }

  if (filter.role) {
    conditions.push(eq(users.role, filter.role));
  }

  if (filter.minAge !== undefined) {
    conditions.push(gte(users.age, filter.minAge));
  }

  if (filter.maxAge !== undefined) {
    conditions.push(lte(users.age, filter.maxAge));
  }

  return conditions.length > 0 ? and(...conditions) : undefined;
}

// use
const items = db
  .select()
  .from(users)
  .where(buildUserFilter({ keyword: "alice", role: "admin", minAge: 20 }))
  .all();

Using PostgreSQL

Switching to PostgreSQL only requires modifying two files:

// src/db/schema.ts — use pg import instead
import {
  pgTable,
  serial,
  text,
  integer,
  timestamp,
  boolean,
} from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  age: integer("age"),
  role: text("role").notNull().default("user"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// src/db/index.ts — use pg driver instead
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.js";

export function createDatabase(config: { url: string }) {
  const client = postgres(config.url);
  const db = drizzle(client, { schema });
  return { db, client };
}

The business code (service layer, routing layer) does not need to be modified at all.

Project mode summary

HierarchyResponsibilitiesDocumentation
SchemaTable definition + type inferencesrc/db/schema.ts
Connection FactoryCreate database instancesrc/db/index.ts
Plug-inInitialize connection, mount app.db, clean upsrc/plugins/database.ts
Service layerBusiness logic + Drizzle querysrc/services/user.ts
Routing layerRequest verification + calling service + responsesrc/routes/users.ts
Type declarationapp.db type extensiontypes/vext.d.ts

Core Principles

  1. Schema is type: Use $inferSelect / $inferInsert to infer types from Drizzle schema without manually maintaining the interface
  2. Plug-in management life cycle: The creation and destruction of database connections is the responsibility of the plug-in and is managed through app.extend() and app.onClose()
  3. Service layer encapsulation query: All Drizzle queries are encapsulated in the service layer, and the routing layer only performs orchestration
  4. Declarative routing layer: Routing focuses on verification rules, document configuration and service invocation

Next step