home / skills / outfitter-dev / agents / stack-patterns

This skill helps you learn and apply Outfitter stack patterns across handlers, results, errors, and package conventions for safer, reusable code.

npx playbooks add skill outfitter-dev/agents --skill stack-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (11)
SKILL.md
8.9 KB
---
name: stack-patterns
version: 0.1.0
description: Reference for Outfitter Stack patterns including Result types, Handler contract, Error taxonomy, and @outfitter/* package conventions. Use when learning the stack, looking up patterns, understanding packages, or when "Result", "Handler", "error taxonomy", "OutfitterError", "CLI output", "pagination", "MCP server", "MCP tool", "structured logging", "redaction", "test handler", "daemon", "IPC", or "@outfitter/*" are mentioned.
allowed-tools: Read Grep Glob
---

# Outfitter Stack Patterns

Primary reference for @outfitter/* package conventions.

## Handler Contract

Handlers are pure functions that:
- Accept typed input and context
- Return `Result<TOutput, TError>`
- Know nothing about transport (CLI flags, HTTP headers, MCP tool schemas)

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

### Example

```typescript
import { Result, NotFoundError, type Handler } from "@outfitter/contracts";

export const getUser: Handler<{ id: string }, User, NotFoundError> = async (input, ctx) => {
  ctx.logger.debug("Fetching user", { userId: input.id });
  const user = await db.users.findById(input.id);

  if (!user) {
    return Result.err(new NotFoundError("user", input.id));
  }
  return Result.ok(user);
};
```

**Why?** Testability (just call the function), reusability (same handler for CLI/MCP/HTTP), type safety (explicit types), composability (handlers wrap handlers).

## Result Types

Uses `Result<T, E>` from `better-result` for explicit error handling.

```typescript
import { Result } from "@outfitter/contracts";

// Create
const ok = Result.ok({ name: "Alice" });
const err = Result.err(new NotFoundError("user", "123"));

// Check
if (result.isOk()) {
  console.log(result.value);  // TypeScript knows T
} else {
  console.log(result.error);  // TypeScript knows E
}

// Pattern match
const message = result.match({
  ok: (user) => `Found ${user.name}`,
  err: (error) => `Error: ${error.message}`,
});

// Combine
const combined = combine2(result1, result2);  // tuple or first error
```

## Error Taxonomy

Ten categories map to exit codes and HTTP status:

| Category | Exit | HTTP | When to Use |
|----------|------|------|-------------|
| `validation` | 1 | 400 | Invalid input, schema failures |
| `not_found` | 2 | 404 | Resource doesn't exist |
| `conflict` | 3 | 409 | Already exists, version mismatch |
| `permission` | 4 | 403 | Forbidden action |
| `timeout` | 5 | 504 | Operation took too long |
| `rate_limit` | 6 | 429 | Too many requests |
| `network` | 7 | 503 | Connection failures |
| `internal` | 8 | 500 | Unexpected errors, bugs |
| `auth` | 9 | 401 | Authentication required |
| `cancelled` | 130 | 499 | User interrupted (Ctrl+C) |

```typescript
import { ValidationError, NotFoundError, getExitCode } from "@outfitter/contracts";

new ValidationError("Invalid email", { field: "email" });
new NotFoundError("user", "user-123");

getExitCode(error.category);   // 2 for not_found
getStatusCode(error.category); // 404 for not_found
```

## Validation

Use Zod with `createValidator` for type-safe validation returning Results:

```typescript
import { createValidator } from "@outfitter/contracts";
import { z } from "zod";

const InputSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const validateInput = createValidator(InputSchema);

// In handler
const inputResult = validateInput(rawInput);
if (inputResult.isErr()) return inputResult;
const input = inputResult.value;  // typed as z.infer<typeof InputSchema>
```

## Context

`HandlerContext` carries cross-cutting concerns:

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

const ctx = createContext({
  logger: myLogger,           // structured logger
  config: resolvedConfig,     // merged configuration
  signal: controller.signal,  // cancellation
  workspaceRoot: "/project",
});

// ctx.requestId is auto-generated UUIDv7 for tracing
```

| Field | Type | Description |
|-------|------|-------------|
| `requestId` | `string` | Auto-generated UUIDv7 |
| `logger` | `Logger` | Structured logger |
| `config` | `ResolvedConfig` | Merged config |
| `signal` | `AbortSignal` | Cancellation signal |
| `workspaceRoot` | `string` | Project root |
| `cwd` | `string` | Current directory |

## Package Reference

| Package | Purpose | When to Use |
|---------|---------|-------------|
| `@outfitter/contracts` | Result types, errors, Handler contract | Always (foundation) |
| `@outfitter/types` | Type utilities, collection helpers | Type manipulation |
| `@outfitter/cli` | CLI commands, output modes, formatting | CLI applications |
| `@outfitter/mcp` | MCP server, tool registration, Zod schemas | AI agent tools |
| `@outfitter/config` | XDG paths, config loading, env handling | Configuration needed |
| `@outfitter/logging` | Structured logging, sinks, redaction | Logging needed |
| `@outfitter/daemon` | Background services, IPC, health checks | Long-running services |
| `@outfitter/file-ops` | Secure paths, atomic writes, file locking | File operations |
| `@outfitter/state` | Pagination, cursor state | Paginated data |
| `@outfitter/testing` | Test harnesses, fixtures, Bun test | Testing |

**Selection guidance:**

- All projects start with `@outfitter/contracts`
- CLI apps add `@outfitter/cli` (includes UI components)
- MCP servers add `@outfitter/mcp`
- Projects with config add `@outfitter/config`
- File operations need `@outfitter/file-ops` for safety

## Type Utilities

`@outfitter/types` provides collection helpers and type utilities:

### Collection Helpers

```typescript
import { sortBy, dedupe, chunk } from "@outfitter/types";

// Sort by property
const users = [{ name: "Bob" }, { name: "Alice" }];
sortBy(users, "name");         // [{ name: "Alice" }, { name: "Bob" }]
sortBy(users, u => u.name);    // Same, with accessor function

// Remove duplicates
dedupe([1, 2, 2, 3, 3, 3]);    // [1, 2, 3]
dedupe(users, u => u.name);    // Dedupe by property

// Split into chunks
chunk([1, 2, 3, 4, 5], 2);     // [[1, 2], [3, 4], [5]]
```

### Type Utilities

Standard TypeScript utility types for common patterns:

```typescript
import type { Prettify, DeepPartial, Nullable } from "@outfitter/types";

// Prettify: Flatten complex intersection types for better IntelliSense
type Combined = { a: string } & { b: number };
type Pretty = Prettify<Combined>;  // Shows { a: string; b: number }

// DeepPartial: Make all properties optional recursively
type Config = { db: { host: string; port: number } };
type PartialConfig = DeepPartial<Config>;

// Nullable: T | null
type MaybeUser = Nullable<User>;
```

## Domain Error Mapping

Map your domain errors to the 10 taxonomy categories:

| Domain Error | Stack Category | Error Class | Exit | HTTP |
|--------------|----------------|-------------|------|------|
| Not found | `not_found` | `NotFoundError` | 2 | 404 |
| Invalid input | `validation` | `ValidationError` | 1 | 400 |
| Already exists | `conflict` | `ConflictError` | 3 | 409 |
| No permission | `permission` | `PermissionError` | 4 | 403 |
| Auth required | `auth` | `AuthError` | 9 | 401 |
| Timed out | `timeout` | `TimeoutError` | 5 | 504 |
| Connection failed | `network` | `NetworkError` | 7 | 503 |
| Limit exceeded | `rate_limit` | `RateLimitError` | 6 | 429 |
| Bug/unexpected | `internal` | `InternalError` | 8 | 500 |
| User cancelled | `cancelled` | `CancelledError` | 130 | 499 |

**Mapping examples:**

```typescript
// "User not found" -> NotFoundError
new NotFoundError("user", userId);

// "Invalid email format" -> ValidationError
new ValidationError("Invalid email", { field: "email" });

// "User already exists" -> ConflictError
new ConflictError("Email already registered", { email });

// "Cannot delete admin" -> PermissionError
new PermissionError("Cannot delete admin users");

// Unexpected errors -> InternalError
new InternalError("Database connection failed", { cause: error });
```

## Bun-First APIs

Prefer Bun-native APIs:

| Need | Bun API |
|------|---------|
| Hashing | `Bun.hash()` |
| Globbing | `Bun.Glob` |
| Semver | `Bun.semver` |
| Shell | `Bun.$` |
| Colors | `Bun.color()` |
| String width | `Bun.stringWidth()` |
| SQLite | `bun:sqlite` |
| UUID v7 | `Bun.randomUUIDv7()` |

## References

### Core Patterns

- [Handler Contract](references/handler.md)
- [Error Taxonomy](references/errors.md)
- [Result Utilities](references/results.md)
- [Conversion Patterns](references/conversion.md)

### Package Deep Dives

- [CLI Patterns](references/cli.md) - Output modes, pagination, formatting utilities
- [MCP Patterns](references/mcp.md) - Tool registration, resources, schemas
- [File Operations](references/file-ops.md) - Atomic writes, locking, secure paths
- [Logging Patterns](references/logging.md) - Structured logging, sinks, redaction
- [Testing Patterns](references/testing.md) - Test harnesses, fixtures
- [Daemon Patterns](references/daemon.md) - Lifecycle, IPC, health checks

Overview

This skill is a concise reference for the Outfitter stack patterns, covering the Handler contract, Result types, error taxonomy, and @outfitter/* package conventions. It helps engineers adopt consistent, testable handlers and map domain errors to exit/HTTP codes. Use it to standardize CLI, MCP, daemon, and library behavior across projects.

How this skill works

It documents the canonical Handler signature: a pure function receiving typed input and a HandlerContext and returning Result<T, E>. It explains use of better-result for explicit results, Zod-based validation helpers, and a ten-category error taxonomy that maps to exit and HTTP status codes. It also lists package responsibilities and Bun-first API recommendations to guide implementation choices.

When to use it

  • When writing or testing handlers that must be transport-agnostic
  • When mapping domain errors to exit codes and HTTP statuses
  • When validating inputs with Zod and creating typed Results
  • When choosing which @outfitter/* packages to add to a project
  • When designing CLI output, MCP tools, or background daemons

Best practices

  • Always start new projects with @outfitter/contracts for types, Result utilities, and error classes
  • Keep handlers pure and transport-agnostic so they are reusable across CLI, HTTP, and MCP
  • Use createValidator with Zod to return typed Result values instead of throwing
  • Map domain errors to the 10-category taxonomy to ensure consistent exit and HTTP codes
  • Use HandlerContext for cross-cutting concerns: structured logger, requestId, signal, config, and workspace/cwd
  • Prefer Bun-native APIs where available for performance and consistency

Example use cases

  • Implementing a getUser handler that returns Result.ok(user) or Result.err(NotFoundError) and is called from CLI, MCP, and HTTP layers
  • Validating raw CLI input with a Zod schema and returning ValidationError via Result.err
  • Mapping database uniqueness conflicts to ConflictError so the CLI returns exit code 3 and HTTP 409
  • Building an MCP tool using @outfitter/mcp with registered Zod schemas and handlers
  • Writing a daemon with @outfitter/daemon that uses the HandlerContext signal for graceful shutdown

FAQ

Why return Result instead of throwing?

Result makes control flow explicit, preserves strong TypeScript types for success and error branches, and simplifies testing and composition.

When should I use @outfitter/cli vs @outfitter/mcp?

Use @outfitter/cli for command-line apps and formatted output/UI components. Use @outfitter/mcp when registering tools with the MCP server or building tool schemas for AI agent interaction.