Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.15-brightgreen" alt="Version: 26.06.15"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.16-brightgreen" alt="Version: 26.06.16"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.15"
__version__ = "26.06.16"
103 changes: 65 additions & 38 deletions src/pyfly/cli/templates/hex/app_services.py.j2
Original file line number Diff line number Diff line change
@@ -1,98 +1,125 @@
"""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
{% elif has_mongodb %}
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 %}
28 changes: 4 additions & 24 deletions src/pyfly/cli/templates/hex/controllers.py.j2
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 %}
3 changes: 1 addition & 2 deletions src/pyfly/cli/templates/hex/dto.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 9 additions & 8 deletions src/pyfly/cli/templates/hex/persistence.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
10 changes: 5 additions & 5 deletions src/pyfly/cli/templates/hex/ports_inbound.py.j2
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Inbound ports — use-case interfaces."""
"""Inbound ports — use-case interfaces (driven side)."""

from __future__ import annotations

Expand All @@ -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: ...
10 changes: 5 additions & 5 deletions src/pyfly/cli/templates/hex/ports_outbound.py.j2
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Outbound ports — repository / infrastructure interfaces."""
"""Outbound ports — repository / infrastructure interfaces (driving side)."""

from __future__ import annotations

Expand All @@ -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: ...
Loading
Loading