FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·125·14 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.

By the end, you should be able to look at an API request and identify which boundary owns each decision. You should also be able to explain the behavior you want before choosing whether the implementation uses native http, Express, or another framework.

Why build one API without Express

The goal is not to advocate writing production routers manually. A framework is usually the right choice once an application needs mature routing, middleware composition, integrations, and established operational conventions.

The reason to build this small API without Express is different: it makes the boundaries visible. When routing and body parsing are not hidden behind framework APIs, it becomes easier to see which parts are HTTP mechanics and which parts are application design.

The useful distinction is:

text
HTTP framework = request-routing/middleware conveniences

The API itself still has to define and enforce the following responsibilities:

text
contract
validation
authorization
business rules
persistence
errors
pagination
concurrency

Those responsibilities survive a framework change. If the service layer understands Express response objects, for example, moving to another framework becomes a rewrite rather than a routing change. The point of this exercise is to keep that coupling out of the domain code.

API contract first

Start with the resource and its externally visible behavior. For this lesson, the resource is a task:

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"
}

The fields already suggest several design decisions. id identifies the resource, completed is a domain value rather than a string from a query parameter, the timestamps have a machine-readable format, and version gives clients a way to detect concurrent edits.

The initial endpoint set is:

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

Write the contract before implementation. Decide what each endpoint accepts, returns, and rejects before letting a router or database schema accidentally make those decisions for you. This also gives framework-independent tests something stable to assert.

Response shape

A consistent envelope makes client code simpler and leaves room for metadata. A list response might look like this:

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

Even an empty list has the same shape as a populated list. meta can later carry pagination information without changing the meaning of data.

Use a similarly predictable shape for failures:

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

The machine-readable code is more stable than a human-facing message, while fields lets a client place validation feedback next to the relevant input. Do not expose stack traces. They reveal implementation details and are useful to an attacker, while the server logs can retain the diagnostic information for operators.

HTTP layer

The HTTP layer translates an incoming request into an application operation and translates the result back into an HTTP response. A minimal native router might begin like this:

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);
  }

  ...
}

Here the URL object gives the handler a reliable way to inspect the path and query string. Manual path parameters, such as the :id in /tasks/:id, still require explicit parsing and matching. A framework makes that routing code cleaner, but it does not remove the need to decide what the parameter means or how an invalid identifier should fail.

The handler should remain an adapter. It can read headers, parse the body, select a status code, and serialize JSON, but it should delegate domain logic rather than implement task rules itself.

Service layer

The service layer owns an application operation. It can normalize input, enforce business rules, and call a repository without knowing whether the request arrived through native Node, Express, a test, or a queue.

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,
  });
}

The service receives application data and a small context object. It does not receive Node request or response objects. That separation means the same createTask function can be tested directly and can be reused when the HTTP framework changes.

There is also a security benefit: the service accepts the fields it needs instead of being handed an entire request object whose unrelated properties might accidentally influence a later operation.

Repository

The repository hides persistence details from the service. For now, an in-memory Map is enough to demonstrate the boundary:

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;
  },
};

The repository decides how records are stored and retrieved. The service should not need to know that this implementation uses a Map, and the HTTP layer should not need to know either. Later, the Map can be replaced with MongoDB without rewriting the HTTP semantics. The replacement will still need to preserve the contract, including how missing records and conflicts are represented.

Input validation boundary

Network input is untrusted regardless of whether the caller is your own frontend. The boundary includes:

text
body
path params
query params
headers
cookies

Validate each value before using it in business logic, a query, a log message, or a persistence operation. Validation should establish the shape, types, allowed values, and limits that the rest of the application is allowed to assume.

Do not rely on TypeScript types alone. TypeScript checks code during development, but a JSON request arrives at runtime and is not type-checked by the compiler. Use schema libraries in production when helpful; they can make parsing, coercion policy, unknown-field handling, and error reporting explicit.

Unknown fields

Suppose a client sends:

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

Do not pass the whole body into persistence:

js
repository.insert(requestBody);

Unexpected fields can otherwise cross the input boundary and become stored data or future behavior. Construct the allowed object explicitly:

js
{
  title,
  completed
}

Another valid policy is to configure a schema to strip unknown fields or reject them. Choose and document the policy. The important part is that the behavior is deliberate rather than an accidental consequence of object spreading.

Mass assignment

Mass assignment is the more dangerous version of the same mistake: copying client-controlled properties onto a privileged object.

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

An attacker may send:

json
{
  "role": "admin"
}

If role is accepted simply because it appears in the request, the caller may be able to change authorization state. Use allowlisted update fields and construct the update explicitly. A user profile endpoint might allow a display name but never accept role from that endpoint.

PATCH semantics

PATCH means partial update. The client sends only the properties it wants to change:

json
{
  "title": "Updated"
}

An omitted property is not automatically null, false, or an empty string. Converting omission into a value can unintentionally erase data. Normalize presence carefully:

