# ADR-002 — Adapter Behaviour for South-Side Systems

**Date:** 2026-04-26
**Status:** Accepted
**Deciders:** Architecture Team

---

## Context

The south side of the middleware must integrate with fundamentally different systems:
- Core Banking (ISO 8583 over TCP, or proprietary REST)
- Data Warehouse (bulk SQL-style queries, streaming)
- Internal HTTP/REST/SOAP APIs
- File-based systems (SFTP, CSV, XML)

Business logic in `mw_router` must dispatch to the correct system without coupling itself
to any specific protocol or client library.

---

## Decision

Define a `MwKernel.Adapter` Elixir behaviour. Every south-side adapter app **must** implement
this behaviour. The router dispatches exclusively through this interface.

```elixir
defmodule MwKernel.Adapter do
  @type state :: map()
  @type message :: MwKernel.Message.t()
  @type config :: map()

  @callback connect(config()) :: {:ok, state()} | {:error, term()}
  @callback send(state(), message()) :: {:ok, message()} | {:error, term()}
  @callback health_check(state()) :: :ok | {:error, term()}
  @callback disconnect(state()) :: :ok
end
```

Each adapter app (`adapter_banking`, `adapter_dw`, `adapter_http`, `adapter_file`) implements
all four callbacks and is registered in the routing table as its module name.

---

## Rationale

### Alternatives Considered

| Option | Description | Rejected Because |
|--------|-------------|-----------------|
| Direct calls | Router calls `AdapterBanking.send/2` directly | Hardcodes south-side; router must change every time a new adapter is added |
| GenServer per adapter | Adapter is a GenServer; router sends messages | Adds unnecessary indirection; state management is simpler as process state inside adapter's own GenServer, not forced via this interface |
| Protocol (Elixir Protocol) | Use `defprotocol` instead of behaviour | Protocols dispatch on data type, not module; behaviours are the correct tool for module-level contracts |
| Dynamic dispatch via config | Store function references in routing table | Serialisation and hot-reload complexity; behaviours provide compile-time callback verification |

### Why Behaviour?

- `@behaviour` callbacks are **checked at compile time**. If an adapter app is missing a
  `health_check/1` implementation, the compiler raises a warning/error — no runtime surprise.
- Mockable in tests: `Mox.defmock(MockAdapter, for: MwKernel.Adapter)` allows unit testing
  of the router without starting any real adapter connection.
- Self-documenting: the behaviour module is the integration contract. New engineers know
  exactly what an adapter must do by reading one file.

---

## Extended Callbacks (Streaming Adapters)

For `adapter_dw` and `adapter_file` which are inherently streaming, an optional extended
behaviour adds:

```elixir
defmodule MwKernel.StreamingAdapter do
  @behaviour MwKernel.Adapter

  @callback stream(state(), query :: map()) ::
    {:ok, Enumerable.t()} | {:error, term()}
end
```

Adapters implementing `MwKernel.StreamingAdapter` are dispatched via a separate streaming
code path in `mw_router`.

---

## Consequences

### Positive
- Zero changes to router or core logic when adding a new south-side system
- Compile-time guarantee that all adapters fulfil the contract
- Clean mock boundary for unit tests
- `health_check/1` enables the admin dashboard adapter health panel

### Negative
- Lowest-common-denominator interface: fire-and-forget adapters need to wrap async results
  as synchronous {:ok, message()} responses (or use PubSub callback pattern)
- Adapter `state` is opaque `map()` — no static type checking of adapter-internal state;
  mitigated by Dialyzer specs inside each adapter
