mcpField
A
adonisjs-best-practices

adonisjs-best-practices

github.com/futuregerald開発7216 回インストール今日更新
リポジトリ

adonisjs-best-practices について

adonisjs-best-practicesは、AdonisJS v6アプリを開発する人向けの実践ガイドです。ルーティング、バリデーション、認証、データベース操作、認可、テストの一般的なパターンを扱います。

主な機能

  1. 01ルートとコントローラーのパターン
  2. 02VineJSによるリクエスト検証
  3. 03認証とBouncerによる認可
  4. 04Lucid ORMのクエリとトランザクション
  5. 05HTTPテストとデータベース分離

活用シーン

  1. 01ルートとコントローラーの実装
  2. 02認証とアクセス権チェックの追加
  3. 03検証付きデータベース機能の構築
  4. 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() を使用する

コメント