Swapping Logging* transports for live HTTP delivery
Every domain that needs to communicate with an outside system (SMS
gateway, court registrar, MoR e-filing, settlement bank, etc.) ships
with a Logging* placeholder transport. That placeholder is
intentional — it lets the rest of the system boot, the use cases
remain testable, and the failure modes stay deterministic before the
external endpoint is live.
Once the counterparty publishes an API endpoint, the swap is the same three-step pattern everywhere.
The pattern
1. Build the live transport
Subclass HttpJsonTransport (see
apps/api/src/_infra/shared-infra/http-json-transport.ts) for JSON APIs, or
hand-roll a fetch wrapper if the protocol is XML / form / SOAP.
The contract you implement must match the existing port interface:
- SMS —
SmsSender.send(input): Promise<SmsSendResult> - Email —
EmailSender.send(input): Promise<EmailSendResult> - Court remit —
CourtRegistrarTransport.submit(input): Promise<CourtRegistrarSubmissionResult> - Settlement bank —
SettlementBankPort.submit(input): Promise<SettlementBankResult>
2. Bind the URL + auth via env-derived DI tokens
Three env vars per transport:
<DOMAIN>_<TRANSPORT>=HTTP— the toggle<DOMAIN>_<TRANSPORT>_URL=https://...— the endpoint<DOMAIN>_<TRANSPORT>_AUTH=Bearer ...— the auth header
Bind both URL + auth as DI tokens so the live transport can @Inject
them. The pattern is in
apps/api/src/payroll/consumers/payroll-consumers.module.ts —
COURT_REGISTRAR_HTTP_URL + COURT_REGISTRAR_HTTP_AUTH are
factories over AppConfig.
3. Swap the port binding via a config-driven factory
The port itself (the DI token the use case injects) is bound to a
factory that returns either the Logging* or Http* instance based
on the env switch:
{
provide: COURT_REGISTRAR_TRANSPORT,
inject: [
APP_CONFIG,
LoggingCourtRegistrarTransport,
HttpCourtRegistrarTransport,
],
useFactory: (
config: AppConfig,
logging: LoggingCourtRegistrarTransport,
http: HttpCourtRegistrarTransport,
) => (config.PAYROLL_COURT_TRANSPORT === 'HTTP' ? http : logging),
}
Both instances are registered as providers; the factory selects which
one to expose under the port token. The use case never sees the
difference — it just @Inject(COURT_REGISTRAR_TRANSPORT)s the port.
Current placeholders + their swap targets
| Domain | Placeholder class | Env switch | Endpoint env var | Status |
|---|---|---|---|---|
| Court remit | LoggingCourtRegistrarTransport | PAYROLL_COURT_TRANSPORT | PAYROLL_COURT_HTTP_URL | HttpCourtRegistrarTransport ✅ extends HttpJsonTransport; bound by payroll-consumers.module.ts. Waiting on registrar publishing an endpoint. |
| SMS | LoggingSmsSender | SMS_PROVIDER | SMS_HTTP_URL (new), SMS_HTTP_AUTH (new) | EthioTelecomSmsSender is the live SMPP path. HttpSmsSender ✅ skeleton in apps/api/src/_infra/sms/http-sms-sender.ts — subclass + override payload() to match Twilio / Africa's Talking / MTN schema. |
LoggingEmailSender | EMAIL_PROVIDER | EMAIL_HTTP_URL (new), EMAIL_HTTP_AUTH (new), EMAIL_HTTP_FROM (new) | SmtpEmailSender is the live nodemailer path. HttpEmailSender ✅ skeleton in apps/api/src/_infra/email/http-email-sender.ts — subclass + override payload() for Postmark / SendGrid / Mailgun / Resend. | |
| Lending + EWA disbursement | (none — gRPC transport) | n/a | n/a | Out of scope for HttpJsonTransport — uses IntegrationGateway.grpc-client.ts. |
Why this pattern (and not env-only)
A pure env switch (if (process.env.X === '...') { ... }) inside the
transport class is tempting and breaks two invariants:
- Testability. With DI you can inject a fake transport in unit
tests without touching
process.env. Without DI you'd reach into the environment from each spec. - Concurrency safety. A class with
if (process.env...)inside each call re-reads the env per request — fine at the boot layer but wasteful at the hot path. DI-bound factories resolve once.
Rollout checklist
- Get the counterparty's:
- Endpoint URL
- Authentication scheme (Bearer / mutual TLS / signed request)
- Request shape (JSON / XML / SOAP)
- Response shape (confirmation id field / status code / errors)
- Build the
Http*class implementing the port. - Add the two env vars (
*_URL+*_AUTH) toAppConfig. - Add the
*_TRANSPORTtoggle env var (LOGGING|HTTP). - Bind the URL + auth + factory in the domain module.
- Update
.env.examplewith all three vars (commented out). - Verify the live transport against a staging endpoint before
flipping
PAYROLL_*_TRANSPORT=HTTPin production.
Testing the live transport
Unit tests stub global.fetch:
(global as unknown as { fetch: unknown }).fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ confirmationId: 'CONF-42' }),
}));
See apps/api/src/payroll/consumers/court-registrar-transport.spec.ts
for the full pattern (happy path, network failure, non-2xx, header
fallback).