Backend
Modular monolith
Section titled “Modular monolith”The backend is organised by business domain, never by technical layer. A controllers/,
services/, models/ tree puts every unrelated feature into the same three folders and
guarantees they grow into each other.
apps/api/src/├── modules/│ ├── auth/ device registration, session issue and revoke│ ├── users/ profile, language, voice preferences│ ├── commands/ the pipeline: transcribe, gate, plan, validate│ ├── vocabulary/ per-user prompt bias from contacts and installed apps│ ├── consent/ retention grants, revocation, export, deletion│ ├── telemetry/ structured events, aggregation, no transcripts│ ├── billing/ subscription state, usage counters│ └── admin/ support tooling, audit log, permission checks├── shared/ database, cache, queue, logging, tracing, security└── app/ routes, middleware, config, bootstrapNothing here has an independent scaling axis yet, so nothing is a separate service. Evolution names the conditions that would change that.
The dependency rule
Section titled “The dependency rule”Each module has the same four layers, and dependencies point one way.
flowchart TD
accTitle: Dependency rule
accDescr: Presentation, meaning route handlers and parsing, depends on application use cases. Application depends on domain entities and port interfaces. Infrastructure, meaning Postgres, Redis and provider SDKs, also depends on domain. Domain depends on nothing.
p[presentation<br/>route handlers, parsing] --> a[application<br/>use cases]
a --> d[domain<br/>entities + port interfaces]:::core
i[infrastructure<br/>Postgres, Redis, provider SDKs] --> d
Domain and application code never import Express, Postgres, or a provider SDK. That is the rule that earns the structure.
TranscribeCommand depends on a TranscriptionPort interface. HostedTranscriptionAdapter
implements it in infrastructure/. Moving transcription on-device later is then a one-file
change, not a rewrite.
Without the rule, these are four folders with architectural names and no architecture.
Module boundaries
Section titled “Module boundaries”| Module | Owns | Must not |
|---|---|---|
auth |
Device binding, session lifetime, token rotation | Know what a command is |
commands |
Confidence gate, plan validation, provider orchestration | Persist audio or transcripts |
vocabulary |
Building the bias prompt from user data | Send contact data to the planner |
consent |
Whether retention is permitted, right now | Be bypassed by any other module |
telemetry |
Counters, durations, outcomes | Accept a transcript and a user identifier together |
billing |
Entitlement checks, usage counters | Block a command on payment failure without spoken warning |
admin |
Support actions, every one audited | Read the database directly |
Cross-module calls go through the owning module’s application layer. No module imports
another module’s infrastructure/ or queries its tables.
Contracts
Section titled “Contracts”One schema, generated downward. The mobile client needs a Kotlin client for the pipeline and TypeScript types for the React Native layer. A hand-written contract would be maintained three times.
The YAML is written by hand and everything else is generated from it. It is language-neutral, so the Kotlin side reads the same contract the TypeScript side does, without depending on either.
flowchart TD
accTitle: Contract generation
accDescr: openapi.yaml is written by hand and is the source of truth. From it, hey-api generates the Zod schemas the backend validates with, openapi-typescript generates TypeScript types for React Native and admin, openapi-generator generates the Kotlin client for the pipeline, and redocly generates the API reference for the docs site.
src[openapi.yaml<br/>written by hand · source of truth]:::core
src -->|hey-api| zod[Zod schemas<br/>backend runtime validation]
src -->|openapi-typescript| ts[TypeScript types<br/>React Native + admin]
src -->|openapi-generator| kt[Kotlin client<br/>pipeline]
src -->|redocly| docs[Docs site<br/>API reference]
Validation and documentation come from one declaration. A breaking change fails a client build instead of reaching a user.
How to regenerate everything, and where the code still differs, is on the API reference page.