FORM FOLLOWS DOMAIN. DOMAIN COMES FIRST.NODE.JS / REACT
INPUT → LOGIC → RESULT

ONE USE CASE.
ANYWHERE.

@domain-first/handlers

A careful use case needs a parser, an output check, a result type, logging and a try/catch — written by hand, every time. Handlers does that ritual for you: describe the contracts and the logic, then call it from anywhere.

01 / The problem

Every use case.
The same ritual.

The business logic of a use case is often a few lines. Everything around it is not: checking what comes in, checking what goes out, reporting failures, logging, metrics. A careful team writes all of it by hand — for every use case.

01

Contracts are written twice. A TypeScript type says what the input should be; a hand-written parser checks it at runtime. Nothing keeps the two in sync, and the output is rarely checked at all.

02

Failures have no shape. Invalid input, domain errors and crashes all arrive as unknown. Each team invents its own Result type and its own way to tell bad input from a real failure.

03

Cross-cutting code leaks into the logic. Logging and metrics sit inside every use case, between the lines that matter, copied with small differences each time.

04

Every caller wants something different. A queue consumer needs a result it can inspect; a script wants exceptions; a route needs a status. Each one unwraps and rewraps the use case by hand.

02 / The approach

Describe the use case once.
Skip the ritual.

A handler is a use case with explicit contracts: what comes in, what goes out and what happens in between. You write the schemas and the logic. Parsing, the output check, the result shape and the hooks for logging and metrics come with the handler — the same way for every use case.

It knows nothing about HTTP or any framework, so the same handler serves a queue consumer, a script, a route and a test. Callers that prefer exceptions use .unsafe().

AT THE EDGES

@domain-first/handlers-rest exposes a handler over HTTP; @domain-first/handlers-next plugs it into the Next.js App Router.

03 / The idea, then the code

Say what the use case does.
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

the frame

export const publishProject = defineHandler({

01“To publish a project, I only need its id.”

  inputSchema: z.object({ projectId: z.uuid() }),

02“And I get the published project back.”

  outputSchema: z.instanceof(Project),

03“Find the project. If there is none, that’s not found.”

  handler: async ({ projectId }) => {
    const project = await projects.find(projectId);
    if (!project) throw new ProjectNotFound({ projectId });

04“Otherwise publish it, save it and return it.”

    const live = project.published();
    await projects.save(live);
    return live;
  },

the frame

});

05“Call it from a route, a queue or a test. Bad input or a missing project comes back as a typed failure, never a throw.”

const result = await publishProject({ projectId });
if (!result.success && InputParsingError.is(result.error)) { /* … */ }

5 sentences. 5 pieces of code. Nothing in the code that the idea didn’t ask for. No parsing, no try/catch, no Result type to invent. Imports are omitted.

04 / Start

Define the first handler.

Terminal
npm i @domain-first/handlers

Use any Standard Schema validator. Changing a call signature with withTransformedContract() and hook details are in the README.

README on npm ↗

Guide and the full API.

Handlers REST ↗

Expose a handler as a typed REST endpoint.

Next.js ↗

Serve endpoints from the App Router.