Naalya Handbook
Recipes

Add an Endpoint

Add one route to an existing controller — a DTO, the handler, a service method, and an audit trail.

You've got a controller that already exists — say the one that manages roles — and you need it to do one more thing. Maybe "archive an announcement", "suspend a user", "publish a draft". This is the most common change you'll make on this backend, and it follows a fixed rhythm. Learn the rhythm once and every new route is muscle memory.

Here's the mental model. A request flows through four layers, and you touch each one in order:

  1. A DTO validates and documents the incoming body (only if the route takes input).
  2. The controller handler is the thin front door — it declares the HTTP verb, who's allowed in, what OpenAPI says about it, and what shape comes back.
  3. The service method holds the actual logic — find the thing, check it, change it.
  4. An audit entry records the mutation so we know who changed what, and when.

The golden rule: controllers stay thin, services do the work. A handler should read like a table of contents — decorators on top, a one-line call to the service inside. If you're writing business logic in a controller, it belongs in the service instead.

We'll use "archive a role" as our running example. The real shape of all of this lives in apps/server/src/app/role/role.controller.ts — open it alongside this page and you'll see the exact patterns we're about to build.

This recipe assumes the module already exists

We're adding a route to a controller that's already wired up — its service, repository, and module registration are all in place. If you're starting a brand-new resource from scratch, begin with Add a Resource Module and come back here for each endpoint.


Step 1: Add a DTO (only if the route takes input)

A DTO — Data Transfer Object — is a plain class that describes the JSON body a request is allowed to send. Two libraries decorate every field: class-validator rejects bad input before your code ever runs, and @nestjs/swagger teaches the OpenAPI docs (and the generated API clients) what each field is. One field, both decorators.

DTOs live in the module's dto/ folder, one class per file. Our archive route takes an optional reason, so that's a single optional string:

apps/server/src/app/role/dto/archive-role.dto.ts
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';

export class ArchiveRoleDto {
  @IsOptional()
  @IsString()
  @ApiPropertyOptional({ description: 'Why it was archived (kept in the audit log).' })
  reason?: string;
}

Use @ApiProperty for required fields and @ApiPropertyOptional for optional ones — they must agree with the class-validator rules next to them. Only bother with a description for fields whose meaning isn't obvious from the name.

A GET with no body needs no DTO

DTOs are for input. A read-only @Get that takes nothing — or only a path param like :id — skips this step entirely. You'll still declare an output DTO on the handler in Step 3; that's a different object describing the response.


Step 2: Add the service method

The handler will be thin because the service does the thinking. The pattern here is check-then-act: load the resource, throw if it's missing, then make the change. Throwing NotFoundException is how you turn "this id doesn't exist" into a clean 404 — Nest catches the exception and shapes the HTTP response for you.

This lives next to the existing service methods, in role.service.ts:

apps/server/src/app/role/role.service.ts
async archive(id: string): Promise<RoleEntity> {
  const role = await this.roleRepository.findOne({ where: { id } });
  if (!role) {
    throw new NotFoundException('Role not found');
  }
  // ... apply the change and return the updated entity
  return this.roleRepository.save({ ...role, archivedAt: new Date() });
}

Return the affected entity from the method. The handler hands that return value straight back to the caller — and, as you'll see in Step 4, the audit machinery snapshots it too, so returning the updated row earns you a free record of the new state.


Step 3: Add the route to the controller

Now the handler. The most reliable way to get the decorator stack right is to copy a neighbouring handler in the same controller and adjust it — the order and the pieces are consistent across the codebase. Here's the archive route modelled on the real updateRole handler:

apps/server/src/app/role/role.controller.ts
@RequirePermissions({ action: Action.MANAGE, resource: Resource.ROLE })
@Audit({
  action: ActionAuditAction.UPDATE,
  resource: AuditResourceType.ROLE,
  labelField: 'name',
})
@Patch(':id/archive')
@ApiOperation({
  operationId: 'archiveRole',          // required · camelCase · unique API-wide
  summary: 'Archive Role',             // <= 3 words
  description: 'Archive a role so it no longer appears in active lists.',
})
@ApiResponse({ status: 200, description: 'Role archived.', type: RoleDto })
@ApiNotFoundErrorResponse('Role not found.')
async archive(
  @Param('id') id: string,
  @Body() dto: ArchiveRoleDto,
  @CurrentUser('sub') actorId: string,
) {
  return this.roleService.archive(id);
}

