Introduce a repository only when separating persistence behavior creates a real testing or architecture benefit.
Course navigationPhase 24⌄
Repository Pattern
Introduce repository boundaries only where they improve persistence isolation, domain language, testing, or future changeability.
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.
Principles to keep while coding
A repository should express persistence operations in application terms rather than mirror every ORM method.
Keep Prisma-specific details from leaking through layers if the repository is intended to create a meaningful boundary.
Do not add repository abstractions to trivial CRUD solely because the pattern exists.
Guided phase lab
Do these in order. Each step should end with evidence before the next one starts.
- 01
Choose a service whose persistence queries are complex enough to justify a boundary.
- 02
Write repository methods in application language rather than generic findAll/create/update/delete.
- 03
Move Prisma-specific selection/query details into the implementation.
- 04
Decide whether persistence types may cross the boundary or need mapping and document the trade-off.
- 05
Unit-test service behavior with a fake only where it proves useful behavior.
- 06
Integration-test the real repository against PostgreSQL so fake and implementation do not drift.
Failures you should know how to diagnose
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.
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.
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.
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.
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.
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
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
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
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
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
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
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
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
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
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
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
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,
Guided Build Lab
Purpose Introduce repository boundaries only where they improve persistence isolation, domain language, testing, or future changeability.
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.
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?
Mastery Capstone Lab
Integrate Phase 24 without following a transcript.
Exercises that increase independence
Choose a service whose persistence queries are complex enough to justify a boundary.
Move Prisma-specific selection/query details into the implementation.
reproduce and diagnose — GenericRepository<T> mirroring ORM methods.
deliver One TaskFlow aggregate/use case refactored behind a purposeful repository interface with real database integration tests.
Can explain when not to use a repository and Can design task-specific repository methods.
Do not leave the phase only because you reached the bottom
Phase exit criteria
Scenario and concept quiz
These questions test engineering choices rather than whether you remember a lesson title.
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?: stringreference/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()
})
})