Course navigationPhase 24
Volume 07 · Phase 24

Repository Pattern

Introduce repository boundaries only where they improve persistence isolation, domain language, testing, or future changeability.

16lessons + labs
4klesson words
10source references
What you will ship

Phase outcome and project increment

Learning outcome

Introduce repository boundaries only where they improve persistence isolation, domain language, testing, or future changeability.

Concrete deliverable

One TaskFlow aggregate/use case refactored behind a purposeful repository interface with real database integration tests.

Figure 24.1 — Where this phase lives in TaskFlow
service/use case
repository interface
Prisma implementation
database
mapped domain/application result
Trace the flow left-to-right for implementation and right-to-left for debugging. At every arrow, ask what data crosses the boundary and what proves it is trustworthy.
Instructor mental model

Principles to keep while coding

01

Introduce a repository only when separating persistence behavior creates a real testing or architecture benefit.

02

A repository should express persistence operations in application terms rather than mirror every ORM method.

03

Keep Prisma-specific details from leaking through layers if the repository is intended to create a meaningful boundary.

04

Do not add repository abstractions to trivial CRUD solely because the pattern exists.

Code-along path

Guided phase lab

Do these in order. Each step should end with evidence before the next one starts.

  1. 01

    Choose a service whose persistence queries are complex enough to justify a boundary.

  2. 02

    Write repository methods in application language rather than generic findAll/create/update/delete.

  3. 03

    Move Prisma-specific selection/query details into the implementation.

  4. 04

    Decide whether persistence types may cross the boundary or need mapping and document the trade-off.

  5. 05

    Unit-test service behavior with a fake only where it proves useful behavior.

  6. 06

    Integration-test the real repository against PostgreSQL so fake and implementation do not drift.

Debugging studio

Failures you should know how to diagnose

Failure drill 1

GenericRepository<T> mirroring ORM methods

Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.

Failure drill 2

Repository wrapping every trivial Prisma call with no value

Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.

Failure drill 3

Mocks proving an imaginary database behavior

Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.

Failure drill 4

Mapping everything into duplicate types without a domain need

Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.

Watch-order style

Lessons

The source chapters come first. Every phase now ends with a guided build, a debugging workshop, and an independent challenge so reading turns into transferable skill.

01

When a Repository Earns Its Place

When a Repository Earns Its Place Volume 07 - Advanced Application Architecture | Phase 24 The smell is not Prisma itself Prisma is already a strong type-safe data access API. Wrapping every

2 min
02

Design the TaskRepository Contract

Design the TaskRepository Contract Volume 07 - Advanced Application Architecture | Phase 24 Design from the consumer inward Start from what TasksService actually needs. Do not start by repro

2 min
03

NestJS DI Tokens and Module Binding

NestJS DI Tokens and Module Binding Volume 07 - Advanced Application Architecture | Phase 24 Interfaces disappear at runtime TypeScript interfaces are compile-time only, so NestJS cannot inj

2 min
04

Prismataskrepository Query and Mapping Implementation

PrismaTaskRepository: Query and Mapping Implementation Volume 07 - Advanced Application Architecture | Phase 24 Persistence details belong here The implementation owns Prisma relation filter

2 min
05

Refactor TasksService to Business Intent

Refactor TasksService to Business Intent Volume 07 - Advanced Application Architecture | Phase 24 Before A service method that mixes policy, Prisma query construction, mapping, and database

2 min
06

Transactions and the Repository Boundary

Transactions and the Repository Boundary Volume 07 - Advanced Application Architecture | Phase 24 Repositories make transactions harder if designed carelessly A transaction spanning Task, Me

2 min
07

Query Specific Repositories vs Generic CRUD

Query-Specific Repositories vs Generic CRUD Volume 07 - Advanced Application Architecture | Phase 24 The generic repository trap Generic repository trap GenericRepository<T> TaskRepository f

2 min
08

Unit Testing with a Fake Repository

Unit Testing with a Fake Repository Volume 07 - Advanced Application Architecture | Phase 24 The repository pays rent in tests NestJS makes provider replacement straightforward. Instead of t

2 min
09

Repository Integration Tests with PostgreSQL

Repository Integration Tests with PostgreSQL Volume 07 - Advanced Application Architecture | Phase 24 Test the real adapter separately The concrete Prisma repository should be tested against

2 min
10

Mapping Persistence Types and Leakage Tradeoffs

Mapping, Persistence Types and Leakage Trade-offs Volume 07 - Advanced Application Architecture | Phase 24 Three choices A repository can return Prisma-generated types, application-owned str

2 min
11

Incremental Adoption and Anti Patterns

Incremental Adoption and Anti-Patterns Volume 07 - Advanced Application Architecture | Phase 24 Do not big-bang refactor TaskFlow migrates read/list and straightforward write paths first. Tr

2 min
12

Hands On Lab and Phase Exit

Hands-On Lab and Phase Exit Volume 07 - Advanced Application Architecture | Phase 24 Lab 1 - Identify pressure Open the current TasksService. Mark each line as HTTP concern, business policy,