Read it top to bottom — each decorator answers one question:

DecoratorWhat it declares
@RequirePermissionsThe CASL action + resource pair the caller must hold
@AuditThat this write should be recorded (Step 4)
@Patch(':id/archive')The HTTP verb and path
@ApiOperationThe OpenAPI identity — operationId, summary, description
@ApiResponseThe success status and the response DTO type
@ApiNotFoundErrorResponseA shared decorator for the 404 the service can throw

A few things worth slowing down on. @RequirePermissions takes an { action, resource } pair from the Action and Resource enums in @app/shared/permission; a guard checks the caller's role against it and returns 403 if they're not allowed. @CurrentUser('sub') is a param decorator that pulls one claim — here the user id, sub — out of the verified JWT, so you never trust an id from the body. And the response type: RoleDto is what makes the generated clients return a typed object instead of any.

operationId is a unique key, not a label

operationId must be camelCase and unique across the entire API — Swagger, the Scalar docs, and every generated client key off it. Two handlers with the same operationId will collide silently and produce a broken client. Keep summary to three words or fewer; put the real sentence in description.


Step 4: Audit the mutation

Every write that changes data should leave a trail — who did it, to what, and what it looked like before and after. On this backend you don't write that logging by hand inside the handler. You declare it with the @Audit decorator, and an interceptor does the recording for you after the method succeeds. That's why Step 3 already has the trail covered — the decorator is the audit.

Look again at just that decorator:

apps/server/src/app/role/role.controller.ts
@Audit({
  action: ActionAuditAction.UPDATE,       // the verb: CREATE | UPDATE | DELETE | ...
  resource: AuditResourceType.ROLE,        // which kind of thing changed
  labelField: 'name',                      // human-readable label, read off the result
})

ActionAuditAction and AuditResourceType are enums in @app/shared — always pick a value, never type a free-form string, so the audit log stays queryable. By default the interceptor captures the handler's return value as the "after" snapshot — which is exactly why Step 2 returns the updated entity. For an UPDATE or DELETE you usually also want the "before" state; the decorator accepts a loadBefore callback that fetches the prior row, and idParam if your id lives under a param other than :id.

The decorator records — your service must succeed

The audit entry is written only when the handler returns without throwing. So order matters: let the service do its NotFoundException check first (Step 2). A request that 404s never reaches the audit step, which is correct — nothing changed, so there's nothing to record.

Auth and background events log differently

@Audit is for CRUD writes that return the affected entity. Login attempts, token issuance, and background-job lifecycle events are recorded through other paths — don't reach for @Audit there. When in doubt, copy what a neighbouring write in the same module does.


Step 5: Test it, then eyeball the docs

A new route needs cases on both the controller spec and the service spec. Mock the dependency you're not testing — the service in a controller test, the repository in a service test — using vi.fn() wired in through useValue. Always assert two paths: the happy one, and the NotFoundException when the id is missing.

Then run the checks and open the live docs to confirm your operation actually shows up:

terminal
pnpm test
pnpm lint
pnpm start:dev   # open /docs — your operationId and its schema should be there

If archiveRole and its request/response schema appear in the API docs, the OpenAPI decorators are correct and the generated clients will pick it up. The full testing approach — fixtures, mocking strategy, and what to assert — lives in Testing.


Recap

1 · DTO

Validate and document the input in the module's dto/ folder — skip it for a body-less GET.

2 · Service

check-then-act: find it, throw NotFoundException if missing, change it, return the entity.

3 · Handler

Thin: permission + @Audit + verb + @ApiOperation(operationId) + a typed @ApiResponse.

4 · Audit

@Audit declares the trail; the interceptor records the return value when the method succeeds.

Where to go next

On this page