GuidesData

Database Integration

Connect NextRush to databases using an Extension for connection lifecycle and the Repository pattern.

Connect NextRush to databases using an Extension for connection lifecycle management and the Repository pattern for data access.

This example demonstrates patterns that work with any database driver. Two implementations are shown: SQLite (better-sqlite3) and PostgreSQL (Drizzle ORM).


Architecture

Controller → Service → Repository → Database Extension
  • Database Extension: Manages connection lifecycle (connect in setup(), disconnect in destroy())
  • Repository: Data access layer, injected via DI
  • Service: Business logic, validation
  • Controller: HTTP handling

Connection Management

Always use connection pooling in production. Both the SQLite (WAL mode) and PostgreSQL (connection pool) examples below demonstrate proper lifecycle management through the Extension pattern.


Database Extension

A database connection is a long-lived service that attaches state to the app — the rare case an Extension is for (most integrations are middleware or a registrar). setup() connects and ctx.decorate() attaches the connection to the app; destroy() disconnects during graceful shutdown.

src/extensions/database.extension.ts
import Database from 'better-sqlite3';
import type { Extension } from 'nextrush';

declare module '@nextrush/core' {
  interface Application {
    db: Database.Database;
  }
}

export function database(): Extension {
  let db: Database.Database;

  return {
    name: 'database',
    setup(ctx) {
      const dbPath = process.env.DB_PATH ?? ':memory:';
      db = new Database(dbPath);
      db.pragma('journal_mode = WAL');

      db.exec(`
        CREATE TABLE IF NOT EXISTS users (
          id TEXT PRIMARY KEY,
          name TEXT NOT NULL,
          email TEXT UNIQUE NOT NULL,
          created_at TEXT NOT NULL
        )
      `);

      ctx.decorate('db', db);
    },
    destroy() {
      db.close();
    },
  };
}

Connection setup belongs in setup(), not in a constructor. NextRush calls destroy() during graceful shutdown (app.close()) to close connections cleanly, in reverse registration order.


Repository Pattern

Base Interface

src/repositories/base.repository.ts
export interface BaseRepository<T, CreateDto, UpdateDto> {
  findAll(): Promise<T[]>;
  findById(id: string): Promise<T | null>;
  findByEmail(email: string): Promise<T | null>;
  create(data: CreateDto): Promise<T>;
  update(id: string, data: UpdateDto): Promise<T | null>;
  delete(id: string): Promise<boolean>;
}

User Repository

src/repositories/user.repository.ts
import { Repository } from 'nextrush/class';
import type { BaseRepository } from './base.repository';

export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

export interface CreateUserDto {
  name: string;
  email: string;
}

export interface UpdateUserDto {
  name?: string;
  email?: string;
}

@Repository()
export class UserRepository implements BaseRepository<User, CreateUserDto, UpdateUserDto> {
  async findAll(): Promise<User[]> {
    throw new Error('Not implemented — register a concrete repository via DI');
  }

  async findById(_id: string): Promise<User | null> {
    throw new Error('Not implemented');
  }

  async findByEmail(_email: string): Promise<User | null> {
    throw new Error('Not implemented');
  }

  async create(_data: CreateUserDto): Promise<User> {
    throw new Error('Not implemented');
  }

  async update(_id: string, _data: UpdateUserDto): Promise<User | null> {
    throw new Error('Not implemented');
  }

  async delete(_id: string): Promise<boolean> {
    throw new Error('Not implemented');
  }
}

SQLite Implementation

Install

$ pnpm add better-sqlite3
$ pnpm add -D @types/better-sqlite3

Repository

SQLite repository implementation (click to expand)
src/repositories/user.sqlite.repository.ts
import { Repository } from 'nextrush/class';
import type { Application } from 'nextrush';
import type { User, CreateUserDto, UpdateUserDto } from './user.repository';
import type { BaseRepository } from './base.repository';

@Repository()
export class UserSqliteRepository implements BaseRepository<User, CreateUserDto, UpdateUserDto> {
  constructor(private app: Application) {}

  private get db() {
    return this.app.db;
  }

  async findAll(): Promise<User[]> {
    const rows = this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all();
    return rows.map(this.toUser);
  }

  async findById(id: string): Promise<User | null> {
    const row = this.db.prepare('SELECT * FROM users WHERE id = ?').get(id);
    return row ? this.toUser(row) : null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const row = this.db.prepare('SELECT * FROM users WHERE email = ?').get(email);
    return row ? this.toUser(row) : null;
  }