js
const patch = {};

if ('title' in input) {
  patch.title = normalizeTitle(input.title);
}

The in check distinguishes “the client supplied this property” from “the client did not mention it.” You still need to validate the supplied value and apply an allowlist for fields that may be changed.

PUT versus PATCH

PUT often represents full replacement semantics: the request describes the complete representation that should exist at the target. PATCH represents partial changes. Exact behavior varies by API, so do not make clients infer semantics from the verb alone.

Document whether omitted fields are required, preserved, or reset, and document whether the operation is idempotent. Clients need explicit behavior, especially when they retry a request or update a resource concurrently.

Filtering

Query parameters arrive as strings. For example:

text
GET /tasks?completed=false

Parse the string deliberately:

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(...);

This accepts the two supported boolean representations and rejects a supplied value that is neither. A common bug is:

js
Boolean('false') // true

JavaScript treats every non-empty string as truthy, so that expression does not parse the word false. Never use it for query booleans. The same principle applies to numeric limits and enum-like parameters: parse and validate them instead of trusting JavaScript coercion.

Sorting

Sorting is another input boundary. Expose only fields that the API intentionally supports:

text
createdAt
updatedAt
title

Do not pass an arbitrary client field directly into a database sort expression. Besides producing confusing behavior, that can expose fields the API did not intend to make queryable and may interact badly with database-specific expressions.

Validate the direction against the documented values:

text
asc
desc

For stable pagination, also choose a deterministic tie-breaker, such as id, when the primary sort field can contain duplicates. A stable order is necessary if clients are going to move through multiple pages.

Offset pagination

Offset pagination is easy to understand and often sufficient for small or relatively stable collections:

text
?page=3&limit=20

or:

text
?offset=40&limit=20

The server skips a number of records and returns the requested limit. The trade-off appears when the dataset is large or changes while the client is paging. Deep offsets can be expensive because the database may still need to scan past skipped rows. Inserts and deletes between requests can also shift positions, causing duplicate or missing items between pages.

Cursor pagination

Cursor pagination asks for records after a position in a stable ordering:

text
?after=opaqueCursor&limit=20

The cursor represents the last position the client saw rather than a count of rows to skip. One stable ordering could be:

text
createdAt DESC, id DESC

The cursor may contain the values needed to continue that ordering:

json
{
  "createdAt": "...",
  "id": "..."
}

In a real API, the cursor is commonly encoded and possibly signed if the client should not tamper with its contents. Even an unsigned opaque cursor must be validated for format and decoded safely. Never use unsigned cursor content as authorization. A cursor can describe where to continue a listing; it must not grant access to another user's records.

Pagination limits

Always bound the amount of data a caller can request. A request such as this should be clamped or rejected:

text
limit=1000000

For example:

js
const limit = Math.min(parsedLimit, 100);

A huge result can exhaust application memory, increase database work, and make response latency unpredictable. The limit is both an API usability decision and an operational safety boundary. Also validate that the parsed value is a positive, finite integer before applying the clamp.

Idempotency

Retries are normal in distributed systems. A client may time out after the server has created a resource and then send the request again because it cannot tell whether the first request succeeded.

An idempotent operation can be repeated without changing the final effect beyond the first application. GET is conceptually idempotent, and DELETE is often designed idempotently. POST create operations normally are not: two successful requests usually create two resources.

For a critical create operation, the client can send:

text
Idempotency-Key

The server stores the key and the operation result for a defined scope and time. If the same request is retried with the same key, the server returns the stored result instead of creating a duplicate resource. The implementation must define what happens if the same key is reused with different request data; silently treating those as the same operation is unsafe.

This pattern matters for:

  • payments;
  • orders;
  • bookings;
  • external webhook processing.

Idempotency storage itself needs a uniqueness rule and a lifecycle policy. A key that is never expired grows without bound, while a key that expires too soon may not protect the retry window the client actually needs.

Optimistic concurrency

Idempotency protects retries of one logical operation. Optimistic concurrency protects against two valid clients editing the same resource based on different versions.

The task includes a version:

json
{ "version": 4 }

One client reads version 4 and starts editing. Another client updates the task, so the stored version becomes 5. If the first client later sends:

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

the server can reject the stale update with:

text
409 Conflict

That is safer than silently overwriting the changes made to version 5. The update should check the expected version as part of the write, not merely read the version first and hope nothing changes before persistence.

An HTTP-native alternative uses ETag and If-Match. The underlying idea is the same: the client identifies the representation it edited, and the server refuses a write when that representation is no longer current. The Mongo lesson later implements version and concurrency patterns.

Request IDs

A request ID lets operators correlate a client-visible failure with server logs. At the boundary, an implementation might use an upstream ID when present and generate one otherwise:

js
const requestId =
  request.headers['x-request-id'] ??
  crypto.randomUUID();

