AverageDevs
ArchitectureNext.js

Practical Guide to Implementing Clean Architecture in Full-Stack Projects

A hands-on blueprint for applying Clean Architecture in modern TypeScript stacks: domains, use cases, adapters, repositories, testing, and Next.js integration with diagrams and code.

Practical Guide to Implementing Clean Architecture in Full-Stack Projects

Clean Architecture is not about ivory tower diagrams. It is a discipline to keep your business logic independent from frameworks and IO so that features stay flexible and tests are boring. In practice, that means you separate core rules (domain and use cases) from details (database, HTTP, UI), invert dependencies at boundaries, and wire things together at the edge.

We will build a pragmatic structure in TypeScript that works with Next.js App Router and a Node backend. Expect ASCII diagrams, small but complete code snippets, and guidance that has survived refactors. For complementary topics, see Integrate OpenAI API in Next.js, deployment knobs in Deploy Next.js on a VPS, and content workflows in CMS with AI.

The big picture

                  ┌────────────────────────────────────────────────┐
                  │                    UI                          │
                  │  Next.js pages, routes, server actions         │
                  └──────────────────────┬─────────────────────────┘
                                         │ calls
                                 ┌───────▼───────┐
                                 │  Application  │
                                 │  Use Cases    │
                                 └───────┬───────┘
                                         │ depends-on interfaces only
                          ┌──────────────▼──────────────┐
                          │           Domain             │
                          │  Entities, Value Objects     │
                          └──────────────┬──────────────┘
                                         │ ports (interfaces)
            ┌────────────────────────────▼────────────────────────────┐
            │                   Infrastructure                         │
            │  DB adapters, HTTP clients, queues, file storage         │
            └─────────────────────────────────────────────────────────┘

Rules:

  • Domain and use cases do not import framework packages.
  • Use cases depend on ports (interfaces). Adapters implement those ports.
  • Composition happens at the edge: API routes, server actions, or boot files.

Project layout that scales

Use vertical slices to avoid sprawling shared folders. Each feature has its domain, use cases, and adapters.

src/
  features/
    accounts/
      domain/           # entities, value objects
      application/      # use cases, ports
      infrastructure/   # db adapters, external gateways
      interface/        # API routes, RSC actions, UI hooks
  core/                 # cross-cutting: logging, config, result

This shape reduces cross feature coupling and encourages small boundaries.

Domain: entities and value objects

Keep it boring and explicit. Do not import database or HTTP here.

// src/features/accounts/domain/User.ts
export type UserId = string;

export type User = {
  id: UserId;
  email: string;
  name: string;
  createdAt: Date;
};

export const createUser = (params: { id: UserId; email: string; name: string; now: Date }): User => {
  if (!/^[^@]+@[^@]+\.[^@]+$/.test(params.email)) throw new Error("Invalid email");
  return { id: params.id, email: params.email, name: params.name, createdAt: params.now };
};

Application: use cases and ports

Use cases encode workflows and depend on ports, not concrete adapters.

// src/features/accounts/application/ports.ts
import type { User, UserId } from "../domain/User";

export interface UserRepoPort {
  findByEmail(email: string): Promise<User | null>;
  findById(id: UserId): Promise<User | null>;
  save(user: User): Promise<void>;
}

export interface IdGeneratorPort { nextId(): string }
export interface ClockPort { now(): Date }
// src/features/accounts/application/registerUser.ts
import { createUser } from "../domain/User";
import type { UserRepoPort, IdGeneratorPort, ClockPort } from "./ports";

export type RegisterUserInput = { email: string; name: string };
export type RegisterUserOutput = { id: string };

export const registerUser = ({ repo, ids, clock }: { repo: UserRepoPort; ids: IdGeneratorPort; clock: ClockPort }) =>
  async (input: RegisterUserInput): Promise<RegisterUserOutput> => {
    const existing = await repo.findByEmail(input.email);
    if (existing) throw new Error("Email already registered");
    const user = createUser({ id: ids.nextId(), email: input.email, name: input.name, now: clock.now() });
    await repo.save(user);
    return { id: user.id };
  };

Infrastructure: adapters implement ports

Adapters translate from your ports to real IO. They can use Prisma, fetch, Redis, anything. The use case never sees those details.

// src/features/accounts/infrastructure/prismaUserRepo.ts
import { prisma } from "@/core/prisma"; // your prisma client
import type { UserRepoPort } from "../application/ports";
import type { User, UserId } from "../domain/User";

