Modh
AboutServicesWorkPlaybookResourcesBook a call
Book a call
Agent Skills/Backend / Infrastructure/SOLID Webhook Architecture
Backend / Infrastructureadvanced6 min

webhook SOLID Webhook Architecture

Enforce SOLID principles in webhook architecture for maintainability and extensibility. Use when creating webhook routes, adding event handlers, building handler registries, or refactoring webhook code. Enforces registry pattern, single responsibility, and dependency injection.

Install this skill

git submodule add https://github.com/modh-labs/playbook.git .playbook

One submodule installs all 17 skills. Reference .playbook/skills/webhook-architecture/SKILL.md from your AGENTS.md.


Webhook Architecture Skill

When This Skill Activates

  • Creating new webhook routes
  • Adding new webhook event handlers
  • Refactoring webhook architecture for maintainability
  • Discussing webhook extensibility or testability
  • Adding a new event type to an existing webhook endpoint

Core Principles

Single Responsibility (SRP)

  • Route handler: HTTP concerns only (signature verification, routing, responses)
  • Event handlers: Business logic for ONE event type each
  • Services: Shared logic (organization resolution, side effects)

Open/Closed (OCP)

  • New handlers do NOT modify route.ts
  • Use a handler registry for event routing
  • Extend by adding files, not modifying existing ones

Dependency Injection (DI)

  • Pass execution context to handlers
  • No hidden service creations inside handlers
  • Testable, mockable dependencies

Core Rules

1. Route Handler = HTTP Concerns ONLY

Route handlers should ONLY handle signature verification, request parsing, handler routing, and HTTP responses.

// WRONG - Route doing business logic
export async function POST(req: Request) {
  const body = await req.json();

  if (body.type === "order.created") {
    const db = await createServiceClient();
    const { data } = await db
      .from("orders")
      .select("*")
      .eq("external_id", body.id)
      .single();
    // ... 200 lines of business logic ...
  }
}

