Back to blog
Sep 07, 2025
7 min read

Old Pattern, New Stack: Unit of Work + Repositories in Next.js

You won't find this in the docs, but it's a pragmatic approach to implementing Unit of Work + Repository + DTO patterns in Next.js v15 with MongoDB, without over-abstracting yourself into misery.

Old Pattern, New Stack: Unit of Work + Repositories in Next.js

I recently finished Patterns of Enterprise Application Architecture by Martin Fowler. It’s DHH’s favourite book and described Fowler’s Active Record pattern heavily influenced Ruby on Rails. Twenty-five years on, patterns in this book like Unit of Work, Repository, Data Transfer Object + Unit of Work + Service + Repository still have their place. There’s a very strong argument Clean Code, Gang of Four, and OOP patterns have less relevance in modern software development.

Unit of Work: A pattern that maintains a list of objects affected by a business transaction and coordinates writing out changes and resolving concurrency problems.

Service: A layer that contains application logic and orchestrates operations between domain objects and repositories to implement business use cases.

Repository: A pattern that encapsulates the logic needed to access data sources, centralizing common data access functionality and providing better maintainability and decoupling.

Data Transfer Object: A simple object that carries data between processes or layers without containing any business logic, used to reduce the number of method calls between systems.

Whatever your opinion about old heads like Martin Fowler, Kent Beck, Uncle Bob - you should at least read what they have to say. Modern server frameworks like Next.js (App Router) may tempt you drop business logic straight into route handlers or Server Actions. This post demonstrates a real use-case for enterprise patterns in a Next.js v15 codebase with MongoDB, without over-abstracting yourself into a big ball of mud..

ADR: Every implementation is a trade-off

Pros: Domain Driven Design, Separation of Concerns, Testability, Transactions, Idempotency & Consistency, Flexibility

Cons: Over-engineering, Boilerplate, Complexity, Learning Curve, Philosophical Differences

Why bother?

  • Single responsibility: HTTP concerns live in routes; business rules live in services; persistence details live in repos
  • Testability: mock repos to unit-test your use cases; integration-test your route thinly
  • Transactions: a UoW holds your DB session and coordinates an atomic workflow
  • Idempotency & consistency: easy to enforce when the orchestration is in one place
  • Flexibility: Separation of concerns allows you to swap out implementations easily

When to reach for it

  • A route touches multiple collections or must be atomic
  • You have derived state to maintain (e.g., author summaries)
  • You need reusable use cases (admin tools, jobs, HTTP)

If your handler is “read one, write one,” don’t cargo-cult the pattern—keep it simple.

Minimal contracts (TypeScript)

Use Data Transfer Objects to keep your contracts tiny; grow them only when needed.

Model

// models/AuthorDTO.ts
export interface AuthorDTO {
  id: string;
  hasTips: boolean;
  latestTipTimestamp: Date | null;
}
export type CloseTipDTO = {
  marketId: string;
  closedCounts: { sports: number; racing: number; single: number; total: number };
  authors: AuthorDTO[];
};

Service

// services/TipsService.ts (interface)

export interface TipsService {
  closeAcrossAllCollections(marketId: string): Promise<CloseTipDTO>;
}

Repositories

// repositories/TipsRepo.ts (interface)
export interface TipsRepo {
  closeByMarketId(marketId: string, closedAt: Date): Promise<{ modifiedCount?: number }>;
  distinctAuthorsByMarketId(marketId: string): Promise<string[]>;
  latestOpenByAuthors(authorIds: string[]): Promise<Record<string, Date>>; // authorId -> max(createdAt)
}
export interface AuthorsRepo {
  bulkUpdateSummaries(dtos: AuthorDTO[]): Promise<void>;
}

UnitOfWork

// uow/UnitOfWork.ts
import type { ClientSession } from "mongodb";
export interface UnitOfWork {
  withTransaction<T>(fn: (uow: UnitOfWork) => Promise<T>): Promise<T>;
  getSession(): ClientSession | undefined;
}

Concrete example: “Close tips” use case

Goal: Given a marketId, close tips across three collections (sports, racing, single) and recompute per-author summary (hasTips, latestTipTimestamp) across all collections.

Repositories (Concrete Implementations)

Each tips repo is the same shape; only the collection name differs. If our schema, queries, or whatever changes, we’re safe.

SportsEventTipsRepo

// repositories/SportsEventTipsRepo.ts (same pattern for Racing/Single)
export class SportsEventTipsRepo {
  constructor(private readonly db: Connection) {}
  private col() { return this.db.collection("sports-event-tips"); }

  closeByMarketId(marketId: string, closedAt: Date) {
    return this.col().updateMany({ marketId, closedAt: null }, { $set: { closedAt } });
  }

