Backend Engineering · Field guide 06

Layered Request Handling

Follow one request through every layer and give each decision one clear owner. This chapter connects protocol behavior to the decisions a production service must make.

Application layer10 sections34 min0% read

One request contains many jobs

A request is easier to design when every decision has one clear owner.

Follow a request that publishes an existing document. Earlier boundaries have already matched the route, authenticated the principal, and validated the identifier and body. This chapter begins where application work starts.

The handler translates transport data. The application service coordinates the use case. The domain decides whether the state may change. The repository persists through a narrow contract. Middleware wraps the path without becoming the path.

publish-request.http
POST /documents/document-7/publish
Authorization: Bearer <redacted>
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Content-Type: application/json

{ "publishAt": null }
System visualOne request, six ownership boundaries
  1. 01Request edge

    Route, identity, validated input, deadline, and cancellation are ready.

  2. 02Handler

    Translate transport values into one application command.

  3. 03Service

    Coordinate load, authorization, decision, and persistence.

  4. 04Domain

    Accept or reject the publish state transition.

  5. 05Repository

    Load and save through domain-shaped operations.

  6. 06Response edge

    Translate the typed outcome into an HTTP response.

Read diagram as text

The request edge establishes trusted transport facts. The handler maps them to a command. The application service coordinates the use case. The domain owns the state transition. The repository loads and saves through a narrow port. Finally, the handler maps the application outcome to an HTTP response.

The handler translates

A handler knows the transport contract, but it should not become the use case.

The publish handler reads the validated document ID, principal, body, and cancellation signal. It creates one transport-neutral command and calls one application operation.

When the operation returns, the handler maps the typed outcome to status and response data. It does not query storage, enforce a publish transition, construct dependencies, or expose an unexpected error stack.

publish-handler.ts
export function createPublishHandler(
  publishDocument: PublishDocument,
) {
  return async (request: PublishHttpRequest) => {
    const outcome = await publishDocument({
      documentId: request.params.documentId,
      principal: request.principal,
      publishAt: request.body.publishAt,
      signal: request.context.signal,
    });

    return mapPublishOutcome(outcome);
  };
}
Handler ownership test
Belongs hereLeaks another boundary
Read an accepted route parameterBuild an SQL or ORM query
Create an application commandDecide whether an archived document may publish
Map a typed outcome to HTTPInstantiate the repository or tracing client
Pass the cancellation signalConvert every failure into a generic 200 response

The service coordinates one use case

An application service describes the operation in domain terms and stays unaware of HTTP.

Publishing requires several collaborators: load the document, authorize the principal against that document, ask the domain for a transition, and save with the version that was loaded. The service owns this order.

Expected rejections are typed outcomes. Unexpected adapter failures still throw so an outer error boundary can record and map them safely.

publish-document.ts
export function createPublishDocument(deps: PublishDependencies) {
  return async (command: PublishDocumentCommand) => {
    command.signal.throwIfAborted();
    const document = await deps.documents.findById(
      command.documentId,
      command.signal,
    );

    if (!document) return { kind: "not-found" } as const;
    if (!deps.canPublish(command.principal, document)) {
      return { kind: "forbidden" } as const;
    }

    const transition = publishDraft(
      document,
      command.publishAt ?? deps.now(),
    );
    if (!transition.ok) return {
      kind: "invalid-transition",
      code: transition.code,
    } as const;

    const saved = await deps.documents.save(
      transition.document,
      document.version,
      command.signal,
    );
    return saved === "conflict"
      ? { kind: "conflict" } as const
      : { kind: "published", document: transition.document } as const;
  };
}

The domain decides what may change

A business rule should still make sense after HTTP, the framework, and the datastore are removed.

The publish transition accepts only a draft. It returns a new published document and leaves the source unchanged. That rule can run in a unit test with no request object or repository.

Authorization remains a separate decision because it needs the current principal and resource. The service coordinates both decisions without hiding either inside transport or persistence code.