// CORRECT - Route only handles HTTP
export async function POST(req: Request) {
  // 1. Verify signature
  const body = await req.text();
  const signature = req.headers.get("x-provider-signature");
  if (!verifySignature(body, signature)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // 2. Parse and route to handler
  const parsed = JSON.parse(body);
  const { eventType, payload } = parseWebhook(parsed);
  const handler = WEBHOOK_HANDLERS[eventType];

  if (!handler) {
    return NextResponse.json({ success: true }); // ACK unknown events
  }

  // 3. Build context
  const context = await buildWebhookContext(payload, eventType);

  // 4. Execute handler
  await handler.execute(payload, context);

  // 5. Return HTTP response
  return NextResponse.json({ success: true });
}

2. Use Handler Registry Pattern (Open/Closed)

Adding new event types should NOT modify route.ts. Use a registry:

// lib/handler-registry.ts
import type { z } from "zod";

export interface WebhookHandler<T = unknown> {
  /** Zod schema for payload validation */
  schema: z.ZodSchema<T>;
  /** Handler function */
  execute: (payload: T, context: WebhookContext) => Promise<WebhookResult>;
  /** Does this handler need organization context? */
  requiresOrganization?: boolean;
}

export interface WebhookContext {
  organizationId?: string;
  supabase: SupabaseClient;
  logger: WebhookLogger;
}

export interface WebhookResult {
  success: boolean;
  [key: string]: unknown;
}

// Register all handlers -- ONE place to add new events
export const WEBHOOK_HANDLERS: Record<string, WebhookHandler> = {
  "order.created": {
    schema: OrderCreatedSchema,
    execute: handleOrderCreated,
    requiresOrganization: true,
  },
  "order.cancelled": {
    schema: OrderCancelledSchema,
    execute: handleOrderCancelled,
    requiresOrganization: true,
  },
  "account.connected": {
    schema: AccountEventSchema,
    execute: handleAccountConnected,
    requiresOrganization: false,
  },
  // Adding new handler = add entry here + create handler file
  // NO changes to route.ts needed!
};

Benefits of Registry Pattern:

  • Adding new handler = 1 registry entry + 1 handler file
  • Validation schemas colocated with handlers
  • Route.ts stays small (100-150 lines)
  • Easy to test handlers in isolation
  • Clear inventory of all supported events

3. Extract Organization Resolution to Service

Organization lookup is shared logic -- extract to a service:

// lib/resolve-organization.ts

/**
 * Resolve organization ID from webhook payload.
 * Different providers store org context differently.
 */
export async function resolveOrganization(
  provider: string,
  payload: unknown
): Promise<string | undefined> {
  const db = await createServiceClient();

  switch (provider) {
    case "payment-provider": {
      // Check metadata first, then lookup by customer
      const metadata = (payload as any).metadata;
      if (metadata?.organization_id) return metadata.organization_id;
      const customerId = (payload as any).customer;
      const { data } = await db
        .from("billing")
        .select("organization_id")
        .eq("customer_id", customerId)
        .single();
      return data?.organization_id;
    }
    case "calendar-provider": {
      const configId = (payload as any).configuration_id;
      const { data } = await db
        .from("scheduling_configs")
        .select("organization_id")
        .eq("external_config_id", configId)
        .single();
      return data?.organization_id;
    }
    case "auth-provider": {
      return (payload as any).organization_id;
    }
  }
}

4. Pass Execution Context to Handlers

Handlers should receive all dependencies via context -- no hidden creations:

// WRONG - Handler creates own dependencies
export async function handleOrderCreated(payload: OrderPayload) {
  const db = await createServiceClient();       // Hidden dependency
  const logger = createModuleLogger("orders");  // Hidden dependency
  // ...
}

// CORRECT - Dependencies injected via context
export async function handleOrderCreated(
  payload: OrderPayload,
  context: WebhookContext
): Promise<WebhookResult> {
  const { supabase, logger, organizationId } = context;
  // All dependencies explicit and testable
}

5. One Handler Per Event Type

Each handler file should handle ONE event type:

handlers/
  order-created.ts         # Only order.created
  order-cancelled.ts       # Only order.cancelled
  order-updated.ts         # Only order.updated
  account-connected.ts     # Only account.connected
  account-expired.ts       # Only account.expired

NOT:

handlers/
  order-handler.ts         # WRONG: Handles multiple events
  account-handler.ts       # WRONG: Big switch statement inside

6. Colocate Validation Schemas with Handlers

Keep Zod schemas near the code that uses them:

// handlers/order-created.ts
import { z } from "zod";

export const OrderCreatedSchema = z.object({
  order_id: z.string().min(1),
  customer_email: z.string().email(),
  items: z.array(z.object({
    product_id: z.string(),
    quantity: z.number().positive(),
    price: z.number().nonnegative(),
  })),
  total: z.number().nonnegative(),
  created_at: z.number(),
});

export type OrderCreatedPayload = z.infer<typeof OrderCreatedSchema>;

export async function handleOrderCreated(
  payload: OrderCreatedPayload,
  context: WebhookContext
): Promise<WebhookResult> {
  // Handler implementation
}

Adding a New Event Handler (Checklist)

  1. Create handler file: handlers/[event-name].ts

    • Export Zod schema for payload validation
    • Export handler function accepting (payload, context)
    • Follow observability patterns (webhook logger lifecycle)
  2. Register in registry: lib/handler-registry.ts

    • Add entry with schema, handler function, and flags
    • Set requiresOrganization appropriately
  3. Done! No route.ts changes needed.


Directory Structure

app/api/webhooks/[provider]/
  route.ts                     # HTTP concerns only (~100-150 lines)
  lib/
    handler-registry.ts        # Event -> handler mapping
    resolve-organization.ts    # Org resolution service
  handlers/
    order-created.ts           # One file per event
    order-cancelled.ts
    order-updated.ts
    account-connected.ts

Common Mistakes

MistakeImpactFix
Business logic in route.tsMonolithic, untestable routeExtract to handler files
Big switch statementsHard to extend, violates OCPUse handler registry
Modifying route.ts for new eventsMerge conflicts, risk to existing handlersUse registry pattern
Hidden dependencies in handlersUntestable, hard to mockPass via context
Multiple events per handler fileViolates SRP, hard to find codeOne file per event
Inline validation schemasNot reusable, not testableColocate and export from handler
Duplicated org lookup logicDRY violation, inconsistent behaviorExtract to service

Quick Reference

ConcernLocationSize Guide
Signature verificationroute.ts~10 lines
Event routingroute.ts -> registry~5 lines
Payload validationregistry + schema~20 lines
Org resolutionlib/resolve-organization.ts~30 lines
Business logichandlers/*.tsAs needed
HTTP responseroute.ts~5 lines

Target: Route.ts should be <=200 lines. If larger, extract logic.


Detailed References

  • Generic TypeScript handler template: references/handler-template.ts

Related Skills

search-code

Code Quality Audit

Systematic audit and remediation of a route or module to production-grade quality. Use when auditing for SOLID violations, missing error handling, type safety gaps, dead code, duplicated patterns, or insufficient test coverage. Also use when remediating code to gold-standard clean architecture.

Backend / Infrastructure
heart-pulse

Cron Monitor Check-Ins

A cron monitor is only as real as the check-ins that feed it. Vercel's Sentry integration auto-registers a monitor per cron that EXPECTS check-ins; if the route never calls captureCheckIn, the monitor reports "missed check-in" on every tick forever — a permanent phantom outage that trains your team to ignore alerts. Use when adding/auditing any scheduled job (Vercel cron, GitHub Action schedule, k8s CronJob) wired to a heartbeat monitor (Sentry Crons, Cronitor, Healthchecks.io, Better Stack).

Backend / Infrastructure
fingerprint

Dedup Key Completeness

A de-duplication, idempotency, or uniqueness guard must key on every dimension that makes two records legitimately distinct. A missing dimension silently collapses separate records into one (a lost write with no error). Use when writing an upsert, an onConflict target, a unique constraint, a "have we seen this already?" time window, or any "skip if it exists" guard — and when a bug report says "my second booking/order/ticket disappeared or merged into the first."

Backend / Infrastructure
←Back to Agent Skills