0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI駆動開発においてNext.jsのAppRouterの最適な設計方針

0
Last updated at Posted at 2026-04-23

概要

AI駆動開発においては、設計方針などを事前に決めておいて、それをClaudeなどに読み込ませて、開発することができます。こうすることで最適なシステム開発ができます。

なぜこの記事を書いたのか

最近では、コーディングは、AIに任せています。
しかし、AIに全部任せてしてしまうと以下の課題が出てきてしまいます

  • コードが肥大化してしまう
  • 可読性、保守性の悪いコードになってしまう
  • UIやビジネスロジックなどの責務が混ざってしまう
  • デグレが発生し、手戻りが起きてしまう

コーディングは、AIが担う以上エンジニアが責任を持って品質担保をすることで、
売上につながるためのUIやUXを実現していく必要
があります。

よりAI駆動開発を効率化するために

事前に設計方針などをまとめて、それをAIに読み込ませて、開発をしていくのがいいです。これをやることで以下のメリットがあります。

  • 仕様変更が起きても開発がスムーズになる
  • 長期的なメンテナンス性が向上する
  • 新たな機能追加がしやすくなる
  • バグなどの発生を減らして、デグレを減らすことができる
  • リリース速度を上げることができて、ビジネスチャンスが増える

設計の全体像

大まかな流れとして、以下の手順です。

1.要件定義
2.ユースケースの洗い出し
3.ドメインの設計
4.UI設計
5.テーブル設計、API設計

これらの設計方針などは、プロジェクト配下に.docsディレクトリを作成し、
それをAIに読み込ませて、開発を進めて行きます。
これをやらないとAIが勝手に開発を進めてしまい、品質がめちゃくちゃになってしまいます。

フロントエンドの設計

Next.jsのAppRouterの設計です。
大まかに分けると以下の通りです。

意図的にレイヤーを分ける

frontend/src/
├─ app/           # App Router: ルート・レイアウト・メタデータ(薄く保つ)
├─ features/      # 専属ロジック、ドメインごとの機能群
├─ shared/        # 共通UI・レイアウト・providerなど(アプリ全体で使うもの)
└─ external/      # 外部接続(dto, handler, service, repository, client)

6つの原則

# 原則 内容
1 ディレクトリ責務の明確化 app/薄く、features/司令塔、external/I/O専用、shared/共通
2 app/は薄く保つ page.tsxはPageTemplateに委譲するだけ
3 認証状態ベースのルートグループ (authenticated)(guest)(neutral)(public)
4 Server-first × TanStack Query サーバーでprefetch→HydrationBoundaryでクライアントへ
5 Server Actionsの管理 Route Handler廃止、features/◯◯/actions/に集約
6 設計を壊さない仕組み ESLintカスタムルール・DTO・error.tsx・テスト

原則1.ディレクトリの責務を分ける

AppRouterを安全に運用するには、責務を分けるのがいいです。

1. app/薄く保つ

ルーティングの定義だけ(薄くする)

frontend/src/
├─ app/           # App Router: ルート・レイアウト・メタデータ(薄く保つ)
├─ features/      # 専属ロジック、ドメインごとの機能群
├─ shared/        # 共通UI・レイアウト・providerなど(アプリ全体で使うもの)
└─ external/      # 外部接続(dto, handler, service, repository, client)

2.features/司令塔

機能ごとのロジックと UI をまとめて、ドメインごとにディレクトリを切る

features/articles/
├── components/
│   ├── server/   # Server Components(PageTemplate)
│   └── client/   # Container / Presenter
├── hooks/        # TanStack Query
├── queries/      # QueryKey + DTOヘルパー
├── actions/      # Server Actions(薄いラッパー)
└── types/        # 型定義

3.shared/共通

アプリ全体で横断的に使うもの」を置く場所

shared/
├── components/   # 再利用可能な UI コンポーネント 
├── hooks/        #  共通カスタムフック
├── lib/          #  ユーティリティ・バリデーション
├── providers/    #  アプリ全体のコンテキストプロバイダー
└── types/        #  共通型定義     