publish-domain.ts
export function publishDraft(
  document: Document,
  publishedAt: number,
): DomainPublishResult {
  if (document.state !== "draft") {
    return {
      ok: false,
      code: "cannot-publish-" + document.state,
    };
  }

  return {
    ok: true,
    document: Object.freeze({
      ...document,
      state: "published",
      publishedAt,
      version: document.version + 1,
    }),
  };
}
Publish transition table
Current stateDecisionRepository call
draftCreate a new published valueSave with the loaded version
publishedReject cannot-publish-publishedNone
archivedReject cannot-publish-archivedNone

A repository speaks application language

The application should request capabilities, not reach through an abstraction to rebuild datastore queries.

The publish service needs findById and save with optimistic version evidence. Those operations hide whether the adapter uses SQL, an ORM, an in-memory fake, or a remote store.

A repository that exposes arbitrary table names, query builders, or persistence rows pushes storage coupling back into the service. Keep the port narrow and map adapter records at its edge.

Repository port versus storage leakage
Application-shaped portLeaky alternativeWhy it matters
findById(documentId, signal)query(table, filters)The use case names the capability it needs
save(document, expectedVersion, signal)updateRow(rawRecord)Concurrency evidence stays explicit
Document or nullORM entity with lazy relationsDomain code does not depend on adapter behavior

Middleware wraps the path

Middleware is control flow around a request operation, so order and return direction are part of its contract.

Before phases run from the outer middleware inward. After and finally phases unwind in reverse. A middleware may short-circuit by returning a response without calling next.

Calling next twice can duplicate a write or response. Swallowing a failure can turn an error into a hanging request or false success. The example composer rejects a second call deterministically.

System visualMiddleware enters inward and unwinds outward
  1. 01
    Outer before

    Create context, start timing, or apply coarse admission policy.

  2. 02
    Inner before

    Run the next ordered cross-cutting concern.

  3. 03
    Handler and use case

    Translate, coordinate, decide, and persist once.

  4. 04
    Inner after

    Observe the outcome or execute cleanup in finally.

  5. 05
    Outer after

    Finish the span, record safe timing, and close context.

Read diagram as text

The outer middleware runs its before phase, followed by the inner middleware. The handler and use case run at the center. Control then returns through the inner after phase and finally the outer after phase. A short-circuit returns before reaching inner work, while cleanup still follows the applicable finally path.

middleware.ts
export type Middleware<Request, Response> = (
  next: Handler<Request, Response>,
) => Handler<Request, Response>;

// The repository example composes from right to left and gives
// every middleware invocation an at-most-once guarded next().
const handler = composeMiddleware(publishHandler, [
  errorBoundary,
  requestContext,
  timing,
]);

Request context stays small and scoped

Cross-cutting code needs correlation and lifetime facts without turning context into a hidden bag of dependencies.

A useful context contains a server-controlled request ID, trace relationship, deadline, cancellation signal, and a small allowlist of observability fields. Business commands still receive identity and resource facts explicitly.

Node.js AsyncLocalStorage can carry a store through asynchronous work created inside run(). The platform documentation prefers run() over enterWith() for most cases because enterWith() can affect later event handlers. Missing context must be handled deliberately.

node-request-context.ts
const requestContext = new AsyncLocalStorage<RequestContext>();

export function runRequest<T>(
  context: RequestContext,
  operation: () => T,
): T {
  return requestContext.run(Object.freeze(context), operation);
}

export function currentRequest(): RequestContext {
  const context = requestContext.getStore();
  if (!context) throw new Error("request-context-missing");
  return context;
}
Explicit inputs and request-scoped context
ValuePass explicitlyContext candidateNever propagate
Document IDYes—business commandNo
Principal for authorizationYes—decision inputLogging may use a safe subject categoryBearer token or credential
Request ID and trace relationshipOnly when required by a portYesUnparsed attacker-controlled baggage
Deadline and AbortSignalYes to cancellable I/OYesMutable global timeout state

Compose dependencies at the edge

The code that uses a dependency should not also decide which concrete implementation to construct.

The composition root creates the repository adapter, injects it into the publish service, injects the service into the handler, and wraps the handler with ordered middleware. Application code never asks a global locator for hidden collaborators.

