FORM FOLLOWS DOMAIN. DOMAIN COMES FIRST.NODE.JS / REACT
HTTP → HANDLER → HTTP

PUT A
BOUNDARY
AROUND IT.

@domain-first/handlers-rest

Every HTTP route repeats the same chores, and the API description drifts away from the code. Handlers REST puts a typed boundary around a handler and generates OpenAPI from the same definition.

01 / The problem

The edge repeats itself.

Even when the logic lives in a handler, the HTTP edge has to translate: read the request, convert strings, call the use case, choose a status, shape the response. By hand, in every route.

01

Request parts arrive as strings. req.params.userId is untyped; query values need converting and checking, route by route.

02

Statuses are decided case by case. Every route has its own try/catch mapping errors to 400, 404 or 500 — slightly differently each time.

03

Documentation is a second copy. OpenAPI is written next to the code and drifts from it with the first change that skips it.

04

Clients guess the contract. Request and response types on the frontend are copied from the docs or retyped from memory.

02 / The approach

An endpoint is a boundary
around a handler.

The use case stays in the handler. An endpoint describes only the HTTP side: the route, the request parts and how they map to the handler's input and back. Shared concerns — context, which error is which status and what the client sees — are configured once. Types and OpenAPI come from the same definitions.

03 / The idea, then the code

Say how it looks over HTTP.
That’s the code.

First the idea, in the words you would use with a teammate. Then the code, right next to it. Read them side by side: the code you write is the idea, almost word for word.

The ideaThe code you write

01“For every endpoint: a malformed request is a 400; a missing project is a 404, and the client gets its id.”

const api = createEndpoint({
  errors: [
    httpStatus(400, {
      code: 'INVALID_REQUEST',
      match: [RequestParsingError, InputMappingError],
    }),
    httpError(ProjectNotFound, 404, {
      details: {
        schema: z.object({ projectId: z.string() }),
        map: (e) => ({ projectId: e.details.projectId }),
      },
    }),
  ],
})(nextAdapter);

02“Publishing is POST /projects/:projectId/publish. And only here, a project that is already live is a 409.”

const publishPOST = api(publishProject, {
  route: {
    method: 'post',
    path: ['projects', ':projectId', 'publish'],
  },
  errors: [httpError(ProjectAlreadyLive, 409)],
})

03“The id comes from the path.”

  .input(() => ({}))
  .mapInput(({ params }) => ({ projectId: params.projectId }))

04“The response is the project, as plain data.”

  .output(() => ({ body: Project.schema }))
  .mapOutput((project) => ({ body: project.snapshot }));

05“And it’s served by one Next.js route.”

const handler = nextEndpoints([publishPOST]);
export { handler as POST };

5 sentences. 5 pieces of code. Nothing in the code that the idea didn’t ask for. No route files, no try/catch, no status ladder. An error says only what its mapping lets it say; anything unmapped is a bare 500. Imports are omitted.

04 / Start

Install the boundary.

Terminal
npm i @domain-first/handlers-rest @domain-first/handlers zod

Plug in a framework adapter, or use the built-in mockAdapter while testing. Response shapes, context and the full error table are in the README.

README on npm ↗

Guide and the full API.

Next.js ↗

The App Router adapter.

OpenAPI ↗

Generate the document in a project.

Orval ↗

A typed React Query client from that document.