The Repository Pattern

Muyiwa OlayinkaMuyiwa Olayinka
28 min read

Motivation

In my early days of building backends, I didn’t really see the harm in tightly coupling my service layer to the database. After all, I knew my choice of database and ORM and if I was going to use one it would never change — until it did. I tried migrating a simple API to Drizzle ORM . The amount of changes involved: refactoring queries, debugging type mismatch, wasting hours on what should have been a Trivial swap.

Now imagine this in production: A monolithic API with hundreds of endpoints, suddenly needing to support a second database or ditch its ORM for raw SQL. Refactoring would be a nightmare. That’s when i discovered about the Repository Pattern — a design principle that decouples your business logic from data access, making such changes trivial. After seeing how this changed my approach to backend design, I knew I had to share this with every developer facing the same struggles.

Introduction to the Repository pattern

The Repository Pattern — popularized by Eric Evans in Domain-Driven Design — solves a universal backend problem: business logic cluttered with database calls. Instead of scattering SQL, ORM queries, or API logic across your codebase, Repositories act as a ‘single source of truth’ for data access.

Think of them like a library catalog: Your app simply asks for data (e.g., getUserByEmail), and the Repository handles the how—whether it’s querying PostgreSQL, fetching from Redis, or calling an external API. This abstraction not only cleans up your code but also makes it trivial to swap databases or ORMs later.

While Evans envisioned Repositories as part of DDD, their utility extends to any application where you want to decouple ‘what your app does’ from ‘where its data lives’.

Core Concepts of the Repository pattern

Before we delve into the Repository pattern, let’s clarify some fundamental ideas behind the Repository Pattern.

What is a Repository?

Think of a Repository as the gatekeeper or dedicated assistant for your application’s data. Its main job is to stand between your business logic and the messy details of how data is stored and retrieved. It does this by:

  • Acting like a Collection: It presents data as if it were a simple list or collection of objects (e.g., users.findById('...') or orders.save(order)), hiding the underlying complexity of database queries or API calls.

  • Hiding Complexity & Ensuring Separation of Concerns: Just like a TV remote lets you change channels without seeing the internal wiring, the Repository hides database specifics (SQL, ORM syntax, connection details, caching logic) from your core business rules, thus enforcing a critical Separation of Concerns.

  • Enabling Persistence Ignorance: Your core application objects (like our User entity) don’t need to know how or where they are stored. The Repository handles all the saving, fetching, and deleting, allowing your domain objects to focus purely on their data and behaviors.

How do we actually build one?

At a high level, implementing the pattern involves:

  • Define an interface (e.g., IUserRepository with methods like findByEmail).This is the contract your business logic will depend on.

  • Implement it: Write one or more concrete classes (e.g., TypeORMUserRepository, DrizzleORMUserRepository) that provide the actual logic to perform those operations using a specific database technology.

  • Provide the concrete Repository instance to your services or other business logic classes through Dependency Injection.

Why is it so powerful?

  1. Unmatched Flexibility: Need to swap from MySQL to PostgreSQL, or change ORMs? No panic! Since your service only depends on the interface, you just create a new implementation of the IUserRepository for the new technology and inject that instead. Your core business logic remains untouched!

  2. Effortless Testability: Want to test your service logic without hitting a real database? Easy! Create a “mock” object that implements the IUserRepository interface and feed it predictable data. This makes unit tests fast and reliable. For integration tests, it simplifies using test-specific databases.

  3. Centralized Data Access Logic: Any logic related to data retrieval or saving (like specific query patterns, caching strategies, or soft deletes) lives within the Repository. This centralizes concerns, making changes and maintenance much simpler than scattering data logic across your service layer.

Below is a diagrammatic representation of how the repository pattern works in a Typescript Node.js Backend application.

  • Router: HTTP endpoint manager

  • Controller: Traffic director. Responsible for parsing requests, delegates logic to service

  • Service: Business logic Hub. Responsible for handling the business logic

  • Repository: Data access abstraction. Responsible for communication with the database.

  • MySQL: Persistent data storage. Responsible for storing of data.

  • MongoDB: Another persistent data storage. Responsible for storing of data also.

  • Redis: An In-Memory database for caching of data.

Implementing the Repository Pattern with Typescript

Firstly, we’re setting up a simple user management API to perform CRUD operations on users. You can find the full code here.

To see the power of repository patterns come alive, I have three different setups:

  • TypeORM + MySQL here

  • DrizzleORM + PostgreSQL here

  • Mongoose + MongoDB here

The awesome takeaway? It literally doesn’t matter which database or ORM you fancy! our business logic will remain blissfully unaware of these nitty-gritty details, all thanks to the Repository Pattern.

Let’s take a look at the folder structure for a TypeORM + MySQL Repository Pattern implementation.

src
|--entity           /* Our data models (like the User!) */
|--migrations       /* Database schema changes (TypeORM specific here)
|--repos            /* Where the Repository pattern lives! 🏠*/
|--user             /* User-related service logic*/
|--data-soursce.ts  /* TypeORM connection setup */
|--main.ts          /* Our application's entry point */
.env
.gitignore
package.json
tsconfig.json

Since this is a beginner-friendly blog, I would like to take you through the development process as well.

  • Database setup

  • server setup

  • Repository Pattern setup

Database Setup

Before we dive into the code, let’s get your MySQL database ready! Whether you’re a command-line warrior or prefer the comfort of a GUI (MySQL Workbench), these steps will have your database up and running in no time.

Why Create a Dedicated User?
Good question! While it’s tempting to use the default root user, creating a separate database user for your application:

  • Boosts security (no accidental "oops-I-deleted-everything" moments)

  • Makes credential management easier

  • Follows production-grade best practices

Pro Tip: Since we’re using the classic mysql package (not mysql2), we'll stick to traditional authentication. But don't worry—it's just as powerful!

Choose Your Adventure:

  • Terminal Titans: Follow the CLI steps below (my personal favorite — it’s like being a database wizard 🧙♂️).

  • GUI Enthusiasts: Jump to MySQL Workbench — I’ve included notes for you too!

Ready? Let’s get your database humming!

mysql --version /* Check if MySQL is installed */
mysql  Ver 8.0.41-0ubuntu0.24.04.1 for Linux on x86_64 ((Ubuntu))

sudo mysql /* Login with sudo privileges */

SELECT USER() /* see the current user */

CREATE DATABASE userz /* Create a Database named users */

SHOW DATABASES; /* list all database */

CREATE USER 'users'@'localhost' IDENTIFIED BY 'pa55word'; /* create a new user called "user" with a password */

GRANT ALL PRIVILEGES ON userz.* TO 'users'@'localhost'; /* grant user with all privileges on the database userz */

FLUSH PRIVILEGES /* Reloads permissions */

exit /* leave MySQL session */

mysql -u users -p /* enter mysql session with username user */

USE userz; /* select the userz database */

SELECT DATABASE() /* Check if the userz database has been selected from the previous command*/

SHOW TABLES /* Should be empty now, but shows tables later */

sudo mysql

ALTER USER 'rep_pat'@'localhost' IDENTIFIED WITH mysql_native_password BY 'pa55word';  /* Authenticate using mysql native password */

FLUSH PRIVILEGES;  /* Reloads permissions */

Using GUI

  1. Open MySQL workbench → Connect to localhost as root

  2. Navigate to “Users and Privileges” → Add a new user.

  3. Under “Schema Privileges”, grant access to userz.

Server setup

  1. Project Initialization
mkdir repository-pattern-example; cd repository-pattern-example
  • Creates a new project folder and navigates into it.
git init
  • Initializes the project with Git repository for version control
gh repo create repository-pattern --public --source=. --remote=upstream --description=" a code example of the repository pattern"
  • Creates a public GitHub repository(requires GitHub CLI to be installed on your computer) and links it to your local project
git remote origin add <your-github-repo-url>
  • Connects your local repo to GitHub
git branch -M main
  • Renames the default branch from Master to Main (modern Git convention)
  1. Folder Structure
mdkir src/entity src/migrations src/user src/repos
  • Creates a structured directory layout:
    — entity: Database model (e.g., User.ts)
    — migrations: Database schema change scripts
    — user: Feature-specific code (controllers, services, routes)
    — repos: Repository implementations
cd src; touch entity/User.ts user/user.controller.ts user/user.service.ts user/user.routes.ts
  • Creates empty TypeScript files for:
    — User.ts: your database entity/model
    — user.controller.ts: HTTP request handler
    — user.service.ts: Business logic
    — user.routes.ts: API endpoints
touch repos/user.repository.ts data-source.ts main.ts
  • Adds critical files:
    — user.repository.ts: Repository pattern implementation
    — data-source.ts: Database connection setup
    — main.ts: App entry point
cd ..; touch .env .gitignore
  • Creates configuration files:
    — .env: Stores environment variables (database credentials, etc.)
    — .gitignore: Specifies files to exclude from Git(e.g, node_modules, pnpm-lock.yaml, *logs)

3. Dependency Installation

pnpm init
  • Generates a package.json file to track dependencies (alternative to npm init/ yarn init).
tsc --init
  • Creates a tsconfig.json file with default TypeScript compiler settings.
pnpm install express typescript dotenv bcryptjs ts-node typeorm reflect-metadata mysql
  • Installs production dependencies:
    — express: Web framework
    — typeorm: ORM for database interactions
    — mysql: MySQL driver
    — bcryptjs: Password hashing package
    — dotenv: loads .env variables
    — reflect-metadata: Enables TypeORM decorators
pnpm install -D @types/express @types/node nodemon
  • Installs development dependencies:
    — Type definitions (@types/) for Node.js and Express
    — nodemon: Auto-restarts server during development

We need to add some scripts to the package.json file to make running scripts easier.

"scripts": {
   "dev": "nodemon --watch src --exec ts-node src/main.ts",
    "migration:create": "ts-node ./node_modules/typeorm/cli.js migration:create src/migrations/seedUsers",
    "migration:generate": "ts-node ./node_modules/typeorm/cli.js migration:generate src/migrations/InitialSchema -d src/data-source.ts",
    "migration:run": "ts-node ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts",
    "migration:revert": "ts-node ./node_modules/typeorm/cli.js migration:revert -d src/data-source.ts",
    "migration:show": "ts-node ./node_modules/typeorm/cli.js migration:show -d src/data-source.ts"
}

Key Scripts:

  • dev:
    — Runs the server in development mode with nodemon(auto-restarts on file changes).
    — Uses ts-node to execute TypeScript files directly (no manual compilation needed).

  • Migration Commands (TypeORM-specific):
    — migration:create: Generates an empty migration file for custom SQL.
    — migration:generate: Auto-generates migrations based on entity changes
    — migration:run: Applies pending migrations to the database
    — migration:revert: Rolls back the most recent migration.
    — migration:show: Lists all migrations and their status.

PORT=5000
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=user
DB_PASSWORD=pa55word
DB_NAME=userz

in our .env file, we need to add some environmental variables. What each variable does:

  • PORT: The port your Express server listens on (e.g., http://localhost:5000).

  • DB_*: MySQL connection details (matches these to your database setup)

{
  "compilerOptions": {
    "target": "es2016",                                  
    "experimentalDecorators": true,                   
    "emitDecoratorMetadata": true,                   
    "module": "commonjs",                                
    "esModuleInterop": true,                            
    "forceConsistentCasingInFileNames": true,            
    "strict": true,                                         
    "skipLibCheck": true
  }
}

We also need to make some changes to tsconfig.json. Critical settings:

  • expermientalDecorators + emitDecoratorMetadata
    — Required for TypeORM to use decorators like @Entity, @Column.

  • esModuleInterop
    — Allows mixing CommonJS(require) and ES Modules(import).

  • strict:
    — Enforces TypeScript’s strictest checks (catches bugs early).

Here is the main entry to our application.

src/main.ts

// Load environment variables from .env file
import * as dotenv from 'dotenv';
dotenv.config();

// Import core dependencies
import express, { Application, Request, Response } from 'express';
import { AppDataSource } from './data-source';
import userRouter from './user/user.routes';

// Initialize Express application
const app: Application = express();

// Set server port - falls back to 5000 if PORT isn't in .env
const PORT: number = +process.env.PORT! || 5000;

// Middleware to parse JSON request bodies
app.use(express.json());

// Mount user routes under /api/v1/users prefix
app.use('/api/v1/users', userRouter);

// Health check endpoint - useful for monitoring and deployments
app.get('/api/v1/healthcheck', (req: Request, res: Response) => {
  res.send({ status: 'Server is healthy' });
});

/**
 * Database Initialization & Server Startup
 * 
 * 1. First establishes connection to database using TypeORM
 * 2. Only starts Express server if database connection succeeds
 * 3. Handles connection errors gracefully
 */
AppDataSource.initialize()
  .then(() => {
    console.log('Database connected successfully');

    app.listen(PORT, () => {
      console.log(`Server running on http://localhost:${PORT}`);
      console.log(`API endpoints available at http://localhost:${PORT}/api/v1`);
    });
  })
  .catch((error) => {
    // If database connection fails, log error and exit process
    console.error('Database connection failed', error);
    process.exit(1); // Exit with error code
  });

/**
 * Graceful Shutdown Handler
 * 
 * When the process receives SIGTERM (like during deployment restarts):
 * 1. Closes database connections cleanly
 * 2. Exits the Node.js process
 * 
 * Prevents database connection leaks and ensures data integrity
 */
process.on('SIGTERM', () => {
  console.log('SIGTERM received - shutting down gracefully...');
  AppDataSource.destroy().then(() => {
    console.log('Database connection closed');
    process.exit(0); // Exit with success code
  });
});

Here is the data source which is where the connection setup to our database is defined.

src/data-source.ts

// Load environment variables from .env file
import * as dotenv from 'dotenv';
dotenv.config();

// Import TypeORM dependencies
import { DataSource } from 'typeorm';
// Import entity classes
import { User } from './entity/User';

/**
 * TypeORM Data Source Configuration
 * 
 * This is the central database connection setup that will be used throughout the application.
 * It's exported so other parts of the app (like repositories and migrations) can use it.
 */
export const AppDataSource = new DataSource({
  // Database type (mysql, postgres, etc.)
  type: 'mysql',

  // Connection parameters (fallback to defaults if .env missing)
  host: process.env.DB_HOST || 'localhost',          // Database server address
  port: parseInt(process.env.DB_PORT || '3306'),     // Convert port string to number
  username: process.env.DB_USERNAME || 'root',       // Database username
  password: process.env.DB_PASSWORD || '',           // Database password
  database: process.env.DB_NAME || 'your_database',  // Database name

  /**
   * Development vs Production Settings
   */
  synchronize: true, // AUTO-CREATES TABLES ON STARTUP (DANGER in production!)
                    // Set to false in production and use migrations instead
  logging: false,   // Disables query logging (set to true for debugging)

  /**
   * Application Entities and Migrations
   */
  entities: [User], // List all database entities here
  subscribers: [],  // Event subscribers (empty for most applications)

  // Migration configuration
  migrations: [__dirname + '/migrations/*.ts'], // Location of migration files
});

This entity is very important as this is how our table is going to be generated via migration process.

src/entity/User.ts

// Import TypeORM decorators for entity definition
import { Entity, PrimaryGeneratedColumn, Column} from 'typeorm';

/**
 * User Entity Class
 * 
 * Represents a user in the database and contains business logic for user operations.
 * TypeORM will automatically create a corresponding 'user' table based on this class.
 */
@Entity() // Marks this class as a TypeORM entity
export class User {
  // Unique identifier using UUID (better than auto-increment IDs for distributed systems)
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  // User's full name
  @Column()
  name!: string;

  // Unique email address (enforced at database level)
  @Column({ unique: true })
  email!: string;

  // Hashed password (never store plain text passwords!)
  @Column()
  password!: string;

  // Automatic timestamp when record is created
  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  createdAt!: Date;

  // Automatic timestamp when record is updated
  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })
  updatedAt!: Date;

}

/* ------------------------- */
/*         DTOs              */
/* (Data Transfer Objects)   */
/* ------------------------- */

/**
 * CreateUserDto - Used for user creation requests
 * Ensures required fields are present when creating a user
 */
export interface CreateUserDto {
  name: string;
  email: string;
  password: string;
}

/**
 * UpdateUserDto - Used for user updates
 * All fields are optional (partial updates)
 */
export interface UpdateUserDto {
  name?: string;
  email?: string;
  password?: string;
}

Now you can create the table user since you have created the entity for the user. To do this, run the migration script in you package.json.

pnpm run migration:generate

pnpm run migration:run

Upon a successful migration, you can now check your MySQL session and you should see the table by running this command.

DESCRIBE userz;

Now let’s focus on the business logic for the application.

src/user/user.service.ts

// Import DTOs and Entity from our domain model
import { CreateUserDto, UpdateUserDto, User } from '../entity/User';
// Import repository interface for dependency injection
import { IUserRepository } from '../repos/user.repository';
// Imports the package for password hashing and comparison.
import * as bcrypt from 'bcryptjs';

/**
 * User Service Class
 * 
 * Contains core business logic for user operations.
 * Delegates data persistence to the injected repository.
 */
export class UserService {
  /**
   * Constructor with Dependency Injection
   * @param userRepository - The repository handling data persistence
   */
  constructor(private userRepository: IUserRepository) {}

  /**
   * Creates a new user with validation
   * @param userData - User creation data
   * @throws Error if validation fails or email exists
   * @returns The created user
   */
  async create(userData: CreateUserDto): Promise<User> {
    // Validate email format
    if (!this.validateEmail(userData.email)) {
      throw new Error("Invalid email format");
    }

    // Validate password length
    if (userData.password.length < 8) {
      throw new Error("Password must be at least 8 characters long");
    }

    // Check for existing user by email
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('User with this email already exists');
    }

    // Hash password before storage
    const hashedPassword = await bcrypt.hash(userData.password, 10);

    // Create user entity
    const user = new User();
    user.name = userData.name;
    user.email = userData.email;
    user.password = hashedPassword;

    // Persist the new user via repository
    return this.userRepository.create(user);
  }

  /**
   * Retrieves all users
   * @returns Array of all users
   */
  async findAll(): Promise<User[]> {
    return this.userRepository.findAll();
  }

  /**
   * Finds a user by ID
   * @param id - User ID
   * @throws Error if user not found
   * @returns The requested user
   */
  async findById(id: string): Promise<User> {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new Error('User not found');
    }
    return user;
  }

  /**
   * Updates a user's information
   * @param id - User ID to update
   * @param userData - Partial update data
   * @returns The updated user or null if not found
   */
  async update(id: string, userData: UpdateUserDto): Promise<User | null> {
    return this.userRepository.update(id, userData);
  }

  /**
   * Deletes a user
   * @param id - User ID to delete
   */
  async delete(id: string): Promise<void> {
    await this.userRepository.delete(id);
  }

  /**
   * Validates user credentials
   * @param email - User's email
   * @param password - User's password
   * @returns User if valid, null otherwise
   */
  async validateUser(email: string, password: string): Promise<User | null> {
    const user = await this.userRepository.findByEmail(email);
    if (!user) return null;

    const isValid = await bcrypt.compare(password, user.password);
    return isValid ? user : null;
  }

  /**
   * Validates email format
   * @param email - Email to validate
   * @returns true if valid, false otherwise
   * @private
   */
  private validateEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}

Next is our controller file where we parse the body of the request and also send the necessary HTTP code to the client.

src/controller/user.controller.ts

import { Request, Response } from 'express';
import { CreateUserDto, UpdateUserDto } from '../entity/User';
import { UserService } from './user.service';

/**
 * User Controller Class
 * 
 * Handles HTTP requests and responses for user operations.
 * Acts as the bridge between routes and business logic (service layer).
 */
export class UserController {

  /**
   * Constructor with Dependency Injection
   * @param userService - Injected service containing business logic
   * 
   * Note: We explicitly bind methods to maintain 'this' context
   * when used as Express route handlers
   */
  constructor(private userService: UserService) {
    this.create = this.create.bind(this);
    this.findAll = this.findAll.bind(this);
    this.findById = this.findById.bind(this);
    this.update = this.update.bind(this);
    this.delete = this.delete.bind(this);
  }

  /**
   * Create User Endpoint
   * POST /users
   */
  async create(req: Request, res: Response) {
    try {
      // 1. Parse incoming DTO from request body
      // 2. Delegate to service layer
      // 3. Return created user with 201 status
      const user = await this.userService.create(req.body as CreateUserDto);
      res.status(201).json(user);
    } catch (error: any) {
      // Handle specific error cases as needed
      res.status(400).json({ error: error.message });
    }
  }

  /**
   * Get All Users Endpoint
   * GET /users
   */
  async findAll(req: Request, res: Response) {
    try {
      const users = await this.userService.findAll();
      res.json(users);
    } catch (error: any) {
      // Server error (database connection, etc.)
      res.status(500).json({ error: error.message });
    }
  }

  /**
   * Get User by ID Endpoint
   * GET /users/:id
   */
  async findById(req: Request, res: Response) {
    try {
      const user = await this.userService.findById(req.params.id);
      res.json(user);
    } catch (error: any) {
      // Not found error
      res.status(404).json({ error: error.message });
    }
  }

  /**
   * Update User Endpoint
   * PUT /users/:id
   */
  async update(req: Request, res: Response) {
    try {
      const user = await this.userService.update(
        req.params.id,
        req.body as UpdateUserDto
      );
      res.json(user);
    } catch (error: any) {
      // Bad request (validation errors, etc.)
      res.status(400).json({ error: error.message });
    }
  }

  /**
   * Delete User Endpoint
   * DELETE /users/:id
   */
  async delete(req: Request, res: Response) {
    try {
      await this.userService.delete(req.params.id);
      // Successful deletion returns no content
      res.status(204).send();
    } catch (error: any) {
      res.status(400).json({ error: error.message });
    }
  }
}

Up next is the user routes file where endpoints are defined and the controller is called.

src/routes/user.routes.ts

import express from 'express';
import { UserRepository } from '../repos/user.repository';
import { UserController } from './user.controller';
import { UserService } from './user.service';

// Create a new Express router instance for user-related routes
const userRouter = express.Router();

// Instantiate the UserRepository
// This is responsible for interacting with the database or data source
const userRepository = new UserRepository();

// Instantiate the UserService and inject the UserRepository
// The service uses the repository to perform business operations
const userService = new UserService(userRepository);

// Instantiate the UserController and inject the UserService
// The controller handles incoming HTTP requests and delegates to the service
const userController = new UserController(userService);

// Define route for creating a new user (POST /users)
userRouter.post('/', userController.create);

// Define route for retrieving all users (GET /users)
userRouter.get('/', userController.findAll);

// Define route for retrieving a user by ID (GET /users/:id)
userRouter.get('/:id', userController.findById);

// Define route for updating a user by ID (PUT /users/:id)
userRouter.put('/:id', userController.update);

// Define route for deleting a user by ID (DELETE /users/:id)
userRouter.delete('/:id', userController.delete);

// Export the configured router to be used in the main app
export default userRouter;

The Repository Pattern Setup

src/repos/user.repository.ts

// Import necessary modules and classes
import { Repository } from 'typeorm'; // TypeORM's Repository class for database operations
import { AppDataSource } from '../data-source'; // Database connection setup
import { CreateUserDto, UpdateUserDto, User } from '../entity/User'; // User entity and DTOs

// Interface defining the contract for UserRepository
// This ensures all required methods are implemented and helps with type safety
export interface IUserRepository {
  create(userData: CreateUserDto): Promise<User>; // Create a new user
  update(id: string, userData: UpdateUserDto): Promise<User>; // Update existing user
  findAll(): Promise<User[]>; // Get all users
  findById(id: string): Promise<User | null>; // Find user by ID
  findByEmail(email: string): Promise<User | null>; // Find user by email (useful for authentication)
  delete(id: string): Promise<void>; // Delete a user
}

// Concrete implementation of the UserRepository
export class UserRepository implements IUserRepository {
  private ormRepository: Repository<User>; // TypeORM repository instance for User entity

  constructor() {
    // Initialize the TypeORM repository using the AppDataSource (database connection)
    this.ormRepository = AppDataSource.getRepository(User);
  }

  /**
   * Creates a new user in the database
   * @param userData Data for creating a new user (CreateUserDto)
   * @returns Promise that resolves to the created User entity
   */
  async create(userData: CreateUserDto): Promise<User> {
    // First create a User instance with the given data
    const user = this.ormRepository.create(userData);
    // Then save it to the database
    return this.ormRepository.save(user);
  }

  /**
   * Updates an existing user
   * @param id ID of the user to update
   * @param userData Data to update (UpdateUserDto)
   * @returns Promise that resolves to the updated User entity
   * @throws Error if user is not found after update
   */
  async update(id: string, userData: UpdateUserDto): Promise<User> {
    // First perform the update operation
    await this.ormRepository.update(id, userData);
    // Then fetch the updated user
    const updatedUser = await this.findById(id);
    // Throw error if user not found (shouldn't happen if update succeeded)
    if (!updatedUser) throw new Error('User not found after update');
    return updatedUser;
  }

  /**
   * Retrieves all users from the database
   * @returns Promise that resolves to an array of User entities
   */
  async findAll(): Promise<User[]> {
    // Simple find operation without any conditions returns all records
    return this.ormRepository.find();
  }

  /**
   * Finds a user by their ID
   * @param id User ID to search for
   * @returns Promise that resolves to User entity or null if not found
   */
  async findById(id: string): Promise<User | null> {
    // findOneBy searches by the given criteria (in this case, ID)
    return this.ormRepository.findOneBy({ id });
  }

  /**
   * Finds a user by their email address
   * @param email Email address to search for
   * @returns Promise that resolves to User entity or null if not found
   */
  async findByEmail(email: string): Promise<User | null> {
    // Useful for authentication (checking if email exists)
    return this.ormRepository.findOneBy({ email });
  }

  /**
   * Deletes a user from the database
   * @param id ID of the user to delete
   * @returns Promise that resolves when deletion is complete
   */
  async delete(id: string): Promise<void> {
    // Simple delete operation by ID
    await this.ormRepository.delete(id);
  }
}

Lastly, we start the server

pnpm run dev

P.S I have implemented other scenarios of the application using Drizzle + Postgress and Mongoose + MongoDB in the GithHub repository, please check the branches to see the different implementations.

Testing Repositories in Typescript

Firstly we will be installing the necessary testing packages

pnpm install -D jest @types/jest ts-jest supertest @types/supertest

Now let's create some test files

touch src/repos/user.repository.test.ts src/user/user.service.test.ts test-data-source.ts

After creating those required files, we have to also make some changes to user.respository.ts file to make sure that when we are testing, the test-data-source is used instead of the data-source

src/repos/user.repository.ts

  constructor() {
    const dataSource =
      process.env.NODE_ENV === 'test' ? TestDataSource : AppDataSource;
    this.ormRepository = dataSource.getRepository(User);
  }

Ready to make sure everything works? Testing is crucial! For this guide, we’ll dive into the two heavy hitters of backend testing: Unit tests and Integration tests. They’re the gold standard for building reliable applications.

We’ll start with Unit tests. The idea here is to test individual pieces of our code in isolation. Since our UserService talks to the UserRepository, hitting a real database every time we test the service would be slow and cost resources.

So, for unit tests, we’ll mock the user repository. Think of it as creating a stand-in, a “fake” repository that we control completely. This lets us test our service’s logic lightning-fast, without ever touching the actual database. We’re testing the service’s behavior, assuming the repository does its job correctly (we’ll check that assumption later!).

src/user/user.service.test.ts

/ Import necessary modules and classes
import { User } from '../entity/User'; // User entity
import { IUserRepository } from '../repos/user.repository'; // User repository interface
import { UserService } from './user.service'; // The service we're testing

// Define valid user data that will be used in multiple tests
const validUserData = {
  name: 'Test User',
  email: 'test@example.com',
  password: 'securePassword123!', // Note: This should meet your password requirements
};

// Create a mock repository using Jest's mocking capabilities
// This implements the IUserRepository interface with mock functions
const mockRepo: jest.Mocked<IUserRepository> = {
  findByEmail: jest.fn(), // Mock function for email lookup
  create: jest.fn(),      // Mock function for user creation
  findAll: jest.fn(),     // Mock function for getting all users
  findById: jest.fn(),    // Mock function for finding by ID
  update: jest.fn(),      // Mock function for updates
  delete: jest.fn(),      // Mock function for deletions
};

// Describe block for the UserService test suite
describe('UserService', () => {
  let service: UserService; // This will hold our service instance

  // beforeEach runs before each test case
  beforeEach(() => {
    jest.clearAllMocks(); // Clear all mock calls and instances
    service = new UserService(mockRepo as any); // Create new service with mock repo
  });

  // Nested describe block for testing the create() method
  describe('create()', () => {
    // Test case: Should reject duplicate emails
    it('should reject duplicate emails', async () => {
      // Arrange (setup test conditions)
      // Mock the repository to return a user (simulating existing user)
      mockRepo.findByEmail.mockResolvedValue(new User());

      // Act & Assert
      // Expect the service to throw an error when email exists
      await expect(service.create(validUserData)).rejects.toThrow(
        'User with this email already exists'
      );

      // Verify the mock was called with the correct email
      expect(mockRepo.findByEmail).toHaveBeenCalledWith(validUserData.email);
    });

    // Test case: Should create new users with valid data
    it('should create new users with valid data', async () => {
      // Arrange
      // Create a mock user object with expected properties
      const mockUser = new User();
      Object.assign(mockUser, {
        id: '123', // Simulated user ID
        ...validUserData, // Spread the valid user data
        createdAt: new Date(), // Simulated timestamps
        updatedAt: new Date(),
      });
      // Mock the password comparison method
      mockUser.comparePassword = jest.fn();

      // Setup mock repository responses
      mockRepo.findByEmail.mockResolvedValue(null); // No existing user
      mockRepo.create.mockResolvedValue(mockUser); // Return our mock user

      // Act
      const result = await service.create(validUserData);

      // Assert
      expect(result).toEqual(mockUser); // Should return the mock user
      expect(mockRepo.create).toHaveBeenCalled(); // Verify create was called
    });

    // Test case: Should hash passwords before saving
    it('should hash passwords before saving', async () => {
      // Arrange
      mockRepo.findByEmail.mockResolvedValue(null); // No existing user
      // Mock create to simply return what it receives
      mockRepo.create.mockImplementation(async (user) => user);

      // Act
      await service.create(validUserData);

      // Assert
      // Get the first argument of the first call to create
      const savedUser = mockRepo.create.mock.calls[0][0];
      // Password should be hashed (not plain text)
      expect(savedUser.password).not.toBe(validUserData.password);
      // Check if password matches bcrypt pattern (starts with $2a$, $2b$ or $2y$)
      expect(savedUser.password).toMatch(/^\$2[ayb]\$.{56}$/);
    });
  });
});

While unit tests check the individual pieces in isolation (like our service logic using a mocked repository), integration tests are where we connect things and see if they work together. Here, we’ll use the actual UserRepository to ensure it correctly talks to the database. It's about verifying that the "integration" point between our application and the persistence layer is solid.

src/repos/user.repository.ts

// Import necessary modules and classes
import { User } from '../entity/User'; // User entity model
import { TestDataSource } from '../test-data-source'; // Test database connection
import { UserRepository } from './user.repository'; // Repository we're testing

// Describe block for the UserRepository test suite
describe('UserRepository', () => {
  let repository: UserRepository; // This will hold our repository instance

  // beforeAll runs once before all tests in this describe block
  beforeAll(async () => {
    // Initialize the test database connection
    await TestDataSource.initialize();
    // Create a new instance of the repository
    repository = new UserRepository();
  });

  // afterAll runs once after all tests complete
  afterAll(async () => {
    // Destroy the database connection to clean up
    await TestDataSource.destroy();
  });

  // beforeEach runs before each test case
  beforeEach(async () => {
    // Clear all data from the User table between tests
    // This ensures each test starts with a clean slate
    await TestDataSource.getRepository(User).clear();
  });

  // Test case: should create and find users
  it('should create and find users', async () => {
    // Arrange - create a test user object
    const user = new User();
    user.name = 'Test';
    user.email = 'test@test.com';
    user.password = 'hashed_password'; // Note: In real tests, this would be properly hashed
    user.comparePassword = async () => true; // Mock password comparison method

    // Act - create the user in the database
    const createdUser = await repository.create(user);
    // Then try to find the user by ID
    const found = await repository.findById(createdUser.id);

    // Assert - verify the found user has the same email
    expect(found?.email).toBe(user.email);
  });
});

Moment of truth! Let’s see those tests in action. Run the command below in your terminal:

pnpm run test

If all goes well (and with the repository pattern, it should!😉), you’ll see output similar to this, confirming your tests passed:

Unit and Integration test results.

Fantastic! You’ve successfully implemented and tested your application’s data access layer using the Repository pattern which is a huge step towards building robust, maintainable backends!

When to and When not to use the Repository Pattern

Let’s start with then to use the Repository Pattern

  1. Complex Business Logic Application

    • When your application has complex business rules that needs to be separated from the data access layer.

    • When you need to centralize data access logic from maintainability

  2. Multiple Data Sources

    • When you are working with multiple databases(e.g., MySQL + MongoDB).

    • When you might need to switch database ORM in future.

  3. Testability Requirements

    • When you need to easily mock data access for unit testing

    • When you want to test the business logic without hitting the real database

  4. Large-Scale Applications

    • When building applications that will grow over time and need maintainable architecture.

    • When multiple teams need to work on different layers independently

  5. Domain-Driven Design(DDD) Projects

    • When you need to work with domain objects rather than raw database entities.

    • When following DDD principles where the domain layer should be persistence-ignorant

Now, let’s look at situations where the Repository Pattern may not be the best option

  1. Simple CRUD Applications

    • When your application is primarily simple Create-Read-Update-Delete operation

    • When the abstraction provides no real benefits (YAGNI principle)

  2. Tight Deadlines for small projects

    • When building a prototype or MVP that won’t evolve
  3. Microservices with Simple Data Needs

    • when building small, focused microservices with simple data requirements

    • When each service has its own dedicated database that won’t change

  4. Performance-Critical Applications

    • When you need to write highly optimized SQL queries for performance

    • When the abstraction layer might prevent necessary database optimizations

Well there is always a middle ground, for many projects, a lightweight repository pattern can be beneficial without being overly prescriptive. Consider:

  • Using your ORM’s built-in repository (like TypeORM’s) as a base

  • Only abstracting what you need to (maybe just complex queries)

  • Starting simple and adding repository layers as needs emerge

P.S The repository pattern is a tool, not a religion. Use it where it add value, skip it where it is unnecessary.

Conclusion and Best Practices

We’ve journeyed from the pain of tightly coupled data access to understanding, implementing, and testing the Repository Pattern. By introducing this layer of abstraction, we empower our applications with significant benefits:

  • Decoupled & Cleaner Code: Your business logic (UserService) is no longer cluttered with database specifics, making it easier to read, understand, and maintain.

  • Enhanced Testability: Mocking the repository interface in unit tests becomes trivial, leading to faster, more reliable tests for your core logic.

  • Increased Flexibility: Swapping databases, ORMs, or even integrating other data sources (like caching layers or external APIs) becomes a matter of implementing a new repository class, without forcing changes throughout your service layer.

  • Centralized Data Logic: Common data access concerns and complex query logic are encapsulated in one place, improving consistency and simplifying updates.

The Repository Pattern is a powerful tool, but like any tool, its effectiveness depends on how it’s used. To truly leverage its benefits, consider these best practices:

  • Always Depend on the Interface: This is perhaps the most critical rule. Your services and other business logic should only know about and interact with the IUserRepository interface, never the concrete UserRepository implementation. This is the key to decoupling.

  • Keep Repositories Focused: A repository’s primary responsibility is data persistence — finding, saving, deleting, and potentially abstracting complex queries. Avoid putting unrelated business logic (like complex calculations, external service calls, or workflow steps) inside your repositories. That belongs in your service layer.

  • Repository Methods Should Return Domain Objects: Repository methods should ideally return your application’s core domain entities (like your User class), or collections of them. Avoid leaking ORM-specific query builder objects or raw database results out of the repository.

  • Hide Query Implementation Details: The service layer should ask what data it needs (e.g., findByEmail(email)), not how to fetch it (e.g., findWhere("email = ?", email)). The repository encapsulates the internal querying logic (whether it's SQL, ORM syntax, etc.).

  • Consider the Unit of Work Pattern (for complex transactions): For scenarios where a single business operation requires coordinating changes across multiple repositories (e.g., saving a user and creating a related profile entry atomically), consider implementing the Unit of Work pattern. It helps manage transactions at a higher level within the service layer. (Keep this brief if you haven’t covered Unit of Work at all).

The Repository Pattern provides a clear architectural boundary that pays dividends in larger or evolving applications. By adhering to its principles and following these best practices, you’re well on your way to building backends that are robust, maintainable, and adaptable to change.

Thank you so much for reading through this blog I really hope you got to learn something new. Follow me for more blogs like this; I will be releasing Authentication with JWT using Private and Public Keys next, I’m sure you don’t want to miss that!

2
Subscribe to my newsletter

Read articles from Muyiwa Olayinka directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Muyiwa Olayinka
Muyiwa Olayinka