  async create(data: CreateUserDto): Promise<User> {
    const id = crypto.randomUUID();
    const createdAt = new Date().toISOString();

    this.db
      .prepare(
        `
      INSERT INTO users (id, name, email, created_at)
      VALUES (?, ?, ?, ?)
    `
      )
      .run(id, data.name, data.email, createdAt);

    return { id, name: data.name, email: data.email, createdAt: new Date(createdAt) };
  }

  async update(id: string, data: UpdateUserDto): Promise<User | null> {
    const existing = await this.findById(id);
    if (!existing) return null;

    const updates: string[] = [];
    const values: unknown[] = [];

    if (data.name !== undefined) {
      updates.push('name = ?');
      values.push(data.name);
    }
    if (data.email !== undefined) {
      updates.push('email = ?');
      values.push(data.email);
    }

    if (updates.length > 0) {
      values.push(id);
      this.db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
    }

    return this.findById(id);
  }

  async delete(id: string): Promise<boolean> {
    const result = this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
    return result.changes > 0;
  }

  private toUser(row: unknown): User {
    const r = row as { id: string; name: string; email: string; created_at: string };
    return {
      id: r.id,
      name: r.name,
      email: r.email,
      createdAt: new Date(r.created_at),
    };
  }
}

PostgreSQL with Drizzle ORM

Install

$ pnpm add drizzle-orm postgres
$ pnpm add -D drizzle-kit

Schema

src/database/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

Database Extension

src/extensions/drizzle.extension.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import type { Extension } from 'nextrush';
import * as schema from '../database/schema';

declare module '@nextrush/core' {
  interface Application {
    drizzle: ReturnType<typeof drizzle<typeof schema>>;
  }
}

export function drizzleExtension(): Extension {
  let client: ReturnType<typeof postgres>;

  return {
    name: 'drizzle',
    setup(ctx) {
      const connectionString = process.env.DATABASE_URL;
      if (!connectionString) {
        throw new Error('DATABASE_URL environment variable is required');
      }

      client = postgres(connectionString);
      const db = drizzle(client, { schema });
      ctx.decorate('drizzle', db);
    },
    async destroy() {
      await client.end();
    },
  };
}

Repository

Drizzle ORM repository implementation (click to expand)
src/repositories/user.drizzle.repository.ts
import { Repository } from 'nextrush/class';
import { eq } from 'drizzle-orm';
import type { Application } from 'nextrush';
import { users, type User } from '../database/schema';
import type { BaseRepository, CreateUserDto, UpdateUserDto } from './user.repository';

@Repository()
export class UserDrizzleRepository implements BaseRepository<User, CreateUserDto, UpdateUserDto> {
  constructor(private app: Application) {}

  private get db() {
    return this.app.drizzle;
  }

  async findAll(): Promise<User[]> {
    return this.db.select().from(users).orderBy(users.createdAt);
  }

  async findById(id: string): Promise<User | null> {
    const [user] = await this.db.select().from(users).where(eq(users.id, id));
    return user || null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const [user] = await this.db.select().from(users).where(eq(users.email, email));
    return user || null;
  }

  async create(data: CreateUserDto): Promise<User> {
    const [user] = await this.db.insert(users).values(data).returning();
    return user;
  }

  async update(id: string, data: UpdateUserDto): Promise<User | null> {
    const [user] = await this.db.update(users).set(data).where(eq(users.id, id)).returning();
    return user || null;
  }

  async delete(id: string): Promise<boolean> {
    const result = await this.db.delete(users).where(eq(users.id, id)).returning();
    return result.length > 0;
  }
}

Service Layer

The service layer contains business logic, independent of the database choice:

src/services/user.service.ts
import { Service } from 'nextrush/class';
import { NotFoundError, ConflictError, BadRequestError } from 'nextrush';
import { UserRepository } from '../repositories/user.repository';
import type { User, CreateUserDto, UpdateUserDto } from '../repositories/user.repository';

@Service()
export class UserService {
  constructor(private repo: UserRepository) {}

  async findAll(): Promise<User[]> {
    return this.repo.findAll();
  }

  async findById(id: string): Promise<User> {
    const user = await this.repo.findById(id);
    if (!user) {
      throw new NotFoundError(`User ${id} not found`);
    }
    return user;
  }

  async create(data: CreateUserDto): Promise<User> {
    if (!data.name?.trim()) {
      throw new BadRequestError('Name is required');
    }
    if (!data.email?.trim()) {
      throw new BadRequestError('Email is required');
    }

    const existing = await this.repo.findByEmail(data.email);
    if (existing) {
      throw new ConflictError('Email already exists');
    }

    return this.repo.create({
      name: data.name.trim(),
      email: data.email.toLowerCase().trim(),
    });
  }

  async update(id: string, data: UpdateUserDto): Promise<User> {
    const existing = await this.repo.findById(id);
    if (!existing) {
      throw new NotFoundError(`User ${id} not found`);
    }

    const updated = await this.repo.update(id, {
      name: data.name?.trim(),
      email: data.email?.toLowerCase().trim(),
    });

    return updated!;
  }

  async delete(id: string): Promise<void> {
    const deleted = await this.repo.delete(id);
    if (!deleted) {
      throw new NotFoundError(`User ${id} not found`);
    }
  }
}

Controller

src/controllers/user.controller.ts
import { Controller, Get, Post, Put, Delete, Body, Param, Ctx } from 'nextrush/class';
import type { Context } from 'nextrush';
import { UserService } from '../services/user.service';
import type { CreateUserDto, UpdateUserDto } from '../repositories/user.repository';

@Controller('/users')
export class UserController {
  constructor(private userService: UserService) {}

  @Get()
  async findAll() {
    return { data: await this.userService.findAll() };
  }

  @Get('/:id')
  async findOne(@Param('id') id: string) {
    return { data: await this.userService.findById(id) };
  }

  @Post()
  async create(@Body() data: CreateUserDto, @Ctx() ctx: Context) {
    const user = await this.userService.create(data);
    ctx.status = 201;
    return { data: user };
  }

  @Put('/:id')
  async update(@Param('id') id: string, @Body() data: UpdateUserDto) {
    return { data: await this.userService.update(id, data) };
  }

  @Delete('/:id')
  async remove(@Param('id') id: string, @Ctx() ctx: Context) {
    await this.userService.delete(id);
    ctx.status = 204;
  }
}

Entry Point

src/index.ts
// reflect-metadata is auto-imported by the nextrush meta-package
import { createApp, listen } from 'nextrush';
import { registerControllers } from 'nextrush/class';
import { createContainer } from 'nextrush/class';
import { cors } from '@nextrush/cors';
import { json } from '@nextrush/body-parser';

import { database } from './extensions/database.extension';
import { UserRepository } from './repositories/user.repository';
import { UserSqliteRepository } from './repositories/user.sqlite.repository';

const container = createContainer();

// Swap repository implementation without changing business logic
container.register(UserRepository, { useClass: UserSqliteRepository });

const app = createApp({ container });

// Database is a long-lived service — an Extension, booted at ready()
app.extend(database());

app.use(cors());
app.use(json());

// Registrar — reads app.router + app.container, must be awaited before listen()
await registerControllers(app, { root: './src', prefix: '/api' });

await listen(app, 8080); // adapters call app.ready() for you

Register UserRepository with a different implementation (e.g., UserDrizzleRepository) to swap databases without touching services or controllers.


Testing with Mocks

src/services/user.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UserService } from './user.service';
import type { UserRepository } from '../repositories/user.repository';

describe('UserService', () => {
  let service: UserService;
  let mockRepo: UserRepository;

  beforeEach(() => {
    mockRepo = {
      findAll: vi.fn().mockResolvedValue([]),
      findById: vi.fn().mockResolvedValue(null),
      findByEmail: vi.fn().mockResolvedValue(null),
      create: vi.fn().mockImplementation((data) => ({
        id: '1',
        ...data,
        createdAt: new Date(),
      })),
      update: vi.fn(),
      delete: vi.fn().mockResolvedValue(true),
    } as unknown as UserRepository;

    service = new UserService(mockRepo);
  });

  it('should create user', async () => {
    const user = await service.create({
      name: 'Test',
      email: 'test@example.com',
    });

    expect(user.name).toBe('Test');
    expect(mockRepo.create).toHaveBeenCalled();
  });

  it('should throw on duplicate email', async () => {
    (mockRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue({ id: '1' });

    await expect(service.create({ name: 'Test', email: 'existing@example.com' })).rejects.toThrow(
      'Email already exists'
    );
  });
});

Next Steps

Was this helpful?

On this page