Arnaud Renaud

Clean Architecture in Practice with TypeScript, Prisma, Next.js

TL;DR

You can review and try the code produced in this article:

https://github.com/arnaudrenaud/clean-architecture-typescript-prisma-nextjs

If you want to review it step by step, see commit history.

What is the purpose of Clean Architecture?

The Clean Architecture software design pattern, as defined by Robert C. Martin in this article, is one of a handful of patterns that propose a way to separate concerns in an application codebase.

Achieving a proper separation of concerns will make your code more readable, testable, adjustable: have you ever tried to make a clean cut in a plate of spaghetti? This is why, software-wise, one should prefer lasagna.

What does it involve in practice? Just like lasagna, it means having your code spread over clearly distinct layers.

Terms and concepts

Layer names and exact boundaries may vary: I suggest using the following, based on this article by ndepend:

  • Domain: domain-specific entities with their attributes, relations, constants: it does not rely on any infrastructure, and only defines domain logic
  • Commands (or use cases): implementation of functionalities: it often relies on infrastructure (a database, a third-party service) but only through an abstract interface
  • Infrastructure: implementation of the interfaces relied on by commands: it uses external dependencies (an ORM, a third-party SDK)
  • User interface: user interface code triggered by the user (HTTP routes and controllers, CLI commands): it is the glue between the user and the command

Clean architecture diagram

In addition to this definition of layers, Clean Architecture uses dependency inversion to avoid polluting application logic with infrastructure or user interface.

Practical example

Specification

Let’s write an application to manage reservations.

Each Reservation has the following attributes:

  • identifier
  • start date
  • end date

A valid reservation must respect the following condition:

  • start date must be anterior to end date

We’ll write a single command: MakeReservation, which takes the following parameters:

  • start date
  • end date

and has the following behavior:

  • if reservation is invalid, throw exception
  • if reservation overlaps existing reservation, throw exception
  • else, save reservation

Reservations must be saved in a relational database. The command must be callable through HTTP (POST /api/reservations).

Implementation

The codebase will be built upon a blank Next.js TypeScript boilerplate (npx create-next-app@latest).

We will proceed from the inside out:

  1. the domain
  2. the command
  3. a repository implementation with a Prisma-managed SQLite database
  4. a user interface with a Next.js HTTP API route

Domain

The only domain entity we need to deal with is the Reservation:

// src/domain/Reservation.ts

export type Reservation = {
  id: string;
  startDate: Date;
  endDate: Date;
};

export enum ReservationExceptions {
  END_DATE_MUST_BE_AFTER_START_DATE = "END_DATE_MUST_BE_AFTER_START_DATE",
  DATES_MUST_NOT_OVERLAP_EXISTING_RESERVATION = "DATES_MUST_NOT_OVERLAP_EXISTING_RESERVATION",
}

export function validateReservation(startDate: Date, endDate: Date): void {
  if (endDate <= startDate) {
    throw new Error(ReservationExceptions.END_DATE_MUST_BE_AFTER_START_DATE);
  }
}

This domain file contains first and foremost entity properties. The only logic it contains must not rely on any infrastructure.

Commands

We have only one command to implement (MakeReservation) but a real-world application would have plenty – hence it is good practice to write each command in its own file:

// src/commands/MakeReservation.ts

import { validateReservation } from "@/domain/Reservation";

export const makeReservation = async (startDate: Date, endDate: Date) => {
  validateReservation(startDate, endDate);

  // if it overlaps existing reservation, throw exception
  // else, save reservation
};

The work is only half done here: how do we replace the commented part by the actual implementation? We would need access to a reservation repository, for which we will use Prisma.

Now, even without knowing which repository implementation we will eventually use, we can keep writing the command with methods from an abstract repository interface: that is dependency inversion.

We need two methods, one to find any overlapping reservations (let’s call it findOverlappingReservations), the other to save the new reservation (saveReservation).

// src/infrastructure/ReservationRepository/ReservationRepositoryInterface.ts

import { Reservation } from "@/domain/Reservation";

export interface ReservationRepositoryInterface {
  findOverlappingReservations(
    startDate: Date,
    endDate: Date
  ): Promise<Reservation[]>;
  saveReservation(startDate: Date, endDate: Date): Promise<Reservation>;
}

Now we can replace the comments in MakeReservation.ts by the actual logic:

// src/commands/MakeReservation.ts

import {
  ReservationExceptions,
  validateReservation,
} from "@/domain/Reservation";
import { ReservationRepositoryInterface } from "@/infrastructure/ReservationRepository/ReservationRepositoryInterface";

export const MakeReservation =
  (reservationRepository: ReservationRepositoryInterface) =>
  async (startDate: Date, endDate: Date) => {
    validateReservation(startDate, endDate);

    if (
      (
        await reservationRepository.findOverlappingReservations(
          startDate,
          endDate
        )
      ).length
    ) {
      throw new Error(
        ReservationExceptions.DATES_MUST_NOT_OVERLAP_EXISTING_RESERVATION
      );
    }

    return reservationRepository.saveReservation(startDate, endDate);
  };

Of course, we will need to pass a concrete implementation reservationRepository to the MakeReservation wrapper function before running the command. This is the point of dependency inversion: without knowing the underlying implementation, we can still write and test application code.

Infrastructure

Let’s use Prisma to implement a reservation repository on an SQLite database.

First, set up Prisma:

npm install prisma --save-dev
npx prisma init --datasource-provider sqlite

Declare a model for the Reservation entity:

// prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model Reservation {
  id String @id @default(cuid())

  startDate DateTime
  endDate   DateTime
}

Create and apply a database migration and generate the TypeScript client:

npx prisma migrate dev --name init

Provide a Prisma client that will be used by the repository implementation (as per Prisma documentation):

// src/infrastructure/clients/prisma.ts

import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient();
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

Finally, implement the reservation repository using Prisma:

// src/infrastructure/ReservationRepository/PrismaReservationRepository.ts

import { Reservation } from "@/domain/Reservation";
import prisma from "@/infrastructure/clients/prisma";
import { ReservationRepositoryInterface } from "@/infrastructure/ReservationRepository/ReservationRepositoryInterface";

export class PrismaReservationRepository
  implements ReservationRepositoryInterface
{
  findOverlappingReservations(
    newStartDate: Date,
    newEndDate: Date
  ): Promise<Reservation[]> {
    return prisma.reservation.findMany({
      where: {
        OR: [
          { startDate: { gte: newStartDate, lte: newEndDate } },
          { endDate: { gte: newStartDate, lte: newEndDate } },
          { startDate: { lte: newStartDate }, endDate: { gte: newEndDate } },
        ],
      },
    });
  }

  saveReservation(startDate: Date, endDate: Date): Promise<Reservation> {
    return prisma.reservation.create({ data: { startDate, endDate } });
  }
}

Moving Prisma-specific code to this class helps us keep our command leaner with nothing more than application logic.

User interface

Let’s write a Next.js API route to make the command callable via HTTP POST /reservations.

We’ll use Zod to parse and validate the request body:

npm install zod

Here we must conform to Next.js directory naming convention and use src/app/api/reservations/route.ts for our file:

// src/app/api/reservations/route.ts

import { z } from "zod";
import { PrismaReservationRepository } from "@/infrastructure/ReservationRepository/PrismaReservationRepository";
import { MakeReservation } from "@/commands/MakeReservation";

const makeReservation = MakeReservation(new PrismaReservationRepository());

const MakeReservationArguments = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
});

export const POST = async (request: Request) => {
  const body = MakeReservationArguments.safeParse(await request.json());

  if (!body.success) {
    return Response.json(
      {
        errorMessage: body.error,
      },
      { status: 400 }
    );
  }

  try {
    return Response.json({
      reservation: await makeReservation(
        new Date(body.data.startDate),
        new Date(body.data.endDate)
      ),
    });
  } catch (error) {
    return Response.json(
      {
        errorMessage: (error as Error).message,
      },
      { status: 400 }
    );
  }
};

Take a closer look at this line, where all the pieces finally come together:

const makeReservation = MakeReservation(new PrismaReservationRepository());

A Prisma reservation repository is instantiated and provided to the command.

Resulting codebase

You can review and try the full codebase here: https://github.com/arnaudrenaud/clean-architecture-typescript-prisma-nextjs.

Final thoughts

With its strong emphasis on separation of concerns and dependency inversion, Clean Architecture leaves you no choice but to truly set your application logic aside from any infrastructure or interface code. This brings multiple advantages:

  • easier to swap implementations as they are explicitly defined and isolated
  • easier to read complex business rules as they remain pure logic
  • easier to write tests (either with mock implementations or actual database repositories for integration testing)
  • the realization that you do not need any framework to start implementing your application logic

On the other hand, Clean Architecture requires you to ask yourself where any particular piece of code belongs, but that is a sane discipline to keep your codebase scalable.