Skip to content

feat: add hexagonal architecture scaffolding#2

Open
mariushelf wants to merge 4 commits into
mainfrom
feat/hexagonal-architecture
Open

feat: add hexagonal architecture scaffolding#2
mariushelf wants to merge 4 commits into
mainfrom
feat/hexagonal-architecture

Conversation

@mariushelf

Copy link
Copy Markdown
Owner

What

Restructures the generated package into a hexagonal (ports-and-adapters) layout, ported from the structure of the qualitative-feedback-analysis (QFA) repository.

Package layout

src/{{ project_slug }}/
  domain/     entities, value objects, errors, driven ports (inner core, no infra)
  services/   application services / use cases (depend only on domain)
  adapters/   driven adapter implementations of domain ports
  main.py     driving adapter + composition root (may import every layer)

No web framework is pulled in — main.py is the driving adapter. A small, clearly-marked seed slice (a Note entity, a NoteRepository port, an InMemoryNoteRepository adapter, and a NoteService) demonstrates the full pattern and is meant to be deleted once real domain modelling starts. Search # --- example to find/remove it.

Enforcement (the important part)

The layering is enforced by import-linter contracts in pyproject.toml, not just by convention:

  • Enforce hexagonal layersadapters > services > domain. main.py is intentionally not a layer, so the composition root is free to wire adapters into services without exceptions.
  • Domain / services stay free of infrastructureforbidden contracts (seeded with httpx/requests/sqlalchemy as illustrative examples to extend).

Wired into:

  • .pre-commit-config.yaml (lint-imports hook)
  • Makefile (import_lint, folded into make lint)
  • CI lint job in cicd.yaml

Docs / AGENTS.md

AGENTS.md gains an Architecture section (layer rules, package responsibilities) and the QFA rule that every class implementing a port must explicitly inherit from it (discoverability over structural typing), plus a short Testing & Linting section.

Deliberately excluded

  • Documentation (docs/) — handled in a separate PR.
  • QFA-specific CI (Docker build, Postgres integration job) and infrastructure adapters (SQLAlchemy/Alembic, Presidio, LiteLLM) — kept the template dependency-free.

Verification

The template's own test suite (tests/test_template.py) passes end-to-end on the generated project — pytest, make lint (ruff + ty + import-linter), make test, python -m main, and pre-commit run --all-files (incl. the new lint-imports hook). All 6 tests green.

Restructure the generated package into a hexagonal (ports-and-adapters)
layout with domain/services/adapters layers and main.py as the driving
adapter / composition root. A small, clearly-marked ("# --- example")
seed slice -- a Note entity, a NoteRepository port, an in-memory adapter,
and a NoteService -- demonstrates the pattern end to end and is meant to
be deleted once real domain modelling begins.

Enforce the layering with import-linter contracts in pyproject.toml
(layer ordering plus domain/services kept free of infrastructure), wired
into pre-commit (lint-imports hook), the Makefile (folded into `make
lint`), and the CI lint job. Document the architecture and the
"every port implementation must explicitly inherit from its port" rule
in AGENTS.md.
Switch the example NoteRepository port from typing.Protocol to an
abc.ABC with @AbstractMethod, and update the adapter and AGENTS.md
wording accordingly: an ABC enforces the contract at instantiation time
(a subclass missing an abstract method cannot be instantiated), so
inheritance is mandatory rather than a convention layered on top of
structural typing.

Also rename AGENTS.md -> AGENTS.md.jinja so the new {{ project_slug }}
references in the Architecture section actually render (copier only
templates files with the .jinja suffix), and add an Architecture section
to the README stating the project uses hexagonal architecture.
…ecture

# Conflicts:
#	template/pyproject.toml.jinja
The project_slug default sanitised only a blocklist of characters (spaces,
hyphens, dots), so other characters leaked into the slug. The default
project_name ("<author>'s new project") contains an apostrophe, producing the
invalid package name "joe_doe's_new_project" on a defaults render.

Switch to allowlist sanitisation (collapse every run of non-alphanumeric
characters into a single underscore, then trim) and add a validator that
rejects any project_slug that is not a valid Python package name, whether
derived or user-supplied. Add regression tests covering both paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant