Skip to content

Ports and adapters

A port is an interface the application layer declares; an adapter is a concrete implementation of it. The core depends only on ports, which is why it can be tested with fakes and deployed with two different runtimes.

Port Contract Production adapters
Collector collect(source) → Candidate[] rss, webFetch (page), tavily (search)
Evaluator evaluate(items, condition, lastKnownState?, options?) → EvaluationResult cascade over key-rotating LLM adapters
Embedder embed(text) → number[] key-rotating LLM embedder
Notifier notify(notification), plus a channel telegram, fcm, console
Mailer send(message)account email only nodemailer, console
WatchDrafter draft, revise, explain key-rotating LLM drafter
DomainChecker check(domains) → Record<domain, boolean> HTTP HEAD checker
WatchSource loadWatches() YAML files (import only)
Repository Watches, run state, history, feedback, trust rules, devices, keys, usage, users mongo, memory

Collector — returns clean text items, each with a URL and a fetch time. It may throw; the loop tolerates a failing source and continues with the others. It should validate its own output and drop malformed items rather than failing the whole source.

Evaluator — returns a verdict, or throws. It must not apply the threshold: the decision belongs to the pure core. The options argument carries per-watch governance, notably whether a secondary model may see this content.

Embedder — best-effort from the loop’s perspective. A failure disables semantic deduplication for that run rather than failing the run.

Notifier — throws on failure. The loop treats a channel failure as tolerable if another channel succeeded, and queues the alert if none did.

Repository — the widest port, and the one to be most careful with. Every optional field of run state must be handled in both directions.

Several adapters wrap others rather than talking to a provider directly:

CascadeEvaluator second opinion + failover
└── KeyRotatingEvaluator picks a key, rotates on rate limits
└── LlmEvaluator prompt, chunking, structured output

The same pattern applies to the embedder and the drafter. Each layer does one thing, and each can be tested in isolation.

Every port has a fake used by the tests: canned collectors, scripted evaluators and embedders, an in-memory notifier that records what it was asked to send, and a full in-memory repository. This is why the suite needs no network and no keys — and it is the reason a new adapter should come with a fake if it introduces a new port.

entrypoints/wiring.ts is the single place the real graph is assembled: key pools per use case, collectors sharing one pooled search client, the cascade evaluator, notifiers with console fallbacks, and the shared per-watch lock. Both runtimes call it.

To add an adapter, implement the port, add a test with a fake transport, and register it in the wiring. Nothing in domain/ or application/ changes.

Rare, and worth resisting. Before adding one, check whether the capability fits an existing port. If it genuinely does not:

  1. Declare the interface in application/ports.ts with a documented contract.
  2. Accept it as an optional dependency in the loop, so existing callers still work.
  3. Provide a fake alongside the real adapter.
  4. Wire it in one place.