| name | adonisjs-controllers |
| description | Thin HTTP layer controllers for AdonisJS v6. Controllers contain zero domain logic, only HTTP concerns. Use when working with controllers, HTTP layer, API vs web patterns, or when user mentions controllers, routes, HttpContext, or HTTP responses in an AdonisJS project. |
AdonisJS Controllers
Controllers are extremely thin. They handle HTTP concerns only and contain zero domain logic.
Related guides:
Philosophy
Controllers should ONLY:
- Destructure and read from
HttpContext (request, params, auth, session)
- Validate input (via VineJS validators)
- Call services or actions (injected via constructor or
@inject)
- Return responses (JSON, redirects, views)
Controllers should NEVER:
- Contain domain logic or business rules
- Query the database directly (e.g.
Post.query() inside a controller)
- Perform calculations or data transformations
- Handle complex conditional branching beyond HTTP concerns
File Naming & Location
AdonisJS v6 uses snake_case for all files and directories.
# ✅ CORRECT — snake_case filenames
app/controllers/posts_controller.ts
app/controllers/users_controller.ts
app/controllers/order_items_controller.ts
# ❌ INCORRECT — PascalCase filenames (v5 style)
app/Controllers/Http/PostsController.ts
Scaffold with Ace
node ace make:controller posts
node ace make:controller posts --resource
node ace make:controller cancel_order --singular
Controller Naming Conventions
Controller class names use PascalCase with the Controller suffix. The file name uses snake_case.
Standard Resource Controllers
export default class PostsController {}
export default class UsersController {}
export default class OrderItemsController {}
export default class PostController {}
export default class UserController {}
Nested Resource Controllers
For nested resources, combine parent (singular) + child (plural):
export default class PostCommentsController {}
export default class OrderItemsController {}
Pattern: {ParentSingular}{ChildPlural}Controller
RESTful Methods Only
Controllers must only use AdonisJS's standard RESTful method names.
Standard RESTful Methods
For full web applications (with forms/views):
| Method | HTTP Verb | Purpose |
|---|
index | GET | List all resources |
create | GET | Show form to create a new resource |
store | POST | Persist a newly created resource |
show | GET | Display a single resource |
edit | GET | Show form to edit an existing resource |
update | PUT/PATCH | Persist updates to an existing resource |
destroy | DELETE | Remove a resource |
For APIs (no HTML forms):
- Use:
index, show, store, update, destroy
- APIs must NOT include
create or edit — those are for rendering HTML forms
Forbidden Method Names
Never use custom method names on a resource controller:
export default class OrdersController {
async all() {}
async get() {}
async add() {}
async remove() {}
async cancel() {}
}
Non-RESTful Actions: Invokable (Single-Action) Controllers
If an endpoint doesn't fit standard RESTful methods, extract it to its own invokable controller with a handle method.
import type { HttpContext } from '@adonisjs/core/http'
import { inject } from '@adonisjs/core'
import OrderService from '#services/order_service'
@inject()
export default class CancelOrderController {
constructor(private orderService: OrderService) {}
async handle({ params, response }: HttpContext) {
const order = await this.orderService.cancel(params.id)
return response.json({ data: order })
}
}
router.resource('orders', () => import('#controllers/orders_controller'))
router.post('/orders/:id/cancel', () => import('#controllers/cancel_order_controller'))
Why invokable controllers?
- Single Responsibility Principle
- Intent is clear from the controller's name
- Independently testable
- Prevents bloated resource controllers
Importing Controllers in Routes
AdonisJS v6 recommends lazy-loading controllers for better boot performance and HMR support.
Lazy Import (Recommended)
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'])
Using the Resource Helper
router.resource('posts', () => import('#controllers/posts_controller'))
router.resource('posts.comments', () => import('#controllers/post_comments_controller'))
Barrel File (Generated)
If using the #generated/controllers barrel file:
import { controllers } from '#generated/controllers'
router.resource('posts', controllers.Posts)
Dependency Injection
Use @inject() and constructor injection to receive services. AdonisJS's IoC container handles instantiation.
import type { HttpContext } from '@adonisjs/core/http'
import { inject } from '@adonisjs/core'
import PostService from '#services/post_service'
@inject()
export default class PostsController {
constructor(private postService: PostService) {}
async index({ response }: HttpContext) {
const posts = await this.postService.getAll()
return response.json({ data: posts })
}
}
For method-level injection (when the service itself requires HttpContext):
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import PostService from '#services/post_service'
export default class PostsController {
@inject()
async index({ response }: HttpContext, postService: PostService) {
const posts = await postService.getAll()
return response.json({ data: posts })
}
}
Full Resource Controller Example
import type { HttpContext } from '@adonisjs/core/http'
import { inject } from '@adonisjs/core'
import OrderService from '#services/order_service'
import { createOrderValidator, updateOrderValidator } from '#validators/order_validator'
@inject()
export default class OrdersController {
constructor(private orderService: OrderService) {}
async index({ response }: HttpContext) {
const orders = await this.orderService.paginate()
return response.json({ data: orders })
}
async show({ params, response }: HttpContext) {
const order = await this.orderService.findOrFail(params.id)
return response.json({ data: order })
}
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(createOrderValidator)
const order = await this.orderService.create(payload)
return response.created({ data: order })
}
async update({ params, request, response }: HttpContext) {
const payload = await request.validateUsing(updateOrderValidator)
const order = await this.orderService.update(params.id, payload)
return response.json({ data: order })
}
async destroy({ params, response }: HttpContext) {
await this.orderService.delete(params.id)
return response.noContent()
}
}
Validation with VineJS
Validation lives in app/validators/, not in controllers. Use request.validateUsing() to apply a validator.
import vine from '@vinejs/vine'
export const createPostValidator = vine.compile(
vine.object({
title: vine.string().minLength(3).maxLength(255),
body: vine.string().minLength(10),
})
)
export const updatePostValidator = vine.compile(
vine.object({
title: vine.string().minLength(3).maxLength(255).optional(),
body: vine.string().minLength(10).optional(),
})
)
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await this.postService.create(payload)
return response.created({ data: post })
}
Validation errors automatically return a 422 Unprocessable Entity with structured error messages. This is handled by the global exception handler in app/exceptions/handler.ts, which catches E_VALIDATION_ERROR and formats it as a structured HTTP response — no try/catch needed in the controller.
Authorization with Bouncer
Use Bouncer policies for authorization — either inline or via middleware.
async update({ bouncer, params, request, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
await bouncer.authorize('editPost', post)
const payload = await request.validateUsing(updatePostValidator)
const updated = await this.postService.update(post, payload)
return response.json({ data: updated })
}
router
.put('/posts/:id', [PostsController, 'update'])
.use(middleware.can('editPost'))
Response Types
JSON (200 OK)
return response.json({ data: post })
Created (201)
return response.created({ data: post })
No Content (204)
return response.noContent()
Redirect
return response.redirect().toRoute('posts.show', { id: post.id })
Not Found / Errors
Throw exceptions — AdonisJS's exception handler converts them to proper HTTP responses:
import { errors } from '@adonisjs/lucid'
const post = await Post.findOrFail(params.id)
Route Model Binding
AdonisJS supports route model binding via the @adonisjs/route-model-binding package:
router.get('/posts/:post', [PostsController, 'show'])
router.put('/posts/:post', [PostsController, 'update'])
import Post from '#models/post'
export default class PostsController {
async show({ params }: HttpContext) {
const post = await Post.findOrFail(params.post)
return response.json({ data: post })
}
}
Controller Testing
Use Japa API tests (testUtils.db() and client) to test controllers at the HTTP layer.
import { test } from '@japa/runner'
test.group('Posts controller', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('returns a list of posts', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.get('/posts')
.loginAs(user)
response.assertStatus(200)
response.assertBodyContains({ data: [] })
})
test('creates a post', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.post('/posts')
.loginAs(user)
.json({ title: 'Hello World', body: 'Post body here.' })
response.assertStatus(201)
response.assertBodyContains({ data: { title: 'Hello World' } })
})
test('returns 422 when validation fails', async ({ client }) => {
const user = await UserFactory.create()
const response = await client
.post('/posts')
.loginAs(user)
.json({})
response.assertStatus(422)
})
test('returns 401 when unauthenticated', async ({ client }) => {
const response = await client.get('/posts')
response.assertStatus(401)
})
})
Common Mistakes
❌ Domain Logic in Controller
async store({ request, response }: HttpContext) {
const data = request.only(['title', 'body', 'user_id'])
const post = await Post.create(data)
await post.load('tags')
await NotificationService.notifyFollowers(post)
return response.created({ data: post })
}
✅ Delegate to a Service
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await this.postService.create(payload)
return response.created({ data: post })
}
❌ Direct Database Queries in Controller
async index({ response }: HttpContext) {
const posts = await Post.query()
.where('published', true)
.orderBy('created_at', 'desc')
.paginate(1, 20)
return response.json({ data: posts })
}
✅ Use a Service or Repository
async index({ response }: HttpContext) {
const posts = await this.postService.getPublished()
return response.json({ data: posts })
}
❌ Fat Constructors / Non-HTTP Concerns
export default class PostsController {
constructor(
private postService: PostService,
private mailer: MailService,
private cache: CacheService,
private logger: LogService,
private search: SearchService
) {}
}
If your controller needs more than 1–2 injected services, your controller is doing too much. Extract logic into a dedicated service or action class.
Summary
Controllers are HTTP adapters — nothing more.
- Receive the HTTP request via
HttpContext
- Validate input with
request.validateUsing()
- Authorize with
bouncer.authorize()
- Call a service or action with clean data
- Return an HTTP response
Every line of domain logic belongs in a Service, not a Controller.