Skip to content

Crest — API Module Standard ​

ProductCrest — Personal Finance App
Document version0.3 (Draft) — adds naming of bodies, queries, and responses (§1.4); repository rules (§1.3), filters and arrays (§3), relation summaries (§4), repository unit tests (§7)
Date2026-10-02
Based onTechnicalDesign.md v0.12, Conventions.md v0.6 (code style §5 applies to all API code)
OwnerReizkian Y. Radityatama
StatusActive — mandatory for code in Apps/Api

This document says how a module in Apps/Api is written: which file does what, how requests are checked, how the database is reached, and what responses and errors look like. The Technical Design says what the API does; this document says how each piece of it is shaped, so that every module looks like the one before it.

Apps/Api/Src/AccessRequests together with Apps/Api/Src/Admin/AccessRequests is the reference implementation. When in doubt, copy it.


1. Layers ​

Every feature module has the same four layers. A request flows down through them; nothing skips a layer.

LayerOwnsNever does
ControllerRoute, HTTP status, @Public() / guards, @Throttle, OpenAPI decorators, ParseUUIDPipe on path idsBusiness rules, SQL, try/catch for flow
ServiceThe steps of the use case; deciding which error to throw; building repository input field by fieldSQL, pg types, HTTP objects (Request, Response)
RepositorySQL; running it with the right pool and user (WithUser.run); turning rows (snake_case) into DTOs (camelCase); turning database errors it expects into results the service can act onDeciding what is allowed (that is the database's and service's job); throwing HTTP errors
DatabaseWho may see and change which row; rules across rows—

1.1 Files ​

Src/<Feature>/
  <Feature>Module.ts
  <Feature>Controller.ts
  <Feature>Service.ts
  <Feature>Repository.ts
  Dto/                     every class, interface, type, and enum of the module
    <Action><Thing>Body.ts       a request body (validated)
    <Action><Thing>Query.ts      a query string (validated)
    <Action><Thing>Response.ts   what a route sends back
    <Thing>PageResponse.ts       a page of a list (§4)
    New<Thing>.ts                repository input (internal, not part of the API)
  • One main export per file, named after the file (0.5 §1).
  • Bodies, queries, and responses are always named types (§1.4).
  • Types live in Dto/. A controller, service, repository, guard, filter, or module file declares no interface, type, or enum; ESLint fails if it does. (Framework-free code such as Src/Money and Src/Config keeps its types next to its class.)
  • Public DTOs and repository input are separate types. The API's names may suit the client (acceptedTerms); repository input says what it holds (consentAt).

1.2 Admin modules ​

Super Admin endpoints live under Src/Admin/<Feature>/, are served under /v1/admin/..., and are the only code that may import ADMIN_POOL (the crest_admin database role, which has no grants on financial tables). ESLint enforces the import rule. Each admin controller carries @UseGuards(SuperAdminGuard) at class level.

1.3 Repositories ​

  • No shared base repository and no generic database helper layer (no BaseRepository, no findAll(table, filter)). Each repository writes its own SQL, so every query is visible where it runs and none can skip WithUser.run. Small pure helpers with no database access (such as Src/Common/Cursor.ts) are fine.

  • One private method builds a list's filter SQL, named whereClause(filter). It takes the module's own filter type and returns the where conditions and their parameters. Each supported filter adds exactly one condition, written with the real table alias and column name, with a comment saying which UI field or rule it serves. Free-text search is one bracketed or group; the other filters are and conditions after it.

    ts
    private whereClause(filter: AccessRequestListFilter): SqlConditions {
      const conditions = ["r.deleted_at is null"];
      const params: unknown[] = [];
    
      // Status tabs in the admin list (FR-ADM-4).
      if (filter.status !== null) {
        params.push(filter.status);
        conditions.push(`r.status = $${params.length}`);
      }
      …
      return { sql: conditions.join(" and "), params };
    }
  • Every value goes in as a $n parameter; only fixed text written in the repository (table, column, operator) is put into the SQL string.

1.4 Naming what goes in and out ​

Every route says exactly what it takes and what it gives back, with a type whose name tells which it is. The OpenAPI contract, and the Dart and TypeScript clients generated from it, then have a named type for every route.

WhatName ends inRule
A request body (@Body())BodySubmitAccessRequestBody. A route that takes a body takes a class, never an inline object or a string
A query string (@Query())QueryListAccessRequestsQuery
What a route returnsResponseAccessRequestResponse, AccessRequestPageResponse. The return type of a route is a …Response or a Promise<…Response>
Nested parts of a responseResponseFieldErrorResponse inside ApiErrorResponse
Repository input and rows, filters, internal typesanything elseNewAccessRequest, AccessRequestRow, AccessRequestListFilter. They never appear in a controller signature

The old suffix Dto is not used for new types.

