M07C01: Ports & Adapters – Pure Domain, Effectful Infrastructure¶
Progression Note¶
Module 7 moves from pure effect-aware composition to real-world architecture.
We now isolate the pure domain core from all effectful infrastructure, making the core fully testable, composable, and independent of concrete I/O, logging, or external services.
| Module | Focus | Key Outcomes |
|---|---|---|
| 6 | Monadic Flows as Composable Pipelines | bind/and_then, Reader/State-like patterns, configurable error-typed flows |
| 7 | Effect Boundaries & Resource Safety | Ports & adapters, capability interfaces, resource-safe effect isolation |
| 8 | Async FuncPipe & Backpressure | Async streams, bounded queues, timeouts/retries, fairness & backpressure |
Core question
How do you structure a real production system so that the pure domain core depends only on abstract ports while all concrete effects live in swappable adapters — giving you perfect testability, zero hidden dependencies, and the ability to change infrastructure without touching the core?
This is the architectural pattern that finally lets you ship the beautiful monadic pipelines from Module 6 to production without compromise.
Audience: Engineers who love the purity of Module 6 but need to integrate with the real world (files, APIs, databases) without polluting the core.
Outcome
1. You will define narrow, pure ports (protocols) for every external dependency.
2. You will implement adapters in infrastructure that satisfy those ports.
3. You will inject adapters via Reader — keeping the core pure and the shell effectful.
4. You will have mechanical proof (via swappable in-memory adapters) that the core behaves identically regardless of infrastructure.
The Golden Rule of Ports & Adapters¶
The pure core must depend only on abstract ports and explicit inputs (Reader[Env]).
Everything else — concrete files, HTTP clients, loggers, databases — lives in adapters.
1. Laws & Invariants (machine-checked in CI)¶
| Law | Description | Enforcement |
|---|---|---|
| Purity Law | Core functions depend only on explicit inputs and ports; no direct effects or globals | mypy --strict + review |
| Swappability Law | Equivalent adapters yield identical core outputs (same order, same values) for same inputs | Hypothesis (in-memory vs real) |
| Resource Safety | Adapters guarantee cleanup when iterator is exhausted or explicitly closed; consumers must not leak iterators | Context manager + tests |
| Error Mapping | Infra exceptions → typed domain ErrInfo; unexpected bugs propagate |
Runtime contract |
| Laziness Preservation | Streaming ports (Iterator[Result[...]]) never materialise unless explicitly forced |
Property tests |
Note: The Laziness Preservation law applies strictly to production adapters. Test doubles are explicitly permitted to materialise streams for convenient assertion and inspection.
All laws are verified with Hypothesis. Behavioural laws (purity, swappability, error mapping) use both real and in-memory adapters; laziness/resource laws are checked against production adapters only. A single divergence breaks CI.
2. Decision Table – Where Does Code Belong?¶
| Code Does | Layer | Reason |
|---|---|---|
| Business rules, validation | Core | Pure, testable, composable |
| Abstract interfaces | Ports | Domain boundary, pure protocols |
| Concrete I/O, logging, HTTP | Adapters | Effectful, swappable |
| Orchestration, error policy | Shell | Effectful, depends on adapters |
If it’s effectful or concrete → it does not belong in core.
3. Public API – Capability Protocols (domain boundary)¶
# src/funcpipe_rag/domain/capabilities.py – mypy --strict clean
from __future__ import annotations
from collections.abc import Iterator
from datetime import datetime
from typing import Protocol
from funcpipe_rag.core.rag_types import Chunk, RawDoc
from funcpipe_rag.domain.logging import LogEntry
from funcpipe_rag.result.types import ErrInfo, Result
class StorageRead(Protocol):
def read_docs(self, path: str) -> Iterator[Result[RawDoc, ErrInfo]]: ...
class StorageWrite(Protocol):
def write_chunks(self, path: str, chunks: Iterator[Chunk]) -> Result[None, ErrInfo]: ...
class Storage(StorageRead, StorageWrite, Protocol):
"""Composed capability: full read/write access."""
class Clock(Protocol):
def now(self) -> datetime: ...
class Logger(Protocol):
def log(self, entry: LogEntry) -> None: ...
Note: earlier drafts of Module 07 used domain/ports.py with StoragePort/LoggerPort. In the
refactored codebase, these were consolidated into the capability protocols above to avoid overlap.
4. Reference Implementations – Adapters (infra layer)¶
4.1 FileStorageAdapter (real I/O)¶
# src/funcpipe_rag/infra/adapters/file_storage.py
#
# Full implementation lives in the repo (CSV-in / JSONL-out, resource-safe reads,
# atomic writes via temp+fsync+replace).
from funcpipe_rag.domain.capabilities import Storage
class FileStorage(Storage):
def read_docs(self, path: str): ...
def write_chunks(self, path: str, chunks): ...
4.2 InMemoryStorageAdapter (test double)¶
# src/funcpipe_rag/infra/adapters/memory_storage.py
#
# Full implementation lives in the repo (preload docs, collect written chunks).
from funcpipe_rag.domain.capabilities import Storage
class InMemoryStorage(Storage):
def __init__(self, *, preload=None) -> None: ...
def read_docs(self, path: str): ...
def write_chunks(self, path: str, chunks): ...
The in-memory adapter deliberately materialises all successful chunks for easy inspection in tests. This is the only permitted violation of strict laziness — and only in test doubles.
4.3 ConsoleLoggerAdapter¶
# src/funcpipe_rag/infra/adapters/logger.py
from funcpipe_rag.domain.capabilities import Logger
from funcpipe_rag.domain.logging import LogEntry
class ConsoleLogger(Logger):
def log(self, entry: LogEntry) -> None: ...
5. Repo Alignment Note (end-of-Module-07)¶
This repo’s end-of-Module-07 codebase provides the ports/adapters building blocks:
- Capability protocols: src/funcpipe_rag/domain/capabilities.py
- Structured log entries: src/funcpipe_rag/domain/logging.py
- Deferred IO (IOPlan) + wrappers: src/funcpipe_rag/fp/effects/io_plan.py, src/funcpipe_rag/fp/effects/io_retry.py, src/funcpipe_rag/fp/effects/tx.py
- Concrete adapters: src/funcpipe_rag/infra/adapters/file_storage.py, src/funcpipe_rag/infra/adapters/memory_storage.py, src/funcpipe_rag/infra/adapters/logger.py, src/funcpipe_rag/infra/adapters/clock.py, src/funcpipe_rag/infra/adapters/atomic_storage.py
The RAG pipeline surface itself remains the Module 02–06 config-as-data API in src/funcpipe_rag/rag/
(it is not yet fully migrated to capability protocols + IOPlan).
6. Tests (selected)¶
- File storage resource-safety + parse errors:
tests/unit/infra/adapters/test_file_storage.py - File vs in-memory swappability (Hypothesis):
tests/unit/infra/adapters/test_storage_swappability.py - IOPlan monad laws + laziness:
tests/unit/domain/test_io_plan_laws.py - Migration equivalence (legacy call vs IOPlan):
tests/unit/domain/test_io_plan_migration_equivalence.py - Idempotent writes, retry, tx bracketing:
tests/unit/domain/test_idempotent.py,tests/unit/domain/test_retry.py,tests/unit/domain/test_session.py - Structured logging helpers:
tests/unit/domain/test_logging.py
8. Anti-Patterns & Immediate Fixes¶
| Anti-Pattern | Symptom | Fix |
|---|---|---|
| Direct I/O in core | Untestable, coupled | Extract to port/adapter |
| Global/singleton services | Hidden dependencies | Inject via Reader |
| Fat "god" ports | Bloated interfaces | Keep ports narrow and focused |
9. Pre-Core Quiz¶
- Ports live in…? → Domain boundary
- Adapters live in…? → Infra layer
- Core depends on…? → Only ports and explicit inputs
- Where do you put
try/exceptfor infra errors? → Only in adapters or shell - The golden rule? → Core must remain pure and independent of concrete infrastructure
10. Post-Core Exercise¶
- Take one impure function from your codebase and extract a port for its external dependency.
- Implement both a real and an in-memory adapter.
- Write a Hypothesis test that proves swappability (same inputs → same core outputs).
Next: M07C02 – External Effect Libraries (returns / effect) – Optional Comparison
You have now reached production-grade functional architecture: fully pure core, mechanically proven swappability, atomic writes, resource safety, lazy streaming, and graceful typed error handling — all while preserving the beauty of the Module 6 monadic pipelines. This is the real-world pattern that scales.