Skip to content

ADR-000: Hexagonal Architecture

Status: Accepted Date: 2026-01-10 Updated: 2026-10-01 Deciders: gepa-adk maintainers

Context

gepa-adk bridges two external libraries (GEPA's evolutionary optimization and Google ADK's agent execution framework). We need an architecture that:

  1. Keeps core evolution logic independent of external dependencies
  2. Makes external library integration clean and testable
  3. Allows swapping implementations without changing business logic
  4. Enables easy testing with mocks at boundaries

Decision

Adopt Hexagonal Architecture (Ports & Adapters) for gepa-adk with three distinct layers:

Layer Structure

gepa-adk/
├── domain/                    # 🏢 CORE - Pure evolution logic
│   ├── models.py             # EvolutionConfig, EvolutionResult, Candidate
│   ├── types.py              # Type aliases, DTOs
│   └── exceptions.py         # EvolutionError hierarchy
│
├── ports/                     # 🔌 INTERFACES - Protocols
│   ├── adapter.py            # AsyncGEPAAdapter protocol
│   ├── scorer.py             # Scorer protocol
│   └── agent_provider.py     # AgentProvider protocol (optional persistence)
│
├── adapters/                  # 🔧 IMPLEMENTATIONS - ADK-specific
│   ├── evolution/adk_adapter.py  # ADKAdapter implements AsyncGEPAAdapter
│   ├── scoring/critic_scorer.py  # CriticScorer implements Scorer
│   └── workflow/workflow.py  # Workflow utilities
│
├── engine/                    # 🔄 ORCHESTRATION - Async engine
│   ├── async_engine.py       # AsyncGEPAEngine
│   └── proposer.py           # AsyncReflectiveMutationProposer
│
└── utils/                     # 🛠️ UTILITIES
    ├── state_guard.py        # State key preservation
    ├── events.py             # ADK event parsing
    └── schema_utils.py       # Output schema helpers

Layer Rules

Layers are ordered adapters/ > engine/ > ports/ > utils/ > domain/. Each layer imports only layers below it.

Layer Can Import From Cannot Import From
adapters/ ports/ (including the concrete defaults in ports/defaults.py), utils/, domain/, external libs (ADK, LiteLLM) engine/ (receives the engine via injection)
engine/ ports/ (including ports/defaults.py), utils/, domain/, standard library, structlog adapters/, google, litellm
ports/ utils/, domain/, standard library adapters/, engine/, google, litellm
utils/ domain/, standard library, structlog, pydantic adapters/, engine/, ports/, google, litellm
domain/ Standard library without IO modules, structlog Every other layer, google, litellm, IO modules (os, io, logging, httpx, subprocess, socket, sqlite3, shutil, tempfile)

ports/defaults.py holds the concrete default classes (such as the default selection strategies), so both engine/ and adapters/ reach them without a layer exception. Imports guarded by TYPE_CHECKING are exempt.

The [tool.importlinter] contracts in pyproject.toml enforce these rules through uv run lint-imports, in the pre-commit hook and the CI lint job. The separate boundaries shell script was retired on 2026-10-01 (#533).

Dependency Flow

┌─────────────────────────────────────────────────────────────┐
│                      User Application                        │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                     api.py (Public API)                      │
│                evolve(), evolve_sync()                       │
└─────────────────────────────────────────────────────────────┘
                              │
                    ┌─────────┴─────────┐
                    ▼                   ▼
┌──────────────────────────┐  ┌──────────────────────────┐
│      engine/             │  │      adapters/           │
│  AsyncGEPAEngine         │  │  ADKAdapter              │
│  (depends on ports)      │  │  CriticScorer            │
└──────────────────────────┘  │  (implements ports)      │
           │                  └──────────────────────────┘
           │                              │
           └──────────────┬───────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                        ports/                                │
│           AsyncGEPAAdapter, Scorer protocols                 │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                        domain/                               │
│         EvolutionConfig, EvolutionResult, exceptions         │
└─────────────────────────────────────────────────────────────┘

Consequences

Positive

  • Testability: Core engine can be tested with mock adapters (no ADK required)
  • Flexibility: Swap ADK adapter for alternative implementations
  • Clear boundaries: External library changes don't leak into domain logic
  • Maintainability: Each layer has single responsibility
  • Dependency injection: Engine receives adapters, doesn't create them

Negative

  • More files: More structure than a flat module
  • Indirection: Must trace through layers to understand flow
  • Protocol overhead: Defining interfaces adds initial development time

Neutral

  • Learning curve: Team must understand port/adapter pattern
  • Async everywhere: Async-first design permeates all layers

Alternatives Considered

1. Flat Module Structure

# Single gepa_adk.py with everything

Rejected: Doesn't scale, mixes concerns, hard to test without external dependencies.

2. Service-Based Architecture

# services/evolution_service.py
# services/scoring_service.py

Rejected: Services tend to accumulate logic; hexagonal provides clearer boundaries.

3. Plugin Architecture

# plugins/adk.py, plugins/litellm.py

Rejected: Over-engineering for current scope; hexagonal is simpler.

References