Next.js DDD Architect
Expert guidance for implementing Domain-Driven Design (DDD) architecture in Next.js 16 App Router projects with clean architecture principles, domain-driven folders, and modular design patterns.
⚠️ PRAGMATIC DDD: Avoid Over-Engineering
YAGNI Principle: Apply DDD patterns ONLY where they add demonstrable value. Simple CRUD doesn't need full DDD.
The Simpsons API: Real-World Example
This project demonstrates PRAGMATIC DDD with three pattern levels:
🟢 Simple Domains (Direct Repository)
| Domain | Pattern | Why Simple? |
|---|---|---|
| Characters (read) | findAllCharacters() | Public catalog, no business rules |
| Episodes (read) | findAllEpisodes() | Public catalog, no business rules |
| Locations | findAllLocations() | Static reference data |
// app/_lib/repositories.ts - Direct Prisma queries
export async function findAllCharacters(limit = 50) {
return prisma.character.findMany({ take: limit });
}
🟡 Hybrid Domains (Read Simple, Write DDD)
| Domain | Simple Operations | DDD Operations |
|---|---|---|
| Episodes | List, Details | Track progress, Rate |
| Characters | View profile | Follow, Comment |
| Trivia | View facts | Submit new facts |
🔴 Complex Domains (Full DDD)
| Domain | Why Full DDD? |
|---|---|
| Diary | User ownership, business rules, RLS |
| Collections | Ownership, validation, quotas |
| User Progress | State management, history |
// app/_actions/diary.ts - Full DDD pattern
export async function createDiaryEntry(...) {
return withAuthenticatedRLS(prisma, async (tx, user) => {
const useCase = UseCaseFactory.createCreateDiaryEntryUseCase();
await useCase.execute(input, user.id);
});
}
Decision Flowchart
New Feature → Is it read-only?
↓ Yes: Use Simple Repository
↓ No: Has business rules?
↓ No: Simple Server Action + Zod
↓ Yes: Full DDD with UseCase
↓ Requires auth?
↓ Yes: Add RLS wrapper
Reference
See docs/ARCHITECTURE_DECISION_MATRIX.md for the complete decision guide.
When to Use This Skill
✅ Primary Use Cases
- •"Refactor to DDD architecture"
- •"Organize domains in Next.js"
- •"Create domain-driven folders"
- •"Design bounded context"
- •"Separate business logic from framework"
- •"Scale application architecture"
✅ Secondary Use Cases
- •"Where should I put server actions?"
- •"How to organize services and repositories?"
- •"Design domain stores (Zustand/Jotai)"
- •"Create domain-specific components"
- •"Implement layered architecture"
- •"Decouple framework from domain"
❌ Do NOT use when
- •Simple UI components without business logic
- •Quick prototypes or MVPs
- •Single-feature applications
- •Pure API routes without domain logic
Architectural Principles
1. Domain-Driven Folders (Not Feature Folders)
Organize by business domain, not by technical concern.
❌ WRONG - Feature folders
app/
users/
products/
orders/
components/
UserCard.tsx
ProductCard.tsx
services/
userService.ts
productService.ts
✅ CORRECT - Domain-driven folders
domains/
users/
components/
services/
actions/
store/
types.ts
index.ts
products/
components/
services/
actions/
store/
types.ts
index.ts
app/
users/
page.tsx # delivery layer only
products/
page.tsx
2. Layered Architecture
Delivery Layer (/app)
- •Next.js App Router routes, layouts, pages
- •Server components for data fetching orchestration
- •Minimal logic - just composition and orchestration
- •Import from domains via public API (
index.ts)
Domain Layer (/domains)
- •Business logic, entities, value objects
- •Services, repositories, stores
- •Domain-specific components
- •Validations (Zod schemas)
- •Server actions within domain
- •Framework-agnostic (can be ported to other frameworks)
Infrastructure Layer (/app/_lib)
- •Database clients (Prisma, Drizzle)
- •External API integrations
- •Authentication utilities
- •Shared infrastructure concerns
Shared Kernel (/shared)
- •Truly cross-domain utilities
- •UI primitives (
/components/ui) - •Global types and constants
- •Keep minimal - most code belongs in domains
Domain Structure Template
Canonical Domain Structure
domains/
{domain-name}/
components/ # Domain-specific UI components
DomainList.tsx
DomainCard.tsx
DomainForm.tsx
services/ # Business logic & data fetching
getDomain.ts
createDomain.ts
updateDomain.ts
actions/ # Server actions (Next.js specific)
createDomainAction.ts
updateDomainAction.ts
store/ # Client state management
useDomainStore.ts # Zustand/Jotai stores
hooks/ # Domain-specific hooks
useDomainLogic.ts
types.ts # Domain types & interfaces
schemas.ts # Zod validation schemas
constants.ts # Domain constants
index.ts # Public API exports
Exports Strategy (index.ts)
// domains/users/index.ts
// Public API - controlled exports only
// Services (server-side)
export { getUsers } from "./services/getUsers";
export { getUserById } from "./services/getUserById";
// Components (client + server)
export { UserList } from "./components/UserList";
export { UserCard } from "./components/UserCard";
// Actions (server actions)
export { createUserAction } from "./actions/createUserAction";
export { updateUserAction } from "./actions/updateUserAction";
// Types (shared)
export type { User, CreateUserInput, UpdateUserInput } from "./types";
// ❌ Do NOT export:
// - Internal helpers
// - Store internals (export hooks only)
// - Private utilities
Server Actions Within Domain
Pattern: Actions + Services Separation
// domains/users/services/createUser.ts
"use server"; // Optional if called only from actions
import { prisma } from "@/app/_lib/prisma";
import { CreateUserSchema, type CreateUserInput } from "../schemas";
export async function createUser(input: CreateUserInput) {
const validated = CreateUserSchema.parse(input);
return await prisma.user.create({
data: {
name: validated.name,
email: validated.email,
},
});
}
// domains/users/actions/createUserAction.ts
"use server";
import { revalidatePath } from "next/cache";
import { createUser } from "../services/createUser";
import type { CreateUserInput } from "../types";
export async function createUserAction(input: CreateUserInput) {
const user = await createUser(input);
revalidatePath("/users");
return { success: true, user };
}
// app/users/page.tsx (delivery layer)
import { createUserAction, UserList, getUsers } from "@/domains/users";
export default async function UsersPage() {
const users = await getUsers();
return (
<div>
<UserList users={users} />
<form action={createUserAction}>
{/* form fields */}
</form>
</div>
);
}
Why this pattern?
- •Services: Pure business logic, reusable, testable
- •Actions: Next.js-specific (revalidation, redirects, cookies)
- •Separation: Services can be used in API routes, cron jobs, tests
Domain Stores (Client State)
Zustand Store Pattern
// domains/users/store/useUsersStore.ts
"use client";
import { create } from "zustand";
import type { User } from "../types";
interface UsersState {
selectedUser: User | null;
filters: {
search: string;
status: "active" | "inactive" | "all";
};
setSelectedUser: (user: User | null) => void;
setFilters: (filters: Partial<UsersState["filters"]>) => void;
resetFilters: () => void;
}
export const useUsersStore = create<UsersState>((set) => ({
selectedUser: null,
filters: {
search: "",
status: "all",
},
setSelectedUser: (user) => set({ selectedUser: user }),
setFilters: (filters) =>
set((state) => ({ filters: { ...state.filters, ...filters } })),
resetFilters: () =>
set({ filters: { search: "", status: "all" } }),
}));
// domains/users/index.ts
export { useUsersStore } from "./store/useUsersStore";
// app/users/page.tsx
"use client";
import { useUsersStore } from "@/domains/users";
function UsersFilter() {
const { filters, setFilters } = useUsersStore();
return <input onChange={(e) => setFilters({ search: e.target.value })} />;
}
Bounded Contexts & Domain Dependencies
Rule: Domains Should Be Independent
❌ WRONG - Direct domain imports
// domains/orders/services/createOrder.ts
import { getUserById } from "@/domains/users/services/getUserById"; // ❌ Tight coupling
✅ CORRECT - Dependency injection
// domains/orders/services/createOrder.ts
import type { User } from "@/domains/users";
export async function createOrder(
userId: string,
getUserFn: (id: string) => Promise<User> // Injected dependency
) {
const user = await getUserFn(userId);
// ... create order logic
}
// app/orders/actions.ts
import { createOrder } from "@/domains/orders";
import { getUserById } from "@/domains/users";
export async function createOrderAction(userId: string) {
return createOrder(userId, getUserById); // Inject at boundary
}
Shared Types Across Domains
// domains/_shared/types.ts (or /shared/types.ts)
export interface PaginationParams {
page: number;
limit: number;
}
export interface ApiResponse<T> {
data: T;
meta: { total: number; page: number };
}
// domains/users/services/getUsers.ts
import type { PaginationParams } from "@/domains/_shared/types";
export async function getUsers(params: PaginationParams) {
// ...
}
Testing Strategy by Domain
Unit Tests (Services)
// domains/users/services/createUser.test.ts
import { describe, it, expect, vi } from "vitest";
import { createUser } from "./createUser";
import { prisma } from "@/app/_lib/prisma";
vi.mock("@/app/_lib/prisma", () => ({
prisma: {
user: {
create: vi.fn(),
},
},
}));
describe("createUser", () => {
it("creates user with valid input", async () => {
const input = { name: "John", email: "john@example.com" };
vi.mocked(prisma.user.create).mockResolvedValue({
id: "1",
...input,
createdAt: new Date(),
});
const result = await createUser(input);
expect(result.name).toBe("John");
expect(prisma.user.create).toHaveBeenCalledWith({
data: input,
});
});
});
Integration Tests (Actions)
// domains/users/actions/createUserAction.test.ts
import { describe, it, expect } from "vitest";
import { createUserAction } from "./createUserAction";
describe("createUserAction", () => {
it("creates user and revalidates path", async () => {
const result = await createUserAction({
name: "Jane",
email: "jane@example.com",
});
expect(result.success).toBe(true);
expect(result.user.name).toBe("Jane");
});
});
Migration Strategy: Existing Project → DDD
Phase 1: Create Domain Structure
mkdir -p domains/users/{components,services,actions,store}
touch domains/users/index.ts
touch domains/users/types.ts
touch domains/users/schemas.ts
Phase 2: Move Business Logic
// Before: app/_lib/repositories.ts (mixed concerns)
export async function getUserById(id: string) {
/* ... */
}
export async function getEpisodeById(id: number) {
/* ... */
}
// After: Separate by domain
// domains/users/services/getUserById.ts
export async function getUserById(id: string) {
/* ... */
}
// domains/episodes/services/getEpisodeById.ts
export async function getEpisodeById(id: number) {
/* ... */
}
Phase 3: Move Components
// Before: app/_components/UserCard.tsx (generic)
// After: domains/users/components/UserCard.tsx (domain-specific)
// and update imports in app/users/page.tsx
import { UserCard } from "@/domains/users";
Phase 4: Move Actions
// Before: app/_actions/users.ts (all actions in one folder) // After: domains/users/actions/createUserAction.ts // domains/users/actions/updateUserAction.ts
Phase 5: Update Imports
// Before: app/users/page.tsx
import { getUserById } from "@/app/_lib/repositories";
import { UserCard } from "@/app/_components/UserCard";
// After: app/users/page.tsx
import { getUserById, UserCard } from "@/domains/users";
Real-World Example: The Simpsons API
Current Structure (Mixed)
app/
_actions/
episodes.ts
diary.ts
social.ts
_components/
EpisodeTracker.tsx
CommentSection.tsx
_lib/
repositories.ts (mixed domain queries)
Proposed DDD Structure
domains/
episodes/
components/
EpisodeCard.tsx
EpisodeTracker.tsx
EpisodeList.tsx
services/
getEpisodes.ts
getEpisodeById.ts
trackEpisode.ts
actions/
trackEpisodeAction.ts
rateEpisodeAction.ts
store/
useEpisodesStore.ts
types.ts
schemas.ts
index.ts
diary/
components/
DiaryForm.tsx
DiaryEntryCard.tsx
services/
getDiaryEntries.ts
createDiaryEntry.ts
actions/
createDiaryEntryAction.ts
deleteDiaryEntryAction.ts
types.ts
schemas.ts
index.ts
social/
components/
CommentSection.tsx
FollowButton.tsx
services/
getComments.ts
followUser.ts
actions/
addCommentAction.ts
toggleFollowAction.ts
types.ts
schemas.ts
index.ts
app/
episodes/
page.tsx # Delivery layer
[id]/page.tsx
diary/
page.tsx
_lib/
prisma.ts # Infrastructure
auth.ts
Decision Framework
When to Create a New Domain?
✅ Create separate domain when:
- •Clear bounded context (e.g., "users", "episodes", "payments")
- •Independent business rules and validation
- •Can be developed/tested in isolation
- •Has its own data models and entities
❌ Keep in existing domain when:
- •Tightly coupled to parent domain
- •Shared lifecycle with parent
- •Just a UI variant (use components folder)
- •Pure utility function (use shared)
When to Use Shared vs Domain?
Shared:
- •UI primitives (Button, Card, Input)
- •Framework utilities (cn, formatDate)
- •Global constants (API_URL, MAX_RETRIES)
Domain:
- •Business logic (calculateDiscount, validateOrder)
- •Domain-specific components (UserCard, EpisodeTracker)
- •Domain types and schemas
Anti-Patterns to Avoid
❌ Anti-Pattern 1: Mixing Delivery and Domain
// app/users/page.tsx
export default async function UsersPage() {
// ❌ Business logic in route
const users = await prisma.user.findMany({
where: { isActive: true },
orderBy: { createdAt: "desc" },
});
return <div>{users.map(/* ... */)}</div>;
}
✅ Solution: Extract to domain service
// domains/users/services/getUsers.ts
export async function getActiveUsers() {
return await prisma.user.findMany({
where: { isActive: true },
orderBy: { createdAt: "desc" },
});
}
// app/users/page.tsx
import { getActiveUsers, UserList } from "@/domains/users";
export default async function UsersPage() {
const users = await getActiveUsers();
return <UserList users={users} />;
}
❌ Anti-Pattern 2: Domain Coupling
// domains/orders/services/createOrder.ts
import { sendEmail } from "@/domains/notifications"; // ❌ Direct dependency
✅ Solution: Use events or dependency injection
// domains/orders/services/createOrder.ts
import { EventBus } from "@/shared/events";
export async function createOrder(input: CreateOrderInput) {
const order = await prisma.order.create({ data: input });
// Emit event instead of direct call
EventBus.emit("order.created", { orderId: order.id });
return order;
}
❌ Anti-Pattern 3: Anemic Domain Model
// domains/users/types.ts
export interface User {
id: string;
name: string;
email: string;
}
// services/userService.ts (separate from domain)
export function isUserActive(user: User) {
/* ... */
}
✅ Solution: Enrich domain with behavior
// domains/users/types.ts
export class User {
constructor(
public id: string,
public name: string,
public email: string,
public lastLoginAt: Date | null,
) {}
isActive(): boolean {
if (!this.lastLoginAt) return false;
const daysSinceLogin =
(Date.now() - this.lastLoginAt.getTime()) / (1000 * 60 * 60 * 24);
return daysSinceLogin < 30;
}
}
Quick Reference
Domain Checklist
- •
components/- Domain-specific UI components - •
services/- Business logic and data fetching - •
actions/- Server actions with revalidation - •
store/- Client state (Zustand/Jotai) - •
types.ts- Domain types and interfaces - •
schemas.ts- Zod validation schemas - •
index.ts- Public API exports
File Naming Conventions
- •Services:
{verb}{Entity}.ts(e.g.,getUserById.ts,createUser.ts) - •Actions:
{verb}{Entity}Action.ts(e.g.,createUserAction.ts) - •Components:
{Entity}{Component}.tsx(e.g.,UserCard.tsx,UserList.tsx) - •Stores:
use{Entity}Store.ts(e.g.,useUsersStore.ts)
Import Patterns
// ✅ Import from domain public API
import { getUsers, UserList, createUserAction } from "@/domains/users";
// ❌ Never import internals
import { UserList } from "@/domains/users/components/UserList"; // ❌