Skip to content

Hexagonal Architecture Stub Generator

Implements: ADR-0011, ADR-0013 Source: tools/generate_stubs.py

Purpose

generate_stubs.py bridges the gap between a Gherkin feature file and working Python code. Given a directory of .feature files, it scaffolds the four-layer Hexagonal Architecture skeleton so developers start with the correct structure rather than discovering the pattern through trial and error.


Spec-First Workflow

The generator enforces a spec-first discipline:

  1. Write a .feature file that describes desired behaviour in Gherkin.
  2. Run generate_stubs.py <features_dir> <src_dir>.
  3. Implement the generated stubs.
  4. Run pytest --bdd to verify the feature scenarios pass.

This order guarantees that every bounded context has both a human-readable behaviour specification and an implementation scaffold from its first commit.


Generated Structure

For each *.feature file with stem <name>, the following files are created:

<src_dir>/
  domain/
    __init__.py
    <name>_service.py        ← pure business logic
  ports/
    __init__.py
    <name>_port.py           ← typing.Protocol interface
  adapters/
    inbound/
      __init__.py
      mcp_<name>.py          ← FastMCP tool / FastAPI route
    outbound/
      __init__.py
      store_<name>.py        ← concrete persistence adapter

Layer Responsibilities

Domain (domain/<name>_service.py)

Contains pure Python business logic with no I/O, no framework imports. The generated stub raises NotImplementedError, which forces the developer to implement real logic before any test can pass — the error cannot be silently swallowed.

def process_<name>():
    """
    Generated from: <name>.feature
    """
    raise NotImplementedError

Ports (ports/<name>_port.py)

A typing.Protocol class that defines the contract (interface) between the domain and its adapters.

Why Protocol over ABC? Protocol enables structural sub-typing (PEP 544): any class that implements the required methods satisfies the contract without inheriting from the base class. This decouples adapters from the domain completely — the domain never imports from adapters/, so the dependency graph stays clean.

class I<Name>Port(Protocol):
    def execute(self) -> None: ...

Inbound Adapter (adapters/inbound/mcp_<name>.py)

Translates external triggers (HTTP requests, MCP tool calls, CLI arguments) into domain calls. The mcp_ prefix signals that the primary integration surface is FastMCP, but a FastAPI route or CLI handler is equally valid here.

Outbound Adapter (adapters/outbound/store_<name>.py)

Implements the port Protocol and translates domain calls into external side-effects (database writes, HTTP calls to third-party APIs, file I/O). The store_ prefix is a convention, not a constraint.


Idempotency

Every file write is guarded by an existence check:

if not domain_file.exists():
    domain_file.write_text(...)

Re-running the generator on a partially implemented bounded context is safe — existing files (which may already contain real logic) are never overwritten.


Interface Name Derivation

The Protocol class name is derived from feature_name using title-case transformation with I prefix and Port suffix:

Feature stem Interface name
user_auth IUserAuthPort
invoice_export IInvoiceExportPort
product IProductPort

The same derivation logic runs in both the port stub and the outbound adapter comment, so the two files stay consistent.


Exit Codes

Code Meaning
0 Stubs generated successfully
1 features_dir does not exist
2 Insufficient CLI arguments

Exit codes allow CI pipeline steps to treat "no features directory" as a hard failure without parsing stdout.