2 min
LAB

Guided Build Lab

Purpose Introduce repository boundaries only where they improve persistence isolation, domain language, testing, or future changeability.

3 min
LAB

Debugging Workshop

Goal Learn to diagnose this phase's failures systematically instead of reinstalling packages, changing random configuration, or copying a fix without understanding it.

2 min
LAB

Phase Synthesis & Independent Challenge

Mission Rebuild the capability of Phase 24 without following the exact code line-by-line. The goal is transfer: can you use the ideas when the variable names, UI, or route shape changes?

2 min
LAB

Mastery Capstone Lab

Integrate Phase 24 without following a transcript.

2 min
Practice ladder

Exercises that increase independence

Guided

Choose a service whose persistence queries are complex enough to justify a boundary.

Practice

Move Prisma-specific selection/query details into the implementation.

Debug

reproduce and diagnose — GenericRepository<T> mirroring ORM methods.

Independent

deliver One TaskFlow aggregate/use case refactored behind a purposeful repository interface with real database integration tests.

Explain-back

Can explain when not to use a repository and Can design task-specific repository methods.

Mastery gate

Do not leave the phase only because you reached the bottom

Phase exit criteria

Knowledge check

Scenario and concept quiz

These questions test engineering choices rather than whether you remember a lesson title.

0/0 correct · 0/8 answered
01Which practice best matches Phase 24's engineering goal?
02Which practice best matches Phase 24's engineering goal?
03Which practice best matches Phase 24's engineering goal?
04Which practice best matches Phase 24's engineering goal?
05You are working through the Phase 24 lab. Which next action is the most disciplined step?
06You are working through the Phase 24 lab. Which next action is the most disciplined step?
07You are working through the Phase 24 lab. Which next action is the most disciplined step?
08You are working through the Phase 24 lab. Which next action is the most disciplined step?
Source snapshot

Reference files

Attempt the work first. Use snapshots to compare a checkpoint, not as a substitute for understanding.

reference/TaskFlow_Repository_Pattern_Snapshot/ARCHITECTURE_DECISION.md
# ADR - Introduce TaskRepository incrementally

## Decision
Introduce TaskRepository for complex/scoped Task persistence queries and straightforward writes. Do not create GenericRepository<T>. Do not force all cross-feature transactions behind repositories yet.

## Why now
TasksService now contains enough persistence-specific query construction that it obscures business intent and complicates unit testing.

## Consequences
- Service unit tests can use a fake repository.
- Prisma query behavior moves into integration-tested adapter code.
- Some direct Prisma orchestration may remain temporarily where transaction correctness would otherwise become less clear.
reference/TaskFlow_Repository_Pattern_Snapshot/README.md
# TaskFlow Phase 24 - Repository Pattern

This snapshot introduces a focused TaskRepository boundary without converting the entire application into a generic repository architecture.

Default architecture:
- TasksService owns business policy and workflow meaning.
- TaskRepository defines persistence operations the service actually needs.
- PrismaTaskRepository implements those operations with Prisma 7.9.1.
- Cross-feature transaction abstraction is intentionally deferred until repeated pressure justifies it.
reference/TaskFlow_Repository_Pattern_Snapshot/SOURCES.md
# Sources

- NestJS - Custom providers: https://docs.nestjs.com/fundamentals/custom-providers - Interfaces are erased at runtime; custom string/symbol tokens can bind an interface contract to a concrete provider.
- NestJS - Testing: https://docs.nestjs.com/fundamentals/testing - TestingModule can replace/override providers so business services can be tested against doubles.
- Prisma ORM v7 - Unit testing: https://docs.prisma.io/docs/orm/prisma-client/testing/unit-testing - Prisma documents dependency injection and mocking as valid unit-testing approaches.
- Prisma ORM v7 - Best practices: https://www.prisma.io/docs/orm/v7/more/best-practices - Current Prisma guidance separates unit tests with mocks from integration tests against a resettable database.
- Prisma ORM v7 - Transactions: https://docs.prisma.io/docs/orm/v7/prisma-client/queries/transactions - Nested writes, batch transactions, and interactive transactions remain persistence-specific concerns.
reference/TaskFlow_Repository_Pattern_Snapshot/apps/api/src/tasks/repositories/prisma-task.repository.ts
import { Injectable } from "@nestjs/common"
import { PrismaService } from "../../prisma/prisma.service"
import type { Prisma } from "../../generated/prisma/client"
import type { PaginatedTasks, TaskListQuery, TaskRepository, CreateTaskRecord, UpdateTaskRecord } from "./task.repository"

@Injectable()
export class PrismaTaskRepository implements TaskRepository {
  constructor(private readonly prisma: PrismaService) {}

  findById(id: string) {
    return this.prisma.task.findUnique({ where: { id } })
  }

