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 queuesEvery 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:
- Compute phase — read-only. Resolves matching coin rules, skill rules, token configs, and badge criteria against a projected "after" state.
- Apply phase — transactional. Opens a single DB transaction, takes a
pessimistic_writelock 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. - 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_countersJSONB column via ajsonb_setupdate. 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 DTOs —
apps/api/src/**/dto/*.ts— validated byclass-validator+ Swagger-decorated. - Widget props / SDK payloads — import from
@hatched/shared. - DB entities —
apps/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 throwsUpstreamImageExceptionon 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/readyreturns HTTP 200 / 503 based on DB, Redis, queue, and image-provider status. - Metrics:
GET /metricsemits Prometheus text format. Protected byX-Internal-Service-Token. - Logs: structured JSON per request, always carrying
requestId.
See Observability for a full walkthrough including incident triage.