Designing the endpoints before writing them
An API is a contract. Once a front end depends on it, changing it costs work somewhere else — so it is worth thirty minutes of design before the first controller.
Start from what the client needs
Not from your tables. The mistake is generating CRUD endpoints for every model and calling it an API:
GET /products
GET /variants
GET /categories
GET /cart-items
To render one product page the front end now makes three calls and assembles the result. It has to know how your tables relate, which is your problem, not its.
Start from the screens instead:
| Screen | Needs |
|---|---|
| Product list | products with cheapest variant, filters, paging |
| Product detail | one product with every variant and its category |
| Cart | cart lines with current prices and stock |
| Checkout | cart total, delivery slots, addresses |
| Order history | orders with items and status |
Then design endpoints that serve those:
GET /api/products?category=staples&page=1
GET /api/products/:slug
GET /api/cart
POST /api/cart/items
GET /api/orders
Five endpoints, one call per screen. One screen, one request is a good target, and it keeps the client from knowing your schema.
The endpoints
Applying the REST conventions from module 1:
Catalogue
GET /api/categories
GET /api/products list, filtered
GET /api/products/:slug one product with variants
Cart
GET /api/cart
POST /api/cart/items add
PATCH /api/cart/items/:id change quantity
DELETE /api/cart/items/:id remove
DELETE /api/cart empty
Orders
POST /api/orders place an order
GET /api/orders the caller's orders
GET /api/orders/:id
POST /api/orders/:id/cancel an action, not a status update
Auth
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
Admin
POST /api/admin/products
PATCH /api/admin/products/:id
PATCH /api/admin/variants/:id/stock
Five decisions worth defending.
/cart with no id. There is one cart per user and the server knows who is
asking. /users/:id/cart invites a caller to pass somebody else's id, and now
you need a check that would not otherwise exist. Do not put an id in the URL
when the token already identifies the resource — it removes a whole class of
authorisation bug.
/orders/:id/cancel rather than PATCH /orders/:id. Cancelling has rules —
only some statuses allow it, stock must be restored. A generic status update
implies any transition is legal.
/products/:slug not /products/:id. The front end has the slug from the
URL; making it look up an id first is a wasted round trip.
Admin under /admin. The separation makes the guard obvious and lets you
apply it to the whole prefix.
Nesting one level at most. /cart/items/:id is fine.
/users/:id/orders/:id/items/:id is not.
Design the response shape once
Decide now, because changing it later touches everything.
Return the object for a single resource:
{ "id": "clx1", "slug": "atta-5kg", "name": "Aashirvaad Atta" }
Return an envelope for a list, because pagination has nowhere else to go:
{
"items": [...],
"page": 1,
"limit": 20,
"total": 47,
"totalPages": 3,
"hasNext": true
}
Some APIs wrap everything in { "data": ... } for consistency. Both are
defensible; pick one and never mix. An API where some endpoints wrap and
others do not is exhausting to consume.
Be consistent about field naming. camelCase throughout, since both halves
are TypeScript. Never price_paise in one response and pricePaise in another.
Never return internal fields. No passwordHash, no internalNotes, no raw
database rows — the rule from the controllers lesson.
Design the resource shape
A product as the client sees it:
// packages/shared/src/product.ts
export interface VariantSummary {
id: string;
sku: string;
label: string;
unit: Unit;
pricePaise: number;
mrpPaise: number | null;
inStock: boolean;
}
export interface ProductSummary {
id: string;
slug: string;
name: string;
brand: string | null;
imageUrl: string | null;
category: { slug: string; name: string };
cheapestVariant: VariantSummary;
}
export interface ProductDetail extends ProductSummary {
description: string | null;
variants: VariantSummary[];
}
Two decisions here matter.
inStock: boolean rather than stock: 12. The exact count is commercial
information you may not want public, and the client only needs to know whether
to enable the button. Expose the number only where it is useful — "only 3 left"
— and then deliberately.
ProductSummary and ProductDetail are different shapes. A list of forty
products should not carry forty descriptions. Being explicit about this in
types stops the list endpoint quietly growing.
These live in packages/shared so the front end uses the same definitions —
module 9's subject.
Filtering, sorting and paging
Query parameters, consistently named across every list endpoint:
GET /api/products
?category=staples
&q=atta
&minPaise=10000
&maxPaise=50000
&inStock=true
&sort=price_asc
&page=2
&limit=20
Use the same parameter names everywhere. If products use page and orders
use offset, every consumer has to remember which is which.
Name the unit in the parameter. minPaise rather than min removes any
question about whether to send 500 or 50000.
Write it down before you build it
An OpenAPI document, generated from your code:
npm install @nestjs/swagger --workspace=apps/api
// apps/api/src/main.ts
const config = new DocumentBuilder()
.setTitle("Kirana Store API")
.setVersion("1.0")
.addBearerAuth()
.build();
SwaggerModule.setup("api/docs", app, SwaggerModule.createDocument(app, config));
/api/docs now serves interactive documentation with every endpoint, its
parameters and its response shapes — generated from the DTOs, so it cannot go
stale the way a hand-written document does.
Documentation that is generated stays true. Documentation in a wiki does not.
The test of a good design
Could a developer guess the URL for an operation they have not seen?
Given GET /api/products and GET /api/products/:slug, anyone would guess
GET /api/orders and GET /api/orders/:id correctly. That predictability is
worth more than any individual choice.
The related test: can the front end build a screen with one request? If it needs three, the API is exposing your tables rather than serving the client.
Check your work
Why design from screens rather than tables: table-shaped endpoints force the client to make several calls and understand your schema.
Why /cart has no user id: the token identifies the user, so an id in the
URL only creates an opportunity to pass someone else's.
Why /orders/:id/cancel over a status PATCH: cancelling has rules; a
generic status update implies every transition is allowed.
Why :slug rather than :id for products: the client already has the slug
from the URL, so an id would need a lookup first.
Why inStock rather than stock: the count is commercial information, and
the client only needs to know whether to enable the button.
Why summary and detail are different types: a list of forty products should not carry forty descriptions.
Why generated documentation: it is derived from the code, so it cannot drift out of date.
The test of a good API: a developer can guess an endpoint they have not seen, and a screen needs one request.
Practice
- List every screen the Kirana Store needs. For each, write the single request that would serve it.
- Write the full endpoint table. Justify each path in one sentence.
- Design a
/users/:id/cartendpoint and then write down every authorisation check it needs that/cartdoes not. - Define
ProductSummaryandProductDetailinpackages/shared. - Decide your list envelope shape and write it down. Then check every list endpoint you plan matches it.
- Choose
stockorinStockand defend the choice. - Install Swagger and browse
/api/docs. - Give your endpoint table to somebody and ask them to guess the URL for "update a delivery address". If they guess right, the design works.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship