skip to content

The Dismal *Amazing* Thoughts and Adventures of

James Gardner

The Repository Service Pattern to the rescue

Using the Repository Service Pattern

/ 4 min read

Introduction

The “Repository Service Pattern” is a design pattern that separates data access from business logic in applications. Why might you need this when data access can be as simple as this?

  const { rows } = await client.query(
    'SELECT id, email FROM customers'
  );

In simple applications or prototypes, this approach will serve its purpose but let’s bring in the principle of Occam’s Razor:

Everything should be made as simple as possible, but not simpler.

Projects often begin with simple requirements, but it’s crucial not to sacrifice flexibility from the start. When you embed this kind of logic in places where testing is necessary, you may find yourself needing to create an excessive number of mocks for even the most basic tests to pass. In contrast to this, embracing a separation of concerns from the outset offers a lot of advantages. For one it makes it easier to manage and understand your codebase as it grows.

The Repository

Most of the examples I’ve seen use an ORM and/or ES6 classes to implement this pattern. I’ve chosen to naively try this out with factory functions and plain old objects with methods modelled around CRUD operations.

Here’s an attempt at a generic repository that uses Slonik:

import { DatabasePool, sql } from "slonik";
import z, { ZodType } from "zod";

export interface Repository<TSchema extends ZodType<any, any, any>> {
	exists(uuid: string): Promise<boolean>;
	getById(uuid: string): Promise<z.infer<TSchema>>;
	delete(uuid: string): Promise<void>;
}

export const createGenericRepository = <TSchema extends ZodType<any, any, any>>(
	pool: DatabasePool,
	tableName: string,
	schema: TSchema,
) => {
	const repository: Repository<TSchema> = {
		exists: async (uuid: string): Promise<boolean> =>
			pool.connect((connection) =>
				connection.exists(
					sql.typeAlias("uuid")`SELECT 1 FROM ${sql.identifier([tableName])} WHERE uuid = ${uuid}`,
				),
			),

		getById: async (uuid: string): Promise<TSchema> =>
			pool.connect((connection) =>
				connection.one(
					sql.type(schema)`SELECT * FROM ${sql.identifier([tableName])} WHERE uuid = ${uuid}`,
				),
			),

		delete: async (uuid: string): Promise<void> => {
			pool.connect((connection) =>
				connection.query(
					sql.type(schema)`UPDATE ${sql.identifier([tableName])} SET deleted_at=NOW() WHERE uuid = ${uuid}`,
				),
			);
		},
	};

	return repository;
};

For simplicity, I’ve omitted the ‘create’ and ‘update’ methods, leaving their implementation to the user:

export interface StudentRepository extends Repository<typeof StudentSchema> {
	create(schoolId: string, body: z.infer<typeof CreateStudentSchema>): Promise<Student>;
	update(uuid: string, body: z.infer<typeof UpdateStudentSchema>): Promise<Student>;
}

export const createStudentRepository = (pool: DatabasePool): StudentRepository => {
	return {
		...createGenericRepository(pool, "student", StudentSchema),

		create: (schoolId, body) => {
			const { firstName, lastName, dateOfBirth } = body;

			return pool.connect((connection) =>
				connection.one(
					sql.type(StudentSchema)`
                      INSERT INTO student (uuid, first_name, last_name, date_of_birth, school_id)
                      VALUES (${uuid()}, ${firstName}, ${lastName}, ${dateOfBirth}, ${schoolId})
                      RETURNING *
                    `,
				),
			);
		},
		update: (uuid, body) => {
			const { firstName, lastName, dateOfBirth } = body;

			return pool.connect((connection) =>
				connection.one(
					sql.type(StudentSchema)`
                      UPDATE student
                      SET first_name = ${firstName}, last_name = ${lastName}, date_of_birth = ${dateOfBirth}
                      WHERE uuid = ${uuid}
                      RETURNING *
                    `,
				),
			);
		},
	};
};

This approach offers flexibility, and while I could integrate a full range of CRUD operations into the generic repository, I’ll consider this in future discussions. For now, this setup efficiently handles essential CRUD functionalities.

Services

Now, let’s talk about services. They serve as a bridge between the repository and consumers like route handlers. Services provide a dedicated space to abstract data access rules, making it easier to mock during testing. While the service interface can closely mirror the repository, it may have slight differences to accommodate application-specific needs and additional business logic:

export interface StudentService {
  getById(uuid: string, schoolId: string, user: unknown): Promise<Student>;
  create(schoolId: string, body: z.infer<typeof CreateStudentSchema>): Promise<Student>;
}

export const createStudentService = (repository: StudentRepository, logger: Logger): StudentService => {  
  return {
    ...
    
    /**
     * As an example, the 'user' argument can be passed in from the route. e.g. @fastify/jwt hook.
     */
    getById: (uuid, schoolId, user) => {
      if (!user.schools.includes(schoolId)) {
        logger.error(`${uuid} not permitted to access ${schoolId}`);

        throw new NotFound();
      }

      return repository.getById(uuid);
    }
  }
};

Piecing it all together

This is somewhat verbose and all registered from within the entrypoint via a route plugin:

  app.register(students, {
    service: createStudentService(
      createStudentRepository(pool, app.logger)
    )
  });

Conclusion

In this post, I’ve showcased an implementation approach for this pattern, intentionally steering clear of ES6 classes and ORMs for the sake of brevity. There’s loads you could do to take this further such as a custom ‘.extend’ method on the generic repository or perhaps as I mentioned, a comprehensive set of CRUD operations. Conceptually I hope that I’ve demonstrated how beneficial this pattern can be but beyond that how having a “separation of concerns” can help things like testing and organising your project.