DEV Community

Cover image for Building a Choose-Your-Own-Adventure API with NestJS — Part 1: The Foundation
Rodolphe D.
Rodolphe D.

Posted on

Building a Choose-Your-Own-Adventure API with NestJS — Part 1: The Foundation

This is the first post in a series where I learn backend development by building "Grimoire API" — a REST API for a choose-your-own-adventure book, complete with XP and badges (because why not gamify learning to code?). If you know a bit of JavaScript and have played with Express, but NestJS still looks like a wall of decorators, this post is for you.

Why a text adventure API?

I wanted a project I'd actually stay motivated to finish. "Build a generic CRUD API" is the kind of tutorial I abandon after day two. So instead, I'm building the backend for a livre dont vous êtes le héros — a choose-your-own-adventure book — where players read a page, pick a choice, and move forward through the story while earning XP and unlocking badges.

The learning goal comes first, the game is the excuse. Every milestone in this project maps to a specific NestJS concept I want to actually understand, not just copy-paste.

This post covers Milestone 1: The Foundation — the very first working endpoint: GET /pages/:id, which returns one page of the story as JSON.

Where I was starting from

I'd written basic Express routes before — app.get('/something', (req, res) => {...}), that kind of thing. Simple, direct, no magic. NestJS, on the other hand, throws a lot of new vocabulary at you on day one: modules, controllers, providers, dependency injection, decorators, DTOs. It's easy to feel like you need to understand the whole framework before writing a single line.

Turns out you don't. Here's the minimum you need to build one real endpoint.

The four building blocks

NestJS organizes code around four ideas. Once they click, everything else is a variation on them.

1. Controllers — the front door

A controller's only job is to receive an HTTP request and return a response. It should not contain business logic. Mine looks like this:

@Controller('pages')
export class PagesController {
  constructor(private readonly pagesService: PagesService) {}

  @Get(':id')
  getPage(@Param() params: GetPageParamsDto) {
    return this.pagesService.findById(params.id);
  }
}
Enter fullscreen mode Exit fullscreen mode

@Controller('pages') says "everything in here handles requests under /pages". @Get(':id') says "and this method handles GET /pages/:id". That's it — the controller doesn't know how to find a page, it just asks something else to do it.

2. Providers (services) — where the logic lives

That "something else" is a service — in NestJS terms, a provider. It holds the actual behavior:

@Injectable()
export class PagesService {
  findById(id: string): Page {
    // read the JSON file, parse it, return it
    // (or throw a 404 if it doesn't exist)
  }
}
Enter fullscreen mode Exit fullscreen mode

The split matters: the controller deals with HTTP, the service deals with the story. If I ever swap how pages are stored — JSON files today, a database later — the controller doesn't change at all.

3. Dependency Injection — the part that felt like magic

Here's the line that confused me the most at first:

constructor(private readonly pagesService: PagesService) {}
Enter fullscreen mode Exit fullscreen mode

I never write new PagesService() anywhere. NestJS creates the instance for me and hands it to the controller automatically. This is dependency injection (DI): instead of a class creating the things it depends on, those things are given to it from outside.

Why bother? Because it makes testing trivial. In my controller test, I don't need a real PagesService reading real files — I can hand it a fake one:

providers: [
  { provide: PagesService, useValue: { findById: jest.fn() } },
],
Enter fullscreen mode Exit fullscreen mode

The controller has no idea it's talking to a fake. That's the whole point.

4. Modules — the glue

A module just declares "these controllers and these providers belong together":

@Module({
  controllers: [PagesController],
  providers: [PagesService],
})
export class PagesModule {}
Enter fullscreen mode Exit fullscreen mode

Then it gets imported into the app's root module. Think of a module as a folder with a table of contents NestJS can read.

The part that actually tripped me up: validation

The route is GET /pages/:id, and :id is just a string typed by whoever calls the API. Nothing stops someone from requesting /pages/../../etc/passwd or /pages/💀. I wanted every page ID to match the format page-XXX (three digits) before it ever reaches my service.

NestJS's answer is a DTO (Data Transfer Object) combined with class-validator:

export class GetPageParamsDto {
  @Matches(/^page-\d{3}$/, {
    message: 'id must match the format page-XXX (3 digits)',
  })
  id: string;
}
Enter fullscreen mode Exit fullscreen mode

And one line in main.ts to turn validation on globally:

app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
Enter fullscreen mode Exit fullscreen mode

That's it. Now GET /pages/page-001 works, and GET /pages/not-a-valid-id gets rejected with a 400 before my controller code even runs. I didn't write a single if statement — the framework did the guarding for me, declaratively, right next to the field it protects.

Where I got stuck

Fair warning if you're following a similar path: the confusing part isn't the syntax, it's the order of trust. I kept wanting to validate manually inside the controller (if (!id.match(...)) throw ...), out of old Express habits. Letting the DTO + pipe handle it before my code runs required trusting a layer I couldn't see executing. Once I added a console.log inside the controller and watched it never fire for a bad ID, that trust clicked.

What's next

Milestone 1 ends with one working, validated endpoint and a passing test suite (unit tests for the service and controller, plus one end-to-end test hitting the real HTTP layer). No database yet — the story pages are just JSON files for now.

Milestone 2 is where it gets real: persisting player progress in PostgreSQL with TypeORM, so a player's position in the story actually survives a server restart. That's next.

Top comments (0)