  async distinctAuthorsByMarketId(marketId: string) {
    const raw = await this.col().distinct("authorId", { marketId });
    return raw.map((v: any) => v instanceof ObjectId ? v.toString() : String(v));
  }

  async latestOpenByAuthors(authorIds: string[]) {
    if (!authorIds.length) return {};
    const ids = authorIds.map(id => ObjectId.isValid(id) ? new ObjectId(id) : id);
    const rows = await this.col().aggregate<{ authorId: any; latest: Date }>([
      { $match: { authorId: { $in: ids }, closedAt: null } },
      { $group: { _id: "$authorId", latest: { $max: "$createdAt" } } },
      { $project: { _id: 0, authorId: "$_id", latest: 1 } },
    ]).toArray();
    return Object.fromEntries(rows.map(r => [r.authorId.toString(), r.latest]));
  }
}
// repositories/AuthorsRepo.ts
export class AuthorsRepo {
  constructor(private readonly db: Connection) {}
  private col() { return this.db.collection("authors"); }

  async bulkUpdateSummaries(dtos: AuthorDTO[]) {
    if (!dtos.length) return;
    const ops = dtos.map(({ id, hasTips, latestTipTimestamp }) => ({
      updateOne: {
        filter: { _id: ObjectId.isValid(id) ? new ObjectId(id) : id },
        update: { $set: { hasTips, latestTipTimestamp } },
        upsert: false,
      },
    }));
    await this.col().bulkWrite(ops as any, { ordered: false });
  }
}

Service


export class CloseTipsService implements TipsService {
  constructor(
    private sports: SportsEventTipsRepo,
    private racing: RacingEventTipsRepo,
    private single: SingleTipsRepo,
    private authors: AuthorsRepo
  ) {}

  async closeAcrossAllCollections(marketId: string): Promise<CloseTipsDTO> {
    const closedAt = new Date();

    const [s, r, si] = await Promise.all([
      this.sports.closeByMarketId(marketId, closedAt),
      this.racing.closeByMarketId(marketId, closedAt),
      this.single.closeByMarketId(marketId, closedAt),
    ]);

    const ids = Array.from(new Set([
      ...(await this.sports.distinctAuthorsByMarketId(marketId)),
      ...(await this.racing.distinctAuthorsByMarketId(marketId)),
      ...(await this.single.distinctAuthorsByMarketId(marketId)),
    ]));

    let authors: AuthorDTO[] = [];
    if (ids.length) {
      const [sLatest, rLatest, siLatest] = await Promise.all([
        this.sports.latestOpenByAuthors(ids),
        this.racing.latestOpenByAuthors(ids),
        this.single.latestOpenByAuthors(ids),
      ]);

      authors = ids.map(id => {
        const latest = maxDate(sLatest[id], rLatest[id], siLatest[id]) ?? null;
        return { id, hasTips: latest !== null, latestTipTimestamp: latest };
      });

      await this.authors.bulkUpdateSummaries(authors);
    }

    const sports = s.modifiedCount ?? 0;
    const racing = r.modifiedCount ?? 0;
    const single = si.modifiedCount ?? 0;

    return {
      marketId,
      closedCounts: { sports, racing, single, total: sports + racing + single },
      authors,
    };
  }
}

Route (very thin)

// app/api/close-tip/[marketId]/route.ts

export async function PATCH(_req: NextRequest, { params }: { params: Promise<{ marketId: string }> }) {
  const { marketId } = await params;
  if (!marketId) return NextResponse.json({ error: "marketId is required" }, { status: 400 });

  const payload = await getPayload({ config });
  const conn = payload.db.connection;

  const svc = new CloseTipsService(
    new SportsEventTipsRepo(conn),
    new RacingEventTipsRepo(conn),
    new SingleTipsRepo(conn),
    new AuthorsRepo(conn)
  );

  const result = await svc.closeAcrossAllCollections(marketId);
  return NextResponse.json({ ok: true, ...result });
}

Pitfalls (and how to dodge them)

  • Updating the wrong key: filter by _id, not id. Normalize IDs once in repo code (this was a real headache, particularly for those new to MongoDB)
  • Looooong and complex agregation pipelines: Aggregations are very powerful, but they can be very complex and slow if not optimised. You need to be careful with indexes and hot-paths.

Adopting incrementally

  1. Start with your current route code
  2. Extract tiny repos (just the three methods)
  3. Extract the service (a single method that calls the repos)
  4. (Optional) Add UoW to wrap everything in a transaction once you move to a replica set/Atlas

TL;DR

UoW + Service + Repository isn’t “old”—it’s proven. In Next.js App Router, it keeps routes thin, business rules obvious, and persistence details contained. Start with minimal interfaces, wire one use case, add transactions when you need them. Your future self (and your tests) will thank you.