OCPP 1.6J · MODULAR MONOLITH · RACE-TESTED IN CI

Two engines.
One transaction boundary.

An open-source EV charging management system where Oracle 23ai owns the money path and TimescaleDB owns telemetry — joined by an outbox + relay, with the concurrency re-proven on both engines at every push.

0
REST ROUTES · OPENAPI-GATED
0
PL/SQL PACKAGES
0
DB ENGINES, ONE STORE PORT
0
CI JOBS EVERY PUSH
0
ADRs, TRADE-OFFS NAMED
0
KNOWN CVES, AUDIT-GATED
01 — SYSTEM SHAPE

One API port. Two storage engines. No brokers.

A modular monolith (ADR-0001): the Express API and the OCPP 1.6J WebSocket gateway share port :4000; a 2-second relay worker moves telemetry from the OLTP engine to the time-series engine. Every simplification is a documented decision, not an omission.

VoltHub CSMS architecture — Oracle money path + TimescaleDB telemetry joined by an outbox relay
SEAM 1

Store port ADR-0005

Every route is written against createStore()'s surface. The in-process store is the test double; Oracle wraps the same object with write-through PL/SQL package calls. Routes never know which engine is behind them.

SEAM 2

Outbox + relay ADR-0003

meter_reading and outbox_event commit atomically. The relay delivers at-least-once, the sink dedupes — billing never depends on TimescaleDB being up.

SEAM 3

Error-band contract

ORA-20501…209xx maps to HTTP 422/409/402 exactly once, in src/errors.js. The same bands are raised by RAISE_APPLICATION_ERROR in PL/SQL and mirrored in JS.

02 — THE PROOF

Claims are cheap. CI replays the race on every push.

The 30-second demo that sells the whole design: two clients reserve the same connector in the same window. Exactly one wins — proven against the in-process store and real Oracle row locks, in CI, on both engines.

sim — double-reserve race, then a live session
$ simulator --scenario race
POST /api/v1/reservations 201 BOOKED
POST /api/v1/reservations 409 OVERLAP
StatusNotification → OCCUPYING
MeterValues → kWh · kW · ₹ live
POST /invoices/:id/pay 200 SUCCESS
→

Concurrency is asserted, not claimed. Parallel double-reserves and double-pays fire in CI; the suite fails unless exactly one wins. Source: apps/api/test/race.js

→

Money logic lives in PL/SQL, not app code. Seven packages own the writes; a guard trigger blocks direct connector.status UPDATEs. No DELETE grant exists anywhere.

→

Every claim has a receipt. docs/verification.md names what actually ran for each statement in the README — including what was measured and how.

→

Performance numbers stay out of prose. docs/perf.md carries the methodology + 5 reproducible experiments; tables only land from bench/ scripts.

03 — THE TWO ENGINES

Right engine for the right shape of data.

Sessions and billing are ACID-relational. Meter ticks are immutable, time-ordered, and read as window aggregates — the wrong shape for a row-store. Each engine gets what it is good at; the store port keeps them swappable.

MONEY PATH · OLTP

Oracle 23ai

BCNF-honest schema · PL/SQL owns the writes
  • 29-relation schema, 7 PL/SQL packages — reservation, session, billing, tariff, maintenance, audit, seed
  • Overlap exclusion via row locks; error bands map to HTTP exactly once
  • Least-privilege app role — no DELETE grant anywhere in the system
  • Guard trigger blocks direct status UPDATEs around the state machine
  • Runs as a CI service container on every push
OUTBOX
+ RELAY
2s · idempotent
TELEMETRY PATH · TIME-SERIES

TimescaleDB

Hypertables · continuous aggregates · compression
  • 90%+ of rows are immutable meter ticks — append-heavy, window-read
  • 1m/1h continuous aggregates; 7-day compression, 90-day retention
  • Relay inserts with ON CONFLICT DO NOTHING — crash replays are safe
  • Grafana reads caggs directly for the load dashboard
04 — STACK, WITH THE TRADE-OFFS

Choices with reasons, not fashion.

The short version of the trade-off table — the full one, with the accepted costs, lives in the README.

Oracle 23ai Row-lock semantics + PL/SQL packages own the money-path writes. Trade-off: a real Oracle dependency — so CI runs it as a service container every push.
TimescaleDB Hypertables / caggs / compression are the right shape for immutable ticks. Trade-off: caggs query one hypertable — enrichment happens in query-time views + a relay-maintained station_map.
Outbox + relay Same-transaction publication, ack-after-COMMIT relay, idempotent replay. Trade-off: single-writer worker, not a fan-out bus.
Express 5 + pino 10 Boring, dependency-light API core; request-ID error envelopes; drift-gated OpenAPI. Trade-off: hand-rolled middlewares instead of a framework galaxy.
Next.js 16 + React 19 App Router with real client components; CSP at the Next layer; zero chart dependencies. Trade-off: server/client boundary discipline is on the team.
Compose + Caddy One honest VM deploy target with graceful SIGTERM draining. Trade-off: no Kubernetes story — by design, see ADR-0001.
06 — DECISION RECORDS

Seven decisions, each with its accepted cost.

If a reviewer disagrees with a choice, there is a document to disagree with — that is the point.