Skip to content
Express.js ex patterns 5 min read

The Repository Pattern

When your services call the database driver directly, they become welded to a specific persistence technology — swapping Mongo for Postgres, or stubbing the data store in a test, means rewriting business logic. The repository pattern breaks that coupling by hiding the data store behind a small, intention-revealing interface: findById, save, delete. Your services talk to that interface and never see a SQL string or a Mongoose call. The result is business logic that is persistence-agnostic, trivially mockable, and free to evolve as your storage choices change.

What a repository is

A repository is an object that mediates between the domain layer and the data mapping layer, exposing collection-like methods (findAll, findById, save, remove) while concealing the query mechanics behind them. Crucially, it speaks in terms of your domain — “find a user by email” — rather than in terms of the database — “run this SELECT”. Everything above it depends only on the method signatures, so the underlying engine becomes an implementation detail.

LayerKnows aboutDoes not know about
ServiceRepository methods, domain objectsSQL, ORM, connection details
RepositoryThe data store, queries, mappingHTTP, req/res, business rules
Data storeTables/collectionsAnything above it

Tip: A repository is not the same as an ORM model. The ORM (Mongoose, Prisma, Knex) is inside the repository; the repository is the seam you hide it behind so the rest of the app stays decoupled.

Defining a repository

Here is a concrete UserRepository backed by Knex. The service that uses it never imports Knex — it only ever sees these four async methods.

// repositories/userRepository.js
class UserRepository {
  constructor(db) {
    this.db = db; // a configured Knex instance, injected in
  }

  async findAll() {
    return this.db("users").select("id", "name", "email");
  }

  async findById(id) {
    return this.db("users").where({ id }).first();
  }

  async findByEmail(email) {
    return this.db("users").where({ email }).first();
  }

  async save({ name, email }) {
    const [row] = await this.db("users")
      .insert({ name, email })
      .returning("*");
    return row;
  }

  async remove(id) {
    return this.db("users").where({ id }).del();
  }
}

module.exports = UserRepository;

Because the database handle is passed into the constructor rather than imported, the same class works against a real connection in production and an in-memory fake in tests.

Using it from a service

The service layer holds your business rules. It receives a repository instance and calls its methods — it does not care whether the data lives in Postgres, Mongo, or a JSON file.

// services/userService.js
class UserService {
  constructor(userRepository) {
    this.users = userRepository;
  }

  async register({ name, email }) {
    const existing = await this.users.findByEmail(email);
    if (existing) {
      const err = new Error("Email already in use");
      err.status = 409;
      throw err;
    }
    return this.users.save({ name, email });
  }

  async getProfile(id) {
    const user = await this.users.findById(id);
    if (!user) {
      const err = new Error("User not found");
      err.status = 404;
      throw err;
    }
    return user;
  }
}

module.exports = UserService;

Notice the rule “you cannot register a duplicate email” lives in the service, while how a user is found lives in the repository — each concern in exactly one place.

Wiring it into Express

Compose the pieces once at startup and hand the service to a thin controller. The async handlers forward failures to a central error middleware.

// app.js
const express = require("express");
const knex = require("knex")(require("./knexfile"));
const UserRepository = require("./repositories/userRepository");
const UserService = require("./services/userService");

const userRepo = new UserRepository(knex);
const userService = new UserService(userRepo);

const app = express();
app.use(express.json());

const router = express.Router();

router.post("/", async (req, res, next) => {
  try {
    const user = await userService.register(req.body);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

router.get("/:id", async (req, res, next) => {
  try {
    const user = await userService.getProfile(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
});

app.use("/users", router);

app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

Output:

$ curl -s -X POST http://localhost:3000/users \
    -H "Content-Type: application/json" \
    -d '{"name":"Grace Hopper","email":"[email protected]"}'
{"id":42,"name":"Grace Hopper","email":"[email protected]"}

Note: On Express 5.x, rejected promises from async handlers are automatically passed to your error middleware, so the try/catch blocks above become optional. On Express 4.x they are still required (or use an asyncHandler wrapper).

Mocking in tests

The real payoff arrives at test time. Because the service depends only on the repository’s shape, you can inject a fake with no database at all and assert on pure logic.

// userService.test.js
const assert = require("node:assert");
const test = require("node:test");
const UserService = require("./services/userService");

test("register rejects a duplicate email", async () => {
  const fakeRepo = {
    findByEmail: async () => ({ id: 1, email: "[email protected]" }),
    save: async () => assert.fail("save should not be called"),
  };
  const service = new UserService(fakeRepo);

  await assert.rejects(
    () => service.register({ name: "Ada", email: "[email protected]" }),
    { message: "Email already in use" }
  );
});

No connection, no fixtures, no cleanup — the test runs in milliseconds and exercises only the rule you care about.

Best Practices

  • Keep repositories free of business logic; they map and persist, while rules belong in the service layer.
  • Expose domain-meaningful methods (findByEmail) rather than leaking generic query builders to callers.
  • Inject the database handle through the constructor so the same class serves both production and tests.
  • Return plain domain objects, not raw driver result sets, so callers never depend on ORM-specific shapes.
  • Define one repository per aggregate or table to keep each class focused and its method list small.
  • Never let a repository touch req or res; keeping it HTTP-agnostic makes it reusable from jobs and scripts.
  • Resist adding cross-entity queries to a single repository — coordinate multiple repositories from the service instead.
Last updated June 14, 2026
Was this helpful?