Skip to content

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 RandomNumberGenerator ports 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).