An empty answer is declared, never accidental. A route that has nothing to return says so with @HttpCode(HttpStatus.NO_CONTENT) (204, the work is done) or @HttpCode(HttpStatus.ACCEPTED) (202, accepted, finishing later, or answering the same whatever happened) and returns Promise<void>. It never takes the default 200/201 with no body. A route does not invent a field to avoid being empty: an always-true { "ok": true } tells a client nothing that the status code does not.

Enforced in two places. Both fail pnpm lint or pnpm test, so a violation never reaches master:

  • ESLint, on every *Controller.ts (eslint.config.mjs): the type of @Body() must end in Body, the type of @Query() in Query, and every route (@Get, @Post, @Put, @Patch, @Delete) must return a …Response or Promise<…Response>, or be Promise<void> with NO_CONTENT or ACCEPTED declared. A void route on the default status, an inline object type, and a missing return type are errors.
  • Test/Integration/OpenApiContractSpec.ts, on the generated contract: every request body schema ends in Body, and every 2xx answer has a JSON body whose schema ends in Response, except a 204 or 202 that declares none.

2. Access control ​

Four layers, each with one job. Only the last one is the real barrier; the others make failures early and clear.

#LayerWhereJob
1AuthenticationJwtAuthGuard, globalEvery route requires a valid access token and a live session, unless marked @Public()
2Portal roleSuperAdminGuard, on admin controllersSuper Admin, admin-portal session, two-factor passed
3Space roleSpaceRoleGuard + @RequiresSpaceRole() (added with the first space endpoint, M2)Owner or Member of the space in the path; clear 403/404
4Databasecrest_app + row-level security; crest_admin + admin functionsFilters every row, whatever the code above does

2.1 Rules ​

  1. Closed by default. JwtAuthGuard runs on every route. A route is open only with @Public(), and every public route is listed in Test/Integration/RouteAccessSpec.ts, so opening one is a reviewed change.
  2. Roles come from the database, never from the token. The access token carries only sub (user id) and sid (session id). On every request JwtAuthGuard reads the session, the user's status, and is_super_admin through auth_check_session(). A deactivated user, a revoked session, or a removed Super Admin role takes effect on the next request (FR-AUTH-9).
  3. Every user query runs inside WithUser.run, which sets app.user_id for the transaction so that row-level security applies. Repositories do not call APP_POOL.query directly. The two exceptions have no user yet, and call a SECURITY DEFINER function instead of touching tables: the session check that finds the user (auth_check_session()), and public endpoints such as submit_access_request().
  4. Space roles are fixed, not data. The role table in the requirements (BRD §8.4) is mirrored in one place in code (Src/Spaces/SpacePermissions.ts, M2) and in the row-level security policies. A new role changes the enum, the policies, that file, and the requirements in one pull request. There are no role or permission tables.
  5. Rules that depend on the row (e.g. "Members edit only transactions they added", BR-36) are left to row-level security. The service turns "0 rows changed" into 404 (not visible) or 403 (visible, not editable).
  6. Rate limits (@nestjs/throttler) apply to every route with a generous default; login, refresh, password reset, activation, and access requests set tighter limits with @Throttle.

3. Requests ​

  • Validation. Every body and query string is a DTO class with class-validator decorators. The global ValidationPipe rejects unknown fields (whitelist, forbidNonWhitelisted) and converts types (transform). Every DTO property also has @ApiProperty so that the OpenAPI contract is complete.
  • Path ids use ParseUUIDPipe, so a bad id is a 400 with a clear code, not a database error.
  • Creates are idempotent. The client may send the new row's id (a UUID). Repeating the same request does not create a second row.
  • Filters match what the screen needs, and no more: small, flat query fields such as search, categoryIds, from, to. No generic filter language (filter[field][op]=…), no dotted paths ("category.name"), no filter a screen does not use yet.
  • Lists in a query string are repeated keys: ?categoryIds=a&categoryIds=b. This is what OpenAPI's default (style: form, explode: true) and the generated clients send. Express turns repeated keys into an array but a single key into a plain string, so array fields use the one shared helper @Transform(QueryArray.transform) (Src/Common/QueryArray.ts) before @IsArray(); it is the only normalization allowed. Any other spelling (categoryIds[]=a, categoryIds=a,b) is rejected as an unknown or invalid field.
  • Partial updates (PATCH). An omitted field is left unchanged; a field sent as null is cleared (where the column allows it). The service builds the repository input field by field; it never spreads the request body into SQL.

