Warming up the neural circuits...
By the end of this chapter you will:
Once a partner or the frontend integrates with your API, the URL is a you cannot easily break. Design the API contract carefully, before writing code.
If you rename GET /orders to GET /food-orders six months later, every mobile app, every partner integration, every test breaks. Agree with the frontend on the URL, method, request body, and response shape first. Changing it before code = free. Changing it after = expensive.
GET /api/v1/orders list my orders
POST /api/v1/orders place an order
GET /api/v1/orders/:id get one order by ID
PATCH /api/v1/orders/:id update delivery note
DELETE /api/v1/orders/:id cancel an order
GET /api/v1/orders/:id/items nested: items inside an order
POST /api/v1/orders/:id/cancel action: cancel the order
GET /api/v1/restaurants list restaurants near me
GET /api/v1/restaurants/:id/menu get menu for a restaurant✅ Plural nouns — /orders not /order
✅ Lowercase, kebab-case — /menu-items not /MenuItems
✅ Versioning in the path — /api/v1/ — breaking changes go to /v2
✅ Nested only when child cannot exist without parent — /orders/:id/line-items
No verbs in the path (except for non- actions) — /api/v1/orders/create is wrong, use POST /api/v1/orders.
Never use GET for operations with side effects — GET /orders/delete?id=123 is wrong. Search engine crawlers DO call GET endpoints.
Every single API endpoint in this project returns one of two shapes:
{
"success": true,
"message": "Order placed successfully",
"data": {
"id": "ord-abc-123",
"status": "pending",
"totalAmount": "24.99",
{
"success": false,
"message": "Restaurant is currently closed",
"data": {
"code": "RESTAURANT_CLOSED",
"restaurantId": "rst-xyz",
"opensAt": "2026-05-01T18:00:00Z",
In QuickBite, use a shared response helper:
return successResponse('Order placed', order);
return errorResponse('Restaurant is closed', { code: 'RESTAURANT_CLOSED' });Never include a trace in an error response. Log it server-side, return a trace_id so support can look it up.
If you have 100,000 orders and a client calls GET /orders with no pagination, your app sends 100,000 rows from the database to the app to the client. This breaks everything.
GET /api/v1/orders?page=1000&size=50
SQL: SELECT * FROM orders ORDER BY created_at DESC OFFSET 50000 LIMIT 50PostgreSQL scans 50,000 rows just to skip them. Gets worse as data grows.
GET /api/v1/orders?cursor=2026-04-30T12:00:00Z&limit=50
SQL: SELECT * FROM orders WHERE created_at < '2026-04-30T12:00:00Z' ORDER BY created_at DESC LIMIT 50PostgreSQL uses the . Always fast regardless of how deep you are.
Response:
{
"success": true,
"data": [ "... 50 orders ..." ],
"meta": {
"next_cursor": "2026-04-29T08:15:30Z",
"has_more": true,
"limit": 50
}
}The client uses next_cursor to get the next page. If has_more is false, they're done.
Never trust the client. Every piece of data that enters your system must be validated.
// src/modules/orders/dto/create-order.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID, IsArray, ArrayMinSize, ValidateNested, IsInt, Min } from 'class-validator';
import { Type } from
The global ValidationPipe (set in main.ts) runs this automatically on every request body. If any field fails, the request is rejected with HTTP 400 before it reaches your controller.
// src/main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip fields not in the DTO (security!)
forbidNonWhitelisted: false,
transform: true, // convert string → number etc. via @Type()
}));Never return the Sequelize model directly from an endpoint. It exposes every column including internal ones (deleted_at, internal_score, password_hash).
// src/modules/orders/dto/order.response.ts
export class OrderResponse {
@ApiProperty()
id: string;
@ApiProperty({ enum: OrderStatus })
status: OrderStatus;
@ApiProperty()
totalAmount: string;
A user taps "Place Order" on QuickBite. The request reaches your server. The order is created. But the network drops before the response arrives. The app retries. You now have two identical orders and one charge.
The fix: Idempotency-Key header.
Idempotency-Key: 7f3a-abc-123@Post()
async create(
@Headers('idempotency-key') key: string | undefined,
@CurrentUser() user: AuthUser,
@Body() dto: CreateOrderDto,
) {
if (
This is the order you always follow. Never skip steps, never swap them.
1. Define the contract (URL, method, request body, response, errors)
→ Do this on paper or in a comment BEFORE writing code
→ Agree with frontend
2. Write the migration (add any new columns)
3. Write the entity/model
4. Write the input DTO (what the client sends)
5. Write the output DTO (what the server returns)
6. Write the service method (business logic)
7. Write the controller method (HTTP adapter)
8. Wire into the module
9. Add auth/guards/role checks
10. Write tests (happy path + each error path)
11. Add Swagger annotationsIf you write the controller first, you'll keep going back to change it as you discover the real data shapes. Starting from the database and working outward means each layer has solid ground to build on.
| Mistake | Correct approach |
|---|---|
| Returning the Sequelize model directly | Always use an output DTO |
| Using GET for state-changing operations | Use POST/PATCH/DELETE |
| Offset pagination on large tables | Use cursor pagination |
| Not validating input | Use class-validator DTOs + global ValidationPipe |
| Different response shapes per endpoint | Use consistent { success, message, data } envelope |
| No Swagger annotations | Add @ApiProperty to every DTO field |
Breaking changes in /v1 | Create /v2 for breaking changes |
| No idempotency on create endpoints | Add Idempotency-Key header support |
The URL is a promise. Once partners and frontends integrate with it, you can't rename it without breaking them. Design the contract on paper — URL, method, request body, response shape, errors — before writing a single line of code.