# Clean Architecture Frontend

> Clean Architecture patterns for Next.js 16 frontend applications. Enforces strict layer separation (Domain, Application, Infrastructure, Delivery) with the Dependency Rule, ensuring business logic independence from frameworks. Use when designing scalable architecture, implementing use cases, separating concerns, or migrating to maintainable patterns.

- Skill: `majiayu000/clean-architecture-frontend` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/clean-architecture-frontend`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/clean-architecture-frontend/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/clean-architecture-frontend

---


# Clean Architecture for Frontend Development

Expert guidance for implementing Clean Architecture principles in Next.js 16 App Router projects with strict layer separation, dependency inversion, and framework-independent business logic.

---

## ⚠️ PRAGMATIC ARCHITECTURE: When NOT to Use Full Clean Architecture

> **YAGNI Principle:** You Ain't Gonna Need It. Apply Clean Architecture ONLY where complexity justifies it.

### Decision Matrix: Simple vs Full DDD

| Criteria           | Use SIMPLE Pattern   | Use FULL DDD                  |
| ------------------ | -------------------- | ----------------------------- |
| **Operation**      | Read-only CRUD       | Mutations with business rules |
| **Business Rules** | None/trivial         | Complex validation, state     |
| **Data Ownership** | Public data          | User-owned (requires auth)    |
| **Testability**    | Integration tests OK | Need unit tests on logic      |

### Simple Pattern Example (Characters)

```typescript
// ✅ SIMPLE: Direct Prisma queries for read-only public data
// app/_lib/repositories.ts
export async function findAllCharacters(limit = 50) {
  return prisma.character.findMany({ take: limit });
}

// app/characters/page.tsx
const characters = await findAllCharacters();
```

**Why Simple Here?**

- No business rules to enforce
- Public data (no auth needed)
- A UseCase would just be a pass-through wrapper

### Full DDD Example (Diary Entries)

```typescript
// ✅ FULL DDD: Mutations with business rules + auth + RLS
// app/_actions/diary.ts
export async function createDiaryEntry(...) {
  return withAuthenticatedRLS(prisma, async (tx, user) => {
    const useCase = UseCaseFactory.createCreateDiaryEntryUseCase();
    await useCase.execute(input, user.id);
  });
}
```

**Why Full DDD Here?**

- Business rules: character/location must exist
- User ownership: entries belong to users
- Authorization: RLS enforcement
- Testable: UseCase can be unit tested

### Reference: Architecture Decision Matrix

See [docs/ARCHITECTURE_DECISION_MATRIX.md](../../../docs/ARCHITECTURE_DECISION_MATRIX.md) for the complete decision guide including:

- Domain classification (Simple, Hybrid, Complex)
- Migration guidelines
- Anti-patterns to avoid

---

## When to Use This Skill

✅ **Primary Use Cases**

- "Implement Clean Architecture in Next.js"
- "Separate business logic from framework"
- "Create use cases and interactors"
- "Design independent domain layer"
- "Apply Dependency Rule"
- "Implement hexagonal architecture"
- "Design port and adapter pattern"

✅ **Secondary Use Cases**

- "Where should business rules go?"
- "How to make framework-independent code?"
- "Design repository interfaces"
- "Implement dependency injection"
- "Test business logic in isolation"
- "Migrate from monolithic structure"
- "Scale application architecture"

❌ **Do NOT use when**

- Simple static websites
- Quick prototypes without complex logic
- Pure marketing/landing pages
- Applications with minimal business rules
- **Simple CRUD without business logic** (use direct repository pattern)

---

## Core Principles of Clean Architecture

### The Dependency Rule

> **Source code dependencies must point only inward, toward higher-level policies.**

```
┌─────────────────────────────────────────┐
│         Delivery Layer (UI)             │ ← Frameworks, UI, HTTP
│  ┌───────────────────────────────────┐  │
│  │   Infrastructure Layer (Data)     │  │ ← DB, External APIs
│  │  ┌─────────────────────────────┐  │  │
│  │  │  Application Layer (Use Cases)│ │ │ ← Orchestration
│  │  │  ┌───────────────────────┐  │  │  │
│  │  │  │   Domain Layer        │  │  │  │ ← Business Rules
│  │  │  │   (Entities, VOs)     │  │  │  │
│  │  │  └───────────────────────┘  │  │  │
│  │  └─────────────────────────────┘  │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

