Repository Pattern in Node.js with TypeScript and Prisma

Clean Architecture in Node.js: Implementing the Repository Pattern with TypeScript and Prisma

Do you want to build a clean, scalable, and maintainable backend architecture for your Node.js projects? In this post, we’ll explore how to implement the Repository Pattern using TypeScript and Prisma ORM — a powerful approach that helps you write modular, testable, and flexible backend code.

By the end, you’ll understand how the repository pattern decouples data access logic from your business logic, why it’s critical for large projects, and how to integrate it seamlessly into your Express.js applications.


Why Use the Repository Pattern?

In many Node.js projects, controllers or services directly access the database through an ORM like Prisma. This might seem simple at first, but as your codebase grows, this direct dependency introduces several issues:

  • Tight coupling between controllers and the ORM
  • Reduced testability, since mocking database calls becomes harder
  • Scalability challenges, as database logic spreads across multiple layers

The Repository Pattern solves these problems by introducing an abstraction layer between your application logic and the database.


🚀 Complete JavaScript Guide (Beginner + Advanced)

🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)


The Core Idea

At the heart of the Repository Pattern lies an interface — a contract that defines what operations can be performed on a given entity, such as Tasks or Projects.

Here’s how this works conceptually:

  • Your controllers no longer depend on Prisma directly.
  • Instead, they depend on repository interfaces that describe available methods (listTasks, getTask, createTask, etc.).
  • These interfaces are implemented by concrete repository classes — in this case, a PrismaRepository.
  • If you ever switch to another database (e.g., Sequelize, Mongoose, or raw SQL), you just create a new implementation that fulfills the same interface — with no changes needed in your controllers.

This design makes your backend flexible and future-proof.


Step 1: Define Repository Interfaces

Let’s start by defining the interfaces that describe your domain entities and repository contracts.

Create a new file:

src/data/repositories/repository.ts

Here, define the following:

Entity Interfaces

These represent the structure of your database models.

export interface ITask {
  id: string;
  user_id: string;
  project_id?: string | null;
  name: string;
  description?: string | null;
  due_date?: Date | null;
  completed_on?: Date | null;
  created_at: Date;
}

export interface IProject {
  id: string;
  user_id: string;
  name: string;
  description?: string | null;
  created_at: Date;
}

Query Parameter Interfaces

Used for pagination and filtering.

export interface IQueryParameters {
  limit?: number;
  offset?: number;
}

export interface ITaskQueryParameters extends IQueryParameters {
  projectId?: string;
}

export interface IProjectQueryParameters extends IQueryParameters {}

Repository Interfaces

Define how data can be accessed or modified.

export interface ITaskRepository {
  listTasks(query: ITaskQueryParameters, userId?: string): Promise<ITask[]>;
  getTask(id: string, userId?: string): Promise<ITask>;
  createTask(payload: Partial<ITask>, userId?: string): Promise<ITask>;
  updateTask(id: string, payload: Partial<ITask>, userId?: string): Promise<ITask>;
}

export interface IProjectRepository {
  listProjects(query: IProjectQueryParameters, userId?: string): Promise<IProject[]>;
  getProject(id: string, userId?: string): Promise<IProject>;
}

These contracts ensure consistency and enforce a predictable API across your repositories.



Step 2: Create a Base Repository

The BaseRepository provides common functionality like pagination defaults and Prisma client access.

Create src/data/repositories/BaseRepository.ts:

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

export default class BaseRepository {
  protected prisma: PrismaClient;
  protected defaultLimit = 50;
  protected defaultOffset = 0;

  constructor() {
    this.prisma = new PrismaClient();
  }

  protected getClient() {
    return this.prisma;
  }
}

export type Constructor<T = {}> = new (...args: any[]) => T;

This base class will be extended using TypeScript mixins to implement entity-specific repositories.


Step 3: Implement Task Repository

Now let’s implement a Task Repository as a mixin:

Create src/data/repositories/AddTaskRepository.ts:

import { Prisma } from "@prisma/client";
import { EntityNotFoundError } from "@/errors/EntityNotFound";
import BaseRepository, { Constructor } from "./BaseRepository";
import { ITask, ITaskQueryParameters, ITaskRepository } from "./repository";