Lifetimes are part of composition. Immutable configuration and client pools may live for the process. Request IDs and cancellation live for one request. A transaction lives only around the authoritative mutation it protects.

System visualWhere should this responsibility live?

DecisionWhat evidence is required, and why will this code change?

  1. HTTP input or output
    Handler

    Translate between transport and application types.

  2. Cross-cutting request control
    Middleware

    Wrap the chain with explicit order and cleanup.

  3. Use-case coordination
    Application service

    Order collaborators and return typed outcomes.

  4. Business invariant
    Domain

    Decide without framework or datastore knowledge.

  5. Persistence capability
    Repository port

    Express storage needs in application language.

  6. Correlation or lifetime
    Request context

    Carry small immutable execution-scoped facts.

  7. Concrete adapter or lifetime
    Composition root

    Build the graph at the outer edge.

Read diagram as text

Transport translation belongs in the handler. Cross-cutting request control belongs in middleware. Use-case coordination belongs in the application service. Business invariants belong in the domain. Persistence capabilities belong behind repository ports. Correlation and lifetime facts belong in request context. Concrete adapters and lifetimes are selected by the composition root.

composition-root.ts
const documents = createPostgresDocumentRepository(pool);
const publishDocument = createPublishDocument({
  documents,
  canPublish: authorizePublish,
  now: () => Date.now(),
});
const publishHandler = createPublishHandler(publishDocument);

export const handlePublish = composeMiddleware(
  publishHandler,
  [errorBoundary, requestContext, timing],
);

Failures keep their owner

Expected outcomes, unexpected failures, cancellation, and transaction cleanup need different treatment.

Not found, forbidden, invalid transition, and version conflict are modeled application outcomes. The handler maps them intentionally. An unexpected adapter failure reaches the outer error boundary, which returns a generic 500 and records safe diagnostics.

Cancellation is cooperative. Check before starting work, pass the signal to I/O, and stop later stages when it aborts. Cleanup and span completion belong in finally behavior; they must run on success, failure, and cancellation.

Failure ownership and public mapping
FailureOwnerPublic resultDownstream work
Invalid accepted inputHandler/validationStable 4xx problemService not called
Missing or non-disclosed forbidden documentApplication + handler policy404No domain mutation
Invalid state transitionDomainStable 409 codeRepository save not called
Optimistic save raceRepository/transaction409 conflictCaller may reload
Deadline or cancellationRequest lifetime + adapterDeployment timeout policyStop cooperative work
Unexpected adapter failureOuter error boundaryGeneric 500Run cleanup; hide internals
  • Pass one AbortSignal through every cancellable adapter call.
  • Keep a transaction around authoritative mutation—not transport parsing or external calls.
  • Run timing, context closure, and span completion in finally behavior.
  • Never place credentials, personal data, or authorization claims in trace baggage.
  • Do not convert an unexpected failure into a successful application outcome.

Test the seams you designed

A useful test fails when a responsibility crosses the wrong boundary—not when a private folder name changes.

Test the domain transition as a pure function, the application service with a repository fake, and the handler with a fake application operation. Then test middleware order, short-circuiting, double-next protection, failure propagation, cancellation, and concurrent context isolation.

When production behavior is wrong, trace the last completed boundary: transport mapping, service coordination, domain decision, repository result, response mapping, or middleware unwind. The request ID connects safe evidence without making logs the source of truth.

  • The service has no HTTP or framework types.
  • The handler calls one application operation and no repository.
  • A domain rejection performs no save.
  • Middleware before and after order is deterministic.
  • A second next call fails before a duplicate effect.
  • Two concurrent contexts never observe each other's request ID.
  • Unexpected errors and aborted signals remain observable to their owner.

Design questions

  1. Which part of your current handler would still exist if HTTP were replaced with a queue consumer?
  2. Does your service coordinate a use case, or merely rename one repository call?
  3. Can a repository implementation change without changing application commands or outcomes?
  4. Which middleware can short-circuit, and is its position in the chain tested?
  5. What request-scoped value could leak if it were stored in a mutable singleton?