返回 Skill 列表
extension
分类: 开发与工程无需 API Key

nextjs-ddd-architect

针对Next.js 16 App Router项目的领域驱动设计架构。提供领域驱动的文件夹结构、分层架构模式、限界上下文设计以及关注点分离。在重构至DDD、设计模块化架构、组织领域或使用干净架构原则扩展应用程序时使用。

person作者: jakexiaohubgithub

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 couplingCORRECT - 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"; // ❌

Resources