4.external/I/O専用

アプリの外側にある仕組みへのアクセスをまとめる層

external/
├── dto/          # Zodスキーマ + 型
├── handler/      # features層からの入口
├── service/      # ビジネスロジック
├── repository/   # DBアクセス(PrismaやDrizzleなど)
└── client/       # 外部APIクライアント

原則2 app/ は薄く保つ

src/app/(authenticated)/articles/page.tsx
import { ArticlesPageTemplate } from '@/features/articles/components/server/ArticlesPageTemplate';

export default function ArticlesPage() {
  return <ArticlesPageTemplate />;
}

参考までに

原則3 認証状態ベースのルートグループ

app/
├── (guest)/        # 未ログイン専用(login, signup)
├── (authenticated)/# ログイン済み専用(articles, settings)
└── (neutral)/      # 誰でも可(パスワードリセット等)

原則4 Router Handlerではなく、ServerActions化する

なぜRoute Handlerをやめるのか

App Routerでは、クライアントからのデータ変更処理をRoute Handlerapi/ディレクトリ)で実装するのが従来のやり方でしたが、Route Handlerには以下の問題があります。

- `fetch('/api/...')` の呼び出しがhooks内に散らばる
- 認証チェックが各Route Handlerに重複して書かれる
- APIエンドポイントが増えるほど管理が煩雑になる

Server Actionsに移行することで、認証・バリデーション・DB操作の責務を明確に分離できます。


Before: Route Handler

src/app/api/articles/[id]/comments/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
import { createNotification } from '@/lib/notification'

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  try {
    const { id } = await params

    const authResult = await requireAuth()
    if (authResult instanceof Response) return authResult
    const { userId } = authResult

    const { content } = await request.json()

    if (!content || content.trim() === '') {
      return NextResponse.json(
        { message: 'コメント内容を入力してください' },
        { status: 400 },
      )
    }

    const article = await prisma.article.findUnique({ where: { id } })
    if (!article) {
      return NextResponse.json(
        { message: '記事が見つかりません' },
        { status: 404 },
      )
    }

    const comment = await prisma.comment.create({
      data: { content: content.trim(), userId, articleId: id },
      include: {
        user: { select: { id: true, name: true, image: true } },
      },
    })

    await createNotification({
      type: 'comment',
      userId: article.authorId,
      senderId: userId,
      articleId: id,
    })

    return NextResponse.json(comment, { status: 201 })
  } catch (error) {
    console.error('コメント投稿エラー:', error)
    return NextResponse.json(
      { message: 'コメントの投稿に失敗しました' },
      { status: 500 },
    )
  }
}

呼び出し側のhooksはこうなっていました:

features/articles/hooks/useCommentForm.ts
// Before: 
const submitComment = async () => {
  const res = await fetch(`/api/articles/${articleId}/comments`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content }),
  })

  if (!res.ok) {
    const data = await res.json()
    throw new Error(data.message || 'エラーが発生しました')
  }

  router.refresh()
}

After: Server Actions + 3層構造

Route Handlerを以下の3層に分解します。

action.ts                   # 認証チェックのみ
  ↓
handler/mutation.server.ts  # バリデーション・ビジネスロジック
  ↓
repository/*.ts             # DB操作のみ

1. Repository層(DB操作のみ)

external/repository/comment/comment.repository.ts
import { prisma } from '@/lib/prisma'

export const commentRepository = {
  findById: async (commentId: string) => {
    return prisma.comment.findUnique({ where: { id: commentId } })
  },

  create: async (data: {
    content: string
    userId: string
    articleId: string
  }) => {
    return prisma.comment.create({
      data,
      include: {
        user: { select: { id: true, name: true, image: true } },
      },
    })
  },

  update: async (commentId: string, content: string) => {
    return prisma.comment.update({
      where: { id: commentId },
      data: { content },
      include: {
        user: { select: { id: true, name: true, image: true } },
      },
    })
  },

  delete: async (commentId: string) => {
    return prisma.comment.delete({ where: { id: commentId } })
  },
}

2. Handler層(バリデーション・ビジネスロジック)

external/handler/comment/mutation.server.ts
import { commentRepository } from '@/external/repository/comment'
import { articleRepository } from '@/external/repository/article'
import { createNotification } from '@/lib/notification'

export async function createCommentHandler({
  content,
  userId,
  articleId,
}: {
  content: string
  userId: string
  articleId: string
}) {
  // バリデーション
  if (!content || content.trim() === '') {
    return { success: false as const, error: 'コメント内容を入力してください' }
  }

  // 記事の存在確認
  const article = await articleRepository.findAuthorById(articleId)
  if (!article) {
    return { success: false as const, error: '記事が見つかりません' }
  }

  const comment = await commentRepository.create({
    content: content.trim(),
    userId,
    articleId,
  })

  // 自分の記事へのコメントは通知しない
  if (article.authorId !== userId) {
    await createNotification({
      type: 'comment',
      userId: article.authorId,
      senderId: userId,
      articleId,
    })
  }

  return { success: true as const, comment }
}

export async function updateCommentHandler({
  commentId,
  content,
  userId,
}: {
  commentId: string
  content: string
  userId: string
}) {
  if (!content || content.trim() === '') {
    return { success: false as const, error: 'コメント内容を入力してください' }
  }

  const comment = await commentRepository.findById(commentId)
  if (!comment) {
    return { success: false as const, error: 'コメントが見つかりません' }
  }

  // 権限チェック
  if (comment.userId !== userId) {
    return { success: false as const, error: '自分のコメントのみ編集できます' }
  }

  const updated = await commentRepository.update(commentId, content.trim())
  return { success: true as const, comment: updated }
}

export async function deleteCommentHandler({
  commentId,
  userId,
}: {
  commentId: string
  userId: string
}) {
  const comment = await commentRepository.findById(commentId)
  if (!comment) {
    return { success: false as const, error: 'コメントが見つかりません' }
  }

  if (comment.userId !== userId) {
    return { success: false as const, error: '自分のコメントのみ削除できます' }
  }

  await commentRepository.delete(commentId)
  return { success: true as const }
}

3. Action層(認証チェックのみ)

features/articles/actions/comment.action.ts
'use server'

import { auth } from '@/external/auth'
import {
  createCommentHandler,
  updateCommentHandler,
  deleteCommentHandler,
} from '@/external/handler/comment/mutation.server'

export async function createCommentAction({
  articleId,
  content,
}: {
  articleId: string
  content: string
}) {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) return { success: false as const, error: '認証が必要です' }

  return createCommentHandler({ content, userId, articleId })
}

export async function updateCommentAction({
  commentId,
  content,
}: {
  commentId: string
  content: string
}) {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) return { success: false as const, error: '認証が必要です' }

  return updateCommentHandler({ commentId, content, userId })
}

export async function deleteCommentAction({
  commentId,
}: {
  commentId: string
}) {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) return { success: false as const, error: '認証が必要です' }

  return deleteCommentHandler({ commentId, userId })
}

4. 呼び出し側の変化

features/articles/hooks/useCommentForm.ts
// After
import { createCommentAction } from '@/features/articles/actions/comment.action'

const submitComment = async () => {
  const result = await createCommentAction({ articleId, content })

  if (!result.success) {
    throw new Error(result.error)
  }

  setContent('')
  router.refresh()
}

この構造の3つのメリット

1. 責務が明確になる

action は認証のみ、handler はビジネスロジックのみ、repository はDB操作のみ。どこに何を書くかが一目瞭然です。

2. バックエンドの差し替えが容易

将来GoやRustのマイクロサービスに移行する場合、repositoryhandler の実装を差し替えるだけでReactコンポーネントは一切触る必要がありません。

3. テストが書きやすい

handler 層は純粋な関数に近いため、DB操作をモックするだけで単体テストが書けます。

補足

以下は残すことにしました。
ファイルアップロードはServer Actionで扱えないからです。
Server Actionはバイナリデータ(FormDataのファイル)の処理に制限があり、
Cloudinaryへのアップロードのような処理はRoute Handlerのまま残すのがいいです。

  src/app/api/upload/route.ts      ← ファイルアップロード専用
  src/app/api/auth/[...nextauth]/  ← NextAuth専用

原則5: TanStack Queryによるデータフェッチ戦略

なぜTanStack Queryを使うのか

useState + useEffect によるデータ取得には以下の問題があります。

- キャッシュがないため、画面を開くたびに毎回リクエストが走る
- `isLoading` などの状態を自前で管理する必要がある
- 更新後の再取得タイミングの制御が難しい

TanStack Queryを使うことで、キャッシュ・再取得・楽観的更新を宣言的に管理できます。


useNotifications

通知ベルは「開くたびに取得・操作したら即反映」という典型的なユースケースです。

Before: useState + useEffect

features/notifications/hooks/useNotifications.ts
export function useNotifications() {
  const { isOpen, ref, toggle, close } = useDropdown()
  const [notifications, setNotifications] = useState<NotificationWithRelations[]>([])
  const [isLoading, setIsLoading] = useState(false)

  // 毎回手動でfetch
  const fetchNotifications = async () => {
    setIsLoading(true)
    try {
      const result = await listNotificationsAction()
      if (result.success) {
        setNotifications(result.notifications as NotificationWithRelations[])
      }
    } catch (error) {
      console.error('通知取得エラー:', error)
    } finally {
      setIsLoading(false)
    }
  }

  useEffect(() => {
    fetchNotifications()
  }, [])

  // 全件既読:stateを手動で更新
  const markAllAsRead = async () => {
    const result = await markAllNotificationsAsReadAction()
    if (result.success) {
      setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
    }
  }

  // 個別既読:stateを手動で更新
  const markAsRead = async (notificationId: string) => {
    const result = await markNotificationAsReadAction({ notificationId })
    if (result.success) {
      setNotifications((prev) =>
        prev.map((n) => (n.id === notificationId ? { ...n, isRead: true } : n)),
      )
    }
  }

  const handleToggle = () => {
    if (!isOpen) fetchNotifications() // 開くたびに手動fetch
    toggle()
  }

  const unreadCount = notifications.filter((n) => !n.isRead).length

  return {
    isOpen, ref, notifications, isLoading,
    unreadCount, handleToggle, markAllAsRead, markAsRead, close,
  }
}

After: useQuery + useMutation

features/notifications/hooks/useNotifications.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useDropdown } from '@/shared/hooks'
import { NotificationWithRelations } from '@/types'
import {
  listNotificationsAction,
  markAllNotificationsAsReadAction,
  markNotificationAsReadAction,
} from '@/features/notifications/actions/notification.action'

export const notificationKeys = {
  all: ['notifications'] as const,
  list: () => [...notificationKeys.all, 'list'] as const,
}

export function useNotifications() {
  const { isOpen, ref, toggle, close } = useDropdown()
  const queryClient = useQueryClient()

  // 通知一覧取得
  const { data: notifications = [], isLoading } = useQuery({
    queryKey: notificationKeys.list(),
    queryFn: async (): Promise<NotificationWithRelations[]> => {
      const result = await listNotificationsAction()
      if (!result.success) return []
      return result.notifications as NotificationWithRelations[]
    },
    staleTime: 1 * 60 * 1000, // 1分キャッシュ
  })

  // 全件既読:setQueryDataで楽観的に更新
  const { mutate: markAllAsRead } = useMutation({
    mutationFn: markAllNotificationsAsReadAction,
    onSuccess: () => {
      queryClient.setQueryData(
        notificationKeys.list(),
        (prev: NotificationWithRelations[] = []) =>
          prev.map((n) => ({ ...n, isRead: true })),
      )
    },
  })

  // 個別既読:setQueryDataで楽観的に更新
  const { mutate: markAsRead } = useMutation({
    mutationFn: (notificationId: string) =>
      markNotificationAsReadAction({ notificationId }),
    onSuccess: (_, notificationId) => {
      queryClient.setQueryData(
        notificationKeys.list(),
        (prev: NotificationWithRelations[] = []) =>
          prev.map((n) =>
            n.id === notificationId ? { ...n, isRead: true } : n,
          ),
      )
    },
  })

  const handleToggle = () => {
    if (!isOpen) {
      // 開くたびにinvalidateで最新データを取得
      queryClient.invalidateQueries({ queryKey: notificationKeys.list() })
    }
    toggle()
  }

  const unreadCount = notifications.filter((n) => !n.isRead).length

  return {
    isOpen, ref, notifications, isLoading,
    unreadCount, handleToggle, markAllAsRead, markAsRead, close,
  }
}

変更点のポイント

Before After
useState + useEffect useQuery
setNotifications で手動更新 setQueryData で楽観的更新
開くたびに手動fetch invalidateQueries で自動再取得
isLoading を自前管理 useQuery が自動管理

notificationKeys をエクスポートしているので、いいね・コメント後に他のhooksから通知を再取得することも可能です。

// 例:いいね後に通知を再取得
queryClient.invalidateQueries({ queryKey: notificationKeys.list() })

サブ例: reset-password のトークン検証

データ取得目的の useEffectuseQuery に置き換えられます。

Before: useEffect でトークン検証

const [isValidating, setIsValidating] = useState(true)
const [isTokenValid, setIsTokenValid] = useState(false)

useEffect(() => {
  const validateToken = async () => {
    if (!token) {
      setIsValidating(false)
      setIsTokenValid(false)
      return
    }
    try {
      const result = await validateResetTokenAction({ token })
      setIsTokenValid(result.valid)
    } catch (err) {
      setIsTokenValid(false)
    } finally {
      setIsValidating(false)
    }
  }
  validateToken()
}, [token])

After: useQuery でトークン検証

const { isLoading: isValidating, data: tokenData } = useQuery({
  queryKey: ['resetToken', token],
  queryFn: async () => {
    if (!token) return { valid: false }
    return validateResetTokenAction({ token })
  },
  staleTime: Infinity, // トークンは再検証不要
  retry: false,
})

const isTokenValid = tokenData?.valid ?? false

useState 2つと useEffect 1つが、useQuery 1つに置き換わりました。
staleTime: Infinity により、同じトークンを何度検証しても1回しかリクエストが走りません。


TanstackQueryのまとめ

データ取得目的の useEffect は基本的に useQuery に置き換えられます。

  • 読み取りuseQuery(キャッシュ・再取得を自動管理)
  • 変更操作useMutation(楽観的更新・エラーハンドリング)
  • キャッシュ更新invalidateQueries または setQueryData

「動的なものだけクライアントへ渡す」というServer-first設計の原則と組み合わせることで、
サーバーとクライアントの責務が明確に分離されたアーキテクチャが実現できます。

原則6: external/層の整備

なぜexternal/層に移動するのか

src/lib/ にDBクライアントや通知ロジックが混在していると、以下の問題が起きます。

- featuresやappから直接prismaをimportしてしまう
- ビジネスロジックがlib/に散らばり、どこに何があるかわからなくなる
- バックエンドを差し替えたいときに影響範囲が読めない

external/層に集約することで、「UIはexternal/handler/を経由してしか外部にアクセスできない」 という境界が生まれます。


やったこと

1. Prismaクライアントの移動

src/lib/prisma.ts → src/external/repository/client/index.ts
src/external/repository/client/index.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

repositoryだけがこのクライアントをimportして、featuresやappからは直接触れない構造にします。


2. 通知ロジックの移動

src/lib/notification.ts → src/external/service/notification.ts

通知作成はビジネスロジックなので、service/ 層が正しい置き場です。
handler層から呼び出します


3. 認証actionの移動

src/actions/auth.ts → src/features/auth/actions/auth.action.ts

Server Actionsはドメインごとに features/◯◯/actions/ に集約する方針なので、ルート直下の src/actions/ は廃止します。


4. src/types/ の整理

型定義を「ドメイン固有」と「共通」に分けて移動しました。

方針:

  • 特定ドメインにしか使わない型 → features/◯◯/types/
  • 複数ドメインにまたがる型 → shared/types/
# Before
src/types/
├── article.ts
├── comment.ts
├── like.ts
├── notification.ts
├── stock.ts
├── user.ts
├── api.ts
└── next-auth.d.ts

# After
src/features/articles/types/
├── index.ts     ← article.ts
├── comment.ts   ← comment.ts
└── like.ts      ← like.ts

src/features/notifications/types/
└── index.ts     ← notification.ts

src/features/stocks/types/
└── index.ts     ← stock.ts

src/features/users/types/
└── index.ts     ← user.ts

src/shared/types/
├── api.ts           ← api.ts(PaginatedResponse・ApiError)
└── next-auth.d.ts   ← next-auth.d.ts(Session拡張)

comment・likeを features/articles/types/ に置いた理由は、features/comments/features/likes/ が独立したドメインとして存在せず、コンポーネントもすべて features/articles/ 配下にあるためです。


5. src/utils/ の整理

汎用ユーティリティはすべて shared/utils/ に移動しました。

src/utils/date.ts   → src/shared/utils/date.ts
src/utils/string.ts → src/shared/utils/string.ts

特定ドメインに依存しない純粋な関数なので、shared層に配置


移動後のimport修正

移動に伴い、旧パスを参照していたファイルを修正しました。

// Before
import { NotificationWithRelations } from '@/types'
import { timeAgo } from '@/utils'

// After
import { NotificationWithRelations } from '@/features/notifications/types'
import { timeAgo } from '@/shared/utils/date'

この整理で何が変わったか

Before After
src/lib/ にDBクライアント・通知・認証が混在 external/の各層に責務で分類
src/types/ に全型が平置き ドメイン型はfeatures/、共通型はshared/
src/utils/ に汎用関数が平置き shared/utils/に統一
src/actions/ にServer Actionが混在 features/◯◯/actions/に集約

「どこに何があるか」が構造から読み取れる状態になりました。

原則7: Zodスキーマをexternal/dto/に一本化

Zodスキーマが2箇所に分散していました。

# Before: フォーム用と手動バリデーションが混在
src/shared/lib/validations/
├── article.ts
├── auth.ts
├── draft.ts
└── user.ts

# handler内にインラインで書かれていたもの
external/handler/user/mutation.server.ts の updateUserSchema
external/handler/comment/mutation.server.ts は手動バリデーション(Zodなし)

これを external/dto/ に一本化しました。

# After
src/external/dto/
├── article/
│   ├── article.dto.ts  ← createArticleSchema, draftArticleSchema, DRAFT_LIMIT
│   └── index.ts
├── comment/
│   ├── comment.dto.ts  ← createCommentSchema, updateCommentSchema
│   └── index.ts
├── user/
│   ├── user.dto.ts     ← updateUserSchema
│   └── index.ts
└── auth/
    ├── auth.dto.ts     ← signupSchema, loginSchema, forgotPasswordSchema, resetPasswordSchema
    └── index.ts

handler層・app層どちらも external/dto/ からimportする統一構造にしました。

// comment handler(Before: 手動バリデーション)
if (!content || content.trim() === '') {
  return { success: false as const, error: 'コメント内容を入力してください' }
}

// comment handler(After: DTOでバリデーション)
import { createCommentSchema } from '@/external/dto/comment'

const parsed = createCommentSchema.safeParse({ content, articleId });
if (!parsed.success) {
  return { success: false as const, errors: parsed.error.issues };
}

この整理で何が変わったか

Before After
Zodスキーマがvalidations/とhandler内に分散 external/dto/に一本化
handler内に手動バリデーションが混在 全てZodのsafeParse()に統一
フォーム用とサーバー用で管理場所が別 handler層・app層どちらもexternal/dto/からimport

「バリデーションはすべてexternal/dto/を見ればわかる」状態になりました。

原則8: テストは隣に置く

テストファイルを __tests__/ ディレクトリにまとめる方法もありますが、以下の問題があります。

- 実装ファイルとテストファイルが離れていて、対応関係がわかりにくい
- PRレビュー時にロジックとテストを同時に確認しにくい
- ファイルを削除・移動したときにテストが取り残されやすい

「テストは対象ファイルの隣に置く」 ことで、実装とテストが常にセットで管理できます。


配置方針

# DTOのテスト
src/external/dto/article/
├── article.dto.ts
└── article.dto.spec.ts  ← 隣に置く

# Handlerのテスト
src/external/handler/auth/
├── mutation.server.ts
└── mutation.server.spec.ts  ← 隣に置く

# Hooksのテスト
src/features/articles/hooks/useLike/
├── useLike.ts
└── useLike.spec.ts  ← 隣に置く

src/shared/hooks/useDropdown/
├── useDropdown.ts
└── useDropdown.spec.ts  ← 隣に置く

DTOテストの例

// src/external/dto/article/article.dto.spec.ts
import { createArticleSchema } from './article.dto';

describe('createArticleSchema', () => {
  it('タイトルが空の場合はエラー', () => {
    const result = createArticleSchema.safeParse({
      title: '',
      content: '本文',
      tags: ['tag1'],
    });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].message).toBe('タイトルを入力してください');
    }
  });

  it('全て有効な値なら成功', () => {
    const result = createArticleSchema.safeParse({
      title: 'テスト記事',
      content: 'これは本文です',
      tags: ['React', 'TypeScript'],
    });
    expect(result.success).toBe(true);
  });
});

原則8 ESLintルールでアーキテクチャ違反を防ぐ

「人がルールを覚えて守る」には限界があります。
そこで、設計上の禁則をすべてESLintでコード化しました。

src/eslint-local-rules/
├─ restrict-service-imports.js  # client層からservice層へのimportを禁止
├─ restrict-action-imports.js   # Server Actionsはclient/hooksでのみ利用可
├─ use-nextjs-helpers.js        # PageProps/LayoutProps、next/navigationの利用を強制
├─ use-client-check.js          # 'use client' の配置を検証
└─ use-server-check.js          # 'use server' の配置を検証

client層からservice層へのimportを禁止する理由

restrict-service-imports.jsのコメントに書いてある通りで、依存方向を一方向に保つためです。

  components/client, hooks
    → actions          ← ビジネスロジックの境界
      → external/handler
        → external/repository

client 層が external/ を直接叩けると:

  • 認証チェック漏れ — actions に書いた auth() の検証をスキップできてしまう
  • バリデーション漏れ — Zod スキーマを通さずに外部サービスを呼べてしまう
  • テスタビリティ低下 — client のテストが外部依存を持ち、モック範囲が広がる

actions を必ず経由させることで、セキュリティ・バリデーションの抜け道を
アーキテクチャレベルで塞いでいます。

Server Actionsはclient/hooksでのみ利用可

Server Component から actionsを直接呼ぶと、認証・バリデーションが保証されなくなるリスクがあるからです。

中でも責務の分離が重要です。

Server Component  → データ取得(fetch / Prisma 直接)
 Client Component  → ユーザー操作への応答(actions 経由)

Server Component が actions を呼ぶと:

  • 二重責務 — データ取得と書き込みトリガーが同じ層に混在する
  • 意図が不明瞭 — 「なぜ Server Component が mutationを呼んでいるのか」がわかりにくくなる
  • 副作用の追跡が困難 — どこから actions が呼ばれているか grep しにくくなる

actions は「ユーザーの操作に応じた副作用」を担う層なので、操作を受け取る
client/hooks からのみ呼ばれる、という一貫したルールになっている。

この方針で何が変わったか

Before After
__tests__/ にテストをまとめていた 対象ファイルの隣に置く
実装とテストの対応関係がわかりにくい ファイルを開けばテストがすぐ隣にある
PRレビュー時に確認が分散する ロジックとテストを同時にレビューできる

「開く → 変更する → テスト回す」がワンステップで完結する状態になりました。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?