← writing

Building RhizoBook: Structuring a NestJS Backend from the Ground Up

3 min read
nestjsprismapostgresqlbackendarchitectureRhizoBook

When I started RhizoBook — a healthcare appointment scheduling app — the first decision was picking a backend framework. Plain Express would have worked, but I wanted something with opinions built in. NestJS gave me three things I knew I’d need: a dependency injection container, a decorator-driven routing model, and a module system that enforces domain boundaries from day one.

Module-per-domain

The backend is organized into four modules: auth, users, providers, and appointments. Each module owns its controller, service, and any guards or DTOs it needs. Nothing leaks across boundaries without an explicit import.

This structure pays off when you’re debugging. When something goes wrong with appointment booking, you open appointments/ and stay there. The module boundary is also a forcing function — if a service needs something from another domain, you notice, and you ask whether that dependency actually belongs there.

Why JwtAuthGuard is per-controller, not global

NestJS makes it easy to register a guard globally, and for a fully authenticated API that’s the right call. RhizoBook isn’t that. Patients need to browse providers before they create an account — that’s a hard UX requirement. If discovery requires login, you’ve lost people before they’ve seen why they should sign up.

So JwtAuthGuard (at backend/src/auth/jwt-auth.guard.ts) is applied at the controller level with @UseGuards(JwtAuthGuard). The provider list and provider detail endpoints have no guard at all. Every appointment route does. The asymmetry is intentional and documented.

PrismaModule as a singleton

PrismaService extends PrismaClient and implements OnModuleInit to call $connect() on startup. It’s declared in PrismaModule with exports: [PrismaService] so any module that imports PrismaModule gets the same instance injected. This matters — you don’t want N connection pools, you want one.

In tests, mockPrismaService() from src/test-utils/prisma.mock.ts creates a typed mock of every Prisma model method, so tests never touch a real database and never need one running.

Schema decisions worth explaining

A few design choices in backend/prisma/schema.prisma that aren’t obvious from reading the file:

Roles as a DB table, not an enum. Role is its own model with a name string. This lets the frontend read role names from the JWT payload (roleName) rather than hardcoding strings in two places. The role IDs are seeded (provider = 1, patient = 2) and treated as stable.

AvailabilitySlot uses dayOfWeek as an integer (0–6) and startTime/endTime as “HH:MM” strings. ISO 8601 datetimes here would be wrong — availability is a weekly pattern, not a specific calendar date. Storing “Monday 09:00–17:00” as day 1 + string times is simple and correct for this domain.

PatientProfile has medicalNotes. This field stores free-text notes from the patient intake form. It’s the field that makes HIPAA compliance a real future concern — right now data is encrypted at rest by Neon, but there’s no application-layer encryption or access audit trail yet.

AppointmentStatus is an enum with SCHEDULED, CANCELLED, and COMPLETED. The state machine is enforced in the service layer: only SCHEDULED → CANCELLED is currently allowed. COMPLETED is in the schema for future use.

DTOs + class-validator at the boundary

All incoming data is validated through DTOs decorated with class-validator. The ValidationPipe is registered globally, so any DTO with decorators like @IsDateString() or @IsString() gets validated automatically before the handler runs. This means business logic in services never has to defensive-check input types — it can trust the data is well-formed by the time it arrives.

Key files:

// comments via github discussions