Dependencies flow INWARD ONLY:
Delivery → Infrastructure → Application → Domain
```

**Key Rules:**

1. **Inner layers** know nothing about outer layers
2. **Domain** is 100% framework-agnostic (no React, no Next.js)
3. **Application** defines interfaces, Infrastructure implements them
4. **Delivery** depends on everything, but everything else ignores it

### The Four Layers

#### 1. Domain Layer (Innermost)

**Purpose:** Pure business logic and rules

**Contains:**

- Entities (business objects)
- Value Objects (immutable data)
- Domain Services (complex business rules)
- Domain Events
- Business exceptions

**Characteristics:**

- Zero dependencies on frameworks
- No React, no Next.js, no Prisma
- Can be tested with pure TypeScript
- Portable to any framework

**Location:** `/core/domain/`

```typescript
// ✅ CORRECT - Pure domain entity
export class Episode {
  private constructor(
    public readonly id: number,
    public readonly title: string,
    public readonly season: number,
    public readonly episodeNumber: number,
    private _rating: number
  ) {
    this.validateRating(_rating);
  }

  static create(data: EpisodeData): Episode {
    return new Episode(
      data.id,
      data.title,
      data.season,
      data.episodeNumber,
      data.rating
    );
  }

  // Business rule: rating must be 1-5
  private validateRating(rating: number): void {
    if (rating < 1 || rating > 5) {
      throw new InvalidRatingError("Rating must be between 1 and 5");
    }
  }

  updateRating(newRating: number): Episode {
    this.validateRating(newRating);
    return new Episode(
      this.id,
      this.title,
      this.season,
      this.episodeNumber,
      newRating
    );
  }

  get isHighlyRated(): boolean {
    return this._rating >= 4;
  }
}

// ❌ WRONG - Domain depends on framework
import { prisma } from "@/app/_lib/prisma"; // NO!
export class Episode {
  async save() {
    await prisma.episode.update(...); // VIOLATION!
  }
}
```

#### 2. Application Layer (Use Cases)

**Purpose:** Orchestrate business logic for specific use cases

**Contains:**

- Use Cases / Interactors
- Application Services
- DTOs (Data Transfer Objects)
- Repository Interfaces (ports)
- Service Interfaces (ports)

**Characteristics:**

- Defines interfaces for Infrastructure layer
- Orchestrates Domain entities
- Framework-agnostic (but aware of application needs)
- Can import from Domain layer only

**Location:** `/core/application/`

```typescript
// ✅ CORRECT - Use case with interface dependency
import { Episode } from "@/core/domain/entities/Episode";
import { EpisodeRepository } from "@/core/application/ports/EpisodeRepository";

export class TrackEpisodeUseCase {
  constructor(private episodeRepository: EpisodeRepository) {}

  async execute(input: TrackEpisodeInput): Promise<TrackEpisodeOutput> {
    // 1. Fetch episode from repository (interface)
    const episode = await this.episodeRepository.findById(input.episodeId);
    if (!episode) {
      throw new EpisodeNotFoundError(input.episodeId);
    }

    // 2. Apply business rule (domain entity)
    const updatedEpisode = episode.updateRating(input.rating);

    // 3. Persist changes via repository (interface)
    await this.episodeRepository.save(updatedEpisode);

    // 4. Return DTO
    return {
      episodeId: updatedEpisode.id,
      newRating: updatedEpisode.rating,
    };
  }
}

// Interface (port) - defined in Application layer
export interface EpisodeRepository {
  findById(id: number): Promise<Episode | null>;
  save(episode: Episode): Promise<void>;
}

// ❌ WRONG - Use case depends on concrete implementation
import { PrismaEpisodeRepository } from "@/infrastructure/prisma"; // NO!
export class TrackEpisodeUseCase {
  async execute(input: TrackEpisodeInput) {
    const repo = new PrismaEpisodeRepository(); // VIOLATION!
    await repo.save(...);
  }
}
```

#### 3. Infrastructure Layer (Adapters)

**Purpose:** Implement interfaces defined by Application layer

**Contains:**

- Repository implementations (Prisma adapters)
- External service adapters (API clients)
- Database configurations
- Third-party integrations
- Mappers (Domain ↔ Database)

**Characteristics:**

- Implements Application interfaces
- Knows about Domain and Application layers
- Framework-specific code lives here
- Depends inward only

**Location:** `/infrastructure/`

```typescript
// ✅ CORRECT - Infrastructure implements Application interface
import { EpisodeRepository } from "@/core/application/ports/EpisodeRepository";
import { Episode } from "@/core/domain/entities/Episode";
import { prisma } from "@/app/_lib/prisma";

export class PrismaEpisodeRepository implements EpisodeRepository {
  async findById(id: number): Promise<Episode | null> {
    const record = await prisma.episode.findUnique({ where: { id } });
    if (!record) return null;

    // Map Prisma model to Domain entity
    return Episode.create({
      id: record.id,
      title: record.title,
      season: record.season,
      episodeNumber: record.episode_number,
      rating: record.rating,
    });
  }

  async save(episode: Episode): Promise<void> {
    await prisma.episode.update({
      where: { id: episode.id },
      data: {
        rating: episode.rating,
      },
    });
  }
}

// Mapper utility
export class EpisodePrismaMapper {
  static toDomain(record: PrismaEpisode): Episode {
    return Episode.create({
      id: record.id,
      title: record.title,
      season: record.season,
      episodeNumber: record.episode_number,
      rating: record.rating,
    });
  }

  static toPersistence(episode: Episode): PrismaEpisodeData {
    return {
      id: episode.id,
      title: episode.title,
      season: episode.season,
      episode_number: episode.episodeNumber,
      rating: episode.rating,
    };
  }
}
```

#### 4. Delivery Layer (UI/Controllers)

**Purpose:** Handle user interactions and presentation

**Contains:**

- Next.js App Router pages, layouts
- React Server/Client components
- Server Actions (as thin controllers)
- API routes
- Dependency injection/composition

**Characteristics:**

- Depends on all other layers
- Orchestrates use case execution
- Handles HTTP/UI concerns
- Provides dependencies to use cases

**Location:** `/app/`

```typescript
// ✅ CORRECT - Server Action as thin controller
"use server";

import { TrackEpisodeUseCase } from "@/core/application/use-cases/TrackEpisodeUseCase";
import { PrismaEpisodeRepository } from "@/infrastructure/prisma/EpisodeRepository";
import { revalidatePath } from "next/cache";

