Module: Nodejs
Nodejs·125·4 MIN READ

125: Building REST APIs with Native Node — Contracts, Validation, Pagination, Idempotency, and Boundaries

TOPICS COVERED: 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:

text
HTTP framework = request-routing/middleware conveniences

while API design remains:

text
contract
validation
authorization
business rules
persistence
errors
pagination
concurrency

Those responsibilities survive framework changes.

API contract first

Task resource:

json
{
  "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:

text
GET    /tasks
POST   /tasks
GET    /tasks/:id
PATCH  /tasks/:id
DELETE /tasks/:id

Write contract before implementation.

Response shape

List:

json
{
  "data": {
    "tasks": []
  },
  "meta": {
    "nextCursor": null
  }
}

Error:

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Check the request.",
    "fields": {
      "title": "Use at least 3 characters."
    }
  }
}

Do not expose stack traces.

HTTP layer

js
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

js
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:

js
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:

text
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:

json
{
  "title": "Task",
  "isAdmin": true
}

Do not:

js
repository.insert(requestBody);

because unexpected fields can pass into persistence.

Construct allowed object explicitly:

js
{
  title,
  completed
}

or use schema stripping/rejection policy.

Mass assignment

Classic vulnerability:

js
Object.assign(user, req.body);

Attacker sends:

json
{
  "role": "admin"
}

Use allowlisted update fields.

PATCH semantics

PATCH means partial update.

Input:

json
{
  "title": "Updated"
}

Do not turn omitted fields into null.

Normalize presence carefully:

js
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

text
GET /tasks?completed=false

Parse:

js
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:

js
Boolean('false') // true

for query booleans.

Sorting

Allowlist:

text
createdAt
updatedAt
title

Do not pass arbitrary client field directly into database sort expression.

Validate direction:

text
asc
desc

Offset pagination

text
?page=3&limit=20

or:

text
?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

text
?after=opaqueCursor&limit=20

Cursor encodes stable sort position.

Example stable ordering:

text
createdAt DESC, id DESC

Cursor may contain:

json
{
  "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:

text
limit=1000000

Example:

js
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:

text
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:

json
{ "version": 4 }

Client edits version 4.

Another client updates to 5.

First client later sends:

json
{
  "title": "...",
  "version": 4
}

Server can reject:

text
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:

js
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:

text
X-Request-Id

Useful for support/log correlation.

Error mapping

Domain:

text
ValidationError
NotFoundError
ConflictError

HTTP:

text
422
404
409

Unexpected:

text
500

Keep mapping in HTTP boundary.

Service does not call:

js
res.statusCode = 404;

Error format stability

Clients depend on:

json
{
  "error": {
    "code": "...",
    "message": "...",
    "fields": {}
  }
}

Do not change shape casually.

Version API or preserve compatibility.

API versioning

Options:

text
/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:

text
ISO 8601 / RFC3339-like UTC

Example:

text
2026-08-27T14:30:00.000Z

Do not send locale strings:

text
27/08/26 8 PM

as machine API timestamps.

Numbers and money

JavaScript number is floating-point.

Do not represent financial amounts casually:

json
{ "price": 10.1 }

if exact currency arithmetic matters.

Use integer minor units:

json
{ "amountPaise": 1010 }

or decimal strategy appropriate to domain/database.

Authentication placeholder

Native API may initially assume:

js
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:

text
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

  1. Implement native Task CRUD.
  2. Separate repository/service/HTTP layers.
  3. Add runtime validation.
  4. Add filtering/sort allowlist.
  5. Implement offset pagination.
  6. Design cursor pagination.
  7. Add version field and 409 conflict.
  8. Design idempotency key persistence.
  9. Build contract tests independent of Express.
  10. 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.

Official references