はじめに
ドメイン駆動設計(DDD)は、複雑なビジネスロジックを整理し、保守性の高いソフトウェアを構築するための設計手法です。Next.js App Routerの特性を活かしたDDD実践方法を、具体的なコード例とともに解説します。
ディレクトリ構成
src/
├── app/ # Next.js App Router
│ ├── (routes)/ # ルートグループ
│ │ └── users/
│ │ ├── page.tsx
│ │ └── [id]/
│ │ └── page.tsx
│ └── api/ # APIルート(外部連携用)
├── domain/ # ドメイン層
│ ├── models/ # エンティティ・値オブジェクト
│ │ ├── user/
│ │ │ ├── User.ts
│ │ │ ├── UserId.ts
│ │ │ └── Email.ts
│ │ └── order/
│ │ ├── Order.ts
│ │ └── OrderItem.ts
│ ├── repositories/ # リポジトリインターフェース
│ │ └── IUserRepository.ts
│ └── services/ # ドメインサービス
│ └── UserDomainService.ts
├── application/ # アプリケーション層
│ ├── usecases/ # ユースケース
│ │ └── user/
│ │ ├── CreateUserUseCase.ts
│ │ └── GetUserUseCase.ts
│ └── dto/ # データ転送オブジェクト
│ └── UserDTO.ts
├── infrastructure/ # インフラ層
│ ├── repositories/ # リポジトリ実装
│ │ └── PrismaUserRepository.ts
│ └── database/
│ └── prisma.ts
├── presentation/ # プレゼンテーション層
│ ├── components/
│ └── actions/ # Server Actions
│ └── userActions.ts
└── lib/ # 共通ユーティリティ
└── errors.ts
ドメイン層の実装
値オブジェクト
// domain/models/user/Email.ts
export class Email {
private readonly value: string;
private constructor(value: string) {
this.value = value;
}
static create(value: string): Email {
if (!value || !this.isValid(value)) {
throw new Error('Invalid email format');
}
return new Email(value.toLowerCase());
}
private static isValid(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
getValue(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
// domain/models/user/UserId.ts
import { randomUUID } from 'crypto';
export class UserId {
private readonly value: string;
private constructor(value: string) {
this.value = value;
}
static create(value?: string): UserId {
return new UserId(value ?? randomUUID());
}
static reconstruct(value: string): UserId {
if (!value) {
throw new Error('UserId cannot be empty');
}
return new UserId(value);
}
getValue(): string {
return this.value;
}
equals(other: UserId): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
エンティティ
// domain/models/user/User.ts
import { UserId } from './UserId';
import { Email } from './Email';
interface UserProps {
id: UserId;
name: string;
email: Email;
createdAt: Date;
updatedAt: Date;
}
export class User {
private readonly props: UserProps;
private constructor(props: UserProps) {
this.props = props;
}
static create(params: {
name: string;
email: string;
}): User {
if (!params.name || params.name.length < 2) {
throw new Error('Name must be at least 2 characters');
}
const now = new Date();
return new User({
id: UserId.create(),
name: params.name,
email: Email.create(params.email),
createdAt: now,
updatedAt: now,
});
}
static reconstruct(params: {
id: string;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}): User {
return new User({
id: UserId.reconstruct(params.id),
name: params.name,
email: Email.create(params.email),
createdAt: params.createdAt,
updatedAt: params.updatedAt,
});
}
get id(): UserId {
return this.props.id;
}
get name(): string {
return this.props.name;
}
get email(): Email {
return this.props.email;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
changeName(newName: string): User {
if (!newName || newName.length < 2) {
throw new Error('Name must be at least 2 characters');
}
return new User({
...this.props,
name: newName,
updatedAt: new Date(),
});
}
changeEmail(newEmail: string): User {
return new User({
...this.props,
email: Email.create(newEmail),
updatedAt: new Date(),
});
}
}
リポジトリインターフェース
// domain/repositories/IUserRepository.ts
import { User } from '../models/user/User';
import { UserId } from '../models/user/UserId';
import { Email } from '../models/user/Email';
export interface IUserRepository {
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
findAll(): Promise<User[]>;
save(user: User): Promise<void>;
delete(id: UserId): Promise<void>;
exists(email: Email): Promise<boolean>;
}
ドメインサービス
// domain/services/UserDomainService.ts
import { User } from '../models/user/User';
import { Email } from '../models/user/Email';
import { IUserRepository } from '../repositories/IUserRepository';
export class UserDomainService {
constructor(private readonly userRepository: IUserRepository) {}
async isEmailUnique(email: Email): Promise<boolean> {
return !(await this.userRepository.exists(email));
}
async canCreateUser(email: string): Promise<boolean> {
const emailVO = Email.create(email);
return this.isEmailUnique(emailVO);
}
}
インフラ層の実装
Prismaリポジトリ
// infrastructure/repositories/PrismaUserRepository.ts
import { prisma } from '../database/prisma';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { User } from '@/domain/models/user/User';
import { UserId } from '@/domain/models/user/UserId';
import { Email } from '@/domain/models/user/Email';
export class PrismaUserRepository implements IUserRepository {
async findById(id: UserId): Promise<User | null> {
const data = await prisma.user.findUnique({
where: { id: id.getValue() },
});
if (!data) return null;
return User.reconstruct({
id: data.id,
name: data.name,
email: data.email,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
});
}
async findByEmail(email: Email): Promise<User | null> {
const data = await prisma.user.findUnique({
where: { email: email.getValue() },
});
if (!data) return null;
return User.reconstruct({
id: data.id,
name: data.name,
email: data.email,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
});
}
async findAll(): Promise<User[]> {
const data = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
return data.map((d) =>
User.reconstruct({
id: d.id,
name: d.name,
email: d.email,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
})
);
}
async save(user: User): Promise<void> {
await prisma.user.upsert({
where: { id: user.id.getValue() },
update: {
name: user.name,
email: user.email.getValue(),
updatedAt: user.updatedAt,
},
create: {
id: user.id.getValue(),
name: user.name,
email: user.email.getValue(),
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
});
}
async delete(id: UserId): Promise<void> {
await prisma.user.delete({
where: { id: id.getValue() },
});
}
async exists(email: Email): Promise<boolean> {
const count = await prisma.user.count({
where: { email: email.getValue() },
});
return count > 0;
}
}
アプリケーション層の実装
DTO
// application/dto/UserDTO.ts
import { User } from '@/domain/models/user/User';
export interface UserDTO {
id: string;
name: string;
email: string;
createdAt: string;
updatedAt: string;
}
export function toUserDTO(user: User): UserDTO {
return {
id: user.id.getValue(),
name: user.name,
email: user.email.getValue(),
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
export interface CreateUserInput {
name: string;
email: string;
}
export interface UpdateUserInput {
id: string;
name?: string;
email?: string;
}
ユースケース
// application/usecases/user/CreateUserUseCase.ts
import { User } from '@/domain/models/user/User';
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserDomainService } from '@/domain/services/UserDomainService';
import { CreateUserInput, UserDTO, toUserDTO } from '@/application/dto/UserDTO';
export class CreateUserUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly userDomainService: UserDomainService
) {}
async execute(input: CreateUserInput): Promise<UserDTO> {
// ビジネスルール検証
const canCreate = await this.userDomainService.canCreateUser(input.email);
if (!canCreate) {
throw new Error('Email is already in use');
}
// エンティティ生成
const user = User.create({
name: input.name,
email: input.email,
});
// 永続化
await this.userRepository.save(user);
return toUserDTO(user);
}
}
// application/usecases/user/GetUserUseCase.ts
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserId } from '@/domain/models/user/UserId';
import { UserDTO, toUserDTO } from '@/application/dto/UserDTO';
export class GetUserUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(id: string): Promise<UserDTO | null> {
const userId = UserId.reconstruct(id);
const user = await this.userRepository.findById(userId);
if (!user) return null;
return toUserDTO(user);
}
}
export class GetUsersUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(): Promise<UserDTO[]> {
const users = await this.userRepository.findAll();
return users.map(toUserDTO);
}
}
// application/usecases/user/UpdateUserUseCase.ts
import { IUserRepository } from '@/domain/repositories/IUserRepository';
import { UserDomainService } from '@/domain/services/UserDomainService';
import { UserId } from '@/domain/models/user/UserId';
import { Email } from '@/domain/models/user/Email';
import { UpdateUserInput, UserDTO, toUserDTO } from '@/application/dto/UserDTO';
export class UpdateUserUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly userDomainService: UserDomainService
) {}
async execute(input: UpdateUserInput): Promise<UserDTO> {
const userId = UserId.reconstruct(input.id);
const existingUser = await this.userRepository.findById(userId);
if (!existingUser) {
throw new Error('User not found');
}
let user = existingUser;
if (input.name) {
user = user.changeName(input.name);
}
if (input.email && input.email !== existingUser.email.getValue()) {
const canUpdate = await this.userDomainService.canCreateUser(input.email);
if (!canUpdate) {
throw new Error('Email is already in use');
}
user = user.changeEmail(input.email);
}
await this.userRepository.save(user);
return toUserDTO(user);
}
}
プレゼンテーション層(Server Actions)
// presentation/actions/userActions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { PrismaUserRepository } from '@/infrastructure/repositories/PrismaUserRepository';
import { UserDomainService } from '@/domain/services/UserDomainService';
import { CreateUserUseCase } from '@/application/usecases/user/CreateUserUseCase';
import { UpdateUserUseCase } from '@/application/usecases/user/UpdateUserUseCase';
import { GetUsersUseCase, GetUserUseCase } from '@/application/usecases/user/GetUserUseCase';
import { UserId } from '@/domain/models/user/UserId';
// 依存性の解決(実際のプロジェクトではDIコンテナを使用)
function createDependencies() {
const userRepository = new PrismaUserRepository();
const userDomainService = new UserDomainService(userRepository);
return { userRepository, userDomainService };
}
export async function createUser(formData: FormData) {
const { userRepository, userDomainService } = createDependencies();
const useCase = new CreateUserUseCase(userRepository, userDomainService);
const name = formData.get('name') as string;
const email = formData.get('email') as string;
try {
await useCase.execute({ name, email });
revalidatePath('/users');
redirect('/users');
} catch (error) {
if (error instanceof Error) {
return { error: error.message };
}
return { error: 'An unexpected error occurred' };
}
}
export async function updateUser(formData: FormData) {
const { userRepository, userDomainService } = createDependencies();
const useCase = new UpdateUserUseCase(userRepository, userDomainService);
const id = formData.get('id') as string;
const name = formData.get('name') as string;
const email = formData.get('email') as string;
try {
await useCase.execute({ id, name, email });
revalidatePath('/users');
revalidatePath(`/users/${id}`);
} catch (error) {
if (error instanceof Error) {
return { error: error.message };
}
return { error: 'An unexpected error occurred' };
}
}
export async function deleteUser(formData: FormData) {
const { userRepository } = createDependencies();
const id = formData.get('id') as string;
const userId = UserId.reconstruct(id);
await userRepository.delete(userId);
revalidatePath('/users');
}
export async function getUsers() {
const { userRepository } = createDependencies();
const useCase = new GetUsersUseCase(userRepository);
return useCase.execute();
}
export async function getUser(id: string) {
const { userRepository } = createDependencies();
const useCase = new GetUserUseCase(userRepository);
return useCase.execute(id);
}
ページコンポーネント
// app/(routes)/users/page.tsx
import Link from 'next/link';
import { getUsers } from '@/presentation/actions/userActions';
import { UserList } from '@/presentation/components/UserList';
export default async function UsersPage() {
const users = await getUsers();
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">ユーザー一覧</h1>
<Link
href="/users/new"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
新規作成
</Link>
</div>
<UserList users={users} />
</div>
);
}
// presentation/components/UserList.tsx
import Link from 'next/link';
import { UserDTO } from '@/application/dto/UserDTO';
import { deleteUser } from '@/presentation/actions/userActions';
interface Props {
users: UserDTO[];
}
export function UserList({ users }: Props) {
return (
<div className="overflow-x-auto">
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-2 text-left">名前</th>
<th className="px-4 py-2 text-left">メール</th>
<th className="px-4 py-2 text-left">登録日</th>
<th className="px-4 py-2 text-left">操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t">
<td className="px-4 py-2">
<Link href={`/users/${user.id}`} className="text-blue-600 hover:underline">
{user.name}
</Link>
</td>
<td className="px-4 py-2">{user.email}</td>
<td className="px-4 py-2">
{new Date(user.createdAt).toLocaleDateString('ja-JP')}
</td>
<td className="px-4 py-2">
<form action={deleteUser}>
<input type="hidden" name="id" value={user.id} />
<button
type="submit"
className="text-red-600 hover:underline"
>
削除
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// app/(routes)/users/new/page.tsx
import { createUser } from '@/presentation/actions/userActions';
import { UserForm } from '@/presentation/components/UserForm';
export default function NewUserPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">ユーザー作成</h1>
<UserForm action={createUser} />
</div>
);
}
// presentation/components/UserForm.tsx
'use client';
import { useFormStatus } from 'react-dom';
import { UserDTO } from '@/application/dto/UserDTO';
interface Props {
action: (formData: FormData) => Promise<{ error?: string } | void>;
user?: UserDTO;
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{pending ? '保存中...' : '保存'}
</button>
);
}
export function UserForm({ action, user }: Props) {
return (
<form action={action} className="space-y-4 max-w-md">
{user && <input type="hidden" name="id" value={user.id} />}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
名前
</label>
<input
type="text"
id="name"
name="name"
defaultValue={user?.name}
required
minLength={2}
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
メールアドレス
</label>
<input
type="email"
id="email"
name="email"
defaultValue={user?.email}
required
className="w-full border rounded px-3 py-2"
/>
</div>
<SubmitButton />
</form>
);
}
まとめ
| レイヤー | 責務 | Next.jsでの実装 |
|---|---|---|
| ドメイン層 | ビジネスロジック | エンティティ、値オブジェクト、ドメインサービス |
| アプリケーション層 | ユースケース | UseCase クラス、DTO |
| インフラ層 | 永続化・外部連携 | Prismaリポジトリ |
| プレゼンテーション層 | UI・入出力 | Server Actions、React Components |