All posts
ArchitectureMay 24, 2026·8 min read

Clean Architecture in Node.js

How to structure a Node.js app so that your business logic doesn't depend on Express, Prisma, or any specific framework. Domain layer, use cases, ports and adapters — with TypeScript examples.

The problem with framework-coupled code

When your business logic is woven through Express routes and Prisma calls, testing is hard (you need the whole stack running), migrating to a new ORM or framework is a rewrite, and understanding what the app actually does requires reading through HTTP plumbing.

The dependency rule

Source code dependencies point inward. The innermost layer (domain) knows nothing about Express, Prisma, or any I/O. The outer layers depend on the inner ones, never the reverse.

Layer 1 (innermost): Domain — entities, business rules, no imports
Layer 2: Use Cases — application-specific business logic
Layer 3: Interface Adapters — controllers, presenters, gateways
Layer 4 (outermost): Frameworks & Drivers — Express, Prisma, Redis

The domain layer

Pure TypeScript. No database, no HTTP, no logging. Just types and functions.

// domain/user.ts
export type User = {
  id: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
};

export function canCreateProject(user: User): boolean {
  return user.role === 'admin' || Date.now() - user.createdAt.getTime() > 86400000;
}

Use cases

Orchestrate domain objects and call external systems through interfaces (ports).

// use-cases/create-project.ts
export interface UserRepository {
  findById(id: string): Promise<User | null>;
}
export interface ProjectRepository {
  create(data: CreateProjectData): Promise<Project>;
}

export async function createProject(
  userId: string,
  data: CreateProjectData,
  userRepo: UserRepository,
  projectRepo: ProjectRepository,
): Promise<Project> {
  const user = await userRepo.findById(userId);
  if (!user) throw new Error('User not found');
  if (!canCreateProject(user)) throw new Error('Not authorized');
  return projectRepo.create(data);
}

Adapters — Prisma implementation

// adapters/prisma-user-repo.ts
export class PrismaUserRepository implements UserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: string): Promise<User | null> {
    return this.prisma.user.findUnique({ where: { id } });
  }
}

The controller

The controller's only job: translate HTTP to use case call, translate result to HTTP response.

// adapters/create-project-controller.ts
app.post('/projects', async (req, res) => {
  try {
    const project = await createProject(
      req.user.id,
      req.body,
      new PrismaUserRepository(prisma),
      new PrismaProjectRepository(prisma),
    );
    res.status(201).json(project);
  } catch (err) {
    if (err.message === 'Not authorized') return res.status(403).json({ error: err.message });
    res.status(500).json({ error: 'Internal error' });
  }
});

The payoff

Unit tests for use cases require zero mocking of HTTP or databases — just pass in fake repository implementations. Swapping Prisma for a different ORM only requires rewriting adapters. The business logic is completely readable without understanding any framework details.

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.