floriaaan
architecturenextjstypescriptddd

Clean Architecture in a Next.js monolith

1

Clean Architecture is often associated with large-scale systems, microservices, and heavy frameworks. But the underlying ideas like separating concerns, pointing dependencies inward, making the domain the centre of everything, are just as valuable in a monolith. Especially a Next.js one.

The core idea

Clean Architecture describes a series of concentric rings:

  • Domain — your business rules; no framework, no I/O.
  • Application — use cases that orchestrate domain objects.
  • Infrastructure — the glue: DB adapters, file readers, HTTP clients.
  • Presentation — React components, server actions, route handlers.

The single inviolable rule: source code dependencies point inward. Infrastructure knows about domain; domain knows nothing about infrastructure.

Why bother in Next.js?

Next.js collapses the stack. A single file can read from the database, render HTML, and return JSON. That's its superpower for prototyping — and its trap for anything you plan to maintain.

Without boundaries:

// app/projects/page.tsx — the "just put it here" trap
import { db } from "@/lib/db";
 
export default async function ProjectsPage() {
  const rows = await db.select().from(projects); // infra leak into UI
  return <ProjectList items={rows} />;
}

With one implicit Drizzle query in a Server Component, you've coupled your rendering layer to your persistence technology. Swapping Postgres for an API, or testing the project listing logic, now requires spinning up a database.

The structure I use

src/
  domain/projects/    ← Project entity, Slug VO, ProjectRepository port
  application/        ← ListProjectsUseCase (uses the port, not the impl)
  infrastructure/     ← DrizzleProjectRepository (implements the port)
  composition-root.ts ← wires everything, only file that knows all layers
  app/                ← Next.js; imports only from composition-root

The composition-root.ts is the seam. It's the only file that breaks the layering — by design. It wires concrete adapters to use cases and exposes them. Every Server Component and Server Action imports from there.

Value objects enforce invariants

A slug that's invalid simply cannot be instantiated:

export class ProjectSlug {
  private constructor(readonly value: string) {}
 
  static create(raw: string): Result<ProjectSlug> {
    const normalized = raw.toLowerCase().trim().replace(/\s+/g, "-");
    if (!normalized.length) return err(new DomainError("INVALID_SLUG", "…"));
    return ok(new ProjectSlug(normalized));
  }
}

The private constructor pattern guarantees that any ProjectSlug in memory passed through validation. You can't accidentally hand raw strings to code that assumes a slug is clean.

Testing becomes trivial

Use cases depend on interfaces. Tests provide fakes:

class InMemoryProjectRepository implements ProjectRepository {
  constructor(private items: Project[] = []) {}
  async findAll() { return ok(this.items); }
  async findFeatured() { return ok(this.items.filter(p => p.featured)); }
  async findBySlug(slug: ProjectSlug) {
    const found = this.items.find(p => p.slug.equals(slug));
    return found ? ok(found) : err(new ProjectNotFoundError(slug.value));
  }
}
 
it("lists only featured projects", async () => {
  const repo = new InMemoryProjectRepository([featured, notFeatured]);
  const useCase = new ListProjectsUseCase(repo);
  const result = await useCase.execute({ featuredOnly: true });
  expect(result.value).toHaveLength(1);
});

Zero database. Hundreds of these run in under a second.

The pragmatic line

Not everything needs the full ceremony. Blog posts read from Markdown files are unlikely to switch to a CMS tomorrow — but the PostRepository interface still makes sense: it lets tests use a fake, and it keeps fs out of application logic.

On the other hand, I don't have aggregates or domain events. The project is too small to warrant them. The goal is clarity, not pattern count.

Conclusion

Clean Architecture in Next.js isn't about adding layers for the sake of it. It's about drawing one clear line: this code knows the business rules; that code knows the database. Once you draw that line, the framework becomes an implementation detail — easy to test around, easy to extend.