4.5 KiB
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";