Skip to Content

Database

One pattern for every database library, and the guidelines that go with it.

Suku has no database layer and no ORM integration packages. It does not need them. A database library is a service like any other, and the same four-step pattern works for all of them.

LibraryGuideTested here against
DrizzleDrizzleSQLite, in process
PrismaPrismaSQLite, through a driver adapter
TypeORMTypeORMSQLite, in process
SequelizeSequelizeSQLite, in process
sqlkitsqlkitPostgreSQL, in process
Kysely and othersOther librariesPostgreSQL, in process

Every guide comes from examples/database in the repository. One test suite runs the same checks against each library: create, list, a duplicate rejected with 409, a transaction that rolls back, one that commits, and the connection closing with the app.

The pattern

1. A service owns the connection

Create the connection once and give it a dispose method. The container calls it when the app closes.

export class Database {
  constructor(readonly client: Client) {}

  dispose() {
    return this.client.close();
  }
}

Most libraries connect asynchronously, and service factories are synchronous. So connect first, then register the result. A feature can be async for exactly this.

export const DatabaseFeature = async () => {
  const client = await connect(env.DATABASE_URL);
  return feature('database', {
    services: [factory(Database, () => new Database(client))],
  });
};

Suku.create({ features: [DatabaseFeature(), UsersFeature] });

Use factory, not value

value(Database, instance) registers something the container did not create, so it will never dispose it. factory(...) makes the container the owner, and dispose runs on app.close().

2. A repository hides the library

Put queries behind a class your routes depend on. Make the contract an abstract class, so it can be a token.

export abstract class UsersRepository {
  abstract list(): Promise<User[]>;
  abstract create(input: NewUser): Promise<User>;
}

@Service()
export class SqlUsers extends UsersRepository {
  constructor(private readonly db: Database) {
    super();
  }
  // queries written with your library
}
services: [singleton(UsersRepository, SqlUsers)];

Routes then know nothing about the library.

export const createUser = route.post('/users', {
  body: NewUser,
  services: { users: UsersRepository },
  handle: ({ body, users }) => users.create(body),
});

In the example, the same routes run unchanged on six libraries, by changing that one registration.

3. Database errors become domain errors

Each library reports a duplicate key differently. Catch that inside the repository and throw your own error, so nothing outside it depends on the library.

export class DuplicateEmail extends Error {
  constructor(readonly email: string) {
    super(`Email ${email} is already registered`);
  }
}
LibraryA unique violation looks like
Drizzle with SQLiteerror.code starting with SQLITE_CONSTRAINT
PrismaPrismaClientKnownRequestError with code === 'P2002'
TypeORMQueryFailedError; inspect the message or driver code
SequelizeUniqueConstraintError
PostgreSQL drivers, sqlkit, Kyselyerror.code === '23505'

4. One handler maps them to HTTP

export const databaseErrors: ErrorHandler = (error) => {
  if (error instanceof DuplicateEmail) {
    return new ConflictException({ code: 'email_taken', message: error.message }).toResponse();
  }
};

feature('users', { routes, services, onError: databaseErrors });

See errors for why this split is worth it.

Transactions

Inside one repository method

When several writes belong together, do them in one method using your library's transaction.

async createMany(inputs: NewUser[]) {
  return this.db.client.transaction(async (tx) => {
    const created = [];
    for (const input of inputs) created.push(await tx.insert(input));
    return created;
  });
}

Across a whole request

When a handler calls several repositories, a middleware can wrap the request in one transaction. This version uses TypeORM; the shape is the same for any library with an async transaction callback.

class Rollback extends Error {
  constructor(readonly response: Response) {
    super('rollback');
  }
}

export const transactional = middleware(async (ctx, next) => {
  const { dataSource } = ctx.provider.get(TypeormConnection);
  try {
    return await dataSource.transaction(async (manager) => {
      ctx.set(MANAGER, manager);
      const response = await next();
      if (response.status >= 400) throw new Rollback(response);
      return response;
    });
  } catch (error) {
    if (error instanceof Rollback) return error.response;
    throw error;
  }
});

route.post('/signup', { middlewares: [transactional], handle });

The detail that matters: by the time a response returns to a middleware, an error in the handler has already been turned into an error response. So the middleware checks the status, and throws inside the transaction to roll it back while keeping that response.

Keep these transactions short. Do not call other services over the network while one is open.

From tests to production

The guides run on in-process databases so the tests need no server. In production you change the driver in the connection service and nothing else. The repository, the routes and the error mapping stay as they are.

LibraryTested here withIn production, typicallyMigrations
Drizzledrizzle-orm/bun-sqlitedrizzle-orm/node-postgres, postgres-js or mysql2drizzle-kit migrate
Prisma@prisma/adapter-libsql@prisma/adapter-pgprisma migrate deploy
TypeORMsqljspostgres or mysqltypeorm migration:run -d data-source.js
Sequelizesqlitepostgres or mysqlsequelize-cli db:migrate
sqlkitPGlite executorPostgresAdapter over a pg poolany SQL migration tool
KyselyPGlite dialectPostgresDialect over a pg poolkysely-ctl migrate or its Migrator

One thing does change with the database: how a unique violation is reported. Check the table in step 3 when you switch.

Guidelines

Configuration

Validate the connection string at startup with defineConfig, so a missing one stops the app before it listens.

export const Env = defineConfig(
  'env',
  z.object({
    DATABASE_URL: z.url(),
    DATABASE_POOL_SIZE: z.coerce.number().int().min(1).default(10),
  }),
);

