HatchedDocs
Platform

Architecture

How Hatched is wired together — request lifecycle, rule engine, and the invariants the platform is built around.

This document describes how Hatched is wired together: the request lifecycle, where the domain logic lives, how types flow between projects, and the few invariants the platform is built around.

Workspace layout

apps/
  api/         NestJS 10 HTTP API + BullMQ worker (same process today)
  dashboard/   Next.js 15 App Router admin UI
  widgets/     Preact + Shadow DOM widgets (buddy, badges, streak, celebrate, login)
  demo/        Next.js demo site embedding all widgets
  docs/        Fumadocs-powered public documentation site
packages/
  shared/      Shared types, Zod schemas, presets, constants
  ui/          Design-token React primitives
  sdk-js/      Official TypeScript SDK (ESM)

Request lifecycle

┌──────────┐  HTTPS  ┌────────────────────┐
│ Customer │────────▶│ Hatched API (Nest) │
│ app /    │         └────────┬───────────┘
│ Dashboard│                  │
└──────────┘                  ▼
                ┌─────────────────────────────┐
                │ 1. RequestIdInterceptor     │  set/echo X-Request-Id
                │ 2. LoggingInterceptor       │  structured JSON access log
                │ 3. Auth guard (API key/JWT) │
                │ 4. RateLimit guard          │
                │ 5. ValidationPipe (DTO)     │
                │ 6. Controller → Service     │
                │ 7. Services → TypeORM / Bull│
                │ 8. GlobalExceptionFilter    │  → canonical error envelope
                └─────────────────────────────┘
                         │          │
                         ▼          ▼
                   Postgres      Redis + BullMQ


                              Webhook-delivery
                              + Image-generation queues

Every request carries a requestId end-to-end: header in → request property → log lines → outgoing webhook payload → response error envelope. See Observability for details.

Rule engine

The rule engine (apps/api/src/rule-engine/rule-engine.service.ts) is the single evaluator for gamification events. It runs a deterministic two-phase pipeline:

  1. Compute phase — read-only. Resolves matching coin rules, skill rules, token configs, and badge criteria against a projected "after" state.
  2. Apply phase — transactional. Opens a single DB transaction, takes a pessimistic_write lock on the buddy row, applies every computed effect (ledger entries, balance update, skill update, badge awards), and commits atomically. If any step throws, everything rolls back.
  3. Post-transaction — non-atomic side effects: progression counters, streak milestone evaluation, evolution readiness, webhook emission.

This split is what makes the engine idempotent (same event_id produces exactly one effect) and race-free (concurrent events for the same buddy serialize on the row lock).

Two ingestion paths — by design

  • POST /events — standard ingestion. The rule engine owns the decision.
  • POST /coins, POST /skills, POST /badges/:id/award, POST /tokens — administrative override. Every write still funnels through the same transactional services (EconomyService, BadgesService) so ledger invariants are preserved regardless of entry point.

This two-path design is intentional per the product spec; do not attempt to collapse them.

Unknown events vs custom counters

  • Event type in EVENT_COUNTER_MAP → increments the canonical column.
  • Event type not in the map → atomically upserts into the progression_metrics.custom_counters JSONB column via a jsonb_set update. Downstream handlers still evaluate normally.
  • Event type neither in the map nor declared → same as above, plus a warning log. Never dropped.

Config versions

Every customer gets an immutable ConfigVersion snapshot at egg creation. Buddies pin a config_version_id for their lifetime. Updating rules creates a new version; existing buddies continue running against their pinned snapshot. This is the mechanism that lets customers iterate on gamification without retroactively changing live buddies' behavior.

Types — single source of truth

packages/shared is the authoritative home for cross-project types and constants:

@hatched/shared
├── presets               Buddy / event presets
├── types                 Shared entity shapes (Buddy, CustomerFeatures, ...)
├── constants/streaks     STREAK_MILESTONES
└── constants/events      EVENT_COUNTER_MAP
  • API DTOsapps/api/src/**/dto/*.ts — validated by class-validator + Swagger-decorated.
  • Widget props / SDK payloads — import from @hatched/shared.
  • DB entitiesapps/api/src/**/entity.ts — snake_case columns; the controller/service layer returns camelCase DTOs.

Casing rules

  • Public API + widget + SDK payloads: camelCase.
  • Postgres columns + TypeORM entities: snake_case.
  • Never leak entities out of controllers — always map to a DTO first.

Error envelope

Every HTTP error is serialized to:

{
  "error": {
    "code": "resource_not_found",
    "message": "Badge with id \"abc\" was not found",
    "details": { "_": "optional" },
    "requestId": "uuid"
  }
}

apps/api/src/common/exceptions/hatched.exception.ts defines the typed subclass hierarchy. The SDK (@hatched/sdk-js) parses the envelope and surfaces HatchedError.requestId to consumers.

Capability model

Each customer has a features JSONB column (CustomerFeatures in @hatched/shared) that toggles optional primitives: marketplace, tokens, evolution, badges, streaks. In UX copy we call these capabilities because they map to pricing-tier readiness, not dev-toggles. The dashboard CapabilityDisabledState component renders the upgrade CTA when a capability is off.

AudienceBrief also carries per-audience overrides so a customer can expose different gamification surfaces to different user segments (e.g. kids vs. adults).

Widgets

All widgets render inside a Shadow DOM so that host-page CSS cannot leak in. They share apps/widgets/shared/tokens.ts (generated from packages/ui/src/tokens.ts — never hand-edit) and a typed WidgetProps contract. The CDN loader (apps/widgets/loader/) resolves widget bundles from dist/widget/v1/*.min.js and mounts them into tagged elements on the host page.

Queues

  • image-generation — egg/hatch/evolve image jobs, backed by fal.ai or Replicate. The provider layer throws UpstreamImageException on failure so the retry logic and error envelope stay consistent.
  • webhook-delivery — signed HMAC webhook dispatch with encrypted-at-rest customer secrets (WEBHOOK_ENCRYPTION_KEY, required in production).

Observability

  • Health: GET /health/ready returns HTTP 200 / 503 based on DB, Redis, queue, and image-provider status.
  • Metrics: GET /metrics emits Prometheus text format. Protected by X-Internal-Service-Token.
  • Logs: structured JSON per request, always carrying requestId.

See Observability for a full walkthrough including incident triage.