# ADR-008 — ETS-Backed Dynamic Routing Table

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

---

## Context

The routing table maps inbound message types to south-side adapters:

```
"transaction.payment"  → AdapterBanking
"account.balance"      → AdapterBanking
"report.daily_summary" → AdapterDw
"file.settlement"      → AdapterFile
```

Operations teams must be able to:
- Add new message type routes without redeploying the application
- Redirect traffic from one adapter to another during incidents (e.g., failover)
- A/B test new adapter versions by routing a percentage of traffic
- Temporarily disable a route (returning a maintenance response)

A hardcoded routing table in application config prevents all of the above.

---

## Decision

Implement a **dual-layer routing table**:

1. **Hot layer — ETS table** (`mw_router.route_table`): in-memory, microsecond lookup,
   authoritative at runtime.
2. **Durable layer — Database** (`route_rules` table in MySQL via `infra_repo`):
   persistent across restarts, editable via admin UI.

On startup, the ETS table is populated from the DB. When an operator edits a route via
`gateway_web`, the change is written to DB and a PubSub message is broadcast to all nodes,
each of which reloads the affected ETS entry.

---

## Route Rule Schema

```elixir
defmodule MwRouter.RouteRule do
  use Ecto.Schema

  schema "route_rules" do
    field :message_type, :string        # e.g. "transaction.payment"
    field :adapter_module, :string      # e.g. "Elixir.AdapterBanking"
    field :active, :boolean, default: true
    field :priority, :integer, default: 100
    field :timeout_ms, :integer, default: 5000
    field :circuit_config, :map         # :fuse threshold config
    field :metadata, :map               # arbitrary tags / notes
    timestamps()
  end
end
```

---

## ETS Table Design

```elixir
defmodule MwRouter.RouteTable do
  @table :mw_route_table

  def init do
    :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
    reload_all()
  end

  def lookup(message_type) do
    case :ets.lookup(@table, message_type) do
      [{^message_type, rule}] when rule.active -> {:ok, rule}
      [{^message_type, rule}] -> {:error, :route_disabled}
      [] -> {:error, :no_route}
    end
  end

  def reload_all do
    rules = InfraRepo.Repo.all(MwRouter.RouteRule)
    :ets.delete_all_objects(@table)
    Enum.each(rules, fn r -> :ets.insert(@table, {r.message_type, r}) end)
  end

  def update_rule(message_type, rule) do
    :ets.insert(@table, {message_type, rule})
    Phoenix.PubSub.broadcast(MwCore.PubSub, "route_table:updated", {:rule_changed, message_type})
  end
end
```

---

## Admin UI Interaction

```
Operator opens gateway_web → /admin/routing
  ├── Sees table of all route_rules (live, from ETS read)
  ├── Clicks "Edit" on "transaction.payment"
  ├── Changes adapter_module to "Elixir.AdapterBankingV2"
  ├── Clicks "Save"
  │     ├── MwRouter.RouteTable.update_rule writes to DB
  │     ├── broadcasts "route_table:updated" on PubSub
  │     └── all nodes reload that ETS entry
  └── Next request to "transaction.payment" routes to AdapterBankingV2
```

Zero downtime. No redeployment. Change takes effect in milliseconds.

---

## Traffic Splitting (Future)

The `metadata` field can carry a `split` configuration for percentage routing:

```json
{"split": [{"adapter": "AdapterBanking", "weight": 80}, {"adapter": "AdapterBankingV2", "weight": 20}]}
```

`MwRouter.Dispatcher` checks for split config and uses `:rand.uniform()` to select adapter.
This enables canary deployments of new adapters.

---

## Fallback and Default Routes

A catch-all route with `message_type: "*"` can be defined as a fallback. If no specific
route matches, the catch-all applies. If no catch-all exists:

```elixir
{:error, :no_route} → 422 {"error": "unroutable_message_type"}
```

---

## Alternatives Considered

| Option | Rejected Because |
|--------|-----------------|
| Hardcoded in config.exs | Requires redeployment for any routing change |
| Dynamic config via environment variables | Cannot change at runtime without restart; no UI |
| External service discovery (Consul) | Operational dependency; overkill for message routing within one app |
| GenServer holding route state | Single point of access; ETS `:read_concurrency` is more performant for high-read/low-write pattern |

---

## Consequences

### Positive
- Route changes take effect in milliseconds without restart
- Operators have full control over routing via admin UI
- ETS read is effectively free (lock-free concurrent reads)
- Supports future traffic splitting and canary adapter routing

### Negative
- ETS state is per-node; multi-node clusters require PubSub broadcast on every change
  (implemented, but adds one round-trip per rule change across nodes)
- Route table is loaded from DB on startup; if DB is unavailable at boot, the table is empty
  — mitigated by a fallback bootstrap file (baked into release) for critical default routes
