125: Building REST APIs with Native Node — Contracts, Validation, Pagination, Idempotency, and Boundaries
Learning objectives
You will learn to:
- design resource-oriented API contracts before selecting a framework;
- build a small native Node JSON API;
- separate HTTP, validation, service, and repository responsibilities;
- design request/response/error envelopes;
- implement filtering, sorting, and pagination;
- understand offset versus cursor pagination;
- understand idempotency and optimistic concurrency;
- distinguish 400, 409, and 422-style failures;
- design versioning and compatibility;
- test API behavior independently of Express.
Why build one API without Express
The goal is not to advocate writing production routers manually.
The goal is to understand:
HTTP framework = request-routing/middleware conveniences
while API design remains:
contract validation authorization business rules persistence errors pagination concurrency
Those responsibilities survive framework changes.
API contract first
Task resource:
{
"id": "t_123",
"title": "Learn Node",
"completed": false,
"version": 4,
"createdAt": "2026-08-27T10:00:00.000Z",
"updatedAt": "2026-08-27T10:30:00.000Z"
}
Endpoints:
GET /tasks POST /tasks GET /tasks/:id PATCH /tasks/:id DELETE /tasks/:id
Write contract before implementation.
Response shape
List:
{
"data": {
"tasks": []
},
"meta": {
"nextCursor": null
}
}
Error:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Check the request.",
"fields": {
"title": "Use at least 3 characters."
}
}
}
Do not expose stack traces.
HTTP layer
function route(request, response) {
const url = new URL(request.url, 'http://localhost');
if (
request.method === 'GET' &&
url.pathname === '/tasks'
) {
return listTasksHandler(request, response, url);
}
...
}
Manual path params require parsing.
A framework makes this cleaner, but the handler should still delegate domain logic.
Service layer
export async function createTask(input, context) {
const title = normalizeTitle(input.title);
if (title.length < 3) {
throw new ValidationError({
title: 'Use at least 3 characters.',
});
}
return taskRepository.insert({
title,
completed: false,
createdBy: context.userId,
});
}
Service does not receive Node request/response.
This makes it reusable/testable.
Repository
For now:
const tasks = new Map();
export const taskRepository = {
async insert(task) {
const id = crypto.randomUUID();
const stored = {
id,
version: 1,
...task,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
tasks.set(id, stored);
return stored;
},
async findById(id) {
return tasks.get(id) ?? null;
},
};
Later replace Map with MongoDB without rewriting HTTP semantics.
Input validation boundary
Network input is untrusted:
body path params query params headers cookies
Validate before business use.
Do not rely on TypeScript types alone. Runtime input is not type-checked.
Use schema libraries in production when helpful.
Unknown fields
Request:
{
"title": "Task",
"isAdmin": true
}
Do not:
repository.insert(requestBody);
because unexpected fields can pass into persistence.
Construct allowed object explicitly:
{ title, completed }
or use schema stripping/rejection policy.
Mass assignment
Classic vulnerability:
Object.assign(user, req.body);
Attacker sends:
{
"role": "admin"
}
Use allowlisted update fields.
PATCH semantics
PATCH means partial update.
Input:
{
"title": "Updated"
}
Do not turn omitted fields into null.
Normalize presence carefully:
const patch = {};
if ('title' in input) {
patch.title = normalizeTitle(input.title);
}
PUT versus PATCH
PUT often represents full replacement semantics.
PATCH partial changes.
Document your API.
Do not argue from verbs alone; clients need explicit behavior.
Filtering
GET /tasks?completed=false
Parse:
const raw = url.searchParams.get('completed');
let completed;
if (raw === 'true') completed = true;
else if (raw === 'false') completed = false;
else if (raw !== null) throw new ValidationError(...);
Never use:
Boolean('false') // true
for query booleans.
Sorting
Allowlist:
createdAt updatedAt title
Do not pass arbitrary client field directly into database sort expression.
Validate direction:
asc desc
Offset pagination
?page=3&limit=20
or:
?offset=40&limit=20
Simple.
Problems for changing large datasets:
- deep offset can be expensive;
- rows inserted/deleted cause duplicate/missing items between pages.
Cursor pagination
?after=opaqueCursor&limit=20
Cursor encodes stable sort position.
Example stable ordering:
createdAt DESC, id DESC
Cursor may contain:
{
"createdAt": "...",
"id": "..."
}
Sign/encode if client should not tamper, or simply validate opaque format.
Never use unsigned cursor content as authorization.
Pagination limits
Clamp or reject:
limit=1000000
Example:
const limit = Math.min(parsedLimit, 100);
A huge result can exhaust memory/database.
Idempotency
Idempotent operation can be repeated without changing final effect beyond first application.
GET is conceptually idempotent.
DELETE often designed idempotently.
POST create normally is not.
For critical create:
Idempotency-Key
Server stores key + operation result for a defined scope/time.
Retrying same request returns same result rather than duplicate resource.
Important for:
- payments;
- orders;
- bookings;
- external webhook processing.
Optimistic concurrency
Task has:
{ "version": 4 }
Client edits version 4.
Another client updates to 5.
First client later sends:
{
"title": "...",
"version": 4
}
Server can reject:
409 Conflict
rather than silently overwriting version 5.
Alternative HTTP-native patterns use ETag/If-Match.
Mongo lesson later implements version/concurrency patterns.
Request IDs
At boundary:
const requestId =
request.headers['x-request-id'] ??
crypto.randomUUID();
Be cautious trusting client request IDs for uniqueness/log safety; you can generate own and optionally retain upstream ID.
Return:
X-Request-Id
Useful for support/log correlation.
Error mapping
Domain:
ValidationError NotFoundError ConflictError
HTTP:
422 404 409
Unexpected:
500
Keep mapping in HTTP boundary.
Service does not call:
res.statusCode = 404;
Error format stability
Clients depend on:
{
"error": {
"code": "...",
"message": "...",
"fields": {}
}
}
Do not change shape casually.
Version API or preserve compatibility.
API versioning
Options:
/v1/tasks Accept header/media type host/version
Many systems use URL major versions for simplicity.
Do not version every code release.
Version when breaking external contract.
Prefer backwards-compatible additions where possible.
Dates
Send interoperable timestamp strings:
ISO 8601 / RFC3339-like UTC
Example:
2026-08-27T14:30:00.000Z
Do not send locale strings:
27/08/26 8 PM
as machine API timestamps.
Numbers and money
JavaScript number is floating-point.
Do not represent financial amounts casually:
{ "price": 10.1 }
if exact currency arithmetic matters.
Use integer minor units:
{ "amountPaise": 1010 }
or decimal strategy appropriate to domain/database.
Authentication placeholder
Native API may initially assume:
const context = {
userId: 'demo-user',
};
Do not mistake this for security.
Authentication/authorization is a dedicated later lesson.
CORS
CORS is a browser policy.
A server-to-server attacker is not stopped by CORS.
Do not use CORS as API authentication.
API test matrix
For POST /tasks:
valid → 201 missing title → 422 extra role field → ignored/rejected too large body → 413 wrong content type → 415 duplicate idempotency key → same result unexpected service error → 500 safe body
Write tests before adding Express.
Then migrating framework should preserve behavior.
Common mistakes
- framework-first without contract;
- HTTP objects passed deep into business logic;
- unvalidated query booleans;
- arbitrary sort fields;
- unlimited page size;
- mass assignment;
- missing concurrency/idempotency plan;
- all failures 400 or 500;
- CORS treated as auth;
- locale date strings;
- leaking internal errors.
Exercises
- Implement native Task CRUD.
- Separate repository/service/HTTP layers.
- Add runtime validation.
- Add filtering/sort allowlist.
- Implement offset pagination.
- Design cursor pagination.
- Add version field and 409 conflict.
- Design idempotency key persistence.
- Build contract tests independent of Express.
- Document v1 API.
Mastery checklist
Explain:
- contract-first API design;
- layer boundaries;
- validation;
- mass assignment;
- PATCH;
- pagination;
- idempotency;
- optimistic concurrency;
- error mapping;
- versioning;
- why framework is not API architecture.
