Use when building AdonisJS v6 applications, implementing features in AdonisJS, or reviewing AdonisJS code. Covers routing, controllers, validation, authentication, database patterns, testing, and error handling.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add Lord1Egypt/awesome-skill-forge --skill skills-sh_futuregerald_futuregerald-claude-plugin_adonisjs-best-practices --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Skills Sh Futuregerald Futuregerald Claude Plugin Adonisjs Best Practices?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/lord1egypt-skills-sh-futuregerald-futuregerald-claude-plugin)More formats (shields.io, HTML) on the badges page.
---
name: adonisjs-best-practices
description: Use when building AdonisJS v6 applications, implementing features in AdonisJS, or reviewing AdonisJS code. Covers routing, controllers, validation, authentication, database patterns, testing, and error handling.
tags: [framework, adonisjs]
---
# 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:
```typescript
// 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:
```typescript
// 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:
```typescript
router
.group(() => {
router.resource('posts', PostsController)
router.resource('comments', CommentsController)
})
.prefix('/api/v1')
.middleware(middleware.auth())
```
**Resource controllers** for RESTful CRUD:
```typescript
router.resource('posts', PostsController)
// Creates: index, create, store, show, edit, update, destroy
```
**Name routes** for URL generation:
```typescript
router.get('/posts/:id', [PostsController, 'show']).as('posts.show')
// Use: route('posts.show', { id: 1 })
```
## Controllers
**Single responsibility**: One controller per resource, thin handlers:
```typescript
// 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:
```typescript
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:
```typescript
// 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:
```typescript
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:
```typescript
// 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**:
```typescript
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)
```typescript
// 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**:
```typescript
router
.group(() => {
router.get('/profile', [ProfileController, 'show'])
})
.middleware(middleware.auth({ guards: ['web'] }))
```
## Authorization (Bouncer)
**Abilities** for simple checks:
```typescript
// 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:
```typescript
// 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**:
```typescript
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:
```typescript
// 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:
```typescript
// 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:
```typescript
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**:
```typescript
// 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**:
```typescript
// 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:
```typescript
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:
```typescript
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 |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!