4. Responses ​

  • JSON fields are camelCase; repositories map from snake_case explicitly.

  • Lists are cursor-paginated, newest first, and return:

    json
    { "items": [ ... ], "nextCursor": "eyJjcmVhdGVkQXQiOi..." }

    nextCursor is null on the last page. Query parameters are limit (1–100, default 20) and cursor. The cursor is opaque to clients (Src/Common/Cursor.ts); it encodes the last row's (created_at, id) and the repository continues with where (created_at, id) < (...). There are no page numbers and no totals: they break when rows are added while paging, and offline sync needs stable cursors.

  • Related records are returned as summaries, never as bare ids. A transaction returns financialAccount: { id, name }, category: { id, name }, and addedBy: { id, name } (FR-SPC-7), not financialAccountId, so the client shows it without more calls. The repository joins what the response shows. Keep an id field alone only where a client needs it to send a change and nothing is displayed from it.

  • Search covers what is shown. If a list displays category.name and addedBy.name, its search matches them too.

  • Read, create, and update of the same resource return the same shape, so a client can show the result without fetching again.

5. Errors ​

Every error has one shape (Technical Design §2.4), produced by the global ApiExceptionFilter:

json
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "The request is not valid.",
  "code": "ValidationFailed",
  "requestId": "0190f0e2-6c6e-7c2a-9f3b-2a1f4e6d9a10",
  "errors": [{ "field": "email", "code": "isEmail" }]
}
  • code is stable and listed in Src/Errors/Dto/ErrorCode.ts. Clients translate code, never detail or title, which are for developers.
  • requestId is the id of this request: also the X-Request-Id response header and a field of every log line of the request. A client shows it when it cannot explain an error, so the owner can find what happened without any request data in the logs.
  • A service throws new ApiException(HttpStatus.Forbidden, ErrorCode.SuperAdminRequired). Nest's built-in exceptions (unknown route, too many requests, body too large) are mapped to codes by the filter.
  • Unexpected errors are logged with their class, database error code, request id, and stack frames, never the message (a database or library message can contain row values or email addresses), and returned as 500 InternalError with no detail. No financial data, request bodies, tokens, or SQL ever appear in a response or a log line.

6. Logging ​

nestjs-pino writes one structured JSON line per request: request id, method, path (without the query string, which may contain search text), status, duration, and the user id once authenticated. Headers, bodies, and query strings are never logged. Tests run with logging off.

7. Tests ​

Every test lives under Test/, never next to the code, and is named <Name>Spec.ts (not .spec.ts). Test/ has three parts, so what a test needs is visible from its folder:

FolderWhatNeeds Docker?Run with
Test/Unit/One class at a time, with its collaborators mocked. It mirrors Src/: the test of Src/Auth/AuthService.ts is Test/Unit/Auth/AuthServiceSpec.ts.Nopnpm test:unit
Test/Integration/The real app and a real PostgreSQL (Testcontainers), and checks that cover the whole app.Yespnpm test:integration
Test/Support/Helpers the tests share: PgMock, TestApp, TestDatabase, ReadDatabaseSchema. Never a test.——

pnpm test runs both. The reason for the split is cost: a unit test runs in milliseconds, so it is run all the time; an integration test starts a database container and is run before a commit.

WhatWhereHow
Service rulesTest/Unit/<Feature>/<Feature>ServiceSpec.tsJest, repository mocked
RepositoriesTest/Unit/<Feature>/<Feature>RepositorySpec.tsJest, pg pool mocked (Test/Support/PgMock.ts): the SQL text, the parameters in order, WithUser.run with the right user, and the mapping from rows to DTOs
Guards, filters, and helper classesTest/Unit/<Folder>/<Name>Spec.tsJest, pools mocked
EndpointsTest/Integration/<Feature>Spec.tsSupertest against the real app and a real PostgreSQL: createTestApp(database) in Test/Support/TestApp.ts. These, not the repository unit tests, prove the SQL works
Database rulesTest/Integration/Database/*Spec.tsAct as each database role and user; a new table or function gets tests here in the same pull request
Route accessTest/Integration/RouteAccessSpec.tsEvery route is public (and listed), or authenticated; every /admin route has SuperAdminGuard and no other route does
ContractTest/Integration/OpenApiContractSpec.tsPackages/ApiContract/OpenApi.json matches the code; every body is a …Body and every success answer a …Response unless it is a declared 204/202 (§1.4)

Apps/Admin follows the same layout for the tests it has: Apps/Admin/Test/Unit/, mirroring Src/, with include in angular.json pointing there.

8. Checklist for a new module ​

  1. Folder and files as in §1.1; types in Dto/; a …Body, …Query, and …Response for every route (§1.4); code style as in 0.5 §5 (doc comment on every service and repository method; helpers are static classes, §5.9).
  2. Every route: authenticated by default, or @Public() and added to Test/Integration/RouteAccessSpec.ts; admin routes under Src/Admin with SuperAdminGuard.
  3. Every query through WithUser.run (or a SECURITY DEFINER function for public routes).
  4. New tables or functions: migration with row-level security and grants, plus database tests.
  5. Errors as ApiException with a code from ErrorCode.
  6. pnpm --filter @crest/api openapi and commit the updated contract.
  7. Unit tests for the service and the repository; endpoint tests against a real database.
  8. pnpm lint && pnpm typecheck && pnpm test pass.

Crest is a personal project by Reizkian Y. Radityatama.