DocumentationArchitecture
DOCS

Architecture

The contract spine, the plugin host that loads everything, the adapter seams that keep it swappable, and how a consumer reuses it without forking.

Introduction

Everything ships as one published package, @openora/core, with subpaths: @openora/core/contracts (isomorphic), @openora/core/react (browser), @openora/core/server (the node engine - kernel, plugin host, db, auth, createApp), @openora/core/compliance, and one subpath per domain.

System overview

Rendering diagram...

Solid arrows are runtime or build dependencies; dashed arrows are adapter seams - one side declares an interface, the other implements it, and you swap freely.

The parts

Contracts

Cross-cutting Zod schemas live under contracts/schemas/. Per-module request and response schemas live in that module's contract/ directory. Every type is inferred, never hand-written.

composeContract owns only health. The composition root - your createApp() entry - composes each enabled module's contract slice into the one runtime contract. From it the build emits docs/openapi.json and a fully typed client, with no codegen step.

API runtime

extensions.config.ts is the single list of enabled plugins, and the only place wiring is turned on.

The plugin host is definePlugin({ id, dependsOn, register }) plus a ModuleRegistry. Inside register(ctx) a plugin binds providers with ctx.provide(token, factory), mounts routers with ctx.routers.add(namespace, factory), subscribes to events, registers job workers, and registers MCP tools.

oRPC defines routes and validates I/O against the Zod contract; its OpenAPIHandler mounts on a Hono server. Dependency wiring is a small functional Container: typed-token factories, lazy, last-wins, no decorators.

Engine

@openora/core/server is the node runtime under one subpath - db (Drizzle client, drizzle-kit migrations, DrizzleService, and the framework-free @openora/core/server/orm re-export), auth (better-auth plus the shared AdminGuard), kernel (logger, typed EventBus, the composition Container), plugin-host, and createApp(), which is domain-agnostic and single-tenant.

Domains

A domain may import the engine zones and a sibling's read-only /schema subpath, but never another domain's internals. Cross-domain communication goes through events, command ports, or shared contracts. Vendor adapter interfaces come from @openora/core/contracts.

Vendor adapters

Concrete implementations of a module's adapter interfaces - PSP, KYC vendor, game aggregator, chat. A mock ships in-tree as the reference; the interface is the seam, and the implementation underneath is swappable.

Background jobs

Long-running work runs off the request path behind the JOB_QUEUE port. A module enqueues; a worker overlay registers the handler via ctx.jobs.worker(...). In-process by default, BullMQ when REDIS_URL is set. Delivery is at-least-once, so handlers must be idempotent - a database guard for anything touching money.

Adapter seams

These are the swap points - the reason the platform is headless and extensible.

| Seam | Interface side | Implementation side | Swap to | | -------------- | ------------------------------- | ------------------------------------------------- | ----------------------------------------- | | Plugin host | definePlugin contract | a module or overlay folder | add or remove features without touching core | | Vendor adapter | a token from @openora/core/contracts | a mock ships today; bind your own in ctx.provide() | a different PSP, KYC provider, or aggregator | | Job queue | JOB_QUEUE token | in-process by default; BullMQ when REDIS_URL is set | your own durable queue, bound as an overlay | | Message broker | MESSAGE_BROKER token | in-process event bus | a durable driver, bound as an overlay | | Consumer link | createApp() + @openora/core | your own thin API entry | bump the version and reinstall |

Request flow

Rendering diagram...

Inter-module communication

The API layer is a backend-for-frontend: it triggers commands and serves reads. Modules do not call each other - they couple to topics, not to each other's routes.

Events for side effects. A module emits via the typed EventBus, with payloads validated against the Zod catalog in contracts/schemas/events.ts. Any number of modules subscribe with ctx.events.on(...). Consumers must be idempotent. A throwing subscriber is logged and isolated - it never breaks the emitter or its siblings.

Synchronous and atomic for money, via command ports. Playing a game - wallet debit, balance check, RNG result - and pre-action gates like KYC or jurisdiction checks run inside a single db.transaction(...). A module that must mutate another synchronously calls the owner's command port, passing its own tx: gaming calls WALLET_COMMANDS.debit(tx, ...). Atomic in-process, yet decoupled enough to split later. Events record what already happened; they never move funds.

Broker behind a seam. The EventBus is a typed facade over a MESSAGE_BROKER port. What ships today is the in-process default. A durable driver - Kafka or Redpanda for a regulated audit, ledger and replay stream, NATS JetStream for lighter fan-out - is an overlay you bind yourself, with no module changes.

Durable events via the outbox. When an event must survive a crash or a process boundary, a service emits it inside its transaction with events.emitInTransaction(tx, ...). The envelope is written to event_outbox atomically with the state change, and the relay publishes it after commit. Consumers deduplicate on eventId.

Client push is separate. Server-sent events, behind the REALTIME_TRANSPORT seam, are client-facing only - chat and live feeds - never the transport between modules.

Modular monolith now, microservices later. The no-cross-domain-imports rule and the broker seam are what make a later extraction cheap. This is a design property, not a shipped feature: today the MESSAGE_BROKER port has exactly one implementation, the in-process bus.

Next

Updated 1 day ago