adonisjs-best-practices について
adonisjs-best-practicesは、AdonisJS v6アプリを開発する人向けの実践ガイドです。ルーティング、バリデーション、認証、データベース操作、認可、テストの一般的なパターンを扱います。
主な機能
- 01ルートとコントローラーのパターン
- 02VineJSによるリクエスト検証
- 03認証とBouncerによる認可
- 04Lucid ORMのクエリとトランザクション
- 05HTTPテストとデータベース分離
活用シーン
- 01ルートとコントローラーの実装
- 02認証とアクセス権チェックの追加
- 03検証付きデータベース機能の構築
- 04分離されたHTTP統合テストの作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ futuregerald/ futuregerald-claude-plugin --skill adonisjs-best-practicesインストール後、Claude Code で/adonisjs-best-practicesを入力して読み込まれたか確認します。
詳細
AdonisJS v6 のベストプラクティス
概要
AdonisJS v6 は、必要な機能が標準で揃った TypeScript ファーストの MVC フレームワークです。中核となる原則は、型安全性、依存性注入、設定より規約です。
使用する場面
- AdonisJS v6 の新機能を構築する
- ルート、コントローラー、ミドルウェアを実装する
- 認証または認可を設定する
- Lucid ORM のモデルとクエリを記述する
- VineJS でバリデーターを作成する
- AdonisJS アプリのテストを記述する
クイックリファレンス
| タスク | パターン |
|---|---|
| ルートからコントローラーを呼び出す | router.get('/users', [UsersController, 'index']) |
| コントローラーを遅延読み込みする | const UsersController = () => import('#controllers/users_controller') |
| リクエストを検証する | const payload = await request.validateUsing(createUserValidator) |
| 認証を確認する | await auth.authenticate() または auth.use('guard').authenticate() |
| アクションを認可する | await bouncer.authorize('editPost', post) |
| リレーションを読み込んでクエリする | await User.query().preload('posts') |
プロジェクト構成
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
ルーティング
HMR 対応と起動の高速化のため、コントローラーを遅延読み込みします。
// start/routes.ts
const UsersController = () => import('#controllers/users_controller')
router.get('/users', [UsersController, 'index'])
router.post('/users', [UsersController, 'store'])
順序が重要です。動的ルートより先に、具体的なルートを定義します。
// 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'])
整理とミドルウェアの一括適用には、ルートグループを使用します。
router
.group(() => {
router.resource('posts', PostsController)
router.resource('comments', CommentsController)
})
.prefix('/api/v1')
.middleware(middleware.auth())
RESTful CRUD には、リソースコントローラーを使用します。
router.resource('posts', PostsController)
// Creates: index, create, store, show, edit, update, destroy
URL 生成には、ルートに名前を付けます。
router.get('/posts/:id', [PostsController, 'show']).as('posts.show')
// Use: route('posts.show', { id: 1 })
コントローラー
単一責任を守ります。リソースごとにコントローラーを1つ用意し、ハンドラーは薄く保ちます。
// 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)
}
}
サービスにはメソッドインジェクションを使用します。
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)
}
}
バリデーション
ビジネスロジックを実行する前に、コントローラーですぐに検証します。
// 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
}
一意性や存在確認には、データベースルールを使用します。
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' })),
})
)
ミドルウェア
用途が異なる3つのスタックがあります。
// 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'),
})
ルートごとに適用します。
router.get('/dashboard', [DashboardController, 'index']).middleware(middleware.auth())
認証
クライアントの種類に応じてガードを選択します。
- セッションガード:サーバーでレンダリングするアプリ(web)
- アクセストークン:SPA/モバイルクライアント(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() }
})
ルートを保護します。
router
.group(() => {
router.get('/profile', [ProfileController, 'show'])
})
.middleware(middleware.auth({ guards: ['web'] }))
認可(Bouncer)
シンプルな確認にはアビリティを使用します。
// 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
})
リソースベースの認可にはポリシーを使用します。
// 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
}
}
コントローラーで使用します。
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)) { ... }
}
データベース(Lucid ORM)
イーガーローディングで N+1 を防ぎます。
// 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')
ビジネスロジックにはモデルフックを使用します。
// 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)
}
}
}
アトミックな操作にはトランザクションを使用します。
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 })
})
エラー処理
カスタム例外:
// 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')
グローバル例外ハンドラー:
// 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)
}
}
テスト
テストクライアントを使ってHTTP テストを実施します。
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)
})
})
トランザクションでデータベースを分離します。
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)
})
})
よくある間違い
| 間違い | 修正 |
|---|---|
| コントローラーを直接インポートする | 遅延読み込みを使用する:() => import('#controllers/...') |
| サービス内で検証する | ビジネスロジックの前にコントローラーで検証する |
| N+1 クエリ | イーガーローディングには .preload() を使用する |
| 具体的なルートより先に動的ルートを定義する | 具体的なルートを先に定義する |
| 認可を省略する | Bouncer で必ず権限を確認する |
| トランザクションを使用しない | 関連する操作を db.transaction() で囲む |
| HTTP 経由ではなく直接テストする | 結合テストには client.get() を使用する |