| name | adonisjs-backend |
| description | AdonisJS 6 backend development patterns — controllers, models, validators, services, middleware, auth, and testing for production-grade TypeScript APIs. |
| license | MIT |
| compatibility | opencode |
| metadata | {"origin":"claude-skills","type":"workflow"} |
AdonisJS 6 Backend Development Patterns
Expert backend developer specializing in AdonisJS 6, Lucid ORM, VineJS validation, and production-grade TypeScript APIs. Inspired by Rails conventions — convention over configuration, MVC architecture, and developer happiness.
When to Activate
- Building or modifying AdonisJS controllers, models, services, or middleware
- Designing REST API endpoints with AdonisJS router
- Working with Lucid ORM (models, relationships, queries, migrations, seeders)
- Implementing validation with VineJS
- Setting up authentication (access tokens, session, social auth)
- Writing AdonisJS tests (Japa)
- Configuring middleware, guards, or exception handlers
- Background jobs with
@adonisjs/limiter, @rlanz/bull-queue, or similar
Project Structure (AdonisJS 6)
app/
├── controllers/ # HTTP controllers (thin, delegate to services)
├── models/ # Lucid ORM models
├── services/ # Business logic layer
├── validators/ # VineJS validation schemas
├── middleware/ # HTTP middleware
├── exceptions/ # Custom exception classes
├── policies/ # Authorization (Bouncer policies)
├── mails/ # Mail classes
├── events/ # Event listeners
├── jobs/ # Background jobs
config/ # App configuration
database/
├── migrations/ # Database migrations
├── seeders/ # Database seeders
├── factories/ # Model factories (for testing)
start/
├── routes.ts # Route definitions
├── kernel.ts # Middleware registration
├── events.ts # Event bindings
tests/
├── unit/
├── functional/
Core Principles
- Convention over configuration — follow AdonisJS conventions, don't fight the framework
- Thin controllers — controllers validate input, call services, return responses
- Fat models — keep query scopes, relationships, and computed properties on models
- Service layer — complex business logic lives in services, not controllers
- Validate early — use VineJS validators before any business logic
- Type everything — leverage TypeScript strictly, no
any
Router & Controllers
Route Definitions
import router from '@adonisjs/core/services/router'
const UsersController = () => import('#controllers/users_controller')
const PostsController = () => import('#controllers/posts_controller')
router.resource('users', UsersController).apiOnly()
router.resource('users.posts', PostsController).apiOnly()
router.group(() => {
router.get('me', [UsersController, 'me'])
router.resource('posts', PostsController).apiOnly()
}).prefix('api/v1').middleware(middleware.auth())
router.get('users/:id', [UsersController, 'show']).as('users.show')
Controller Pattern
import type { HttpContext } from '@adonisjs/core/http'
import { createPostValidator, updatePostValidator } from '#validators/post'
import PostService from '#services/post_service'
import { inject } from '@adonisjs/core'
@inject()
export default class PostsController {
constructor(private postService: PostService) {}
async index({ request, response }: HttpContext) {
const page = request.input('page', 1)
const limit = request.input('limit', 20)
const posts = await this.postService.list(page, limit)
return response.ok(posts)
}
async store({ request, response, auth }: HttpContext) {
const data = await request.(createPostValidator)
post = ..(auth.!, data)
response.(post)
}
() {
post = ..(params.)
response.(post)
}
() {
post = ..(params.)
bouncer.(, post)
data = request.(updatePostValidator)
updated = ..(post, data)
response.(updated)
}
() {
post = ..(params.)
bouncer.(, post)
..(post)
response.()
}
}
Lucid ORM Models
Model Definition
import { DateTime } from 'luxon'
import { BaseModel, column, belongsTo, hasMany, scope } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
import Comment from '#models/comment'
export default class Post extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare title: string
@column()
declare content: string
@column()
declare userId: number
@column()
declare isPublished: boolean
@column.dateTime({ autoCreate: })
:
.({ : , : })
:
( )
: < >
( )
: < >
published = ( {
query.(, )
})
recent = ( {
query.(, , .().({ days }).())
})
() {
{
: .,
: .,
: .,
: .. ? ..() : ,
: ..,
: ..(),
}
}
}
Querying with Lucid
const posts = await Post.query()
.withScopes((s) => s.published())
.preload('user')
.preload('comments', (query) => query.limit(5))
.withCount('comments')
.orderBy('createdAt', 'desc')
.paginate(page, limit)
await Post.query().where('userId', userId).update({ isPublished: false })
import db from '@adonisjs/lucid/services/db'
await db.transaction(async (trx) => {
const post = new Post()
post.useTransaction(trx)
post.fill({ title, content, userId })
await post.save()
const tags = tagIds.map((tagId) => ({ postId: post., tagId }))
trx.().(tags)
})
VineJS Validation
import vine from '@vinejs/vine'
export const createPostValidator = vine.compile(
vine.object({
title: vine.string().trim().minLength(3).maxLength(255),
content: vine.string().trim().minLength(10),
tagIds: vine.array(vine.number().positive()).optional(),
isPublished: vine.boolean().optional(),
})
)
export const updatePostValidator = vine.compile(
vine.object({
title: vine.string().trim().minLength(3).maxLength(255).optional(),
content: vine.string().trim().minLength(10).optional(),
isPublished: vine.boolean().optional(),
})
)
createUserValidator = vine.(
vine.({
: vine.().().({ : , : }),
: vine.().().().()
.({ : , : }),
: vine.().().(),
})
)
Service Layer
import Post from '#models/post'
import type User from '#models/user'
import { inject } from '@adonisjs/core'
import { ModelPaginatorContract } from '@adonisjs/lucid/types/model'
@inject()
export default class PostService {
async list(page: number, limit: number): Promise<ModelPaginatorContract<Post>> {
return Post.query()
.withScopes((s) => s.published())
.preload('user')
.withCount('comments')
.orderBy('createdAt', 'desc')
.paginate(page, limit)
}
async findOrFail(id: number): Promise<Post> {
.(id)
}
(: , : { : ; : ; ?: [] }): <> {
post = .({
: data.,
: data.,
: user.,
})
(data.?.) {
post.().(data.)
}
post
}
(: , : <{ : ; : ; : }>): <> {
post.(data)
post.()
post
}
(: ): <> {
post.()
}
}
Middleware
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import limiter from '@adonisjs/limiter/services/main'
export default class RateLimitMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const throttle = limiter.use({
requests: 100,
duration: '1 minute',
blockDuration: '5 minutes',
})
const key = ctx.auth.user?.id?.toString() ?? ctx.request.ip()
await throttle.penalize(key)
return next()
}
}
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class SilentAuthMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
try {
await ctx.auth.authenticate()
} catch {}
return next()
}
}
Authentication
Access Token Auth (API)
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: number
@column()
declare email: string
@column({ serializeAs: null })
declare password: string
accessTokens = .(, {
: ,
: ,
})
}
import type { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'
import { loginValidator, registerValidator } from '#validators/auth'
export default class AuthController {
async register({ request, response }: HttpContext) {
const data = await request.validateUsing(registerValidator)
const user = await User.create(data)
const token = await User.accessTokens.create(user)
return response.created({ user, token })
}
async login({ request, response }: HttpContext) {
const { email, password } = await request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password)
const token = await User..(user)
response.({ user, token })
}
() {
user = auth.!
..(user, user..)
response.()
}
}
Authorization (Bouncer)
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy, allowGuest } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
@allowGuest()
view(_user: User | null, post: Post): AuthorizerResponse {
return post.isPublished || (_user !== null && post.userId === _user.id)
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId || user.role === 'admin'
}
delete(user: User, post: Post): {
user. === post. || user. ===
}
}
Migrations
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'posts'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE')
table.string('title', 255).notNullable()
table.text('content').notNullable()
table.boolean('is_published').defaultTo(false)
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').notNullable()
table.([])
table.([, ])
})
}
() {
..(.)
}
}
Database Seeders & Factories
import Post from '#models/post'
import factory from '@adonisjs/lucid/factories'
export const PostFactory = factory
.define(Post, ({ faker }) => ({
title: faker.lorem.sentence(),
content: faker.lorem.paragraphs(3),
isPublished: faker.datatype.boolean(),
}))
.relation('user', () => UserFactory)
.relation('comments', () => CommentFactory)
.build()
import { BaseSeeder } from '@adonisjs/lucid/seeders'
import { PostFactory } from '#database/factories/post_factory'
export default class extends BaseSeeder {
async run() {
await PostFactory.with('user').with('comments', 3).createMany(20)
}
}
Exception Handling
import { ExceptionHandler, HttpContext } from '@adonisjs/core/http'
import app from '@adonisjs/core/services/app'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof E_AUTHORIZATION_FAILURE) {
return ctx.response.forbidden({ error: 'You are not authorized' })
}
return super.handle(error, ctx)
}
async report(error: unknown, ctx: HttpContext) {
if (!this.shouldReport(error as any)) return
}
}
import { Exception } from '@adonisjs/core/exceptions'
export default class ResourceNotFoundException extends Exception {
static status = 404
static code = 'E_RESOURCE_NOT_FOUND'
static message = 'The requested resource was not found'
}
Testing (Japa)
import { test } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'
import { PostFactory } from '#database/factories/post_factory'
test.group('Posts | List', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('returns paginated published posts', async ({ client }) => {
await PostFactory.merge({ isPublished: true }).createMany(5)
await PostFactory.merge({ isPublished: false }).createMany(3)
const response = await client.get('/api/v1/posts')
response.assertStatus(200)
response.assertBodyContains({ meta: { total: 5 } })
})
test('requires auth to create a post', ({ client }) => {
response = client.().({
: ,
: ,
})
response.()
})
(, ({ client }) => {
user = .()
response = client
.()
.({ : , : })
.(user)
response.()
response.({ : })
})
})
Events & Listeners
import emitter from '@adonisjs/core/services/emitter'
const PostCreated = () => import('#events/post_created')
emitter.on('post:created', [PostCreated])
import Post from '#models/post'
export default class PostCreated {
constructor(public post: Post) {}
}
import emitter from '@adonisjs/core/services/emitter'
await emitter.emit('post:created', post)
Background Jobs (Bull Queue)
import { BaseJob } from '@rlanz/bull-queue'
export default class SendNotificationJob extends BaseJob {
static get queueName() {
return 'notifications'
}
async handle(payload: { userId: number; message: string }) {
}
async failed(payload: unknown, error: Error) {
}
}
Anti-Patterns to Avoid
async store({ request }: HttpContext) {
const data = request.all()
const post = await Post.create(data)
}
async store({ request, auth }: HttpContext) {
const data = await request.validateUsing(createPostValidator)
const post = await this.postService.create(auth.user!, data)
return response.created(post)
}
const posts = await Post.all()
for (const post of posts) {
console.log(post.user.name)
}
const posts = await Post.query().preload()
db.()
db.(, [email])
Quick Reference — Common Commands
node ace make:controller Post
node ace make:model Post -m -f -c
node ace make:validator post
node ace make:service post
node ace make:middleware rate_limit
node ace make:policy post
node ace make:exception resource_not_found
node ace make:test functional posts/list
node ace migration:run
node ace migration:rollback
node ace migration:fresh --seed
node ace db:seed
node ace serve --hmr
node ace test
node ace test --tags "posts"
node ace repl
Remember: AdonisJS is batteries-included like Rails. Use the framework's conventions and built-in features before reaching for external libraries.