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 indestroy()) - 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.
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
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
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)
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
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
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)
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:
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
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
// 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 youRegister UserRepository with a different implementation (e.g., UserDrizzleRepository) to swap
databases without touching services or controllers.
Testing with Mocks
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
- Class-Based Controllers — Full class-based example
- Authentication — Add user authentication
- Testing Guide — Test database code
- API Reference — Class Runtime — Container and service registration API
- Deployment — Deploy your application to production