Files
youtube-studio-flow/documentation/YouTube Studio Flow/04 - Design Guidelines/04 - Backend Architecture Patterns.md
T

6.9 KiB

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

@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:

// 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):

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:

@Module({
  imports: [AuditModule, PrismaModule],
  controllers: [BlocksController],
  providers: [BlocksService],
})
export class BlocksModule {}

Usage — in the service:

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:

// 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:

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:

// 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:

# 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
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.