ADR-006-F: Hexagonal Ports for External Integrations¶
Status: Accepted Date: 2026-04-16 Decision Makers: Gautham Chellappa Depends on: ADR-0004 (Maximum Portability — inherited)
Context¶
Finnest integrates with many external services: AI providers (Anthropic / Bedrock / Vertex), payroll (KeyPay + future native engine), communications (SES, SMSGlobal, Twilio, FCM), government APIs (VEVO, DVS, ATO STP, NHVR), accounting (Xero, MYOB), file storage (S3), secrets (Bitwarden, SSM), maps, calendars, liveness detection.
Without abstraction, each integration couples business logic to a specific vendor's API shape. Commercial-to-IRAP re-deployment, vendor switches, and testing all become code rewrites rather than config changes. ADR-0004 (inherited) requires portability; ADR-001-F's replaceability (D11) requires vendor freedom.
Commandment #2 forbids speculative abstraction — ports must have ≥2 adapters planned or actual, not hypothetical.
Decision¶
Every external integration hides behind an Elixir behaviour ("port"). Commercial and IRAP deployments select different adapters via config/runtime.exs. Tests use mock adapters.
Ports defined (each has ≥2 real adapters planned or actual)¶
| Port | Adapters | Justification |
|---|---|---|
AiProvider |
AnthropicDirect, BedrockSydney, VertexAU, MockProvider, LocalLLMProvider (Phase 3+) | Commercial + IRAP + Verify fallback + test; 4 real adapters from day one (ADR-010 foundation) |
AwardInterpreter |
KeyPay (launch), NativeAwardEngine (Phase 2) | Second adapter scheduled, not speculation — launch with KeyPay, build native for top 15 Awards |
FileStorage |
S3, S3ObjectLock (IRAP), MockStorage | Commercial + IRAP + test |
Secrets |
Bitwarden SM, SsmParameterStore (IRAP), MockSecrets | Commercial + IRAP + test |
CommEmail |
SES (commercial), SES Sydney (IRAP), MockEmail | Sovereignty delta + test |
CommSMS |
SMSGlobal (commercial + IRAP), MockSMS | Same provider both envs but port lets us swap |
CommVoice |
Twilio (commercial), Disabled (IRAP), MockVoice | Feature disabled in IRAP — adapter implements all-no-op |
CommPush |
FCM (commercial + IRAP), MockPush | Same provider both envs with restricted payload in IRAP |
JobBoard |
SEEK + Indeed + LinkedIn + Jora (commercial), Disabled (IRAP), MockBoard | — |
Accounting |
Xero, MYOB, MockAccounting | Two adapters from Phase 3 go-live |
Government |
VEVO, DVSGateway, ATOStp, NHVR, StateWHS, AGSVAMyClearance, PoliceCheckAU, MockGovt | Each has a distinct external contract |
Maps |
GoogleMaps, MockMaps | One real + test today; OpenStreetMap tiles as future fallback |
Calendar |
GoogleCalendar, Outlook (Microsoft Entra), MockCalendar | Two real + test (from Phase 3) |
Liveness |
AWSRekognitionFaceLiveness, MockLiveness | One real + test today |
DocumentVerification |
GreenID / Equifax / Sterling (one selected Phase 2), MockDVS | One real + test; selection ADR pending |
SuperStream |
KeyPay (Phase 1), DirectATO (Phase 2+), MockSuperStream | Second adapter scheduled |
What does NOT get a port (Commandment #2 literal application)¶
- Single-adapter integrations with no planned alternative stay as direct modules until a second adapter is needed
- Commandment #2 means we don't build
RandomNumberGeneratorports because there's one implementation
Port shape (gold standard)¶
defmodule Finnest.Agents.AiProvider do
@callback classify(intent :: String.t(), context :: map()) ::
{:ok, map()} | {:error, atom()}
@callback generate(messages :: [map()], tools :: [map()], opts :: keyword()) ::
{:ok, map()} | {:error, atom()}
@callback stream(messages :: [map()], tools :: [map()], opts :: keyword()) ::
Enumerable.t() | {:error, atom()}
end
# Adapter example:
defmodule Finnest.Agents.AiProvider.AnthropicDirect do
@behaviour Finnest.Agents.AiProvider
# ...
end
# Runtime selection:
# config :finnest, :ai_provider, Finnest.Agents.AiProvider.AnthropicDirect # commercial
# config :finnest, :ai_provider, Finnest.Agents.AiProvider.BedrockSydney # IRAP
Alternatives Considered¶
| Alternative | Rejected because |
|---|---|
| Direct SDK calls throughout business logic | Vendor lock-in; IRAP redeployment becomes a code rewrite; tests need network mocks |
| Thin facade modules (not formal behaviour) | Nothing enforces signature consistency; easy to drift across domains |
| One mega-port for all external I/O | Violates Interface Segregation — adapters forced to implement irrelevant methods |
| Every external call gets a port regardless of adapter count | Violates Commandment #2; produces speculative abstraction that rots |
Consequences¶
Positive:
- IRAP redeployment is config change, not code rewrite (D2, ADR-0004)
- Vendor switch is one adapter file (D11)
- Tests use deterministic
Mock*adapters — no network required for most suites - Provider failover chains (AI-07) implemented at the port layer, visible in one place
- Commercial + IRAP have different adapters but identical business logic
Negative:
- Extra indirection layer per port
- Adapter drift risk if two adapters are maintained by different people
- Commandment #2 requires discipline — someone must push back when a second adapter is "planned but hypothetical"
Tipping points for re-evaluation:
- Any port ends up with only one adapter for 12+ months and no plan for a second → demote to direct calls (delete the port)
Relationship to Guardrails¶
Enforces / is enforced by: AR-04 (cloud via hexagonal ports), AR-12 (FileStorage port), AR-15 (AiProvider port), AR-19 (IRAP as config variant), CQ-16 (no speculative abstractions), ADR-0004 (Maximum Portability — inherited).