diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14c49b55..e9853671 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.16 (2026-06-05)
+
+### Fixed
+
+- **The `hexagonal` archetype now actually wires its ports.** The generated app
+ defined inbound (use-case) and outbound (repository) ports that **nothing
+ implemented** — `resolve(TodoRepositoryPort)` raised `NoSuchBeanError`, the
+ ports were dead code, and `TodoService`'s docstring "Implements the inbound
+ ports" was false (the DI scanner binds ports by MRO, so an adapter must inherit
+ the port). Now the application service implements the four inbound use-case
+ ports and the in-memory adapter implements the outbound repository port, so both
+ resolve and the architecture is genuinely hexagonal. The use-case boundary is
+ async across all variants (in-memory / relational / document) for consistency.
+- **Generated hexagonal code is `mypy --strict`-clean and handles not-found.** The
+ data-relational / data-document variants dereferenced `find_by_id()`'s
+ `T | None` result without a check (a `mypy` error and a latent `AttributeError`
+ on a missing id); they now raise `ResourceNotFoundException` (→ 404).
+- **DTO id type fixed.** `TodoResponseDTO.id` was `int` in the relational variant
+ while the domain `Todo.id` is always `str`; it is now `str` in every variant.
+
+These surfaced in an audit while validating the `implement-hexagonal-adapter` skill
+(which validated clean — DI resolves a Protocol/ABC outbound port to its adapter,
+the keystone capability for the pattern; zero/multiple-implementation cases raise
+clear `NoSuchBeanError` / `NoUniqueBeanError`).
+
+---
+
## v26.06.15 (2026-06-05)
### Fixed
diff --git a/README.md b/README.md
index bda8d443..e18c48a6 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 91c87359..053c7428 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.6.15"
+version = "26.6.16"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 6a96548e..08514259 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.06.15"
+__version__ = "26.06.16"
diff --git a/src/pyfly/cli/templates/hex/app_services.py.j2 b/src/pyfly/cli/templates/hex/app_services.py.j2
index 8eb8dbe1..e88fae42 100644
--- a/src/pyfly/cli/templates/hex/app_services.py.j2
+++ b/src/pyfly/cli/templates/hex/app_services.py.j2
@@ -1,10 +1,17 @@
-"""Application services — use-case implementations."""
+"""Application services — use-case (inbound port) implementations."""
from __future__ import annotations
from pyfly.container import service
+from pyfly.kernel.exceptions import ResourceNotFoundException
from {{ package_name }}.domain.models import Todo
+from {{ package_name }}.domain.ports.inbound import (
+ CreateTodoUseCase,
+ DeleteTodoUseCase,
+ GetTodoUseCase,
+ ListTodosUseCase,
+)
{% if has_data %}
from {{ package_name }}.domain.models import TodoEntity
from {{ package_name }}.infrastructure.adapters.persistence import TodoRepository
@@ -12,87 +19,107 @@ from {{ package_name }}.infrastructure.adapters.persistence import TodoRepositor
from {{ package_name }}.domain.models import TodoDocument
from {{ package_name }}.infrastructure.adapters.persistence import TodoDocumentRepository
{% else %}
-from {{ package_name }}.infrastructure.adapters.persistence import InMemoryTodoRepository
+from {{ package_name }}.domain.ports.outbound import TodoRepositoryPort
{% endif %}
@service
-class TodoService:
- """Implements the inbound ports for todo management."""
+class TodoService(CreateTodoUseCase, GetTodoUseCase, ListTodosUseCase, DeleteTodoUseCase):
+ """Implements the inbound use-case ports for todo management."""
{% if has_data %}
def __init__(self, repository: TodoRepository) -> None:
self._repository = repository
- async def create(self, title: str, description: str) -> Todo:
- entity = TodoEntity(title=title, description=description)
- saved = await self._repository.save(entity)
- return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed)
+ @staticmethod
+ def _entity_id(todo_id: str) -> int:
+ try:
+ return int(todo_id)
+ except ValueError as exc:
+ raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND") from exc
- async def get(self, todo_id: int) -> Todo:
- entity = await self._repository.find_by_id(todo_id)
+ @staticmethod
+ def _to_domain(entity: TodoEntity) -> Todo:
return Todo(id=str(entity.id), title=entity.title, description=entity.description, completed=entity.completed)
+ async def create(self, title: str, description: str) -> Todo:
+ saved = await self._repository.save(TodoEntity(title=title, description=description))
+ return self._to_domain(saved)
+
+ async def get(self, todo_id: str) -> Todo:
+ entity = await self._repository.find_by_id(self._entity_id(todo_id))
+ if entity is None:
+ raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND")
+ return self._to_domain(entity)
+
async def list_all(self) -> list[Todo]:
- entities = await self._repository.find_all()
- return [Todo(id=str(e.id), title=e.title, description=e.description, completed=e.completed) for e in entities]
+ return [self._to_domain(e) for e in await self._repository.find_all()]
- async def toggle_complete(self, todo_id: int) -> Todo:
- entity = await self._repository.find_by_id(todo_id)
+ async def toggle_complete(self, todo_id: str) -> Todo:
+ entity = await self._repository.find_by_id(self._entity_id(todo_id))
+ if entity is None:
+ raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND")
entity.completed = not entity.completed
- saved = await self._repository.save(entity)
- return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed)
+ return self._to_domain(await self._repository.save(entity))
- async def delete(self, todo_id: int) -> None:
- await self._repository.delete(todo_id)
+ async def delete(self, todo_id: str) -> None:
+ await self._repository.delete(self._entity_id(todo_id))
{% elif has_mongodb %}
def __init__(self, repository: TodoDocumentRepository) -> None:
self._repository = repository
+ @staticmethod
+ def _to_domain(doc: TodoDocument) -> Todo:
+ return Todo(id=str(doc.id), title=doc.title, description=doc.description, completed=doc.completed)
+
async def create(self, title: str, description: str) -> Todo:
- doc = TodoDocument(title=title, description=description)
- saved = await self._repository.save(doc)
- return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed)
+ saved = await self._repository.save(TodoDocument(title=title, description=description))
+ return self._to_domain(saved)
async def get(self, todo_id: str) -> Todo:
doc = await self._repository.find_by_id(todo_id)
- return Todo(id=str(doc.id), title=doc.title, description=doc.description, completed=doc.completed)
+ if doc is None:
+ raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND")
+ return self._to_domain(doc)
async def list_all(self) -> list[Todo]:
- docs = await self._repository.find_all()
- return [Todo(id=str(d.id), title=d.title, description=d.description, completed=d.completed) for d in docs]
+ return [self._to_domain(d) for d in await self._repository.find_all()]
async def toggle_complete(self, todo_id: str) -> Todo:
doc = await self._repository.find_by_id(todo_id)
+ if doc is None:
+ raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND")
doc.completed = not doc.completed
- saved = await self._repository.save(doc)
- return Todo(id=str(saved.id), title=saved.title, description=saved.description, completed=saved.completed)
+ return self._to_domain(await self._repository.save(doc))
async def delete(self, todo_id: str) -> None:
await self._repository.delete(todo_id)
{% else %}
- def __init__(self, repository: InMemoryTodoRepository) -> None:
+ def __init__(self, repository: TodoRepositoryPort) -> None:
self._repository = repository
- def create(self, title: str, description: str) -> Todo:
+ async def create(self, title: str, description: str) -> Todo:
todo = Todo(title=title, description=description)
- self._repository.save(todo)
+ await self._repository.save(todo)
return todo
- def get(self, todo_id: str) -> Todo:
- return self._repository.find_by_id(todo_id)
+ async def get(self, todo_id: str) -> Todo:
+ todo = await self._repository.find_by_id(todo_id)
+ if todo is None:
+ raise ResourceNotFoundException(f"Todo '{todo_id}' not found", code="TODO_NOT_FOUND")
+ return todo
- def list_all(self) -> list[Todo]:
- return self._repository.find_all()
+ async def list_all(self) -> list[Todo]:
+ return await self._repository.find_all()
- def toggle_complete(self, todo_id: str) -> Todo:
- todo = self._repository.find_by_id(todo_id)
+ async def toggle_complete(self, todo_id: str) -> Todo:
+ todo = await self.get(todo_id)
todo.completed = not todo.completed
- self._repository.save(todo)
+ await self._repository.save(todo)
return todo
- def delete(self, todo_id: str) -> None:
- self._repository.delete(todo_id)
+ async def delete(self, todo_id: str) -> None:
+ await self._repository.delete(todo_id)
{% endif %}
diff --git a/src/pyfly/cli/templates/hex/controllers.py.j2 b/src/pyfly/cli/templates/hex/controllers.py.j2
index 9710a5bc..e7f625ee 100644
--- a/src/pyfly/cli/templates/hex/controllers.py.j2
+++ b/src/pyfly/cli/templates/hex/controllers.py.j2
@@ -1,4 +1,4 @@
-"""REST controllers — API layer."""
+"""REST controllers — inbound (driving) adapter for the API layer."""
from pyfly.container import rest_controller
from pyfly.web import PathVar, Valid, delete_mapping, get_mapping, post_mapping, put_mapping, request_mapping
@@ -25,44 +25,24 @@ class TodoController:
@get_mapping("/")
async def list_todos(self) -> list[TodoResponseDTO]:
-{% if has_data or has_mongodb %}
todos = await self._service.list_all()
-{% else %}
- todos = self._service.list_all()
-{% endif %}
return [TodoResponseDTO(id=t.id, title=t.title, description=t.description, completed=t.completed) for t in todos]
@get_mapping("/{todo_id}")
- async def get_todo(self, todo_id: PathVar[{% if has_data %}int{% else %}str{% endif %}]) -> TodoResponseDTO:
-{% if has_data or has_mongodb %}
+ async def get_todo(self, todo_id: PathVar[str]) -> TodoResponseDTO:
todo = await self._service.get(todo_id)
-{% else %}
- todo = self._service.get(todo_id)
-{% endif %}
return TodoResponseDTO(id=todo.id, title=todo.title, description=todo.description, completed=todo.completed)
@post_mapping("/", status_code=201)
async def create_todo(self, body: Valid[TodoCreateRequest]) -> TodoResponseDTO:
-{% if has_data or has_mongodb %}
todo = await self._service.create(title=body.title, description=body.description)
-{% else %}
- todo = self._service.create(title=body.title, description=body.description)
-{% endif %}
return TodoResponseDTO(id=todo.id, title=todo.title, description=todo.description, completed=todo.completed)
@put_mapping("/{todo_id}")
- async def update_todo(self, todo_id: PathVar[{% if has_data %}int{% else %}str{% endif %}]) -> TodoResponseDTO:
-{% if has_data or has_mongodb %}
+ async def update_todo(self, todo_id: PathVar[str]) -> TodoResponseDTO:
todo = await self._service.toggle_complete(todo_id)
-{% else %}
- todo = self._service.toggle_complete(todo_id)
-{% endif %}
return TodoResponseDTO(id=todo.id, title=todo.title, description=todo.description, completed=todo.completed)
@delete_mapping("/{todo_id}", status_code=204)
- async def delete_todo(self, todo_id: PathVar[{% if has_data %}int{% else %}str{% endif %}]) -> None:
-{% if has_data or has_mongodb %}
+ async def delete_todo(self, todo_id: PathVar[str]) -> None:
await self._service.delete(todo_id)
-{% else %}
- self._service.delete(todo_id)
-{% endif %}
diff --git a/src/pyfly/cli/templates/hex/dto.py.j2 b/src/pyfly/cli/templates/hex/dto.py.j2
index e51cce14..bc8bb102 100644
--- a/src/pyfly/cli/templates/hex/dto.py.j2
+++ b/src/pyfly/cli/templates/hex/dto.py.j2
@@ -9,8 +9,7 @@ class TodoCreateRequest(BaseModel):
class TodoResponseDTO(BaseModel):
- id: {% if has_data %}int{% else %}str{% endif %}
-
+ id: str
title: str
description: str = ""
completed: bool = False
diff --git a/src/pyfly/cli/templates/hex/persistence.py.j2 b/src/pyfly/cli/templates/hex/persistence.py.j2
index b8c607ed..b710c819 100644
--- a/src/pyfly/cli/templates/hex/persistence.py.j2
+++ b/src/pyfly/cli/templates/hex/persistence.py.j2
@@ -29,31 +29,32 @@ class TodoDocumentRepository(MongoRepository[TodoDocument, str]):
"""Document repository — inherits full CRUD. Add custom queries by defining stub methods."""
{% endif %}
{% else %}
-"""Persistence adapter — implements outbound repository port."""
+"""Persistence adapter — implements the outbound repository port."""
from __future__ import annotations
from pyfly.container import repository
from {{ package_name }}.domain.models import Todo
+from {{ package_name }}.domain.ports.outbound import TodoRepositoryPort
@repository
-class InMemoryTodoRepository:
- """In-memory implementation. Replace with a database-backed adapter."""
+class InMemoryTodoRepository(TodoRepositoryPort):
+ """In-memory adapter implementing TodoRepositoryPort. Replace with a database-backed adapter."""
def __init__(self) -> None:
self._store: dict[str, Todo] = {}
- def save(self, todo: Todo) -> None:
+ async def save(self, todo: Todo) -> None:
self._store[todo.id] = todo
- def find_by_id(self, todo_id: str) -> Todo:
- return self._store[todo_id]
+ async def find_by_id(self, todo_id: str) -> Todo | None:
+ return self._store.get(todo_id)
- def find_all(self) -> list[Todo]:
+ async def find_all(self) -> list[Todo]:
return list(self._store.values())
- def delete(self, todo_id: str) -> None:
+ async def delete(self, todo_id: str) -> None:
self._store.pop(todo_id, None)
{% endif %}
diff --git a/src/pyfly/cli/templates/hex/ports_inbound.py.j2 b/src/pyfly/cli/templates/hex/ports_inbound.py.j2
index 5f9fde53..316690e6 100644
--- a/src/pyfly/cli/templates/hex/ports_inbound.py.j2
+++ b/src/pyfly/cli/templates/hex/ports_inbound.py.j2
@@ -1,4 +1,4 @@
-"""Inbound ports — use-case interfaces."""
+"""Inbound ports — use-case interfaces (driven side)."""
from __future__ import annotations
@@ -8,16 +8,16 @@ from {{ package_name }}.domain.models import Todo
class CreateTodoUseCase(Protocol):
- def create(self, title: str, description: str) -> Todo: ...
+ async def create(self, title: str, description: str) -> Todo: ...
class GetTodoUseCase(Protocol):
- def get(self, todo_id: str) -> Todo: ...
+ async def get(self, todo_id: str) -> Todo: ...
class ListTodosUseCase(Protocol):
- def list_all(self) -> list[Todo]: ...
+ async def list_all(self) -> list[Todo]: ...
class DeleteTodoUseCase(Protocol):
- def delete(self, todo_id: str) -> None: ...
+ async def delete(self, todo_id: str) -> None: ...
diff --git a/src/pyfly/cli/templates/hex/ports_outbound.py.j2 b/src/pyfly/cli/templates/hex/ports_outbound.py.j2
index 3daa74dc..ca621c28 100644
--- a/src/pyfly/cli/templates/hex/ports_outbound.py.j2
+++ b/src/pyfly/cli/templates/hex/ports_outbound.py.j2
@@ -1,4 +1,4 @@
-"""Outbound ports — repository / infrastructure interfaces."""
+"""Outbound ports — repository / infrastructure interfaces (driving side)."""
from __future__ import annotations
@@ -8,7 +8,7 @@ from {{ package_name }}.domain.models import Todo
class TodoRepositoryPort(Protocol):
- def save(self, todo: Todo) -> None: ...
- def find_by_id(self, todo_id: str) -> Todo: ...
- def find_all(self) -> list[Todo]: ...
- def delete(self, todo_id: str) -> None: ...
+ async def save(self, todo: Todo) -> None: ...
+ async def find_by_id(self, todo_id: str) -> Todo | None: ...
+ async def find_all(self) -> list[Todo]: ...
+ async def delete(self, todo_id: str) -> None: ...
diff --git a/src/pyfly/cli/templates/hex/test_services.py.j2 b/src/pyfly/cli/templates/hex/test_services.py.j2
index 51ebfd55..e5992fc8 100644
--- a/src/pyfly/cli/templates/hex/test_services.py.j2
+++ b/src/pyfly/cli/templates/hex/test_services.py.j2
@@ -9,28 +9,28 @@ class TestTodoService:
self.repository = InMemoryTodoRepository()
self.service = TodoService(self.repository)
- def test_create_todo(self) -> None:
- todo = self.service.create("Buy groceries", "Milk and eggs")
+ async def test_create_todo(self) -> None:
+ todo = await self.service.create("Buy groceries", "Milk and eggs")
assert todo.title == "Buy groceries"
assert todo.id
- def test_list_todos(self) -> None:
- self.service.create("Task A", "")
- self.service.create("Task B", "")
- assert len(self.service.list_all()) == 2
+ async def test_list_todos(self) -> None:
+ await self.service.create("Task A", "")
+ await self.service.create("Task B", "")
+ assert len(await self.service.list_all()) == 2
- def test_get_todo(self) -> None:
- created = self.service.create("Buy groceries", "")
- found = self.service.get(created.id)
+ async def test_get_todo(self) -> None:
+ created = await self.service.create("Buy groceries", "")
+ found = await self.service.get(created.id)
assert found.title == "Buy groceries"
- def test_toggle_complete(self) -> None:
- created = self.service.create("Buy groceries", "")
+ async def test_toggle_complete(self) -> None:
+ created = await self.service.create("Buy groceries", "")
assert created.completed is False
- toggled = self.service.toggle_complete(created.id)
+ toggled = await self.service.toggle_complete(created.id)
assert toggled.completed is True
- def test_delete_todo(self) -> None:
- created = self.service.create("Buy groceries", "")
- self.service.delete(created.id)
- assert len(self.service.list_all()) == 0
+ async def test_delete_todo(self) -> None:
+ created = await self.service.create("Buy groceries", "")
+ await self.service.delete(created.id)
+ assert len(await self.service.list_all()) == 0
diff --git a/tests/cli/test_scaffold_hexagonal_ports.py b/tests/cli/test_scaffold_hexagonal_ports.py
new file mode 100644
index 00000000..92351230
--- /dev/null
+++ b/tests/cli/test_scaffold_hexagonal_ports.py
@@ -0,0 +1,56 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Regression: the generated hexagonal archetype actually WIRES its ports (v26.06.16).
+
+The archetype used to define inbound/outbound ports that nothing inherited, so they
+were dead code — ``resolve(TodoRepositoryPort)`` raised ``NoSuchBeanError`` and the
+service docstring "Implements the inbound ports" was false. The application service
+now implements the inbound use-case ports and the in-memory adapter implements the
+outbound repository port (so both resolve via the DI scanner's MRO binding).
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+from pyfly.cli.templates import generate_project
+
+
+def _src(project_dir: Path, package: str, rel: str) -> str:
+ text = (project_dir / "src" / package / rel).read_text(encoding="utf-8")
+ ast.parse(text) # generated file must be valid Python
+ return text
+
+
+class TestHexagonalArchetypeWiring:
+ def test_ports_are_wired(self, tmp_path: Path) -> None:
+ proj = tmp_path / "hex"
+ generate_project(name="todo", project_dir=proj, archetype="hexagonal", features=["web"], package_name="todo")
+
+ services = _src(proj, "todo", "application/services.py")
+ # The service implements ALL four inbound use-case ports.
+ assert "class TodoService(CreateTodoUseCase, GetTodoUseCase, ListTodosUseCase, DeleteTodoUseCase):" in services
+ assert "from todo.domain.ports.inbound import" in services
+ assert "async def create" in services # async use-case boundary
+
+ # The in-memory adapter implements the outbound repository port.
+ persistence = _src(proj, "todo", "infrastructure/adapters/persistence.py")
+ assert "class InMemoryTodoRepository(TodoRepositoryPort):" in persistence
+ assert "from todo.domain.ports.outbound import TodoRepositoryPort" in persistence
+
+ # The response DTO uses the domain id type (str), not the entity int.
+ dto = _src(proj, "todo", "api/dto.py")
+ assert "id: str" in dto
+ assert "id: int" not in dto
diff --git a/uv.lock b/uv.lock
index 9f39851e..71f374c1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1967,7 +1967,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.6.15"
+version = "26.6.16"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },