Skip to content

Streamlining Backend Architecture: A Guide to the Generic Repository Pattern with TypeScript and Sequelize

Published: at 10:00 AM

In backend development, keeping code scalable and clean is key. The Generic Repository Pattern helps by abstracting data access logic, letting you reuse code across models instead of duplicating it. With TypeScript , you can ensure type safety while handling model-specific logic. In this guide, I’ll show you how to set up a generic repository in TypeScript for reusable CRUD operations, cutting down redundancy and boosting code consistency.

Step-by-Step Guide

1. Setting Up the Base Repository

First, we’ll create a BaseRepository.ts file. This will define a set of common methods like find, findOne, create, update, and delete. Each of these methods will be generic enough to work with any model in your application.

import {
  type Attributes,
  type CountOptions,
  type CreateOptions,
  type DestroyOptions,
  type FindOptions,
  type Model,
  type ModelStatic,
  type UpdateOptions,
} from "sequelize";
import {
  type Col,
  type Fn,
  type Literal,
  type MakeNullishOptional,
} from "sequelize/types/utils";

export abstract class BaseRepository<T extends Model> {
  constructor(protected model: ModelStatic<T>) {}

  async find(options?: FindOptions<Attributes<T>>): Promise<T[]> {
    return await this.model.findAll(options);
  }

  async findOne(options?: FindOptions<Attributes<T>>): Promise<T | null> {
    return await this.model.findOne(options);
  }

  async delete(options?: DestroyOptions<Attributes<T>>): Promise<number> {
    return await this.model.destroy(options);
  }

  async update(
    data: {
      [key in keyof Attributes<T>]?:
        | Fn
        | Col
        | Literal
        | Attributes<T>[key]
        | undefined;
    },
    options: Omit<UpdateOptions<Attributes<T>>, "returning"> & {
      returning: Exclude<
        UpdateOptions<Attributes<T>>["returning"],
        undefined | false
      >;
    }
  ): Promise<[affectedCount: number, affectedRows: T[]]> {
    return await this.model.update(data, options);
  }

  async create(
    data: MakeNullishOptional<T["_creationAttributes"]>,
    options?: CreateOptions<Attributes<T>> | undefined
  ): Promise<T> {
    return await this.model.create(data, options);
  }
}

2. Extending the Base Repository

Now that we have our BaseRepository, the next step is to create specific repositories for each model by extending this base class. This way, we can focus on model-specific logic, while reusing the generic data access methods from the base repository.Here’s an example of how to create a CustomerRepository that extends the BaseRepository.

import { type Customer } from "./models/Customer";
import { BaseRepository } from "./BaseRepository";

class CustomerRepository extends BaseRepository<Customer> {
  // You can add customer-specific methods here if needed
}

export default CustomerRepository;

If you’re curious how the Customer models look like, here is a simplified version of a model generated using the npm package sequelize-auto. This package automatically generates Sequelize models based on your existing database structure, which can be a huge time-saver when building your application. Here’s a simple model example:

import { DataTypes, Model, Optional } from "sequelize";

export interface CustomerAttributes {
  id: string;
  name: string;
  email?: string;
  is_active: boolean;
  created_at: Date;
  updated_at: Date;
}

export type CustomerCreationAttributes = Optional<CustomerAttributes, "id">;

export class Customer
  extends Model<CustomerAttributes, CustomerCreationAttributes>
  implements CustomerAttributes
{
  id!: string;
  name!: string;
  email?: string;
  is_active!: boolean;
  created_at!: Date;
  updated_at!: Date;

  static initModel(sequelize: Sequelize.Sequelize): typeof Customer {
    return Customer.init(
      {
        id: {
          type: DataTypes.UUID,
          allowNull: false,
          primaryKey: true,
        },
        name: {
          type: DataTypes.STRING(255),
          allowNull: false,
        },
        email: {
          type: DataTypes.STRING(255),
          allowNull: true,
        },
        is_active: {
          type: DataTypes.BOOLEAN,
          allowNull: false,
          defaultValue: true,
        },
        created_at: {
          type: DataTypes.DATE,
          allowNull: false,
        },
        updated_at: {
          type: DataTypes.DATE,
          allowNull: false,
        },
      },
      {
        sequelize,
        tableName: "Customers",
        timestamps: true,
        underscored: true,
      }
    );
  }
}

This is a basic model representing a Customer entity with fields like id, name, email, is_active, created_at, and updated_at.

3. Using the Repository in Your Code

Once your repositories are set up, you can use them in your code to handle database interactions. Here’s an example of how you would use the CustomerRepository to find a specific customer based on their ID and organisation_id.

const customerRepository = new CustomerRepository(Customer);

const customer = await customerRepository.findOne({
  where: { id: customer_id, organisation_id },
});

With this setup, your data access logic is now centralized, making it easier to manage and scale as your application grows.

4. Benefits of the Generic Repository Pattern

The benefits of this approach are substantial:

Conclusion

The generic repository pattern is an excellent design choice for TypeScript-based backend projects, allowing for cleaner, reusable, and more maintainable code. By leveraging TypeScript’s static typing system and generics, you ensure that your application is not only easier to scale but also safer from potential runtime errors.