playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-patterns/references/handler.md

4.5 KiB

Handler Contract

The core abstraction in Outfitter Stack. Handlers are pure functions that accept typed input and context, returning Result<TOutput, TError>.

Signature

type Handler<TInput, TOutput, TError extends OutfitterError> = (
  input: TInput,
  ctx: HandlerContext
) => Promise<Result<TOutput, TError>>;

Type Parameters

Parameter Description
TInput Input type (use unknown for raw input that needs validation)
TOutput Success return type
TError Union of possible error types (must extend OutfitterError)

Handler Structure

import {
  Result,
  ValidationError,
  NotFoundError,
  createValidator,
  type Handler,
} from "@outfitter/contracts";
import { z } from "zod";

// 1. Define input schema
const InputSchema = z.object({
  id: z.string().min(1),
  options: z.object({
    includeDeleted: z.boolean().default(false),
  }).optional(),
});

// 2. Create validator
const validateInput = createValidator(InputSchema);

// 3. Define output type
interface UserOutput {
  id: string;
  name: string;
  email: string;
}

// 4. Implement handler
export const getUser: Handler<unknown, UserOutput, ValidationError | NotFoundError> = async (
  rawInput,
  ctx
) => {
  // Validate input
  const inputResult = validateInput(rawInput);
  if (inputResult.isErr()) return inputResult;
  const input = inputResult.value;

  // Log with context
  ctx.logger.debug("Fetching user", { userId: input.id });

  // Business logic
  const user = await db.users.findById(input.id);
  if (!user) {
    return Result.err(new NotFoundError("user", input.id));
  }

  // Return success
  return Result.ok(user);
};

Why Handlers?

Transport Agnostic

Handlers know nothing about:

  • CLI flags and arguments
  • HTTP headers and status codes
  • MCP tool schemas
  • WebSocket messages

This separation means one handler serves all transports.

Testability

Test handlers directly without transport layer:

import { createContext } from "@outfitter/contracts";

test("getUser returns user", async () => {
  const ctx = createContext({});
  const result = await getUser({ id: "user-1" }, ctx);

  expect(result.isOk()).toBe(true);
  expect(result.value.name).toBe("Alice");
});

Composability

Handlers can call other handlers:

const createOrder: Handler<CreateOrderInput, Order, OrderError> = async (input, ctx) => {
  // Call another handler
  const userResult = await getUser({ id: input.userId }, ctx);
  if (userResult.isErr()) {
    return Result.err(new ValidationError("Invalid user", { userId: input.userId }));
  }

  // Continue with order creation
  const order = await db.orders.create({
    user: userResult.value,
    items: input.items,
  });

  return Result.ok(order);
};

Type Safety

TypeScript knows all possible outcomes:

const result = await getUser({ id: "123" }, ctx);

if (result.isOk()) {
  // result.value is UserOutput
  console.log(result.value.name);
} else {
  // result.error is ValidationError | NotFoundError
  switch (result.error._tag) {
    case "ValidationError":
      console.log(result.error.details);
      break;
    case "NotFoundError":
      console.log(result.error.resourceId);
      break;
  }
}

Validation Pattern

Always validate at handler entry:

const handler: Handler<unknown, Output, ValidationError | OtherError> = async (rawInput, ctx) => {
  // First: validate
  const inputResult = validateInput(rawInput);
  if (inputResult.isErr()) return inputResult;
  const input = inputResult.value;  // Now typed!

  // Rest of handler uses validated input
};

Context Usage

Access cross-cutting concerns via context:

const handler: Handler<Input, Output, Error> = async (input, ctx) => {
  // Logging
  ctx.logger.info("Processing", { input });

  // Request tracing
  const requestId = ctx.requestId;

  // Configuration
  const apiUrl = ctx.config.apiUrl;

  // Cancellation
  if (ctx.signal.aborted) {
    return Result.err(new CancelledError("Operation cancelled"));
  }

  // Workspace paths
  const filePath = path.join(ctx.workspaceRoot, input.filename);
};

Error Handling

Never throw in handlers. Return Result.err():

// BAD
if (!user) throw new Error("Not found");

// GOOD
if (!user) return Result.err(new NotFoundError("user", id));

Use taxonomy error classes for consistent categorization:

import {
  ValidationError,
  NotFoundError,
  ConflictError,
  PermissionError,
  InternalError,
} from "@outfitter/contracts";