export async function trackEpisode(episodeId: number, rating: number) {
  // 1. Compose dependencies (Dependency Injection)
  const episodeRepository = new PrismaEpisodeRepository();
  const useCase = new TrackEpisodeUseCase(episodeRepository);

  // 2. Execute use case
  try {
    const result = await useCase.execute({ episodeId, rating });

    // 3. Handle framework-specific concerns
    revalidatePath(`/episodes/${episodeId}`);

    return { success: true, data: result };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

// ✅ CORRECT - Page as composition layer
import { PrismaEpisodeRepository } from "@/infrastructure/prisma/EpisodeRepository";
import { GetEpisodeDetailsUseCase } from "@/core/application/use-cases/GetEpisodeDetailsUseCase";
import { EpisodeDetail } from "@/app/_components/EpisodeDetail";

export default async function EpisodePage({ params }: Props) {
  // Compose dependencies
  const repository = new PrismaEpisodeRepository();
  const useCase = new GetEpisodeDetailsUseCase(repository);

  // Execute use case
  const episode = await useCase.execute({ id: params.id });

  // Render UI
  return <EpisodeDetail episode={episode} />;
}
```

---

## Integration with Next.js 16 App Router

### Directory Structure

```
app/                          # Delivery Layer (Next.js)
  episodes/
    [id]/
      page.tsx               # Thin orchestration layer
      actions.ts             # Server Actions as controllers
  _components/               # Delivery-specific UI components
  _lib/                      # Framework utilities (auth, prisma)

core/                        # Business Logic (Framework-agnostic)
  domain/
    entities/
      Episode.ts             # Pure business object
      Character.ts
    value-objects/
      Rating.ts              # Immutable value with validation
      EmailAddress.ts
    services/
      EpisodeRatingService.ts # Complex domain rules
    exceptions/
      DomainException.ts
      InvalidRatingError.ts

  application/
    use-cases/
      TrackEpisodeUseCase.ts # Orchestrate episode tracking
      GetEpisodeDetailsUseCase.ts
    ports/                   # Interfaces (contracts)
      EpisodeRepository.ts   # Interface for data access
      NotificationService.ts # Interface for notifications
    dtos/
      TrackEpisodeInput.ts
      EpisodeDetailsOutput.ts

infrastructure/              # Adapters (Framework-specific)
  prisma/
    repositories/
      PrismaEpisodeRepository.ts # Implements EpisodeRepository
      PrismaCharacterRepository.ts
    mappers/
      EpisodeMapper.ts       # Domain ↔ Prisma conversion
  email/
    SendgridEmailService.ts  # Implements NotificationService
  cache/
    RedisCache.ts

prisma/
  schema.prisma              # Database schema

components/
  ui/                        # Shadcn UI primitives
```

### Use Case Execution Pattern

#### Step 1: Define Domain Entity

```typescript
// core/domain/entities/Episode.ts
export class Episode {
  private constructor(
    public readonly id: number,
    public readonly title: string,
    private _viewCount: number,
  ) {}

  static create(data: EpisodeData): Episode {
    return new Episode(data.id, data.title, data.viewCount ?? 0);
  }

  incrementViewCount(): Episode {
    return new Episode(this.id, this.title, this._viewCount + 1);
  }

  get viewCount(): number {
    return this._viewCount;
  }
}
```

#### Step 2: Define Repository Interface (Port)

```typescript
// core/application/ports/EpisodeRepository.ts
import { Episode } from "@/core/domain/entities/Episode";

export interface EpisodeRepository {
  findById(id: number): Promise<Episode | null>;
  save(episode: Episode): Promise<void>;
  findTrending(limit: number): Promise<Episode[]>;
}
```

#### Step 3: Implement Use Case

```typescript
// core/application/use-cases/IncrementEpisodeViewsUseCase.ts
import { Episode } from "@/core/domain/entities/Episode";
import { EpisodeRepository } from "@/core/application/ports/EpisodeRepository";

export class IncrementEpisodeViewsUseCase {
  constructor(private episodeRepository: EpisodeRepository) {}

  async execute(input: { episodeId: number }): Promise<void> {
    const episode = await this.episodeRepository.findById(input.episodeId);
    if (!episode) {
      throw new EpisodeNotFoundError(input.episodeId);
    }

    const updatedEpisode = episode.incrementViewCount();
    await this.episodeRepository.save(updatedEpisode);
  }
}
```

#### Step 4: Implement Repository Adapter

```typescript
// infrastructure/prisma/repositories/PrismaEpisodeRepository.ts
import { EpisodeRepository } from "@/core/application/ports/EpisodeRepository";
import { Episode } from "@/core/domain/entities/Episode";
import { prisma } from "@/app/_lib/prisma";
import { EpisodeMapper } from "../mappers/EpisodeMapper";

export class PrismaEpisodeRepository implements EpisodeRepository {
  async findById(id: number): Promise<Episode | null> {
    const record = await prisma.episode.findUnique({ where: { id } });
    return record ? EpisodeMapper.toDomain(record) : null;
  }

  async save(episode: Episode): Promise<void> {
    const data = EpisodeMapper.toPersistence(episode);
    await prisma.episode.update({
      where: { id: episode.id },
      data,
    });
  }

  async findTrending(limit: number): Promise<Episode[]> {
    const records = await prisma.episode.findMany({
      orderBy: { viewCount: "desc" },
      take: limit,
    });
    return records.map(EpisodeMapper.toDomain);
  }
}
```

#### Step 5: Execute from Delivery Layer

```typescript
// app/episodes/[id]/actions.ts
"use server";

import { IncrementEpisodeViewsUseCase } from "@/core/application/use-cases/IncrementEpisodeViewsUseCase";
import { PrismaEpisodeRepository } from "@/infrastructure/prisma/repositories/PrismaEpisodeRepository";

export async function incrementViews(episodeId: number) {
  const repository = new PrismaEpisodeRepository();
  const useCase = new IncrementEpisodeViewsUseCase(repository);

  await useCase.execute({ episodeId });
}
```

---

## Exception Handling in Clean Architecture

### Preserve Domain Exception Types Across Layers

**Critical Pattern:** Domain exceptions are part of your domain model. Never wrap them in generic `Error` as they flow through layers.

#### Domain Layer: Define Exceptions

```typescript
// core/domain/exceptions/DomainException.ts
export abstract class DomainException extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly timestamp: Date = new Date(),
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

// core/domain/exceptions/ValidationException.ts
export class ValidationException extends DomainException {
  constructor(
    message: string,
    public readonly field?: string,
    public readonly value?: unknown,
  ) {
    super(message, "VALIDATION_ERROR");
  }
}

// core/domain/exceptions/NotFoundException.ts
export class NotFoundException extends DomainException {
  constructor(
    public readonly entityType: string,
    public readonly entityId: string | number,
  ) {
    super(`${entityType} with id ${entityId} not found`, "NOT_FOUND");
  }
}
```

#### Application Layer: Throw Domain Exceptions

```typescript
// core/application/use-cases/TrackEpisodeUseCase.ts
import {
  ValidationException,
  NotFoundException,
} from "@/core/domain/exceptions";

export class TrackEpisodeUseCase {
  constructor(private episodeRepository: EpisodeRepository) {}

  async execute(input: { episodeId: number; rating: number }, userId: string) {
    // ✅ Throw domain exceptions for business rule violations
    if (!input.rating || input.rating < 1 || input.rating > 5) {
      throw new ValidationException(
        "Rating must be between 1 and 5",
        "rating",
        input.rating,
      );
    }

    const episode = await this.episodeRepository.findById(input.episodeId);
    if (!episode) {
      throw new NotFoundException("Episode", input.episodeId);
    }

    // ... business logic
  }
}
```

#### Delivery Layer: Preserve Exceptions (DO NOT WRAP)

```typescript
// app/_actions/episodes.ts
"use server";
import { withAuthenticatedRLS } from "@/app/_lib/prisma-rls";
import { UseCaseFactory } from "@/infrastructure/factories";
import {
  ValidationException,
  NotFoundException,
  DomainException,
} from "@/core/domain/exceptions";
import { revalidatePath } from "next/cache";

export async function trackEpisode(episodeId: number, rating: number) {
  return withAuthenticatedRLS(prisma, async (tx, user) => {
    try {
      const useCase = UseCaseFactory.createTrackEpisodeUseCase();
      await useCase.execute({ episodeId, rating }, user.id);

      revalidatePath(`/episodes/${episodeId}`);
      return { success: true };
    } catch (error) {
      // ✅ CORRECT: Preserve domain exception types
      if (error instanceof ValidationException) {
        throw error; // Client gets field, value, code
      }
      if (error instanceof NotFoundException) {
        throw error; // Client gets entityType, entityId
      }
      if (error instanceof DomainException) {
        throw error; // Catch-all for domain exceptions
      }
      if (error instanceof Error) {
        throw error; // Preserve standard errors
      }

      // Only truly unexpected errors get wrapped
      throw new Error("Failed to track episode");
    }
  });
}
```

#### ❌ Anti-Pattern: Wrapping Domain Exceptions

```typescript
// ❌ WRONG - Loses type information and metadata
catch (error) {
  if (error instanceof ValidationException) {
    throw new Error(error.message); // Lost field, value, code!
  }
  throw new Error("Failed");
}
```

**Why This is Wrong:**

- Loses exception type (client can't catch `ValidationException`)
- Loses metadata (field, value, code)
- Breaks type-safe error handling
- Makes debugging harder

#### ✅ Correct Pattern: Type-Safe Error Handling

```typescript
// Client component can now handle specific types
"use client";

export function EpisodeTracker({ episodeId }: Props) {
  const handleTrack = async (rating: number) => {
    try {
      await trackEpisode(episodeId, rating);
      toast.success("Episode tracked!");
    } catch (error) {
      // ✅ Type-safe error handling
      if (error instanceof ValidationException) {
        toast.error(`${error.field}: ${error.message}`);
      } else if (error instanceof NotFoundException) {
        toast.error(`${error.entityType} not found`);
      } else {
        toast.error("Something went wrong");
      }
    }
  };

  return <button onClick={() => handleTrack(5)}>Track</button>;
}
```

### Exception Flow Through Layers

```
┌─────────────────────────────────────────────────────────────┐
│ CLIENT (Presentation)                                       │
│ ✅ Catch specific exception types                           │
│ ✅ Access exception metadata (field, code, entityType)      │
└─────────────────────────────────────────────────────────────┘
                            ▲
                            │ throw ValidationException
                            │ (preserved, not wrapped)
┌─────────────────────────────────────────────────────────────┐
│ DELIVERY LAYER (Server Actions)                            │
│ ✅ Preserve domain exceptions (DO NOT WRAP)                 │
│ ✅ Only wrap truly unexpected errors                        │
└─────────────────────────────────────────────────────────────┘
                            ▲
                            │ throw ValidationException
                            │ (from use case)
┌─────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER (Use Cases)                               │
│ ✅ Throw domain exceptions for business rules               │
│ ✅ Use specific exception types                             │
└─────────────────────────────────────────────────────────────┘
                            ▲
                            │ uses
┌─────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER (Entities, Value Objects, Exceptions)         │
│ ✅ Define domain exceptions                                 │
│ ✅ Encode business rules as exceptions                      │
└─────────────────────────────────────────────────────────────┘
```

### Benefits of Preserving Exception Types

1. **Type Safety** - Client code can catch specific types
2. **Rich Error Information** - Metadata preserved (field, code, entityId)
3. **Better UX** - Field-specific error messages
4. **Debugging** - Full stack traces maintained
5. **Testability** - Tests can verify specific exception types

### Lessons Learned (SonarLint PR #14)

**Files Fixed:**

- [app/\_actions/collections.ts](../../../app/_actions/collections.ts) - Preserved `ValidationException`, `DomainException`
- [app/\_actions/episodes.ts](../../../app/_actions/episodes.ts) - Preserved all domain exceptions
- [app/\_actions/diary.ts](../../../app/_actions/diary.ts) - Improved exception flow
- [app/\_actions/social.ts](../../../app/_actions/social.ts) - Unified error handling

**Impact:**

- Type-safe error handling across entire stack
- Better client-side error messages
- Zero SonarLint warnings
- Improved debugging in production

**Reference:** See [.traces/05-sonarlint-pr14-cleanup.md](../../../.traces/05-sonarlint-pr14-cleanup.md) for complete analysis.

---

## Value Objects and Entities

### Value Objects (Immutable, Identity-less)

Value Objects represent descriptive aspects of the domain with no conceptual identity.

```typescript
// core/domain/value-objects/Rating.ts
export class Rating {
  private readonly value: number;

  private constructor(value: number) {
    this.value = value;
  }

  static create(value: number): Rating {
    if (value < 1 || value > 5) {
      throw new InvalidRatingError("Rating must be between 1 and 5");
    }
    return new Rating(value);
  }

  getValue(): number {
    return this.value;
  }

  equals(other: Rating): boolean {
    return this.value === other.value;
  }

  isHighRating(): boolean {
    return this.value >= 4;
  }

  // Immutable - returns new instance
  increment(): Rating {
    return Rating.create(Math.min(this.value + 1, 5));
  }
}

// core/domain/value-objects/EmailAddress.ts
export class EmailAddress {
  private readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  static create(email: string): EmailAddress {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new InvalidEmailError(email);
    }
    return new EmailAddress(email.toLowerCase());
  }

  getValue(): string {
    return this.value;
  }

  getDomain(): string {
    return this.value.split("@")[1];
  }

  equals(other: EmailAddress): boolean {
    return this.value === other.value;
  }
}
```

### Entities (Identity-based)

Entities have a unique identity that runs through time and different representations.

```typescript
// core/domain/entities/User.ts
import { EmailAddress } from "@/core/domain/value-objects/EmailAddress";

export class User {
  private constructor(
    public readonly id: string,
    private _email: EmailAddress,
    private _name: string,
    private _isActive: boolean,
  ) {}

  static create(data: UserData): User {
    return new User(
      data.id,
      EmailAddress.create(data.email),
      data.name,
      data.isActive ?? true,
    );
  }

  updateEmail(newEmail: string): User {
    const email = EmailAddress.create(newEmail);
    return new User(this.id, email, this._name, this._isActive);
  }

  deactivate(): User {
    return new User(this.id, this._email, this._name, false);
  }

  get email(): string {
    return this._email.getValue();
  }

  get name(): string {
    return this._name;
  }

  get isActive(): boolean {
    return this._isActive;
  }

  // Entity equality is based on ID
  equals(other: User): boolean {
    return this.id === other.id;
  }
}
```

---

## Domain Services

When business logic doesn't naturally fit in an Entity or Value Object, use a Domain Service.

```typescript
// core/domain/services/EpisodeRecommendationService.ts
import { Episode } from "@/core/domain/entities/Episode";
import { User } from "@/core/domain/entities/User";

export class EpisodeRecommendationService {
  calculateRecommendationScore(
    episode: Episode,
    user: User,
    userHistory: Episode[],
  ): number {
    let score = 0;

    // Business rule: Prefer highly rated episodes
    if (episode.isHighlyRated) {
      score += 10;
    }

    // Business rule: Prefer similar seasons
    const watchedSeasons = userHistory.map((ep) => ep.season);
    if (watchedSeasons.includes(episode.season)) {
      score += 5;
    }

    // Business rule: Penalize already watched episodes
    const alreadyWatched = userHistory.some((ep) => ep.equals(episode));
    if (alreadyWatched) {
      score -= 20;
    }

    return score;
  }

  getTopRecommendations(
    availableEpisodes: Episode[],
    user: User,
    userHistory: Episode[],
    limit: number,
  ): Episode[] {
    const scored = availableEpisodes.map((episode) => ({
      episode,
      score: this.calculateRecommendationScore(episode, user, userHistory),
    }));

    return scored
      .sort((a, b) => b.score - a.score)
      .slice(0, limit)
      .map((item) => item.episode);
  }
}
```

---

## Dependency Injection in Next.js

### Manual DI (Simplest)

```typescript
// app/episodes/[id]/page.tsx
import { PrismaEpisodeRepository } from "@/infrastructure/prisma/repositories/PrismaEpisodeRepository";
import { GetEpisodeDetailsUseCase } from "@/core/application/use-cases/GetEpisodeDetailsUseCase";

export default async function EpisodePage({ params }: Props) {
  // Manual dependency injection
  const repository = new PrismaEpisodeRepository();
  const useCase = new GetEpisodeDetailsUseCase(repository);

  const episode = await useCase.execute({ id: params.id });

  return <EpisodeDetail episode={episode} />;
}
```

### Factory Pattern

```typescript
// infrastructure/factories/UseCaseFactory.ts
import { TrackEpisodeUseCase } from "@/core/application/use-cases/TrackEpisodeUseCase";
import { PrismaEpisodeRepository } from "@/infrastructure/prisma/repositories/PrismaEpisodeRepository";

export class UseCaseFactory {
  static createTrackEpisodeUseCase(): TrackEpisodeUseCase {
    const repository = new PrismaEpisodeRepository();
    return new TrackEpisodeUseCase(repository);
  }

  static createGetEpisodeDetailsUseCase(): GetEpisodeDetailsUseCase {
    const repository = new PrismaEpisodeRepository();
    return new GetEpisodeDetailsUseCase(repository);
  }
}

// Usage in Server Action
("use server");
import { UseCaseFactory } from "@/infrastructure/factories/UseCaseFactory";

export async function trackEpisode(episodeId: number, rating: number) {
  const useCase = UseCaseFactory.createTrackEpisodeUseCase();
  return useCase.execute({ episodeId, rating });
}
```

### DI Container (Advanced)

```typescript
// infrastructure/di/Container.ts
import { EpisodeRepository } from "@/core/application/ports/EpisodeRepository";
import { PrismaEpisodeRepository } from "@/infrastructure/prisma/repositories/PrismaEpisodeRepository";

class Container {
  private services = new Map<string, any>();

  register<T>(key: string, factory: () => T): void {
    this.services.set(key, factory);
  }

  resolve<T>(key: string): T {
    const factory = this.services.get(key);
    if (!factory) {
      throw new Error(`Service ${key} not registered`);
    }
    return factory();
  }
}

export const container = new Container();

// Register dependencies
container.register<EpisodeRepository>("EpisodeRepository", () => {
  return new PrismaEpisodeRepository();
});

// Usage
const repository = container.resolve<EpisodeRepository>("EpisodeRepository");
const useCase = new TrackEpisodeUseCase(repository);
```

---

## Testing Strategy by Layer

### Domain Layer Tests (Pure Unit Tests)

Domain tests are the easiest - no mocks, no database, pure logic.

```typescript
// core/domain/entities/Episode.test.ts
import { describe, it, expect } from "vitest";
import { Episode } from "./Episode";
import { InvalidRatingError } from "../exceptions/InvalidRatingError";

describe("Episode", () => {
  it("creates episode with valid data", () => {
    const episode = Episode.create({
      id: 1,
      title: "Simpsons Roasting on an Open Fire",
      season: 1,
      episodeNumber: 1,
      rating: 5,
    });

    expect(episode.title).toBe("Simpsons Roasting on an Open Fire");
    expect(episode.isHighlyRated).toBe(true);
  });

  it("throws error for invalid rating", () => {
    expect(() => {
      Episode.create({
        id: 1,
        title: "Test",
        season: 1,
        episodeNumber: 1,
        rating: 6, // Invalid!
      });
    }).toThrow(InvalidRatingError);
  });

  it("updates rating correctly", () => {
    const episode = Episode.create({
      id: 1,
      title: "Test",
      season: 1,
      episodeNumber: 1,
      rating: 3,
    });

    const updated = episode.updateRating(5);

    expect(updated.rating).toBe(5);
    expect(episode.rating).toBe(3); // Original unchanged (immutability)
  });
});
```

### Application Layer Tests (Use Cases with Mocks)

```typescript
// core/application/use-cases/TrackEpisodeUseCase.test.ts
import { describe, it, expect, vi } from "vitest";
import { TrackEpisodeUseCase } from "./TrackEpisodeUseCase";
import { EpisodeRepository } from "../ports/EpisodeRepository";
import { Episode } from "@/core/domain/entities/Episode";

describe("TrackEpisodeUseCase", () => {
  it("updates episode rating successfully", async () => {
    // Mock repository
    const mockRepository: EpisodeRepository = {
      findById: vi.fn().mockResolvedValue(
        Episode.create({
          id: 1,
          title: "Test",
          season: 1,
          episodeNumber: 1,
          rating: 3,
        }),
      ),
      save: vi.fn().mockResolvedValue(undefined),
      findTrending: vi.fn(),
    };

    const useCase = new TrackEpisodeUseCase(mockRepository);

    // Execute
    const result = await useCase.execute({
      episodeId: 1,
      rating: 5,
    });

    // Verify
    expect(result.newRating).toBe(5);
    expect(mockRepository.findById).toHaveBeenCalledWith(1);
    expect(mockRepository.save).toHaveBeenCalled();
  });

  it("throws error when episode not found", async () => {
    const mockRepository: EpisodeRepository = {
      findById: vi.fn().mockResolvedValue(null),
      save: vi.fn(),
      findTrending: vi.fn(),
    };

    const useCase = new TrackEpisodeUseCase(mockRepository);

    await expect(
      useCase.execute({ episodeId: 999, rating: 5 }),
    ).rejects.toThrow("Episode not found");
  });
});
```

### Infrastructure Layer Tests (Integration Tests)

```typescript
// infrastructure/prisma/repositories/PrismaEpisodeRepository.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { PrismaEpisodeRepository } from "./PrismaEpisodeRepository";
import { Episode } from "@/core/domain/entities/Episode";
import { prisma } from "@/app/_lib/prisma";

describe("PrismaEpisodeRepository", () => {
  let repository: PrismaEpisodeRepository;

  beforeEach(async () => {
    repository = new PrismaEpisodeRepository();
    // Clean database
    await prisma.episode.deleteMany();
  });

  it("saves and retrieves episode", async () => {
    // Create test data
    await prisma.episode.create({
      data: {
        id: 1,
        title: "Test Episode",
        season: 1,
        episode_number: 1,
        rating: 4,
      },
    });

    const episode = await repository.findById(1);

    expect(episode).not.toBeNull();
    expect(episode?.title).toBe("Test Episode");
    expect(episode?.rating).toBe(4);
  });

  it("returns null when episode not found", async () => {
    const episode = await repository.findById(999);
    expect(episode).toBeNull();
  });

  it("updates episode correctly", async () => {
    // Seed
    await prisma.episode.create({
      data: {
        id: 1,
        title: "Test",
        season: 1,
        episode_number: 1,
        rating: 3,
      },
    });

    // Update via repository
    const episode = Episode.create({
      id: 1,
      title: "Test",
      season: 1,
      episodeNumber: 1,
      rating: 5,
    });
    await repository.save(episode);

    // Verify
    const updated = await prisma.episode.findUnique({ where: { id: 1 } });
    expect(updated?.rating).toBe(5);
  });
});
```

---

## Migration Guide from Current Structure

### Step 1: Identify Domain Entities

**Current structure:**

```typescript
// app/_lib/repositories.ts
export async function findCharacterById(id: number) {
  return prisma.character.findUnique({ where: { id } });
}
```

**Target structure:**

```typescript
// 1. Create domain entity
// core/domain/entities/Character.ts
export class Character {
  private constructor(
    public readonly id: number,
    public readonly name: string,
    private _followersCount: number,
  ) {}

  static create(data: CharacterData): Character {
    return new Character(data.id, data.name, data.followersCount ?? 0);
  }

  incrementFollowers(): Character {
    return new Character(this.id, this.name, this._followersCount + 1);
  }

  get followersCount(): number {
    return this._followersCount;
  }
}

// 2. Define repository interface
// core/application/ports/CharacterRepository.ts
export interface CharacterRepository {
  findById(id: number): Promise<Character | null>;
  save(character: Character): Promise<void>;
}

// 3. Implement repository adapter
// infrastructure/prisma/repositories/PrismaCharacterRepository.ts
export class PrismaCharacterRepository implements CharacterRepository {
  async findById(id: number): Promise<Character | null> {
    const record = await prisma.character.findUnique({ where: { id } });
    return record ? CharacterMapper.toDomain(record) : null;
  }

  async save(character: Character): Promise<void> {
    await prisma.character.update({
      where: { id: character.id },
      data: { followers_count: character.followersCount },
    });
  }
}
```

### Step 2: Extract Use Cases from Server Actions

**Current structure:**

```typescript
// app/_actions/social.ts
"use server";
export async function followCharacter(characterId: number, userId: string) {
  const user = await getCurrentUser();
  if (!user) throw new Error("Not authenticated");

  await prisma.characterFollow.create({
    data: { userId: user.id, characterId },
  });

  revalidatePath(`/characters/${characterId}`);
}
```

**Target structure:**

```typescript
// 1. Create use case
// core/application/use-cases/FollowCharacterUseCase.ts
export class FollowCharacterUseCase {
  constructor(
    private characterRepository: CharacterRepository,
    private followRepository: FollowRepository,
  ) {}

  async execute(input: FollowCharacterInput): Promise<void> {
    // Business logic
    const character = await this.characterRepository.findById(
      input.characterId,
    );
    if (!character) {
      throw new CharacterNotFoundError(input.characterId);
    }

    const updatedCharacter = character.incrementFollowers();

    await this.followRepository.create(input.userId, input.characterId);
    await this.characterRepository.save(updatedCharacter);
  }
}

// 2. Server Action becomes thin controller
// app/_actions/social.ts
("use server");
export async function followCharacter(characterId: number) {
  const user = await getCurrentUser();
  if (!user) throw new Error("Not authenticated");

  const useCase = UseCaseFactory.createFollowCharacterUseCase();
  await useCase.execute({ characterId, userId: user.id });

  revalidatePath(`/characters/${characterId}`);
}
```

### Step 3: Progressive Migration Strategy

1. **Week 1-2:** Create core domain entities
   - Extract business rules from scattered code
   - Create entity classes with validation
   - Write domain tests

2. **Week 3-4:** Define application layer
   - Create use cases for critical flows
   - Define repository interfaces
   - Test use cas

…(truncated)
