DocumentationCore Concepts
DOCS

Core Concepts

Contracts, services, routers, plugins, ports and events - the six ideas that cover most of the platform.

Introduction

Six ideas cover most of the platform. Every wiring point is an explicit, typed function call - nothing is auto-discovered, and there are no decorators.

Contracts

Every shape is a Zod schema; the type is inferred. oRPC turns a schema into a validated route and an OpenAPI entry - one source of truth for the server, the client and the docs.

import { oc } from "@orpc/contract";
import * as z from "zod";
 
export const DepositInputSchema = z.object({
  amount: z.number().positive(),
  currency: z.string(),
  provider: z.string().optional(),
});
 
export const walletContract = {
  getBalance: oc
    .route({ method: "GET", path: "/wallet/balance" })
    .output(WalletBalanceSchema),
  deposit: oc
    .route({ method: "POST", path: "/wallet/deposit" })
    .input(DepositInputSchema)
    .output(TransactionResultSchema),
};

Every core module has the same layout - here is the real one for wallet:

packages/core/src/wallet/
├─ contract/       # zod + oRPC contract slice
├─ schema/         # drizzle tables
├─ service/        # business logic
├─ router/         # oRPC handlers
├─ adapters/       # ports, bound to implementations
├─ plugin.ts       # definePlugin registration
├─ __tests__/      # vitest
└─ AGENTS.md       # agent playbook

You don't add modules there. In your own repo you add an overlay plugin under extensions/<name>/ with pnpm gen plugin, and it registers against the same plugin host.

Services

A service holds the business logic. It takes its dependencies as constructor arguments - no container access - and isolates side effects at the edges: database writes, events, adapter calls.

export class WalletService {
  constructor(
    private readonly drizzle: DrizzleService,
    private readonly events: EventBus,
    private readonly payment: PaymentAdapter,
  ) {}
 
  async deposit(userId: string, amount: number, currency: string) {
    const psp = await this.payment.processDeposit(amount, currency, { userId });
    // ...persist in a transaction, then emit after commit
    this.events.emit("wallet.deposit.completed", { userId, amount, currency });
    return { transactionId: psp.id, status: "completed" };
  }
}

Routers

A router is thin oRPC wiring: resolve the caller, call the service, map errors. No business rules live here.

import { implement } from "@orpc/server";
import { getUserId, type OssContext } from "@openora/core/server";
import { walletContract } from "../contract/index.js";
 
export function createWalletRouter(wallet: WalletService) {
  const os = implement(walletContract).$context<OssContext>();
 
  return os.router({
    getBalance: os.getBalance.handler(({ context }) =>
      wallet.getBalance(getUserId(context)),
    ),
    deposit: os.deposit.handler(({ input, context }) =>
      wallet.deposit(getUserId(context), input.amount, input.currency),
    ),
  });
}

Admin routes are guarded at a single enforcement point: plugin.ts resolves the AdminGuard and passes it to the router, and await adminGuard.assert(context) is the handler's first line. Never re-implement the role check.

Plugins

definePlugin is the only way new functionality enters the system. In register(ctx) you bind adapters, add routers, subscribe to events, register job workers, and register MCP tools.

import { definePlugin, EVENT_BUS, DRIZZLE } from "@openora/core/server";
import { PAYMENT_ADAPTER } from "@openora/core/contracts";
import { WalletService } from "./service/wallet.service.js";
import { createWalletRouter } from "./router/index.js";
import { MockPaymentAdapter } from "./adapters/mock/mock-payment-adapter.js";
 
export default definePlugin({
  id: "wallet",
  dependsOn: [], // optional load-order hints
  register(ctx) {
    ctx.provide(PAYMENT_ADAPTER, () => new MockPaymentAdapter());
    ctx.routers.add("wallet", (c) =>
      createWalletRouter(
        new WalletService(c.get(DRIZZLE), c.get(EVENT_BUS), c.get(PAYMENT_ADAPTER)),
      ),
    );
  },
});

Adapter bindings are last-wins: an overlay loaded after a module can rebind its adapter token. Sealed tokens are the exception - ctx.provide() rejects them outright, and ctx.provideSealed() refuses a second bind, so a regulator-mandated service can never be quietly overridden.

Every module ships an AGENTS.md

Domain rules, contracts, invariants, and safe edit boundaries are written for the agent before it touches a line - so AI agents and new engineers ship safely on day one.

Ports and adapters

Third-party integrations - payments, KYC, messaging, realtime, jobs - are ports: an interface plus a typed token. A module depends on the port; you bind any vendor in a plugin. Swap a vendor by binding a different implementation in a later-loading overlay, with no module change.

// default binding (a mock ships in-tree)
ctx.provide(PAYMENT_ADAPTER, () => new MockPaymentAdapter());
 
// your overlay rebinds the same token to a real vendor
ctx.provide(PAYMENT_ADAPTER, () => new AcmePayments(env.ACME_KEY));

Events

Modules stay decoupled by reacting to domain events, never importing each other's internals. Declare the payload in the Zod catalog, emit after the database commit, and subscribe in a plugin.

// emit from a service (after commit)
this.events.emit("wallet.deposit.completed", { userId, amount, currency });
 
// subscribe from a plugin
ctx.events.on("wallet.deposit.completed", (payload) => {
  // e.g. credit a first-deposit bonus
});

Money is the exception. A module that must move funds synchronously resolves the WALLET_COMMANDS port and calls it inside its own transaction, so the debit commits or rolls back with the caller's writes:

const wallet = c.get(WALLET_COMMANDS);
await wallet.debit(tx, { userId, amount, type: "bet" });

Events record what already happened; they never move funds.

The typed client

Once a contract exists, the client is fully inferred - nothing to regenerate:

import { createClient } from "@openora/core/react";
import { contract } from "./contract";
 
const openora = createClient(contract, {
  baseUrl: "https://api.acme.gg",
  headers: { authorization: `Bearer ${session.accessToken}` },
});
 
const balance = await openora.wallet.getBalance();
//    ^? inferred from the wallet contract

Next

Updated 1 day ago