Project structure
AdonisJS 6 organizes code with subpath imports such as #controllers/* and #services/*, validates environment variables at boot, and uses providers for lifecycle work. kastor slots into those three places:
start/env.ts DATABASE_URL validation
start/routes.ts routes
database/schema.ts kastor tables and relations
app/services/db.ts the client (#services/db)
providers/kastor_provider.ts closes the pool on shutdown
app/controllers/posts_controller.ts
app/validators/post.ts VineJS input validation
kastor.config.ts schema: './database/schema.ts'
kastor/migrations/npm install kastor-orm pg
npx kastor-orm init --db postgresMove the starter schema to database/schema.ts and update the config. If the project already uses Lucid, run npx kastor db pull --out database/schema.ts instead, so kastor describes the tables Lucid migrations created.
Validated environment variables
AdonisJS refuses to boot when a declared variable is missing or malformed, which is exactly what you want for a connection string.
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
PORT: Env.schema.number(),
APP_KEY: Env.schema.string(),
DATABASE_URL: Env.schema.string(),
})The service module and the provider
The AdonisJS dev server reloads changed modules with hot module replacement. Keeping the client on globalThis in development makes every reload reuse one pool.
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import env from '#start/env'
import { schema, relations } from '../../database/schema'
const create = () =>
kastor({
adapter: postgres(new pg.Pool({ connectionString: env.get('DATABASE_URL'), max: 10 })),
schema,
relations,
})
const g = globalThis as unknown as { kastorDb?: ReturnType<typeof create> }
const db = g.kastorDb ?? create()
if (env.get('NODE_ENV') !== 'production') g.kastorDb = db
export default db
export type Db = typeof dbimport type { ApplicationService } from '@adonisjs/core/types'
export default class KastorProvider {
constructor(protected app: ApplicationService) {}
async shutdown() {
const { default: db } = await import('#services/db')
await db.$disconnect()
}
}Register the provider in adonisrc.ts under providers with () => import('./providers/kastor_provider.js'). The shutdown method runs when the app receives a termination signal, after the HTTP server stops.
Controllers, validation and routes
Controllers import the service through its alias. VineJS validates the request body; kastor then receives values that are checked at runtime and at compile time.
import type { HttpContext } from '@adonisjs/core/http'
import vine from '@vinejs/vine'
import db from '#services/db'
const createPost = vine.compile(vine.object({ title: vine.string().minLength(3), authorId: vine.number() }))
export default class PostsController {
async index({ request }: HttpContext) {
const page = Number(request.input('page', 1))
const [items, total] = await db.$transaction([
db.posts.findMany({
where: { published: true },
include: { author: { select: { name: true } } },
orderBy: { id: 'desc' },
skip: (page - 1) * 20,
take: 20,
}),
db.posts.count({ where: { published: true } }),
])
return { items, total, page }
}
async show({ params, response }: HttpContext) {
const post = await db.posts.findUnique({ where: { id: Number(params.id) }, include: { tags: true } })
return post ?? response.notFound({ error: 'Post not found' })
}
async store({ request, response }: HttpContext) {
const data: { title: string; authorId: number } = await request.validateUsing(createPost)
return response.created(await db.posts.create({ data }))
}
}import router from '@adonisjs/core/services/router'
const PostsController = () => import('#controllers/posts_controller')
router.get('/posts', [PostsController, 'index'])
router.get('/posts/:id', [PostsController, 'show'])
router.post('/posts', [PostsController, 'store'])The array form of $transaction runs the page query and the count in one transaction, the kastor version of Lucid's paginate(). For cursor pagination, db.posts.findPage returns items and opaque cursors.
Migrations in CI and deploy
Decide who owns schema changes. If Lucid migrations stay in charge, keep node ace migration:run in your deploy and run npx kastor db pull after each new migration to refresh database/schema.ts. If kastor takes over, record the current database once with npx kastor migrate baseline, commit kastor/migrations, and from then on:
- run: npm ci
- run: node ace build
- run: npx kastor migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- run: ./deploy.shThe production build in build/ needs DATABASE_URL in its environment like every other variable in start/env.ts.
Running next to Lucid
Many AdonisJS apps will not drop Lucid at once, and they do not have to. Both libraries can use the same database with separate pools. A practical split is to keep Lucid for authentication and the parts that rely on its model hooks, and use kastor for read heavy endpoints that benefit from nested includes in one query, or for code shared with other TypeScript services. Size both pools so their sum stays under the database's connection limit.
Schema ownership needs one rule: only one tool writes migrations. If Lucid stays in charge, kastor follows with db pull after each change. If kastor takes over, stop creating Lucid migrations and let npx kastor migrate dev write the SQL. Mixing the two leads to two histories that disagree about the same tables.
Transactions follow the same split. A Lucid transaction and a kastor transaction are separate connections, so do not expect a write through one to be rolled back by the other. Keep each use case on one library.
Production checklist
Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:
DATABASE_URLis declared instart/env.ts.- The client is exported from
app/services/db.tsand reused across HMR reloads. - The provider disconnects the pool on shutdown.
- Exactly one tool owns migrations.
- Controllers validate input with VineJS before writing.
Pitfalls
- Two owners. Running both
node ace migration:runandkastor migrate deployfor the same tables leads to conflicting histories. Pick one. - The .js extension. AdonisJS 6 is ESM; lazy imports in
adonisrc.tsuse.jspaths even for TypeScript files. - Serialization. Controllers return values as JSON; convert
bigintkeys before returning them. - Auth. The AdonisJS auth package works with Lucid models. If you drop Lucid entirely, implement the user provider over kastor or keep a Lucid user model only for auth.
Developers coming from Laravel will recognize much of AdonisJS; the Laravel guide maps Eloquent concepts to kastor.
Questions people ask
Q1Should I replace Lucid with kastor in AdonisJS?
Only if you have a reason: inferred types without model classes, a database Lucid does not cover, or one schema shared with other TypeScript services. Lucid is well integrated with AdonisJS, and both can run side by side on the same database.
Q2Where does the kastor client go in AdonisJS?
In a service module such as app/services/db.ts, imported through the #services/db subpath alias. A provider closes the pool when the app shuts down.
Q3Can Lucid migrations and kastor share a database?
Yes, as long as one tool owns schema changes. Keep running node ace migration:run and refresh the kastor schema with npx kastor db pull, or baseline with npx kastor migrate baseline and let kastor own changes.
Q4Does it work with the AdonisJS dev server and HMR?
Yes. Keep the client on globalThis outside production so hot module reloads reuse the same pool.