Keep the URL in the environment or a secret store, never in the repository. Give the app its own database user that can read and write rows but cannot alter tables. Migrations run as a different user.

Connections and pool size

One pool per process. The connection service is a singleton. Never open a connection per request, and never make the connection service scoped: it would connect on every request. Scope only things that are truly per request, such as a transaction handle.

Size the pool for the whole fleet. Every instance of your app has its own pool.

connections used = pool size × instances (PM2 workers, containers, replicas)

Keep that under the database's connection limit, with room for migrations and a console. Ten connections on each of eight PM2 workers is eighty. If the number gets large, put a pooler such as PgBouncer in front of PostgreSQL and keep the per-instance pool small.

Timeouts

A stuck query should fail, not hold a connection forever. Set limits in the driver. With pg:

const pool = new Pool({
  connectionString: env.DATABASE_URL,
  max: env.DATABASE_POOL_SIZE,
  connectionTimeoutMillis: 5_000, // waiting for a free connection
  idleTimeoutMillis: 30_000, // closing idle connections
  statement_timeout: 10_000, // the server cancels a statement after 10 s
});

Other drivers have the same three ideas under other names: how long to wait for a connection, how long an idle one lives, and how long one statement may run.

The pool options above are pg's and are not run by the test suite, because they need a PostgreSQL server. The health check and pagination below are tested in examples/database.

Startup and shutdown

Fail fast at startup. The async feature in step 1 awaits the connection, so Suku.create rejects when the database is unreachable and the process exits before it accepts traffic. Let your process manager restart it. Do not add a retry loop that hides a wrong URL.

Close in order. app.close() stops accepting requests, lets the ones in flight finish, then disposes singletons. That is when dispose closes the pool, so no request loses its connection halfway. Wire it to SIGTERM as shown in deployment.

Health checks

Use two routes. Liveness says the process is running and must not touch the database, or a database outage would make your orchestrator restart healthy processes. Readiness says this instance can serve traffic, and does check the database.

export const health = route.get('/health', { handle: () => ({ status: 'ok' }) });

export const ready = route.get('/ready', {
  services: { db: Database },
  handle: async ({ db }) => {
    if (!(await db.ping())) throw new ServiceUnavailableException('Database is not reachable');
    return { status: 'ready' };
  },
});

ping() runs select 1 and returns false instead of throwing. A 503 from /ready takes the instance out of rotation until the database is back.

Migrations

Run them as a deploy step, before the new version starts, never when the app boots. Several instances starting at once would race, and a slow migration would fail health checks. synchronize and sync() are for tests and prototypes only.

During a rolling deploy the old and the new version run against the same schema for a while, so make each migration compatible with the version before it. To rename a column: add the new one, deploy code that writes both, backfill, deploy code that reads the new one, then drop the old one.

Pagination

Never return a whole table. Validate the page in the route's query schema and cap the limit. Keyset pagination, "rows after this id", stays fast however deep the page is, unlike OFFSET.

const Page = z.object({
  after: z.coerce.number().int().min(0).default(0),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

export const listUsers = route.get('/users', {
  query: Page,
  services: { users: UsersRepository },
  handle: async ({ query, users }) => {
    const rows = await users.listAfter(query.after, query.limit + 1);
    const items = rows.slice(0, query.limit);
    return { items, next: rows.length > query.limit ? items.at(-1)!.id : null };
  },
});

Asking for one row more than the limit tells you whether there is a next page without a second count query.

Queries

Never build SQL from input. Use your library's parameters. The route's schemas reject malformed input, and parameters stop injection.

Do not return rows directly. Give routes a response schema, so a new column such as a password hash cannot leak. See validation.

Watch for queries in a loop. Loading a list and then one related row per item is one query plus one per row. Load the related rows in a single query with a join or an IN list.

Stop work nobody is waiting for. request.signal aborts when the client disconnects. Pass it to drivers that accept one for long reports and exports.

Serverless and edge

On Cloudflare Workers and Vercel there is no long-lived process, so the rules change:

  • Many instances run at once, each with its own pool. Set the pool size to 1 and let a pooler hold the real connections: Hyperdrive on Cloudflare, PgBouncer, or your provider's pooled connection string.
  • Workers cannot open raw TCP sockets from most Node drivers without extra setup. Prefer an HTTP or WebSocket driver, such as the ones from Neon, Turso or PlanetScale, or D1.
  • Nothing calls app.close(), so dispose never runs. Do not rely on it to flush anything.
  • Create the app once per instance, not per request. See deployment.

Testing

Replace the repository, not the database, for fast tests.

const app = await Suku.create({
  features: [UsersFeature],
  configureServices: (services) => services.replace(singleton(UsersRepository, InMemoryUsers)),
});

For tests that should exercise real SQL, an in-process database needs no server: SQLite for most libraries, or PGlite for PostgreSQL. That is how this repository tests every guide. Give each test its own database, in memory or in a temporary file, so tests can run in parallel and never see each other's rows.

Production checklist

  • The connection string is validated at startup and comes from the environment.
  • The connection service is a singleton registered with factory, and has dispose.
  • Pool size times instances is below the database's connection limit.
  • Connection, idle and statement timeouts are set.
  • Migrations run as a deploy step and are compatible with the previous version.
  • /health does not touch the database and /ready does.
  • List routes are paginated with a capped limit.
  • Routes that return rows have a response schema.
  • SIGTERM calls app.close().

On this page