adonisjs-best-practices
AdonisJS v6 Best Practices
Overview
AdonisJS v6 is a TypeScript-first MVC framework with batteries included. Core principle: type safety, dependency injection, and convention over configuration.
When to Use
- Building new AdonisJS v6 features
- Implementing routes, controllers, middleware
- Setting up authentication or authorization
- Writing Lucid ORM models and queries
- Creating validators with VineJS
- Writing tests for AdonisJS apps
Quick Reference
| Task | Pattern |
|---|---|
| Route to controller | router.get('/users', [UsersController, 'index']) |
| Lazy-load controller | const UsersController = () => import('#controllers/users_controller') |
| Validate request | const payload = await request.validateUsing(createUserValidator) |
| Auth check | await auth.authenticate() or auth.use('guard').authenticate() |
| Authorize action | await bouncer.authorize('editPost', post) |
| Query with relations | await User.query().preload('posts') |
Project Structure
app/
controllers/ # HTTP handlers (thin, delegate to services)
models/ # Lucid ORM models
services/ # Business logic
middleware/ # Request interceptors
validators/ # VineJS validation schemas
exceptions/ # Custom exceptions
policies/ # Bouncer authorization
start/
routes.ts # Route definitions
kernel.ts # Middleware registration
config/ # Configuration files
database/ # Migrations, seeders, factories
tests/ # Test suites
Routing
Lazy-load controllers for HMR support and faster boot:
// start/routes.ts
const UsersController = () => import('#controllers/users_controller')
router.get('/users', [UsersController, 'index'])
router.post('/users', [UsersController, 'store'])
Order matters: Define specific routes before dynamic ones:
// CORRECT
router.get('/users/me', [UsersController, 'me'])
router.get('/users/:id', [UsersController, 'show'])
// WRONG - /users/me will never match
router.get('/users/:id', [UsersController, 'show'])
router.get('/users/me', [UsersController, 'me'])
Use route groups for organization and bulk middleware:
router
.group(() => {
router.resource('posts', PostsController)
router.resource('comments', CommentsController)
})
.prefix('/api/v1')
.middleware(middleware.auth())
Resource controllers for RESTful CRUD:
router.resource('posts', PostsController)
// Creates: index, create, store, show, edit, update, destroy
Name routes for URL generation:
router.get('/posts/:id', [PostsController, 'show']).as('posts.show')
// Use: route('posts.show', { id: 1 })
Controllers
Single responsibility: One controller per resource, thin handlers:
// app/controllers/posts_controller.ts
export default class PostsController {
async index({ request, response }: HttpContext) {
const posts = await Post.query().preload('author')
return response.json(posts)
}
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await Post.create(payload)
return response.created(post)
}
}
Method injection for services:
import { inject } from '@adonisjs/core'
import PostService from '#services/post_service'
export default class PostsController {
@inject()
async store({ request }: HttpContext, postService: PostService) {
const payload = await request.validateUsing(createPostValidator)
return postService.create(payload)
}
}
Validation
Validate immediately in controller, before any business logic:
// app/validators/post_validator.ts
import vine from '@vinejs/vine'
export const createPostValidator = vine.compile(
vine.object({
title: vine.string().trim().minLength(3).maxLength(255),
content: vine.string().trim(),
published: vine.boolean().optional(),
})
)
// In controller
async store({ request }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
// payload is now typed and validated
}
Database rules for unique/exists checks:
import vine from '@vinejs/vine'
import { uniqueRule } from '#validators/rules/unique'
export const createUserValidator = vine.compile(
vine.object({
email: vine
.string()
.email()
.use(uniqueRule({ table: 'users', column: 'email' })),
})
)
Middleware
Three stacks with distinct purposes:
// start/kernel.ts
// Server middleware: ALL requests (static files, health checks)
server.use([() => import('#middleware/container_bindings_middleware')])
// Router middleware: matched routes only (auth, logging)
router.use([() => import('@adonisjs/cors/cors_middleware')])
// Named middleware: explicit assignment
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
guest: () => import('#middleware/guest_middleware'),
})
Apply per-route:
router.get('/dashboard', [DashboardController, 'index']).middleware(middleware.auth())
Authentication
Choose guard by client type:
- Session guard: Server-rendered apps (web)
- Access tokens: SPA/mobile clients (api)
// Session-based (web)
router.post('/login', async ({ auth, request, response }) => {
const { email, password } = await request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
return response.redirect('/dashboard')
})
// Token-based (API)
router.post('/api/login', async ({ request }) => {
const { email, password } = await request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password)
const token = await User.accessTokens.create(user)
return { token: token.value!.release() }
})
Protect routes:
router
.group(() => {
router.get('/profile', [ProfileController, 'show'])
})
.middleware(middleware.auth({ guards: ['web'] }))
Authorization (Bouncer)
Abilities for simple checks:
// app/abilities/main.ts
import { Bouncer } from '@adonisjs/bouncer'
import User from '#models/user'
import Post from '#models/post'
export const editPost = Bouncer.ability((user: User, post: Post) => {
return user.id === post.userId
})
Policies for resource-based authorization:
// app/policies/post_policy.ts
import { BasePolicy } from '@adonisjs/bouncer'
import User from '#models/user'
import Post from '#models/post'
export default class PostPolicy extends BasePolicy {
edit(user: User, post: Post) {
return user.id === post.userId
}
delete(user: User, post: Post) {
return user.id === post.userId || user.isAdmin
}
}
Use in controllers:
async update({ bouncer, params, request }: HttpContext) {
const post = await Post.findOrFail(params.id)
await bouncer.authorize('editPost', post) // Throws if unauthorized
// or: if (await bouncer.allows('editPost', post)) { ... }
}
Database (Lucid ORM)
Prevent N+1 with eager loading:
// BAD - N+1 queries
const posts = await Post.all()
for (const post of posts) {
console.log(post.author.name) // Query per post
}
// GOOD - 2 queries total
const posts = await Post.query().preload('author')
Model hooks for business logic:
// app/models/user.ts
import { beforeSave, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
export default class User extends BaseModel {
@column()
declare password: string
@beforeSave()
static async hashPassword(user: User) {
if (user.$dirty.password) {
user.password = await hash.make(user.password)
}
}
}
Transactions for atomic operations:
import db from '@adonisjs/lucid/services/db'
await db.transaction(async (trx) => {
const user = await User.create({ email }, { client: trx })
await Profile.create({ userId: user.id }, { client: trx })
})
Error Handling
Custom exceptions:
// app/exceptions/not_found_exception.ts
import { Exception } from '@adonisjs/core/exceptions'
export default class NotFoundException extends Exception {
static status = 404
static code = 'E_NOT_FOUND'
}
// Usage
throw new NotFoundException('Post not found')
Global exception handler:
// app/exceptions/handler.ts
import { ExceptionHandler, HttpContext } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof NotFoundException) {
return ctx.response.status(404).json({ error: error.message })
}
return super.handle(error, ctx)
}
}
Testing
HTTP tests via test client:
import { test } from '@japa/runner'
test.group('Posts', () => {
test('can list posts', async ({ client }) => {
const response = await client.get('/api/posts')
response.assertStatus(200)
response.assertBodyContains({ data: [] })
})
test('requires auth to create post', async ({ client }) => {
const response = await client.post('/api/posts').json({ title: 'Test' })
response.assertStatus(401)
})
test('authenticated user can create post', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.post('/api/posts')
.loginAs(user)
.json({ title: 'Test', content: 'Content' })
response.assertStatus(201)
})
})
Database isolation with transactions:
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
test.group('Posts', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('creates post in database', async ({ client, assert }) => {
const user = await UserFactory.create()
await client.post('/api/posts').loginAs(user).json({ title: 'Test' })
const post = await Post.findBy('title', 'Test')
assert.isNotNull(post)
})
})
Common Mistakes
| Mistake | Fix |
|---|---|
| Raw controller imports | Use lazy-loading: () => import('#controllers/...') |
| Validating in services | Validate in controller before business logic |
| N+1 queries | Use .preload() for eager loading |
| Dynamic route before specific | Order specific routes first |
| Skipping authorization | Always check permissions with Bouncer |
| Not using transactions | Wrap related operations in db.transaction() |
| Testing directly, not via HTTP | Use client.get() for integration tests |
More from futuregerald/futuregerald-claude-plugin
baoyu-article-illustrator
Smart article illustration skill. Analyzes article content and generates illustrations at positions requiring visual aids with multiple style options. Use when user asks to "add illustrations to article", "generate images for article", or "illustrate article".
16turso-best-practices
Turso and libSQL best practices for SQLite-compatible cloud database development with edge distribution, embedded replicas, and vector search.
15better-auth-best-practices
Skill for integrating Better Auth - the comprehensive TypeScript authentication framework.
12javascript-testing-patterns
Comprehensive JavaScript/TypeScript testing patterns for Jest, Vitest, and AdonisJS/Japa. Use when writing tests, reviewing test code, or debugging test failures.
12code-simplifier
Simplifies and refines code for clarity, consistency, and maintainability while preserving all functionality. Focuses on recently modified code unless instructed otherwise.
12design-principles
Enforce a precise, minimal design system inspired by Linear, Notion, and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.
12