export const prismaUserRepo = (): UserRepoPort => ({
  async findByEmail(email) {
    const row = await prisma.user.findUnique({ where: { email } });
    return row ? { id: row.id as UserId, email: row.email, name: row.name, createdAt: row.createdAt } : null;
  },
  async findById(id) {
    const row = await prisma.user.findUnique({ where: { id } });
    return row ? { id: row.id as UserId, email: row.email, name: row.name, createdAt: row.createdAt } : null;
  },
  async save(user: User) {
    await prisma.user.upsert({
      where: { id: user.id },
      update: { email: user.email, name: user.name },
      create: { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
    });
  },
});

Utility adapters are trivial:

// src/core/adapters.ts
export const uuidIds = (): { nextId: () => string } => ({ nextId: () => crypto.randomUUID() });
export const systemClock = (): { now: () => Date } => ({ now: () => new Date() });

Interface: wire in Next.js API route or server action

Composition lives at the edge. You assemble the use case with concrete adapters and expose it.

// app/api/accounts/register/route.ts
import { NextRequest, NextResponse } from "next/server";
import { registerUser } from "@/features/accounts/application/registerUser";
import { prismaUserRepo } from "@/features/accounts/infrastructure/prismaUserRepo";
import { uuidIds, systemClock } from "@/core/adapters";
import { z } from "zod";

const Schema = z.object({ email: z.string().email(), name: z.string().min(2) });

export const POST = async (req: NextRequest) => {
  const input = Schema.parse(await req.json());
  const exec = registerUser({ repo: prismaUserRepo(), ids: uuidIds(), clock: systemClock() });
  const out = await exec(input);
  return NextResponse.json(out, { status: 201 });
};

This route is thin. The use case is testable without HTTP or Prisma.

Testing strategy that sticks

Test the use case with fake ports. Test adapters with integration tests. Test wiring with a small e2e.

// src/features/accounts/application/registerUser.test.ts
import { registerUser } from "./registerUser";

const fakeRepo = () => {
  let data: any[] = [];
  return {
    findByEmail: async (email: string) => data.find((u) => u.email === email) ?? null,
    findById: async (id: string) => data.find((u) => u.id === id) ?? null,
    save: async (u: any) => void (data.push(u)),
    _data: () => data,
  };
};

test("registers a new user", async () => {
  const repo = fakeRepo();
  const exec = registerUser({ repo, ids: { nextId: () => "1" }, clock: { now: () => new Date(0) } });
  const out = await exec({ email: "a@b.com", name: "Ada" });
  expect(out.id).toBe("1");
  expect(repo._data()).toHaveLength(1);
});

You can test domain rules and error branches quickly without spinning databases.

Error handling and results

Avoid throwing across boundaries. Model results explicitly when flows are complex.

// src/core/result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });

Use it in use cases when you want predictable branching.

Dependency injection without a container

Prefer simple factories over heavyweight containers. Composition roots per route keep scope clear and avoid global singletons.

// src/features/accounts/interface/composition.ts
import { registerUser } from "../application/registerUser";
import { prismaUserRepo } from "../infrastructure/prismaUserRepo";
import { uuidIds, systemClock } from "@/core/adapters";

export const makeRegisterUser = () => registerUser({ repo: prismaUserRepo(), ids: uuidIds(), clock: systemClock() });

Observability and policies

  • Add logging at the composition edge, not inside domain logic.
  • Enforce auth and policies at the interface layer before calling use cases.
  • Propagate request IDs and correlate logs per request.

Working with Server Components

Keep business logic outside client components. Server Components call use cases and return data. Client components focus on interaction and minimal local state. For performance posture that pairs well, read Optimizing Core Web Vitals for Next.js.

Evolving schema and adapters

When database schema changes, the adapter updates first. Ports and use cases change only if the business contract changes. This reduces blast radius. For large refactors, roll adapters side by side and shift traffic gradually.

Common pitfalls

  • Anemic domain: pushing all logic to use cases or controllers. Keep invariants close to entities.
  • Shared utils creep: central folders that become dependency magnets. Prefer feature local code.
  • Over abstracting: adding interfaces without a second implementation or need.
  • Hiding IO inside the domain. Keep IO in adapters.

Rollout plan

  • Start with one feature and carve domain, use case, and adapter layers.
  • Write tests on the use case first to lock behavior.
  • Move route logic into composition and keep routes thin.
  • Measure build and runtime impact. Keep layers small and files short.

Conclusion

Clean Architecture lets you change systems without fear: swap databases, add queues, or expose new transport layers with minimal churn. Keep domain and use cases free of frameworks, depend on ports, implement adapters at the edge, and compose in routes. The result is code that onboards faster, breaks less, and survives product evolution.

Where to go next: