Architecture¶
About this document
Audience: project owner, team, and agentic tools reasoning about system design.
Purpose: describe how the system is structured, why it is structured that way, and the rules that keep it that way.
Companion documents: Data Model for schema details; API Conventions for API contract details.
1. System Overview¶
1.1 Shape¶
The system is a modular monolith at the API boundary, with a small number of supporting processes:
flowchart TD
Caddy["Caddy\n(TLS, /, /api/* proxy)"]
Caddy --> Web["Web SPA\n(static)"]
Caddy --> API["API\n(Node)"]
Caddy --> Bot["Telegram Bot\n(external service via API key)"]
Bot -->|API key| API
API --> Postgres[(Postgres)]
API --> Worker["Worker\n(Node)"]
Worker --> Redis[(Redis)]
Worker --> Postgres
The API and Worker share a TypeScript codebase and contexts. The Telegram bot is a separate process talking to the API like any third-party.
1.2 Processes¶
| Process | Language | Responsibility | Scaling |
|---|---|---|---|
| API | TypeScript | Synchronous request handling for all clients | Stateless; horizontal scaling possible (D) |
| Worker | TypeScript | Background jobs (CSV imports, future async tasks) | Stateless; horizontal scaling possible |
| Web | HTML/JS/CSS | Static SPA assets | Served by Caddy; CDN-able if ever needed |
| Telegram bot | TypeScript or Python (TBD) | Telegram-side I/O; calls API as external service | Single instance MVP |
| Postgres | — | All persistent data | Single instance MVP; managed in cloud profile |
| Redis | — | Job queue, idempotency cache, rate limit counters, report cache | Single instance MVP; managed in cloud profile |
| Caddy | — | TLS, static serving, API reverse proxy | Single instance |
1.3 Deployment Profiles¶
| Profile | TLS | Public Access | Postgres | Redis | Object Storage |
|---|---|---|---|---|---|
| Local | none / self-signed | localhost | container | container | local volume |
| Self-hosted | Caddy ACME | user-controlled | container | container | local volume |
| Cloud | Caddy ACME or front-proxy | open | managed (optional) | managed (optional) | S3-compatible (D) |
Local and self-hosted differ only in TLS and domain configuration. Cloud differs only in optionally swapping container services for managed ones.
2. Architectural Style¶
2.1 Hexagonal (Ports & Adapters)¶
The codebase follows Hexagonal Architecture: pure domain logic at the center, infrastructure adapters at the edges, all dependencies pointing inward.
flowchart TD
DA["Driving Adapters\n(HTTP, Bot, CLI, Worker entry)"]
APP["Application\n(use cases, app services)"]
DOM["Domain\n(entities, value objects, rules)"]
DR["Driven Adapters\n(DB repos, event bus, FX provider)"]
DA -->|calls| APP
APP -->|uses| DOM
DR -->|implements| DOM
style DOM fill:#4051b5,color:#fff
style APP fill:#526cfe,color:#fff
style DA fill:#7c8aff,color:#fff
style DR fill:#7c8aff,color:#fff
Driving ports are the use cases — application services that the outside world calls. Driving adapters (HTTP routes, bot command handlers, CLI commands, queue consumers) call them.
Driven ports are interfaces the application defines for what it needs from the outside (TransactionRepository, EventPublisher, Clock, RatesProvider, FileStorage). Driven adapters in infrastructure/ implement them.
2.2 Modular Monolith¶
The API is a single deployable but is internally organized into bounded contexts. Each context has its own folder, its own domain/application/infrastructure layers, and its own database schema. Contexts do not reach into each other's tables; they communicate via events and via deliberately public read APIs.
This gives us:
- Monolith simplicity in operations (one container, one deployment).
- DDD discipline at the design level (bounded contexts, rich models, clear ownership).
- A clean path to split a context into its own service later if needed.
2.3 Hard Rules¶
Violations of these rules are bugs
- Domain has no infrastructure imports. No DB clients, no HTTP libraries, no logger frameworks.
- Domain has no framework imports. No Fastify/Hono/Express/Nest decorators or types in domain code.
- Repository interfaces live in domain. Implementations live in infrastructure.
- Logger and Clock are injected ports. Never use
console.logornew Date()directly in domain or application code. - No cross-context database access. Each context owns its schema; cross-schema FKs are forbidden.
- No cross-context internal calls. Cross-context interactions go through events or through a context's deliberately public read API.
- Application services orchestrate; domain enforces invariants. Business rules live on rich domain entities/value objects.
- HTTP route handlers are thin. They parse requests, call application services, format responses. No business logic.
3. Bounded Contexts¶
Each context is an Nx lib under libs/ with domain/, application/, and infrastructure/ subfolders.
3.1 Identity¶
Purpose: users, credentials, sessions, OAuth links, Telegram links, recovery codes, API keys.
Owns: user, credential, session, recovery_code, oauth_link, telegram_link, api_key.
Public read API: lookup user by ID/username, list API keys.
Emits: identity.user.*, identity.session.revoked.
Subscribes to: nothing.
3.2 Family¶
Purpose: families, memberships, roles, invitations.
Owns: family, family_member, family_invitation.
Public read API: list user's families, get family, list members, check role.
Emits: family.*.
Subscribes to: identity.user.registered (auto-create private family), identity.user.deleted.
3.3 Ledger¶
Purpose: accounts, regular transactions (income/expense/transfer), categories, tags, transfer groups. Investment transactions are owned by Investment (§3.4).
Owns: account, transaction, category, tag, transaction_tag, transfer_group.
Public read API: list/get accounts and regular transactions with filters.
Emits: ledger.account.*, ledger.transaction.*, ledger.category.*, ledger.tag.*, ledger.transfer_group.*.
Subscribes to: family.deleted (cascade-delete).
3.4 Investment¶
Purpose: symbols, positions, prices, and investment transactions.
Owns: symbol, investment_transaction, position, price_history.
Public read API: list symbols/positions/prices/investment transactions with filters.
Emits: investment.transaction.*, investment.position.*, investment.price.recorded.
Subscribes to: family.deleted. Position recomputation runs synchronously in the same use case (not via event) to keep position state consistent within the DB transaction.
Cross-table transfer exception
A deposit_cash/withdraw_cash investment transaction may be paired with a ledger.transaction via transfer_group. This is the one allowed exception to "contexts don't write each other's tables" — a single use case in the Investment context writes both sides in one DB transaction via Ledger's narrow public write API.
3.5 Currency¶
Purpose: currencies, FX rates, conversion logic. Supporting context for Ledger, Investment, and Reporting.
Owns: currency, fx_rate_history.
Public read API: list currencies, get FX rate for (from, to, as_of_date).
Emits: currency.fx_rate_recorded.
3.6 Import¶
Purpose: async CSV import, routing rows to Ledger or Investment based on target account type.
Owns: import_job, import_row.
Public read API: get job status, list jobs in family.
Emits: import.job.*.
Subscribes to: nothing. Calls Ledger's or Investment's public write API.
3.7 Reporting¶
Purpose: read-only aggregations and reports. No own tables in MVP; reads via public read APIs; caches in Redis.
Public read API: balances, spend by category, income vs expense, net worth, portfolio value.
Subscribes to: ledger.* and investment.* for cache invalidation.
3.8 Audit¶
Purpose: immutable audit log of all family-scoped changes.
Owns: audit_entry.
Public read API: list audit entries for family.
Subscribes to: all events from Family, Ledger, Investment, Identity.
3.9 Notification¶
Purpose: in-app notifications; channel-aware delivery is D.
Owns: notification.
Public read API: list pending notifications, mark read/handled.
Emits: notification.*.
Subscribes to: family.member_added, import.job.completed.
4. Cross-Context Communication¶
4.1 The Three Patterns¶
1. Domain events (primary). Context A emits a typed event; an in-process bus dispatches to all subscribers. Used for "B should know about something A did." Subscribers do not block A's response.
2. Public read API (synchronous). Context A exposes a small interface in its application/ layer; B imports and calls it. Used for synchronous reads that cross context boundaries (e.g., Reporting needs Ledger's transactions). Public APIs are stable contracts; internal repositories are not.
3. Job queue (async expensive work). Used for work that should not block the caller and may take time: CSV imports, large report generation, future LLM processing. Producer publishes; worker consumes.
4.2 Decision Rule¶
When in doubt, pick by these criteria:
- "If A needs B's data right now to complete this request" → public read API
- "If B should react to something A did, but A doesn't care about the result" → event
- "If the work is slow or expensive and the caller shouldn't wait" → job queue
4.3 Event Bus¶
The event bus is an injected port (EventPublisher) with two adapters:
- InProcessEventPublisher (MVP): emits via Node's
EventEmitter-like dispatch; persists each event to theeventstable for durability and inspection. - BrokerEventPublisher (D): publishes to RabbitMQ or Redis Streams; persists to
eventsfor the same reasons.
Subscribers are registered at process startup. Each subscriber is a function that receives an event payload. The bus guarantees:
- Events are persisted before subscribers run (durable record).
- Failed subscribers retry with exponential backoff.
- Subscribers are idempotent (event ID is the dedup key).
4.4 Event Format¶
interface DomainEvent<TPayload> {
id: string; // UUIDv7
type: string; // namespaced: <context>.<entity>.<action>
family_id?: string; // when applicable
user_id?: string; // actor when applicable
occurred_at: string; // ISO8601 UTC
version: number; // event schema version
payload: TPayload;
}
Event types are namespaced as <context>.<entity>.<action> (e.g., ledger.transaction.created). The full catalogue lives in libs/shared-types so subscribers and publishers share the schema.
4.5 Job Queue¶
Redis-backed (BullMQ for MVP). Each context that produces async work defines its job types in <context>/infrastructure/jobs/. Jobs carry a small payload (IDs, not full entities); the worker re-loads from the database.
5. Data Architecture¶
5.1 One Postgres, Many Schemas¶
A single Postgres instance hosts all data. Each bounded context owns a dedicated schema:
identity.{user, credential, session, recovery_code, oauth_link, telegram_link, api_key}
family.{family, family_member, family_invitation}
ledger.{account, transaction, category, tag, transaction_tag, transfer_group}
investment.{symbol, investment_transaction, position, price_history}
currency.{currency, fx_rate_history}
import.{import_job, import_row}
audit.{audit_entry}
notification.{notification}
shared.{events, idempotency_key}
The shared schema holds infrastructure tables used by the framework itself (events table, idempotency keys).
Note: ledger.transaction holds only regular activity (income, expense, transfer). Investment activity (buy, sell, dividend, split, etc.) lives in investment.investment_transaction. The two tables are linked via ledger.transfer_group for cross-account transfers between regular and investment accounts.
5.2 No Cross-Schema Foreign Keys¶
Hardest rule, most important
A context referencing another context's entity stores only the ID; the FK is enforced at the application layer. This rule is what allows a future split: any context can move to its own database without rewriting referential integrity.
Concretely:
ledger.transaction.author_user_idreferencesidentity.user.id— but there is no FK constraint. Ledger validates the user exists by calling Identity's public read API at write time.ledger.account.family_idreferencesfamily.family.id— same pattern.
This rule is what allows a future split: any context can move to its own database without rewriting referential integrity.
5.3 Money & Quantity Representation¶
- Money:
BIGINTminor units +CHAR(3)currency code. Decimal places resolved from thecurrency.currencytable (USD = 2, JPY = 0, crypto capped at 6 in this app). - Crypto/stock quantity:
BIGINTrepresenting value × 10^6 (six decimal places of precision). - No
DECIMAL,FLOAT, orNUMBERfor money or quantity — ever. - In JSON:
{ "minor": <integer>, "currency": "<code>" }. Never a decimal number.
No floating-point for money
No floating-point types for any monetary or quantity field, anywhere.
5.4 IDs¶
All entity IDs are UUIDv7, generated at the application layer (not via DB function). UUIDv7 is time-ordered (index-friendly), multi-client safe, and doesn't leak sequence information.
5.5 Time¶
| Field type | Stored as | Notes |
|---|---|---|
| Transaction logical date | DATE |
No time, no zone. What the user sees on their statement. |
created_at, updated_at, deleted_at |
TIMESTAMPTZ UTC |
System time. |
| Audit timestamps | TIMESTAMPTZ UTC |
System time. |
| Family base timezone | TEXT (IANA name) |
Used for report period boundaries. |
| User display timezone | TEXT (IANA name) |
Used for rendering only; never affects data semantics. |
5.6 Soft Delete¶
Soft-deletable entities use deleted_at TIMESTAMPTZ NULL. Hot-path queries use partial indexes: WHERE deleted_at IS NULL. Hard-deleted entities (tags, notifications, GDPR-deleted data) are physically removed.
5.7 Optimistic Locking¶
All shared mutable entities have version INTEGER NOT NULL DEFAULT 0. Updates use:
Zero rows updated → HTTP 409 with current entity state in the response body.
The API contract for this is in API Conventions §7.
6. Hexagonal Layer Conventions¶
| Layer | Folder | Contains | Imports |
|---|---|---|---|
| Domain | libs/<ctx>/domain/ |
Entities, value objects, repository interfaces, domain events, domain exceptions, domain services (rare) | Standard lib, small pure utilities (date-fns, zod for value-object validation), same-context domain modules only |
| Application | libs/<ctx>/application/ |
Use cases (one class, execute(input): output), DTOs, public read API interfaces, event handlers |
This context's domain layer, @oblyk/types, port interfaces. No HTTP frameworks, ORMs, or loggers — use injected ports |
| Infrastructure | libs/<ctx>/infrastructure/ |
Repository implementations, HTTP routes, bot handlers, job consumers, event subscriber wiring, external adapters | Anything — this is the only layer that imports HTTP frameworks, DB clients, and third-party SDKs |
A use case: load aggregates → call domain methods → persist → publish events → return DTO.
7. Application Composition¶
Nx monorepo. Bounded-context libs and shared libs all live under libs/. Apps under apps/.
7.1 Apps¶
apps/
api/ # @oblyk/api — HTTP entry point; src/middleware/ for HTTP-specific infra
worker/ # @oblyk/worker — background worker
web/ # SPA (TBD)
bot/ # Telegram bot
cli/ # CLI (D)
7.2 Libs¶
libs/
identity/ # @oblyk/identity
family/ # @oblyk/family
ledger/ # @oblyk/ledger
investment/ # @oblyk/investment
currency/ # @oblyk/currency
import/ # @oblyk/import
reporting/ # @oblyk/reporting
audit/ # @oblyk/audit
notification/ # @oblyk/notification
shared/ # grouping folder — not an Nx project
domain-primitives/ # @oblyk/domain-primitives
types/ # @oblyk/types
event-bus/ # @oblyk/event-bus
config/ # @oblyk/config
logging/ # @oblyk/logging
test-helpers/ # @oblyk/test-helpers
Each bounded-context lib has domain/, application/, and infrastructure/ subfolders.
7.3 Boundary Rules¶
Enforced via @nx/enforce-module-boundaries.
@oblyk/<context>may importlibs/shared/*libs and other contexts' public barrel (index.ts) only.apps/*may import any@oblyk/*.libs/shared/*libs may not import from any@oblyk/<context>.
7.4 Wiring¶
Each context exposes a register(app) function in its infrastructure/ layer that binds repos, registers HTTP routes, event subscribers, and job consumers. apps/api/ and apps/worker/ call register for every context they include.
8. Authentication & Authorization¶
8.1 User Authentication¶
Methods: username+password, Google OAuth, Telegram-link.
Token model: JWT access + refresh.
- Access token: short-lived (≤15 min), signed JWT, contains
user_id,iat,exp. Stateless verification. - Refresh token: long-lived, opaque random string stored hashed in
identity.session. Rotates on use; old token invalidated.
Recovery codes: 12 single-use codes generated at signup, hashed in storage, shown once. Used for password reset (R-IDN-005).
TOTP / WebAuthn: D — interface in place, implementations deferred.
8.2 External Service Authentication¶
External services (Telegram bot, user-built CLIs) authenticate via API key in a request header. The API instance has a config file listing keys, scopes, and labels. Auth middleware verifies the key against the config.
Trust model: an API key is a system credential. The key holder can act on behalf of any user on the instance. The instance operator is responsible for key security. This is documented prominently for self-hosters.
Scopes: reader (read-only) or writer (read + write).
8.3 Authorization¶
Authorization is family-membership-based:
- Every family-scoped operation requires a
family_idparameter. - The auth middleware looks up the user's role in the family.
- The use case checks the role against the operation's required role.
Roles per family: owner > editor > viewer. Owner can do anything. Editor can write but only delete their own creations (enforced by checking the entity's creator/author field on delete). Viewer is read-only.
For external-service requests, user_id is supplied in the request payload; the API trusts it (per the trust model) but still enforces the user's role within the family.
8.4 Operator¶
The OPERATOR_TOKEN environment variable is the bootstrap credential for the operator surface. The browser-based operator panel exchanges it at POST /v1/operator/session for a short-lived HttpOnly cookie session, and protected operator routes validate that cookie. The operator credential is not stored in the database. The operator surface is metadata-only — no access to user data.
9. The Web Client¶
A static SPA built with React or Vue (decision deferred). Served by Caddy at /; calls the API at /api/*. Same origin, no CORS configuration needed.
Architectural notes:
- The web client consumes the same public API as bot, CLI, and external services.
- The web client has no privileged access. Anything the web client can do, an external service with the appropriate API key can do.
- Future PWA support is on the table per X-010; designing for offline now would be premature.
10. Telegram Bot¶
A separate process (TypeScript or Python — TBD) that:
- Receives updates from Telegram via long-polling or webhook.
- Maintains a
telegram_chat_id ↔ app_user_idmapping (in the API's Identity context, populated by the link flow). - Calls the API as an external service using its API key, supplying the resolved
user_idper request. - Renders responses (text, simple charts) back to Telegram.
The bot is the canonical example of the external-service pattern. Self-hosters can use the project's bot pointed at their instance, or run their own with their own bot token.
11. Configuration¶
11.1 Sources¶
All runtime configuration is via environment variables, validated at process startup against a typed schema. The application refuses to start if any required value is missing or contains a placeholder (e.g., CHANGE_ME_OR_APP_WONT_START).
11.2 Categories¶
Required secrets:
DATABASE_URLREDIS_URLJWT_SECRETOPERATOR_TOKEN
Optional but needed for specific features:
GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRETSENTRY_DSNTELEGRAM_BOT_TOKEN(for the bot process)
Operational:
LOG_LEVELRATE_LIMIT_PER_USER_PER_MINUTEIDEMPOTENCY_KEY_TTL_HOURSREPORT_CACHE_TTL_MINUTES
11.3 External Services Config¶
A separate config file (e.g., external-services.yaml) lists API keys and scopes. Mounted into the API container; reloaded on SIGHUP or container restart. Self-hosters edit this file to add their own services.
12. Observability¶
12.1 Logging¶
- JSON structured logs to stdout, one line per event.
- Required fields:
timestamp,level,message,request_id. - Optional but encouraged:
user_id,family_id,context,error.stack,event.type. - Log level configurable per env (
LOG_LEVEL); defaultinfo. - Logger is an injected port; no
console.login domain or application code.
12.2 Errors¶
- Sentry for exception tracking when
SENTRY_DSNis set. - Errors enrich Sentry with
user_id,family_id,request_id,context.
12.3 Health¶
GET /health— liveness; no auth; 200 if process is up.GET /health/ready— readiness; no auth; 200 if DB and Redis reachable, 503 otherwise.GET /v1/operator/health/info— extended; operator token required; returns version, uptime, queue depth, recent error count.
12.4 Metrics¶
Prometheus-compatible metrics endpoint is D. When implemented, it exposes request rates, queue depth, error counts, DB pool stats, event bus throughput.
13. Reliability & Failure Modes¶
13.1 Database Transactions¶
A use case that mutates more than one row wraps its writes in a single SQL transaction. Cross-context writes are not transactional across contexts (we don't use distributed transactions); they are coordinated via events with idempotent handlers.
13.2 Event Handler Failures¶
Event handlers retry with exponential backoff. After N retries, the event lands in a dead-letter table for human inspection. Handlers are idempotent (event ID dedup), so retries are safe.
13.3 Redis Unavailability¶
If Redis is down:
- Idempotency check is bypassed (request proceeds without dedup; not catastrophic for typical workloads).
- Rate limiting is bypassed (fail-open; alternatives are worse for a personal app).
- Report cache is bypassed (computed on the fly, slower but correct).
- Job queue is unavailable; new async jobs error gracefully; the user-visible API remains functional.
This degradation is N-REL-004 (D) — the design supports it; the explicit failure-mode coding is deferred.
13.4 Process Restarts¶
API and Worker are stateless. Restarts lose only:
- In-flight HTTP requests (client retries).
- In-flight job processing (BullMQ re-delivers).
- In-flight events not yet handled (re-emitted from
eventstable on next subscriber tick).
14. Extensibility Points¶
These are the ports designed for future implementations. Each is a port (interface) with at least one MVP adapter and a clear path to additional adapters.
| Port | MVP adapter | Future adapters |
|---|---|---|
EventPublisher |
InProcess | RabbitMQ, Redis Streams |
RatesProvider |
Manual (user-entered) | NBU, ECB, fixer.io |
PriceProvider |
Manual (user-entered) | CoinGecko, exchange APIs |
FileStorage |
Local volume | S3-compatible (MinIO, R2, AWS) |
Importer |
CSV (app format) | LLM-based, bank-specific |
NotificationChannel |
In-app | Telegram, email (X-011 says no), push |
LLMProvider |
none | OpenAI, Anthropic, local |
AuthFactor |
password, OAuth, Telegram | TOTP, WebAuthn |
Adding a new adapter is a new class implementing the port. No existing code changes for the addition itself; only configuration changes select the active adapter.
15. Key Decisions With Rationale¶
| Decision | Choice | Rationale |
|---|---|---|
| Architecture style | Hexagonal | Multi-client, extensibility, DDD rich models |
| Service shape | Modular monolith, one Postgres, one schema per context | Solo/small-team; preserves clean split path |
| Cross-context comms | Events primary; public read APIs for sync | Decouples write side; no internal leakage |
| ID strategy | UUIDv7 | Time-ordered, multi-client, no sequence leakage |
| Money | Integer minor units | Eliminates floating-point errors |
| Transfer modeling | Two linked transactions | Cross-currency clean; per-side native amounts |
| Transaction storage | Two tables: ledger.transaction + investment.investment_transaction |
Aligns storage with bounded-context ownership; non-investment users never touch the investment table |
| Cost basis | Average cost only | MVP simplicity; lot-tracking is out of scope |
| Concurrency | Optimistic via version column |
Read-heavy, low conflict rate, no held locks |
| Balance | Stored, sync-updated in same DB tx | O(1) reads; correctness via single-tx mutation |
| Auth | JWT (access+refresh) for users; API key for external services | Stateless; clear external trust model |
| Idempotency | Header-based with Redis cache | Handles retries from network-flaky clients |
| Event versioning | Per-event version field |
Safe schema evolution; survives broker migration |
| Operator auth | Env-var token, never in DB | No admin identity in DB; works for solo-host and cloud |
16. Where to Go Next¶
- Concrete schema: Data Model
- API contract details: API Conventions
- What to build and when: Requirements and Roadmap
- Conventions for agents: Agents
- Domain term definitions: Glossary