Be cautious about trusting client request IDs for uniqueness or log safety. An attacker can send a misleading value, including a value designed to make log searches ambiguous. You can generate your own internal ID and optionally retain the upstream ID as separate metadata.

Return the correlation ID to the caller:

text
X-Request-Id

That gives support and client developers a concrete value to include when investigating a problem. Do not put secrets or sensitive data into the ID itself.

Error mapping

Keep domain errors independent of HTTP. The service can express failures using names such as:

text
ValidationError
NotFoundError
ConflictError

The HTTP boundary maps those errors to the API's status codes:

text
422
404
409

An unexpected failure becomes:

text
500

The exact choice between 400 and 422 should be documented consistently. In this lesson, malformed or semantically invalid task input is represented as a validation-style 422, while the domain-specific conflict is 409 and a missing resource is 404.

Keep the mapping in the HTTP boundary. The service must not call:

js
res.statusCode = 404;

That would make the service depend on Node's response object and would mix transport concerns with business behavior. Unexpected errors should produce a safe public body while the server records enough internal detail to diagnose them.

Error format stability

Clients depend on an error shape just as they depend on a success shape:

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

Do not change that shape casually. A client may branch on code, display message, and iterate over fields. If the external contract must change incompatibly, version the API or preserve compatibility during a migration. Human wording can evolve more safely when clients use the stable code for programmatic behavior.

API versioning

There are several common ways to express a major API version:

text
/v1/tasks
Accept header/media type
host/version

Many systems use URL major versions because they are straightforward to inspect, route, document, and test. Do not version every code release. A version is useful when the external contract has a breaking change, not when an internal implementation detail changes.

Prefer backwards-compatible additions where possible. Adding an optional response field may not require a new major version, while changing a field's meaning, removing a field, or changing error semantics may.

Dates

Clients in different locales need timestamps they can parse consistently. Send interoperable timestamp strings in an ISO 8601 or RFC3339-like UTC form:

text
ISO 8601 / RFC3339-like UTC
text
2026-08-27T14:30:00.000Z

Do not use a locale-formatted value as a machine API timestamp:

text
27/08/26 8 PM

The latter is ambiguous and difficult to parse reliably across locales and runtimes. Keep the wire representation unambiguous; presentation formatting belongs at the client boundary.

Numbers and money

JavaScript number uses floating-point arithmetic. That is often fine for measurements or approximate values, but financial amounts need an explicit exact-arithmetic strategy. Do not represent currency casually as:

json
{ "price": 10.1 }

if exact currency arithmetic matters. A common alternative is integer minor units:

json
{ "amountPaise": 1010 }

You can also use a decimal strategy appropriate to the domain and database. Whichever representation you choose, document the currency, scale, and rounding rules. The API should not leave clients guessing whether 1010 means paise, cents, or a whole-unit amount.

Authentication placeholder

The first native API may use a fixed context so the task behavior can be exercised:

js
const context = {
  userId: 'demo-user',
};

This is a test or teaching placeholder, not authentication. Do not mistake it for security. A real API must authenticate the caller and authorize access to the requested task, and those checks belong at clearly defined boundaries. Authentication and authorization are covered in a dedicated later lesson.

CORS

CORS is a browser policy. It controls whether browser JavaScript is allowed to read a cross-origin response; it is not an identity mechanism.

A server-to-server attacker is not stopped by CORS, because that caller is not subject to the browser's same-origin enforcement. Do not use CORS as API authentication. Authentication must be enforced by the server using an appropriate credential and authorization policy.

API test matrix

Before adding Express, test the contract directly. For POST /tasks, the matrix should include both the ordinary success path and the boundaries most likely to regress:

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

These cases distinguish validation, transport limits, content negotiation, retry behavior, and safe error handling. A test that only asserts a successful task creation does not tell you whether the API is protected against the more important failure modes.

Write tests before adding Express. Then, when the framework changes, the same behavior should continue to pass even though the routing and middleware implementation differs.

Common mistakes

The mistakes below are all boundary failures or contract failures that tend to appear when implementation starts before design:

  • 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.

When debugging an API, identify the first boundary where the behavior becomes incorrect: request parsing, validation, service rules, repository behavior, or response mapping. That narrows the investigation much faster than treating every failure as a router problem.

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.

Work through the exercises in order. The early CRUD implementation gives you a small working surface; the later exercises make its contracts explicit and force you to handle retries, changing data, and concurrent updates. For each exercise, test both the successful request and at least one invalid or conflicting request.

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.

You should be able to explain these in terms of behavior, not just repeat the names. In particular, be ready to show where untrusted input is validated, where a 409 originates, how a cursor differs from an offset, and why a service can be tested without constructing an HTTP response.

Official references

Reader page: /nodejs/lesson/125/building-rest-apis-with-native-node-contracts-validation-pagination-idempotency-and-boundaries