概要
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/ は薄く保つ
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 Handler(api/ディレクトリ)で実装するのが従来のやり方でしたが、Route Handlerには以下の問題があります。
- `fetch('/api/...')` の呼び出しがhooks内に散らばる
- 認証チェックが各Route Handlerに重複して書かれる
- APIエンドポイントが増えるほど管理が煩雑になる
Server Actionsに移行することで、認証・バリデーション・DB操作の責務を明確に分離できます。
Before: Route Handler
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はこうなっていました:
// 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操作のみ)
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層(バリデーション・ビジネスロジック)
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層(認証チェックのみ)
'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. 呼び出し側の変化
// 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のマイクロサービスに移行する場合、repository と handler の実装を差し替えるだけで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
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
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 のトークン検証
データ取得目的の useEffect は useQuery に置き換えられます。
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
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レビュー時に確認が分散する | ロジックとテストを同時にレビューできる |
「開く → 変更する → テスト回す」がワンステップで完結する状態になりました。
参考資料