Skip to main content

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:

  • SMSSmsSender.send(input): Promise<SmsSendResult>
  • EmailEmailSender.send(input): Promise<EmailSendResult>
  • Court remitCourtRegistrarTransport.submit(input): Promise<CourtRegistrarSubmissionResult>
  • Settlement bankSettlementBankPort.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.tsCOURT_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

DomainPlaceholder classEnv switchEndpoint env varStatus
Court remitLoggingCourtRegistrarTransportPAYROLL_COURT_TRANSPORTPAYROLL_COURT_HTTP_URLHttpCourtRegistrarTransport ✅ extends HttpJsonTransport; bound by payroll-consumers.module.ts. Waiting on registrar publishing an endpoint.
SMSLoggingSmsSenderSMS_PROVIDERSMS_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.
EmailLoggingEmailSenderEMAIL_PROVIDEREMAIL_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/an/aOut 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:

  1. 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.
  2. 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

  1. 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)
  2. Build the Http* class implementing the port.
  3. Add the two env vars (*_URL + *_AUTH) to AppConfig.
  4. Add the *_TRANSPORT toggle env var (LOGGING | HTTP).
  5. Bind the URL + auth + factory in the domain module.
  6. Update .env.example with all three vars (commented out).
  7. Verify the live transport against a staging endpoint before flipping PAYROLL_*_TRANSPORT=HTTP in 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).