DEV Community

Cover image for Building a Choose-Your-Own-Adventure API with NestJS — Part 2: Persistence
Rodolphe D.
Rodolphe D.

Posted on

Building a Choose-Your-Own-Adventure API with NestJS — Part 2: Persistence

Part 2 of a series building "Grimoire API" — a choose-your-own-adventure backend in NestJS, learned one milestone at a time. Part 1 covered controllers, providers, DI and validation with a single read-only endpoint. This time: making a player's progress survive a server restart.

The problem with Part 1

By the end of Part 1, GET /pages/:id worked — but every request started from scratch. There was no concept of "a player," let alone where they currently were in the story. To move a player from one page to the next, something has to remember that between requests. That's what a database is for.

Picking the pieces

For this project I'm using PostgreSQL with TypeORM — NestJS's most common ORM pairing, well documented, and closely integrated with NestJS's dependency injection (you @InjectRepository() a repository the same way you inject a service).

I'm running Postgres locally through Docker Compose rather than installing it directly — one docker compose up and it's there, one docker compose down and it's gone, no leftover system service to remember about.

The entity

An entity is a TypeORM class that maps to a database table. Here's PlayerProgress:

// src/progress/entities/player-progress.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';

@Entity()
export class PlayerProgress {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @OneToOne(() => User)
  @JoinColumn()
  user: User;

  @Column({ default: 'page-001' })
  currentPageId: string;

  @Column({ default: 0 })
  xp: number;
}
Enter fullscreen mode Exit fullscreen mode

Two decorators do the heavy lifting: @Entity() says "this is a table," @Column() says "this is a column." @OneToOne + @JoinColumn describe the relationship to User — one player, one progress record.

Notice what's not here: no level column. The spec I wrote before touching any code was explicit about this — level is always calculated from XP, never stored as an independent value. If I stored both, sooner or later they'd drift out of sync (say, a bug that updates XP but forgets to bump level), and now the API is lying to the player about their own progress. Deriving it removes the possibility entirely — there's nothing to get out of sync with itself.

Wiring TypeORM into the module

// src/progress/progress.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlayerProgress } from './entities/player-progress.entity';
import { ProgressService } from './progress.service';
import { ProgressController } from './progress.controller';

@Module({
  imports: [TypeOrmModule.forFeature([PlayerProgress])],
  controllers: [ProgressController],
  providers: [ProgressService],
})
export class ProgressModule {}
Enter fullscreen mode Exit fullscreen mode

TypeOrmModule.forFeature([PlayerProgress]) is what makes @InjectRepository(PlayerProgress) available for injection inside this module — it's the same DI pattern from Part 1, just applied to a repository instead of a plain service.

The repository pattern

Inside the service, I don't write raw SQL — I use the repository, an object TypeORM generates for each entity with methods like findOne, save, update:

// src/progress/progress.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PlayerProgress } from './entities/player-progress.entity';

@Injectable()
export class ProgressService {
  constructor(
    @InjectRepository(PlayerProgress)
    private readonly progressRepo: Repository<PlayerProgress>,
  ) {}

  async findForUser(userId: string): Promise<PlayerProgress> {
    const progress = await this.progressRepo.findOne({
      where: { user: { id: userId } },
    });

    if (!progress) {
      throw new NotFoundException('No progress found for this player');
    }

    return progress;
  }

  async advance(userId: string, nextPageId: string): Promise<PlayerProgress> {
    const progress = await this.findForUser(userId);
    progress.currentPageId = nextPageId;
    return this.progressRepo.save(progress);
  }
}
Enter fullscreen mode Exit fullscreen mode

Everything is async now — talking to a database is I/O, and NestJS (like the rest of Node) expects that to be handled with promises, not callbacks.

A temporary shortcut: no auth yet

This project's milestones are deliberately ordered so authentication (Part 4) comes after persistence. That leaves an awkward gap right now: advance() needs a userId, but there's no login system yet to produce one.

For this milestone, I'm sidestepping it with a single hardcoded "default player" seeded at startup, and the endpoint doesn't take a userId at all:

@Post('choice')
async advance(@Body() dto: AdvanceProgressDto) {
  return this.progressService.advance(DEFAULT_PLAYER_ID, dto.nextPageId);
}
Enter fullscreen mode Exit fullscreen mode

It's not a design I'd keep — it's a conscious, temporary simplification so I can build and test persistence in isolation, without also having to build auth at the same time. Part 4 removes DEFAULT_PLAYER_ID entirely and replaces it with whichever user the JWT identifies. Calling out a shortcut like this in the code (and in this post) matters — future-me needs to know it's not the final shape.

Migrations vs. synchronize: true

NestJS's TypeORM starter examples often show synchronize: true, which auto-updates your database schema to match your entities. Convenient for a demo, dangerous for anything else — it can drop columns based on nothing but code changes, no history, no way to review what's about to happen.

Instead I generated a real migration:

npx typeorm migration:generate src/migrations/CreatePlayerProgress -d src/data-source.ts
npx typeorm migration:run -d src/data-source.ts
Enter fullscreen mode Exit fullscreen mode

This produces a plain TypeScript file with up()/down() methods — reviewable in a PR, reversible, and it's the same workflow I'd use against a real production database later.

What's next

Progress now survives a restart, but the actual game rules — how much XP a choice grants, when a level-up happens, when a badge unlocks — are still just sitting inert on the choices in the JSON content. Part 3 pulls that logic out into dedicated services.

Top comments (0)