The Legacy System — legacy-api¶
What this covers: the system Acme Platform is replacing. legacy-api is a single-process
Express 4 application on TypeORM and PostgreSQL, packaged as a webpack bundle and run on an
Azure Linux App Service. It carries the whole trading business today — deals, purchases,
sales, credit notes, invoices, PDF and email generation, ERP posting, and a PostgreSQL-backed
job queue. This document describes its layering, its multi-tenancy mechanism, its job and
document pipelines, its ERP integration, and how it is deployed. Everything below was read
from apps/legacy-api/**, infra/modules/app-service-legacy-api/, and
.github/workflows/reusable-build-deploy-app.yml in the source repository. Where the
in-repo design documents describe something that is not in the code, that is called out
rather than repeated.
A note on tone: this system works. It posts real invoices to a real ERP for real money every day, and most of what looks crude in it is crude on purpose. Where a design choice looks wrong, the document says which incident produced it.
1. Shape of the application¶
apps/legacy-api/
├── src/
│ ├── server.ts # entrypoint: DataSource.initialize -> validate env -> cron -> jobs -> listen
│ ├── bootstrap.ts # MUST be first import — APM SDK setup, sampling, telemetry processors
│ ├── app.ts # 24 lines: cors, json(50mb), morgan, trimWhitespace, mount /api
│ ├── routes.ts # 87 lines: a { path -> Controller } map, nothing else
│ ├── controllers/ # 27 controllers + a base Controller type. Thin: asyncHandler + Zod + delegate
│ ├── services/ # ~48 modules, imported as `import * as FooService`
│ ├── repositories/ # 46 tenant-scoped factories + 2 base classes
│ ├── models/
│ │ ├── db/ # 71 files: TypeORM entities, enums, subscribers
│ │ ├── request/ # Zod request schemas + typed Request
│ │ ├── response/ # build*Response mappers
│ │ └── error/ # domain error classes -> HTTP status
│ ├── middleware/ # auth, tenant header, roles, validate, rate limit, error handler
│ ├── jobs/ # 4 jobs + lib/ (queue adapter, DLQ, stuck-invoice recovery)
│ ├── api/
│ │ ├── erp/ # 21 files: per-resource ERP clients + guard + throttle + retry + mock
│ │ ├── taxservice/ # published FX rate fetch, government tax service
│ │ ├── storage/ # blob storage client abstraction
│ │ └── httpBaseApi.ts # raw fetch wrapper (no timeout, no retry — see §7)
│ ├── templates/pdf/ # pdfmake document templates (invoice + 4 confirmation types)
│ ├── emailTemplates/ # pug Subject/Body pairs per template
│ ├── helpers/ # 15 modules incl. the canonical line-item money formula
│ └── db/migrations/ # TypeORM migrations, explicitly registered in DataSourceConfig
├── webpack.config.js # bundles workspace libs, externalises real npm deps
└── docker-compose.yml # local PostgreSQL + blob emulator + domain-api
| Concern | Library | Notes |
|---|---|---|
| HTTP | Express ^4.18 |
Routers assembled from a map, no decorators |
| ORM | TypeORM ^0.3 |
numeric and date transformers at the boundary |
| Validation | Zod ^3.22 |
Per-route schemas in models/request |
| Job queue | pg-boss ^10.1 |
pgboss schema in the application database |
pdfmake ^0.2 |
Document definitions as TypeScript | |
pug 3.0 + cloud email SDK |
Subject.pug / Body.pug pairs |
|
| Money | big.js |
Never floats; API strings, DB numeric |
There is no dependency-injection container, no module system, and no framework-level
lifecycle. Wiring is import. That is the single most important fact about this codebase:
every architectural property below is a convention enforced by review and lint, not by a
framework.
2. Request pipeline¶
sequenceDiagram
autonumber
participant Client as legacy-web SPA
participant FD as Front Door
participant App as Express app
participant Router as createRouter
participant Auth as authenticateUserToken
participant IdP as Identity provider Graph API
participant Tenant as handleTradingCompanyHeader
participant Ctrl as Controller
participant Svc as Service module
participant Repo as Tenant-scoped repository
participant PG as PostgreSQL
Client->>FD: HTTPS + Authorization + x-acme-trading-company
FD->>App: forwarded with x-azure-fdid
App->>App: cors, urlencoded, json 50mb, morgan, trimWhitespace
App->>Router: mount under /api
Router->>Auth: only if Controller.requiresAuthentication
Auth->>IdP: GET /me with bearer token
IdP-->>Auth: external user id
Auth->>PG: users.findOneByExternalUserId
PG-->>Auth: user row
Auth->>Auth: reject when user.isActive !== true
Auth->>Tenant: req.currentUser set
Tenant->>Tenant: unscoped role reads header, scoped role uses user.tradingCompany
Tenant->>Ctrl: req.getTradingCompanyOrThrow installed
Ctrl->>Ctrl: validateRequest with Zod schema
Ctrl->>Svc: delegate
Svc->>Repo: RepositoryFactory tradingCompany
Repo->>PG: SELECT with tradingCompanyId injected
PG-->>Client: build*Response JSON
What this shows¶
routes.ts is a map from a path prefix to a controller class, and createRouter walks that
map building one express.Router per controller. Middleware is attached per controller from
two static flags on the controller — requiresAuthentication and requiresAdminRole — not
per route. Authentication is delegated to the identity provider's Graph endpoint: the bearer
token is not validated locally, it is spent on a /me call, and the returned external user
id is looked up in the local user table.
Takeaways¶
- Auth costs a network round trip on every request. Verifying the token by calling the IdP is simple and always correct, but it puts a third party on the hot path of every authenticated call and is the largest single contributor to legacy p95 latency.
- Local user row is the authorisation record. A valid IdP token with no matching
userrow is a 401, andisActive !== trueis a hard fail-closed check with a structured, greppable denial log. Deactivation in Acme is the access gate — the IdP token stays valid. - Tenant selection is role-dependent.
ADMINandFINANCEare unscoped roles and choose their trading company via thex-acme-trading-companyheader;TRADERandMDare pinned touser.tradingCompanyand the header is ignored. There are exactly 4 roles. - Coarse-grained middleware. Because the flags are per controller, "this one endpoint needs admin" is expressed by splitting a controller, not by decorating a route.
- Prefix quirk. Controllers are mounted under
/api, so/v1/dealsis reachable at/api/v1/deals— and the internal controller registered at/api/internalis genuinely reachable at/api/api/internal/.... This is a real, load-bearing wart.
Invariant encoded¶
No handler runs without both
req.currentUserand a resolved tenant. Any controller that setsrequiresAuthenticationgets both middlewares, in that order, andgetTradingCompanyOrThrow()throws rather than returning a nullable. A controller that forgets the flag is unauthenticated — the failure mode is silent, which is why the flag lives on the class and is reviewed, not on each route.
3. Layering, and the namespace-service idiom¶
flowchart TD
C["Controller<br/>asyncHandler, Zod validate, no business logic"]
S["Service module<br/>import star as FooService"]
R["Repository factory<br/>takes TradingCompany, returns bound repo"]
E["TypeORM entity<br/>models/db, transformers for money and dates"]
H["Helpers<br/>dealLineHelpers is the canonical money formula"]
RB["Response builder<br/>build star Response"]
DB[("PostgreSQL")]
C --> S
C --> RB
S --> R
S --> H
R --> E
E --> DB
RB --> E
What this shows¶
Four layers, one direction. Services are modules, not classes: import * as InvoicesService
gives a namespace of exported functions with no constructor and no injected collaborators.
Naming is conventional and consistent — find* / get* for reads, create* / update* /
destroy* for writes, parse* / build* / map* for pure transforms.
Takeaways¶
- The namespace idiom is why testing hurts. With no injection point, a unit test must
mock the module registry (
AppDataSourceplus a repository-mock helper) rather than pass a double. This is the single most cited reason for the rebuild — it is a testability argument, not a performance one. - It is also why the code is easy to read. There is no indirection: a call to
InvoicesService.postInvoiceToErpresolves to exactly one function in one file. New engineers navigate this codebase faster than they navigate the Platform one. - Money has exactly one implementation. The line-amount calculation lives in one helper
module (
helpers/dealLineHelpers.ts) and every caller goes through it — including the report paths that bypass the ORM, which regression tests assert explicitly. A money formula duplicated into hand-written SQL is a defect that reconciles to a different number than the UI, and only a test that reads the SQL will catch it. - Numeric and temporal types are transformed at the ORM boundary.
BigNumericTransformermapsnumerictobig.js;DateTransformermapsdate/timestamptz. Nothing above the entity layer sees a float. - Response shape is explicit.
build*Responsemappers inmodels/response/mean the wire contract is a reviewable artefact rather than "whatever the entity serialises to".
4. Multi-tenancy: the scoped repository factory¶
flowchart LR
subgraph req ["Per request"]
TC["req.getTradingCompanyOrThrow"]
end
subgraph factory ["RepositoryWithTradingCompanyFactory.create"]
Base["AppDataSource.getRepository entity"]
Wrap["RepositoryWithTradingCompany"]
Ext["extend with custom finders"]
end
subgraph guard ["Overridden methods"]
W["whereWithTradingCompanyId<br/>injects tradingCompany.id into every where"]
I["entityWithTradingCompany<br/>stamps tradingCompany and tradingCompanyId on writes"]
U["Unimplemented base<br/>unscoped TypeORM methods throw"]
end
TC --> Base --> Wrap --> Ext
Wrap --> W
Wrap --> I
Wrap --> U
What this shows¶
Tenancy is not a database feature here — there is no row-level security and no schema per
tenant. It is a repository wrapper. RepositoryWithTradingCompanyFactory.create(entity,
tradingCompany, customs) returns a TypeORM repository whose find, findOne, create,
insert and friends have been overridden to inject tradingCompany: { id } into every
where clause and to stamp both tradingCompany and tradingCompanyId onto every written
entity. The base class the wrapper extends is named
RepositoryWithTradingCompanyUnimplementedMethods — the unsafe, unscoped TypeORM surface
throws instead of leaking.
Takeaways¶
- Isolation is opt-out-by-throwing, not opt-out-by-forgetting. Calling an unscoped method is a runtime error, which is the strongest guarantee available without RLS.
- Raw SQL is outside the guard.
UnifiedInvoicesRepositoryand the reporting paths write query-builder / raw SQL; those must add the tenant predicate themselves. This is the known soft spot, and it is why the reporting repositories carry dedicated regression tests. tradingCompanyIdis stamped explicitly on writes in addition to the relation, because entity subscribers run before TypeORM has synced the relation object. That comment in the source is load-bearing.- Unscoped roles cross tenants by design.
ADMINandFINANCElegitimately switch trading companies mid-session via a header. Tenant isolation therefore protects data, not people — an authorised human can see every tenant. - The tenant is an integer.
trading_company.idis a serial. Platform uses UUID tenant ids; any data bridge has to carry a mapping table, not a cast.
5. The domain model, briefly¶
erDiagram
TRADING_COMPANY ||--o{ DEAL : owns
TRADING_COMPANY ||--o{ USER : employs
TRADING_COMPANY ||--o{ ACCOUNTING_MONTH : opens
DEAL ||--o{ PURCHASE : contains
DEAL ||--o{ SALE : contains
PURCHASE ||--o{ PURCHASE_LINE_ITEM : has
SALE ||--o{ SALE_LINE_ITEM : has
PURCHASE_LINE_ITEM ||--o{ SALE_LINE_ITEM : "supplies quantity and unit"
PURCHASE ||--o| PURCHASE_INVOICE : "1:1"
SALE_LINE_ITEM }o--|| SALE_INVOICE : "many:1"
PURCHASE ||--o{ HAULAGE : incurs
PURCHASE ||--o{ OVERHEAD : incurs
SALE ||--o{ SALE_CREDIT_NOTE : corrects
PURCHASE ||--o{ PURCHASE_CREDIT_NOTE : corrects
CUSTOMER ||--o{ CUSTOMER_SITE : has
CUSTOMER ||--o{ CUSTOMER_CONTACT : has
CUSTOMER ||--o{ SALE : "buys on"
ERP_COMPANY ||--o{ ERP_CUSTOMER : mirrors
ERP_COMPANY ||--o{ ERP_SUPPLIER : mirrors
ERP_COMPANY ||--o{ ERP_NOMINAL_CODE : mirrors
TRADING_COMPANY ||--o| ERP_COMPANY : "maps to"
What this shows¶
71 files under models/db, but the spine is short: a Deal aggregates Purchase and Sale; line
items carry the quantities; invoices attach on both sides with different cardinalities.
Takeaways¶
- The two invoice relations have different cardinalities. One side is a direct 1:1 relation; the other has to be reached through the line items, with the intermediate relation eagerly loaded, and callers must test which shape they have before dereferencing. A deliberate asymmetry in an otherwise-symmetrical pair of relations is a reliable source of production bugs, because the code that handles one side reads as if it handles both.
- ERP mirror tables are first-class entities.
erp_customer,erp_supplier,erp_nominal_code,erp_vat_code,erp_currency,erp_bankare local read-model copies of ERP master data, scoped byerp_company, refreshed by sync. Theirreferencecolumn is the join key back to the ERP — a fact that matters enormously for migration (see the companion document). accounting_monthis the period boundary object. Periods are rows that open and close, not a date truncation, so "which period does this belong to" is a lookup against explicit state rather than arithmetic on a timestamp. The assignment rule is therefore data, editable without a deploy, and a closed period is a row that exists rather than a date that has passed.- The frozen figure on the deal is a deliberate denormalisation. The derived value is computed once, at the moment the record is committed, and stored — so that later edits to its inputs cannot drift it away from the number another system has already consumed and acted on. Recomputation is an explicit, narrow event, not a side effect of touching an input.
6. Lifecycles worth knowing¶
Two state machines carry most of the behaviour. The first is the record lifecycle the trading entities share, drawn with neutral state names — the topology is the transferable part:
stateDiagram-v2
direction LR
[*] --> Draft
Draft --> Confirmed : confirm, snapshots the conversion rate
Confirmed --> Received : intake step
Received --> Finalised : quantities agreed
Finalised --> Closed : set downstream, once the invoice reaches Processed
Draft --> Cancelled
Confirmed --> Cancelled
Received --> Cancelled
Closed --> [*]
Cancelled --> [*]
Three cancel edges, and no fourth: past Finalised a record can be corrected but not withdrawn.
Closed is the one state the owning module never sets itself — it arrives from the invoice machine
below, which is the only upstream edge into the trading tables.
The second is the invoice's, worth reproducing because of where it puts the asynchronous boundary:
stateDiagram-v2
direction LR
[*] --> Pending
Pending --> Approved : approver approves
Approved --> Processed : background job completes PDF, email, ERP post
Approved --> Approved : job failed, errorMessage set, user retries
Processed --> [*]
note right of Approved
An invoice stuck in Approved with a null errorMessage
is the failure mode StuckInvoiceRecovery sweeps at boot.
end note
Takeaways¶
Approvedis the async boundary. Approval is synchronous and cheap; everything expensive happens in a queued job afterwards. The UI shows a spinner keyed on the invoice status. See ADR-0012 Targeted status updates for invoice creation.- Failure is recorded on the entity, not only on the job.
invoice.errorMessageis the user-visible retry surface — the job queue itself is invisible to the business. - Illegal transitions are made unrepresentable rather than validated. The three cancel edges
in the first diagram are a union type (
CancellableFromStatus), not a runtime guard, so cancelling fromFinalisedfails to typecheck instead of throwing in production.
7. Background jobs¶
flowchart TD
subgraph app ["legacy-api process"]
Svc["Services enqueue"]
Adapter["AppJobQueueAdapter"]
PgBoss["PgBossJobQueueAdapter<br/>implements JobQueueAdapter"]
Workers["4 workers, poll every 10s"]
Recovery["StuckInvoiceRecovery<br/>runs once at boot"]
Cron["3 cron jobs, node-cron"]
end
subgraph db ["Same PostgreSQL instance"]
Q[("pgboss schema<br/>job, archive")]
DLQ[("per-queue -dlq")]
Main[("application schema")]
end
Svc --> Adapter --> PgBoss --> Q
Q --> Workers --> Svc
Workers -->|failure| DLQ
DLQ --> Recovery
Recovery --> Main
Cron --> Svc
What this shows¶
The queue is a table in the same database as the business data, driven by pg-boss. Four job
types are registered: ProcessSaleInvoiceJob, ProcessPurchaseInvoiceJob,
SyncErpCustomersJob, SendConfirmationEmailJob (the last one added by ADR-0007 Async
confirmation email). Workers poll every 10 seconds. The default enqueue option is
retryLimit: 0 — no automatic retries.
Takeaways¶
- No retries is a deliberate financial-safety choice. A retried invoice post can create a duplicate invoice in the ERP. Recovery is human-triggered ("Retry" in the UI) after the error is shown, not automatic.
- The DLQ exists to write the error back to the business entity. Each queue has a
-dlqcompanion; the handler extracts the message from pg-boss'soutputfield and stamps it oninvoice.errorMessage, falling back to a 15-minute-timeout message. - Boot-time sweep closes the crash window.
recoverStuckInvoices()runs once at startup and finds invoices leftApprovedwith a nullerrorMessage— the state produced when the process died mid-job or was swapped out during a deploy. - Same-database queueing is the right call at this scale. It gives transactional enqueue
semantics for free and removes a broker from the operational surface. The cost is telemetry
noise — the poller generated roughly 186k dependency spans a day until an APM processor was
added to drop
pgboss.jobqueries. - Three cron jobs, all wrapped so a thrown exception cannot kill the scheduler. One refreshes an external system's access token on a short interval; the other two are periodic business housekeeping. The wrapper is the point: an unhandled rejection inside a scheduled callback takes down every other schedule in the same process, and the failure is silent.
Invariant encoded¶
A job never leaves a business entity in an unexplained state. Every terminal path — success, caught error, DLQ, boot-time sweep — writes either a cleared or a populated
errorMessage. The queue is an implementation detail; the entity is the contract.
8. Document generation and delivery¶
sequenceDiagram
autonumber
participant Q as pg-boss worker
participant Job as ProcessSaleInvoiceJob
participant Val as ErpInvoiceValidationService
participant Pdf as pdfmake template
participant Blob as Object storage
participant Mail as Email provider
participant Erp as ERP REST API
participant PG as PostgreSQL
Q->>Job: perform payload
Job->>PG: load trading company and invoice by number
Job->>Job: guard status must be Approved
Job->>Val: validateBeforeErpPost -> warnings
alt emails not yet sent
Job->>Pdf: generateSaleInvoicePdf
Job->>Blob: storeInvoicePdf, name plus timestamp version
opt doNotSend is false
Job->>Mail: sendSaleInvoiceEmail with pug subject and body plus PDF attachment
end
end
Job->>Erp: postInvoiceToErp
Job->>PG: status Processed, errorMessage null
Job->>Job: recomputeAndRemediateForInvoiceArrival per affected deal
Note over Job,PG: any throw -> setInvoiceErrorMessage String(e), job fails
What this shows¶
One job does document rendering, storage, delivery, ERP posting and downstream GP reconciliation, in that order, guarded by an idempotency check on "have emails already been sent".
Takeaways¶
- PDFs are code, not templates.
pdfmakedocument definitions live intemplates/pdf/{saleInvoice,purchaseConfirmation,saleConfirmation,haulageConfirmation,overheadConfirmation}with a sharedtheme.tsand an explicit font registry. There is no HTML-to-PDF step and no headless browser — which is why PDF generation is fast and has never been a scaling problem. - Emails are pug pairs. Every template is a
<name>Subject.pug/<name>Body.pugcouple rendered server-side and sent through the cloud email provider with the PDF as an attachment. In development the message is opened in a browser preview instead of sent. - Blob names are versioned by timestamp.
uploadFileappends-${Date.now()}anddownloadFilelists versions and picks the latest. Overwrite is impossible by construction; the audit trail is the version list. - Asset resolution is dual-path. Both the email templates and the trading-company logos
are looked up first at the webpack-bundled location (
cwd/emailTemplates,cwd/assets) and then at the source path. Startup validates every trading company'slogoFilenameresolves and logs a structuredLOGO_FILE_MISSINGerror if not — added after an incident where invoices for one entity shipped without a logo. - Ordering is not transactional. PDF-store, email-send and ERP-post are three separate side effects with no compensation. The email-already-sent guard is the only idempotency control, and it protects the customer-visible effect, which is the right one to protect.
9. ERP integration¶
flowchart TB
subgraph orchestration ["Sync orchestration"]
Sync["ErpSyncService.syncErpDetails"]
P1["Phase 1 parallel<br/>company, currency, vat code, nominal code"]
P2["Phase 2 sequential<br/>bank, customer, supplier, supplier bank"]
end
subgraph entity ["Per-entity services"]
Gen["ErpEntityService.syncErpEntities<br/>generic upsert engine"]
end
subgraph client ["Client layer"]
Guard["erpApiGuard<br/>the only legal import, ESLint enforced"]
Api["erpApi sendErpGetRequest / sendErpPostRequest"]
Throttle["erpThrottle<br/>PG advisory lock, min 300ms between calls"]
Retry["erpRetry<br/>exponential backoff plus jitter"]
Mock["erpMockApi plus stateful erpMockDatabase"]
Http["httpBaseApi.sendRequest -> fetch"]
end
Sync --> P1 --> Gen
Sync --> P2 --> Gen
Gen --> Guard --> Api
Api --> Throttle
Api --> Retry
Guard -->|non-production| Mock
Api --> Http
What this shows¶
A five-layer client stack whose entry point is deliberately narrowed: erpApiGuard.ts is the
only module the rest of the codebase may import, enforced by an ESLint rule that bans direct
erpApi.ts imports. The guard handles environment filtering (non-production is pinned to a
demo company), retry, logging, and mock selection.
Takeaways¶
- Rate limiting is distributed via a PostgreSQL advisory lock, not an in-process token bucket — so it holds across App Service instances and slots. Minimum 300 ms between calls.
- POST and GET have different retry policies, on purpose. GET retries on 429 and 5xx and network errors; POST retries on 429 only. A retried POST can mint a duplicate invoice in the customer's accounts. This asymmetry is the most important line of defence in the whole integration and was carried forward verbatim into the Platform design (ADR-0024 ERP integration strategy).
- Sync has a hard dependency order. Currencies, VAT codes and nominal codes must exist before customers and suppliers can reference them. Phase 1 runs in parallel, Phase 2 runs sequentially after it.
- Mock mode is a first-class environment, and it is fail-fast.
USE_ERP_MOCK=truein a production environment causesprocess.exit(1)at boot; production additionally requiresERP_POSTING_ENABLEDto be set explicitly, with no default. The startup banner prints the whole resolved configuration. - The known weakness is the bottom of the stack.
httpBaseApi.sendRequestcallsfetchwith no timeout and no AbortController — an in-repo deep-dive attributes the recurring sync hangs to exactly this, compounded by$top=5000page sizes. This is documented, not fixed, because the ERP path is being replaced rather than rebuilt.
stateDiagram-v2
direction LR
[*] --> Active : OAuth authorization code exchange
Active --> Refreshed : cron every 30 min, expiring within 60 min
Refreshed --> Active : new access and refresh token persisted
Active --> Invalid : refresh returns 4xx other than 403
Active --> Rejected : ERP returns 403
Rejected --> Active : operator re-runs the token wizard
Invalid --> Active : operator re-runs the token wizard
Rejected --> [*]
Takeaways¶
- Tokens are ~8 hours and refresh is proactive, guarded by a PostgreSQL advisory lock so concurrent instances cannot both spend the refresh token.
Rejectedis a dead refresh family, not a transient error. Recovery is an operator task — an interactive re-authorisation — and the state is persisted in theerp_tokentable. Diagnosing from APM traces instead of that table has wasted real incident time.- Token telemetry bypasses sampling. Because production APM sampling is 25 %, an explicit
telemetry processor forces
ERP_TOKEN*and commission-webhook events to 100 %. Rare, critical events must not be sampled away.
10. Deployment and runtime¶
flowchart LR
subgraph edge ["Edge"]
FD["Front Door<br/>x-azure-fdid header check"]
end
subgraph plan ["Linux App Service Plan"]
Prod["production slot<br/>node 22-lts, node main.js<br/>always_on, health_check_path"]
Stg["staging slot<br/>started only during deploy"]
end
subgraph data ["Data and platform services"]
PG[("PostgreSQL flexible server<br/>acme_production")]
Blob[("Blob storage<br/>documents")]
Mail["Email service"]
KV["Key Vault via system-assigned identity"]
APM["APM workspace"]
end
subgraph sib ["Sibling services"]
Dom["domain-api<br/>Container Apps, scale to zero"]
end
FD -->|Allow Front Door, deny all else| Prod
Prod --> PG
Prod --> Blob
Prod --> Mail
Prod --> KV
Prod --> APM
Prod -->|HTTP proxy plus webhook| Dom
Stg -.->|slot swap| Prod
What this shows¶
legacy-api is not containerised. It is a webpack bundle (main.js plus externalised npm
dependencies, with workspace libraries bundled in) deployed to an Azure Linux Web App running
the Node 22 LTS stack with app_command_line = "node main.js". Its containerised sibling
domain-api runs on Container Apps with scale-to-zero; the two are different hosting models
in the same environment.
Takeaways¶
- Zero-downtime is a slot swap, not a rollout. The deploy workflow starts the staging
slot, deploys, health-checks
/api/v1/up/ready, runs a smoke test, swaps, re-verifies through Front Door, and auto-rolls-back by swapping again on failure. The staging slot is stopped afterwards to avoid paying for it. - Sticky settings are the safety mechanism.
DB_MIGRATIONS_RUN_ENABLEDandERP_POSTING_ENABLEDare pinned to the slot, so the staging slot can never run production migrations or post real invoices, and APM connection strings stay per-slot. - The app is Front-Door-only.
ip_restriction_default_action = "Deny"with a single allow rule matching the Front Door id header. Anything that needs to call the internal reconcile endpoint must go through Front Door. - Managed identity for secrets, VNet integration for egress. System-assigned identity
reads Key Vault;
vnet_route_all_enabledputs outbound traffic on the subnet. - Startup is a validation gauntlet. Environment banner → consistency checks
(
NODE_ENVvsENVIRONMENT_NAMEmismatch warning) → ERP environment validation (fatal on mock-in-production or missing credentials) → logo file validation → cron start → queue start → listen. Several of these checks exist because a specific incident happened.
11. Observability¶
bootstrap.ts must be the first import in server.ts, before anything else loads. It
configures the APM SDK to complement the platform's auto-instrumentation agent rather than
duplicate it: HTTP requests, dependencies and console are left to the agent, while the SDK
adds exception capture, W3C trace-context correlation, live metrics, disk-retry caching, and —
critically — sampling that the agent cannot express.
- Sampling is environment-derived: 25 % production, 50 % staging, 100 % development.
- One telemetry processor drops noise:
pgboss.jobpolling queries and empty keepalive database dependencies. - A second telemetry processor forces
sampleRate = 100for messages or exceptions carryingCOMMISSION_*,DEAL_PROFITABILITY,ERP_TOKEN*, or theCOMMISSION_WEBHOOK_FAILURE/ERP_TOKEN_RECOVERYcategories.
The pattern generalises: sample the volume, never the alerts. Structured single-line JSON
logs with a category field are the second half of it — every alertable condition in this
codebase is a greppable category string.
12. Honest assessment¶
What it does well
- Correctness where it counts. The money formula has one home and regression tests that assert every SQL path joins the conversion table. GP is frozen at lock so it cannot drift. POST is not retried. These are mature financial-system instincts.
- Operational legibility. Four roles, one tenant header, one queue table, one database. An engineer can hold the whole runtime in their head, which is worth more than it sounds at 3 a.m.
- Fail-fast configuration. The process refuses to boot in several specific misconfigurations rather than running wrongly. Each of those checks is a scar.
- Cheap, predictable hosting. One always-on App Service with a swap slot; no cluster, no service mesh, no GitOps controller to be broken.
Where it hurts
- No injection seam → mocking the module registry, slow and brittle unit tests, integration tests disabled in CI.
- Auth on the hot path → an IdP round trip per request.
- No timeout in the HTTP base client → the recurring ERP sync hang has a known root cause and no fix in this codebase.
- Tenant safety is repository-shaped → raw-SQL reporting paths sit outside the guard.
- Coarse middleware flags → authorisation granularity is per controller, not per route.
None of these are reasons to rewrite on their own. Together with a product requirement for true multi-tenancy on UUID tenants, per-context deployability, and an auditable event trail, they are. That argument, and the sequence for acting on it, is the companion document.
Related decisions¶
| ADR | Title | Relevance here |
|---|---|---|
| ADR-0007 | Async confirmation email | 4th job type, queue-based delivery |
| ADR-0008 | Lockable deals default config | Deal lifecycle eligibility |
| ADR-0009 | Invoice net amount formula — single source of truth | One canonical money calculation |
| ADR-0011 | Commission webhook consistency model | Fail-open webhook to domain-api |
| ADR-0012 | Targeted status updates for invoice creation | Approved as the async boundary |
| ADR-0024 | ERP integration strategy | What Platform reuses from this ERP client |
| ADR-0046 | Commission sync remediation envelope | Reconcile cron and stop-loss caps |
Companion document: 02-strangler-migration.md.