# 04 - Backend Architecture Patterns Established patterns for the NestJS backend of YouTube Studio Flow. These rules define how modules, services, and controllers are structured. For frontend patterns, see [[03 - Component Patterns]]. --- ## Module structure Every feature module follows this layout: ``` modules/feature-name/ feature-name.module.ts feature-name.controller.ts feature-name.service.ts dto/ create-feature.dto.ts update-feature.dto.ts ``` The `dto/` folder is optional for read-only modules, but required for any module that accepts request bodies. --- ## Controller responsibilities Controllers handle routing and guards only. They must not contain business logic. **What belongs in a controller:** - Route decorators (`@Get`, `@Post`, `@Patch`, `@Delete`) - Guard declarations (`@UseGuards`) - Role decorators (`@Roles`) - Extracting `req.user.id` and `req.user.teamId` - Calling one service method and returning the result **What does not belong in a controller:** - Database queries - Conditional logic - Transformations beyond passing arguments ### Auth pattern ```typescript @UseGuards(JwtAuthGuard) @Controller('blocks') export class BlocksController { // Read operations — JwtAuthGuard on class is sufficient @UseGuards(JwtAuthGuard, RolesGuard) @Roles(TeamRole.EDITOR) @Post() create(@Request() req, @Body() dto: CreateBlockDto) { return this.blocksService.create(req.user.teamId, req.user.id, dto); } } ``` - `JwtAuthGuard` on the class: all routes require authentication - `RolesGuard` + `@Roles(TeamRole.EDITOR)` on write methods only --- ## Service responsibilities All business logic lives in services. **What belongs in a service:** - All database access via `PrismaService` - Team scoping on every query - Audit logging for every mutation - Queue enqueue calls - Validation that requires database state Services receive `teamId` and `actorId` as arguments from the controller — they never extract these from a request object. --- ## Team scoping — the cardinal rule Every database query must be scoped to the current team. The ownership chain is: ``` Team → Channel → Video ``` For video queries, the scope travels through the channel: ```typescript // CORRECT await this.prisma.video.findMany({ where: { channel: { teamId } }, }); // WRONG — missing team scope await this.prisma.video.findMany({ where: { id: videoId }, }); ``` For resources owned directly by a team (blocks, templates, collaborators, variables): ```typescript await this.prisma.descriptionBlock.findMany({ where: { teamId }, }); ``` Violating team scoping is a data leak between tenants. There are no exceptions. --- ## Audit logging Required for every user-facing mutation: create, update, and delete. Background/system operations (queue processors, scheduled jobs) do not get audit logs. **Setup — in the module:** ```typescript @Module({ imports: [AuditModule, PrismaModule], controllers: [BlocksController], providers: [BlocksService], }) export class BlocksModule {} ``` **Usage — in the service:** ```typescript constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, ) {} async update(teamId: string, actorId: string, id: string, dto: UpdateBlockDto) { const before = await this.prisma.descriptionBlock.findUnique({ where: { id } }); const after = await this.prisma.descriptionBlock.update({ where: { id }, data: dto, }); await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, after); return after; } ``` Tracked entity types: `Video`, `VideoConfig`, `DescriptionBlock`, `Template`, `Collaborator`, `TeamVariable`, `SavedView`, `TeamMember`. --- ## Render engine usage Never fetch blocks, variables, or collaborators manually to construct a render. Always go through `VideoRenderService`: ```typescript // In module imports: // VideoRenderModule (re-exports VideoRenderService) constructor(private readonly videoRenderService: VideoRenderService) {} const { rendered, hash } = await this.videoRenderService.render(videoId); ``` `VideoRenderService` is the single source of truth for fetching render data and delegating to `RenderEngineService`. The only exceptions are `video-configs.service.ts` and `templates.service.ts`, which may call `RenderEngineService` directly for preview rendering because they already hold all the data. See the CLAUDE.md render engine section for the full block type behavior reference. --- ## YouTube API quota All YouTube write operations cost quota. `videos.update` costs 50 units. The daily budget is 10,000 units, resetting at midnight Pacific Time. Required pattern before every YouTube write: ```typescript const ok = await this.quotaService.canSpend(50); if (!ok) throw new Error('Quota exceeded'); await this.quotaService.spend(50, 'videos.update', { videoId, channelId }); // ... make the YouTube API call ``` Never make a YouTube write call without checking quota first. --- ## Queue enqueue pattern Jobs are enqueued with a deterministic `jobId` to prevent duplicate queuing: ```typescript // Standard enqueue — deduplicates by videoId: await this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}`, }); // Forced rerun — bypass deduplication: await this.lintQueue.add('lint', { videoId }, { jobId: `lint-${videoId}-${Date.now()}`, }); ``` Queue names are defined in a `QUEUES` constant. Job name strings use kebab-case: `'lint'`, `'youtube-sync'`, `'render'`. --- ## Prisma enum rule Never remove a value from a Prisma enum. Removing an enum value in PostgreSQL requires a raw SQL migration and risks data loss if existing rows reference the removed value. When a concept is retired from the UI, mark it as deprecated in comments and hide it from the frontend. Leave the enum value in the schema. Current example: `BlockType.GLOBAL` and `BlockType.REPEATABLE` are removed from the UI but remain in the DB enum. --- ## Schema change workflow After any change to `backend/prisma/schema.prisma`: ```bash # 1. Stop the running backend process npx prisma generate # regenerates the Prisma client npx prisma migrate deploy # applies pending migrations # 2. Restart the backend ``` Run these from the `backend/` directory. Both the API process (`src/main.ts`) and the worker process (`src/worker.ts`) must be restarted. --- ## Error handling Use NestJS built-in HTTP exceptions at service boundaries. Do not add generic `try/catch` blocks for errors that cannot occur. | Situation | Exception | |---|---| | Entity not found | `NotFoundException` | | Team scoping violation | `ForbiddenException` | | Invalid input state | `BadRequestException` | ```typescript const block = await this.prisma.descriptionBlock.findUnique({ where: { id } }); if (!block) throw new NotFoundException(`Block ${id} not found`); if (block.teamId !== teamId) throw new ForbiddenException(); ``` Let NestJS handle unhandled exceptions. The default exception filter returns structured error responses with the correct HTTP status codes.