Shared TypeScript Domain Architecture
AppRuntime design, pure domain policy, native/web sharing targets, permission architecture.
Status: architectural constraint for the greenfield recommendation | Recorded: 2026-07-16
Decision
Boja is TypeScript full stack. Business rules are shared across React Native and Web by default, with platform-specific code limited to rendering, navigation, device capabilities and platform lifecycle integration.
We want to preserve the best idea from TRW's app/src/client.js/: one cohesive, discoverable application-facing domain API containing entities, permissions, entitlements and operations. We do not want to preserve its MobX coupling or its role as a second mutable entity database.
With Zero, the correct division is:
- Zero owns persisted reactive state: normalized rows, local persistence, query subscriptions, optimistic writes, a short-glitch optimistic queue while connecting, synchronization and reconciliation. A distinct app-owned write-ahead outbox owns durable pending message-send commands.
- Shared domain modules own meaning: permissions, entitlements, invariants, policies, validation, projections, selectors and command semantics.
- A small headless
AppRuntimeowns process-scoped capabilities: authenticated actor, Zero handle, command entry points, clock/ID generation and service adapters. - React owns view subscription and composition: components subscribe to Zero queries and ephemeral UI state without making domain decisions.
- Platform adapters own native/web differences: storage credentials, deep links, push notifications, media capture, files, sharing and app lifecycle.
The design must avoid copying Zero rows into MobX, Zustand, Redux, Jotai or class-owned maps. That creates dual sources of truth and reintroduces TRW's cache/event reconciliation burden.
What to Keep from TRW client.js
TRW's legacy client provides useful ergonomics:
- A single discoverable application client.
- Normalized identity maps for users, channels, members, servers and messages.
- Relationship traversal such as
message.author,message.channeland member lookup. - Centralized permission and entitlement calculations.
- Commands near entities (
channel.sendMessage, message operations, server operations). - Shared logic outside React components.
- A stable vocabulary that UI code can use instead of raw transport calls.
Concrete examples include:
app/src/client.js/Client.tsaggregates users, members, messages, channels, servers, feature flags, attributes and notifications.app/src/client.js/maps/Messages.tsexposes message relationships and message behavior.app/src/client.js/maps/Channels.tscombines channel data, derived state, permissions and commands.app/src/client.js/permissions/calculator.tscentralizes layered role, attribute, timeout and channel permission policy.
Architectural Boundaries Around client.js Lessons
Boja's application-facing domain API keeps these boundaries:
- Zero rows remain the canonical persisted entity records.
- Domain records and calculators are immutable and framework-free.
- Relationships use schema/query composition rather than
Clientbackreferences. - WebSocket handlers own ephemeral events only and never mutate a second entity map.
- UI notifications, network adapters and product policy occupy separate layers.
- Permission calculators receive every dependency explicitly.
- Discriminated unions, validated contracts and shared policy replace runtime class identity.
Zero already handles local normalized synced data, connected/short-glitch optimistic mutation and persistence. The message-only durable outbox is a separate command store, not an entity replica. Duplicating Zero rows into another store would force every Zero change to be mirrored and every optimistic rollback to be reconciled twice.
Proposed Package Architecture
apps/
web/ # React web shell
native/ # Expo / React Native shell
packages/
contracts/ # schemas, IDs, DTOs, discriminated unions
domain/ # pure product rules; no React, RN, DOM, DB or Zero
permissions/
entitlements/
chat/
courses/
streams/
servers/
billing/
zero-schema/ # Zero/Postgres schema and relationships
zero-queries/ # named shared query definitions
zero-mutators/ # shared optimistic mutation core
app-runtime/ # small headless application facade over capabilities
react-bindings/ # shared hooks/providers usable by native and web
design-tokens/ # shared colors, themes, semantic tokens (no rendering code)
ui-web/ # web-only components (React DOM)
ui-native/ # native-only components (React Native)
platform/ # interfaces and native/web implementations
services/
api/ # TypeScript server query/mutate/auth endpoints
workers/ # outbox consumers, media, notification jobs
Avoid a generic shared/ dumping ground. Packages should declare what they own.
Layer 1: Contracts
Use plain, serializable and schema-validated records:
export type Channel = {
id: ChannelId;
serverId: ServerId;
kind: "text" | "thread" | "announcement";
name: string;
parentChannelId: ChannelId | null;
};
export type Membership = {
userId: UserId;
serverId: ServerId;
roleIds: readonly RoleId[];
timedOutUntil: number | null;
};
Contracts are shared by clients and services, but they contain no framework behavior and no ambient runtime state.
Layer 2: Pure Domain Logic
Business rules should be deterministic functions over explicit inputs. They must run identically in native UI, web UI, optimistic mutations and server-authoritative mutations.
export type ChannelAccessInput = {
actor: User;
server: Server;
membership: Membership | null;
serverDefaultPermissions: readonly CapabilityEffect[];
roles: readonly Role[];
attributes: readonly UserAttribute[];
channel: Channel;
channelDefaultPermissions: PermissionOverride;
channelRoleOverrides: readonly ChannelRoleOverride[];
channelAttributeOverrides: readonly ChannelAttributeOverride[];
moderation: ModerationState;
ownerOrPrivileged: OwnerOrPrivilegedState;
permissionConfig: PermissionConfiguration;
entitlements: readonly Entitlement[];
now: number;
};
export const calculateChannelCapabilities = (
input: ChannelAccessInput,
): ChannelCapabilities => {
// deterministic policy only
};
Rules in this package include:
- Permission/capability calculation.
- Role and channel override precedence.
- Entitlement resolution.
- Billing-plan-to-entitlement projection rules.
- Message/thread/reply validation.
- Course progression prerequisites.
- Stream access and moderation decisions.
- Unread/read-state reductions.
- Display-independent entity projections and sorting.
- Command preconditions and user-safe rejection codes.
Domain functions must not import React, Zero, Postgres, Better Auth, Expo, browser APIs, network clients or analytics.
One policy implementation, two trust levels
The client runs shared policy for immediate UX and optimistic behavior. The server runs the same policy with authoritative rows before committing.
Client permission results are hints, never a security boundary. The server supplies trusted actor identity and loads authoritative membership, server defaults, rank-ordered roles, user attributes, channel overrides, moderation, typed configuration and entitlement rows.
Layer 3: Zero as the Entity Store
Zero replaces TRW's Users, Members, Channels, Messages and related MobX collections.
Use:
- Zero schema relationships instead of entity
clientbackreferences. - Named Zero queries instead of collection getters.
- Query composition/selectors instead of mutable entity methods.
- Zero mutators instead of methods that directly call REST and then patch a cache.
- Zero's local database instead of custom cache serialization.
- Zero optimistic execution and rollback instead of bespoke pending maps wherever possible.
Zero queries run immediately against local data and then synchronize authoritative results. Named query definitions can be shared between client and server, while the server adds trusted permission filters.
Example shape:
export const channelQueries = {
detail: defineQuery(channelIdSchema, ({ args: channelId }) =>
zql.channel
.where("id", channelId)
.related("server")
.related("overrides")
),
recentMessages: defineQuery(messageWindowSchema, ({ args }) =>
zql.message
.where("channelId", args.channelId)
.where("sortId", "<=", args.before ?? MAX_SORT_ID)
.orderBy("sortId", "desc")
.limit(args.limit)
),
};
The server version of channelQueries.detail and recentMessages must filter by authoritative access. Query arguments are not credentials.
Layer 4: Shared Mutators and Commands
Zero explicitly supports sharing mutator logic between client and server. Use that for deterministic validation and row changes, then extend the server execution with authoritative authorization and outbox writes.
export const sendMessageArgs = schema.object({
id: messageIdSchema,
channelId: channelIdSchema,
content: messageContentSchema,
replyToId: messageIdSchema.nullable(),
});
export const sendMessage = defineMutator(
sendMessageArgs,
async ({ tx, args, ctx }) => {
// Shared deterministic checks and optimistic row write.
},
);
Server execution additionally:
- Resolves Better Auth identity.
- Loads authoritative channel, membership, server defaults, rank-ordered roles, user attributes, channel overrides, moderation/configuration and entitlements.
- Runs the shared capability policy.
- Performs the transactional write.
- Writes durable side effects to an outbox.
Do not perform email, push, analytics, moderation APIs or media processing in the client-shared mutator. Server-only side effects belong behind an outbox/worker boundary.
Layer 5: The Headless AppRuntime
A class is appropriate for lifecycle and capability ownership, but not as another reactive database.
export class AppRuntime {
readonly actor: ActorSession;
readonly chat: ChatCommands;
readonly servers: ServerCommands;
readonly courses: CourseCommands;
readonly billing: BillingCommands;
constructor(
readonly zero: AppZero,
capabilities: RuntimeCapabilities,
) {
this.actor = capabilities.actor;
this.chat = createChatCommands(zero, capabilities);
this.servers = createServerCommands(zero, capabilities);
this.courses = createCourseCommands(zero, capabilities);
this.billing = createBillingCommands(zero, capabilities);
}
}
The runtime may own
- The Zero instance lifecycle.
- Auth/session lifecycle.
- Typed command namespaces.
- ID generation, clock and connectivity capabilities.
- Upload, media, push and navigation ports.
- Logout/account-switch cleanup.
- Non-persisted process lifecycle resources.
The runtime must not own
- Maps of users/messages/channels copied from Zero.
- Observable entity instances.
- A second subscription mechanism for database rows.
- Cached permission answers without versioned dependencies.
- React component state.
This keeps the loved appClient.chat.sendMessage(...) / appClient.permissions... discoverability without making the class a god store.
Entity Ergonomics Without Mutable Entity Classes
Preferred default: records + selectors
const messageAuthor = (
message: Message,
usersById: ReadonlyMap<UserId, User>,
) => usersById.get(message.authorId) ?? null;
For React, named queries should materialize the relationships needed by the screen, reducing manual lookup bags.
Rich views when justified
If relationship-heavy operations genuinely benefit from an object API, use short-lived immutable views over a query snapshot:
export class MessageView {
constructor(
readonly data: Message,
private readonly graph: MessageGraphSnapshot,
) {}
get author(): User | null {
return this.graph.usersById.get(this.data.authorId) ?? null;
}
get canEdit(): boolean {
return canEditMessage(this.graph.editPolicyInput(this.data));
}
}
Rules:
- A view is not observable.
- A view does not mutate its record.
- A view is recreated from the current query snapshot.
- A view does not perform network calls.
- A view never becomes the canonical identity of a row.
Start with functions. Add view classes only when they measurably improve a relationship-heavy domain API.
React Binding Strategy
Create shared hooks in packages/react-bindings that work in both React DOM and React Native:
export const useChannelCapabilities = (channelId: ChannelId) => {
const [data, result] = useQuery(channelQueries.accessContext({ channelId }));
const now = usePolicyClock();
return {
capabilities: data
? calculateChannelCapabilities({ ...data, now })
: null,
result,
};
};
Hooks may compose Zero queries and pure domain logic. They should not duplicate the policy itself.
Use local component state or a minimal shared ephemeral store only for UI concerns such as: open composer draft UI, selected tab, modal state, local list anchor/measurement state, temporary recording state.
Durable drafts and local presentation state that must survive restart belong in explicitly owned local persistent tables. The only durable queued-action exception is the app-owned message-send outbox; it is not a generic command queue or an arbitrary UI store.
Native/Web Sharing Target
Share by default
- Contracts and schemas.
- Zero schema, named queries and mutators.
- Permissions and entitlements.
- Domain validation and calculations.
- Command namespaces.
- React providers and data hooks.
- Navigation-independent feature controllers.
- Formatting, parsing and projection logic.
- Design tokens, theme definitions and component semantic vocabulary (variant names, size scales, state names) — but NOT component implementations.
Keep platform-specific
- Navigation container and URL/deep-link integration.
- Secure credential storage.
- Push token registration and notification presentation.
- Filesystem, media picker, camera/microphone and uploads.
- Background execution constraints.
- Share sheets and system intents.
- Browser-only DOM virtualization or native list implementations when their performance needs diverge.
- Live-stream player/native media SDK adapters.
- ALL component rendering code. Native and web implement their own components against the shared token/semantics packages (decided constraint, not an option).
Rendering is never shared between native and web. Sharing stops at tokens, theming, component semantics and logic.
Permission and Entitlement Architecture
Define one code-generated canonical capability registry rather than scattered booleans. The registry generates the namespaced TypeScript union, fixtures, admin labels and policy documentation:
export type Capability =
| "channel.view"
| "message.send"
| "message.moderate"
| "thread.create"
| "stream.start"
| "stream.join"
| "course.manage"
| "server.billing.manage";
export type CapabilityDecision = {
allowed: boolean;
reason:
| "allowed"
| "not-member"
| "role-denied"
| "channel-denied"
| "missing-entitlement"
| "timed-out"
| "server-suspended";
};
The evaluation engine is one pure-TypeScript calculator. Defaults and overrides are human-readable capability_key + effect rows, where effect is allow or deny. It evaluates: server.default_permissions → server roles in rank order → applicable user attributes → channel default → channel roles in rank order → channel attributes → timeout mask (ALLOW_IN_TIMEOUT) → bounded owner/privileged bypass. Entitlements and hard quotas gate the result afterward. A bitset may be compiled as a derived performance cache, with the rows and manifest remaining authoritative.
Roles remain the primary in-server grant mechanism, and a role reorder changing the result is intentional. User attributes are a first-class cross-server input for subscription tiers, staff and other platform-wide facts. Free-tier IDs and attribute ordering come from typed data-driven configuration rather than magic constants.
The calculator accepts every record and configuration value explicitly and returns the effective capability set, explainable decisions and an optional server-only trace. It must not import a global singleton. Unknown manifest keys fail closed on the server. Channels belong to one server in v1. Property tests cover every allow/deny layer, cross-server attributes, timeout masks, bounded bypass, entitlement-after-permission ordering and role permutations; admin UI warns that reordering roles may change access.
Permissions answer may this actor perform this action on this resource? Entitlements answer has this account/server purchased or been granted this product capability? Keep those concepts separate, then compose them in capability policy.
Failure Modes to Guard Against
- Dual stores: Zero rows copied into another reactive entity map.
- God runtime: every feature, event and UI concern placed on one class.
- Ambient policy: permission functions read global runtime state instead of explicit inputs.
- Client authority: shared logic mistaken for server authorization.
- Framework contamination: domain packages import React, Zero or platform APIs.
- Stale rich objects: long-lived entity classes retain old row snapshots.
- Over-sharing: native UX degraded to preserve identical web components.
- Server/client drift: separate permission or validation implementations.
- Query-as-credential: trusting client-supplied server/channel/user IDs without authenticated context.
- Side effects in optimistic mutators: notifications or external calls execute twice or offline.
Architecture Acceptance Tests
- Domain package tests run in plain TypeScript without DOM, React Native, Zero server or database setup.
- The same permission fixture suite runs against client prediction and server authorization inputs.
- Property tests prove the layered allow/deny algebra, including intentional role-order sensitivity and cross-server attribute effects.
- Native and web invoke the same command and policy modules.
- Cold offline launch renders from Better Auth's cached identity plus Zero's local database.
- No persisted entity is copied into a second app-wide reactive store.
- Account switching isolates/deletes the previous user's Zero storage according to policy.
- Rejected server mutations roll back optimistic state without manual entity reconciliation.
- Channel access removal causes synced rows to disappear and future mutations to fail.
- Entity/view helpers cannot perform I/O.
- Platform modules can be replaced in tests through narrow capability ports.
Build Boja's cohesive, typed application API: preserve a cohesive, typed and discoverable application API; preserve centralized entities, permissions and entitlement semantics; move all deterministic meaning into shared pure TypeScript modules; let Zero be the only persisted reactive entity store; use a small AppRuntime class only for lifecycle and commands; use React bindings for reactivity rather than putting observability into domain objects; run the same policy and mutation core on native, web and server, while keeping server identity and data authoritative.