export function AddTaskRepository<TBase extends Constructor<BaseRepository>>(Base: TBase) {
  return class TaskRepositoryMixin extends Base implements ITaskRepository {
    private mapTask(task: Prisma.Task): ITask {
      return { ...task };
    }

    async listTasks(query: ITaskQueryParameters, userId?: string): Promise<ITask[]> {
      const tasks = await this.prisma.task.findMany({
        where: { user_id: userId, project_id: query.projectId },
        take: query.limit ?? this.defaultLimit,
        skip: query.offset ?? this.defaultOffset,
      });
      return tasks.map(this.mapTask);
    }

    async getTask(id: string, userId?: string): Promise<ITask> {
      const task = await this.prisma.task.findFirst({ where: { id, user_id: userId } });
      if (!task) throw new EntityNotFoundError("Task not found");
      return this.mapTask(task);
    }

    async createTask(payload: Partial<ITask>, userId?: string): Promise<ITask> {
      const task = await this.prisma.task.create({ data: { ...payload, user_id: userId! } });
      return this.mapTask(task);
    }

    async updateTask(id: string, payload: Partial<ITask>, userId?: string): Promise<ITask> {
      const task = await this.prisma.task.update({
        where: { id },
        data: payload,
      });
      return this.mapTask(task);
    }
  };
}

Step 4: Implement Project Repository

Create src/data/repositories/AddProjectRepository.ts:

import { Prisma } from "@prisma/client";
import { EntityNotFoundError } from "@/errors/EntityNotFound";
import BaseRepository, { Constructor } from "./BaseRepository";
import { IProject, IProjectQueryParameters, IProjectRepository } from "./repository";

export function AddProjectRepository<TBase extends Constructor<BaseRepository>>(Base: TBase) {
  return class ProjectRepositoryMixin extends Base implements IProjectRepository {
    private mapProject(project: Prisma.Project): IProject {
      return { ...project };
    }

    async listProjects(query: IProjectQueryParameters, userId?: string): Promise<IProject[]> {
      const projects = await this.prisma.project.findMany({
        where: { user_id: userId },
        take: query.limit ?? this.defaultLimit,
        skip: query.offset ?? this.defaultOffset,
      });
      return projects.map(this.mapProject);
    }

    async getProject(id: string, userId?: string): Promise<IProject> {
      const project = await this.prisma.project.findFirst({ where: { id, user_id: userId } });
      if (!project) throw new EntityNotFoundError("Project not found");
      return this.mapProject(project);
    }
  };
}

Step 5: Combine Repositories

Finally, combine everything into a single, unified repository:

// src/data/repositories/index.ts
import BaseRepository from "./BaseRepository";
import { AddTaskRepository } from "./AddTaskRepository";
import { AddProjectRepository } from "./AddProjectRepository";

export const repository = new (AddProjectRepository(AddTaskRepository(BaseRepository)))();

Your controllers can now simply import this repository and call its methods.


Step 6: Update Controllers

Refactor your controllers to use the new repository abstraction.

Example (tasks.controller.ts):

import { Request, Response } from "express";
import { repository } from "@/data/repositories";

export async function listTasks(req: Request, res: Response) {
  const tasks = await repository.listTasks({}, req.auth.payload.sub);
  res.json(tasks);
}

export async function getTask(req: Request, res: Response) {
  const task = await repository.getTask(req.params.id, req.auth.payload.sub);
  res.json(task);
}

export async function createTask(req: Request, res: Response) {
  const task = await repository.createTask(req.body, req.auth.payload.sub);
  res.status(201).json(task);
}

You’ve now completely decoupled your business logic from the ORM layer — a key principle of Clean Architecture.


Conclusion

We’ve successfully refactored a Node.js Express project to use the Repository Pattern with TypeScript, Prisma, and mixins.

This pattern promotes separation of concerns, improves testability, and gives you the flexibility to swap out your data layer with minimal effort — critical advantages for scaling production-grade applications.

Share this article

Similar Posts