  findScopedById(taskId: string, userId: string) {
    return this.prisma.task.findFirst({
      where: {
        id: taskId,
        project: {
          workspace: {
            memberships: { some: { userId } },
reference/TaskFlow_Repository_Pattern_Snapshot/apps/api/src/tasks/repositories/task.repository.ts
import type { TaskPriority, TaskStatus } from "../../generated/prisma/client"

export const TASK_REPOSITORY = Symbol("TASK_REPOSITORY")

export type TaskRecord = {
  id: string
  projectId: string
  assigneeId: string | null
  title: string
  description: string | null
  status: TaskStatus
  priority: TaskPriority
  dueAt: Date | null
  completedAt: Date | null
  createdAt: Date
  updatedAt: Date
}

export type TaskListQuery = {
  projectId?: string
reference/TaskFlow_Repository_Pattern_Snapshot/apps/api/src/tasks/tasks.module.phase24.ts
import { Module } from "@nestjs/common"
import { TasksController } from "./tasks.controller"
import { TasksService } from "./tasks.service.phase24"
import { TASK_REPOSITORY } from "./repositories/task.repository"
import { PrismaTaskRepository } from "./repositories/prisma-task.repository"

@Module({
  controllers: [TasksController],
  providers: [
    TasksService,
    {
      provide: TASK_REPOSITORY,
      useClass: PrismaTaskRepository,
    },
  ],
  exports: [TasksService],
})
export class TasksModule {}
reference/TaskFlow_Repository_Pattern_Snapshot/apps/api/src/tasks/tasks.service.phase24.ts
import { Inject, Injectable } from "@nestjs/common"
import { TASK_REPOSITORY, type TaskRepository } from "./repositories/task.repository"

@Injectable()
export class TasksService {
  constructor(
    @Inject(TASK_REPOSITORY)
    private readonly tasks: TaskRepository,
  ) {}

  async findOneForUser(taskId: string, userId: string) {
    const task = await this.tasks.findScopedById(taskId, userId)
    if (!task) {
      // Reuse the Phase 13/19 application exception contract.
      throw new Error("Task not found in authorized scope")
    }
    return task
  }

  async listForUser(userId: string, query: Parameters<TaskRepository["listScoped"]>[1]) {
reference/TaskFlow_Repository_Pattern_Snapshot/tests/fake-task.repository.ts
import type { CreateTaskRecord, PaginatedTasks, TaskListQuery, TaskRecord, TaskRepository, UpdateTaskRecord } from "../apps/api/src/tasks/repositories/task.repository"

export class FakeTaskRepository implements TaskRepository {
  readonly items = new Map<string, TaskRecord>()

  async findById(id: string) { return this.items.get(id) ?? null }
  async findScopedById(id: string, _userId: string) { return this.items.get(id) ?? null }

  async listScoped(_userId: string, query: TaskListQuery): Promise<PaginatedTasks> {
    const all = [...this.items.values()]
    const start = (query.page - 1) * query.pageSize
    const data = all.slice(start, start + query.pageSize)
    const totalItems = all.length
    const totalPages = Math.ceil(totalItems / query.pageSize)
    return { data, meta: { page: query.page, pageSize: query.pageSize, totalItems, totalPages, hasPreviousPage: query.page > 1, hasNextPage: query.page < totalPages } }
  }

  async create(input: CreateTaskRecord) {
    const now = new Date()
    const task = { id: crypto.randomUUID(), projectId: input.projectId, assigneeId: null, title: input.title, description: input.description ?? null, status: "TODO" as const, priority: "MEDIUM" as const, dueAt: null, completedAt: null, createdAt: now, updatedAt: now }
reference/TaskFlow_Repository_Pattern_Snapshot/tests/prisma-task.repository.integration-checklist.md
# PrismaTaskRepository integration checklist

Use a dedicated resettable PostgreSQL test database.

- Apply the same Prisma migrations used by development.
- Seed two Users, two Workspaces, Memberships, Projects and Tasks.
- listScoped(userA) never returns Workspace B data.
- findScopedById(userA, taskB) returns null.
- status/priority/search filters produce the expected rows.
- page metadata and row query share the same filter.
- stable sort uses id as a tiebreaker.
- nullable assignee and dueAt values map correctly.
- database errors are translated at the appropriate boundary.
reference/TaskFlow_Repository_Pattern_Snapshot/tests/tasks.service.repository.spec.ts
import { Test } from "@nestjs/testing"
import { TASK_REPOSITORY } from "../apps/api/src/tasks/repositories/task.repository"
import { TasksService } from "../apps/api/src/tasks/tasks.service.phase24"
import { FakeTaskRepository } from "./fake-task.repository"

describe("TasksService repository boundary", () => {
  it("can replace persistence without starting PostgreSQL", async () => {
    const fake = new FakeTaskRepository()
    const moduleRef = await Test.createTestingModule({
      providers: [
        TasksService,
        { provide: TASK_REPOSITORY, useValue: fake },
      ],
    }).compile()

    const service = moduleRef.get(TasksService)
    expect(service).toBeDefined()
  })
})