Hexagonal vs Clean Architecture: Structure, Code, and What Most People Get Wrong¶
You've read the articles. You've seen the diagrams — the concentric circles, the hexagons, the boxes with arrows. You understand the concepts in theory. Then you open a blank IDE and stare at the folder structure trying to figure out where your UserService goes, and everything collapses into a pile of guesses.
Both Hexagonal Architecture and Clean Architecture promise the same thing: code that is testable, maintainable, and independent of frameworks. Both succeed. But they use different metaphors, different vocabulary, and different folder conventions — and the community uses both terms interchangeably, which adds to the confusion.
Part 1: The Shared DNA¶
Before separating them, understand what they share. Both architectures are applications of one foundational principle:
The Dependency Rule: source code dependencies must point inward, toward higher-level policy. Nothing in an inner layer can know anything about an outer layer.
In plain English: your business logic must not import your database driver, your HTTP framework, or your email library. Ever.
WRONG (the default way everyone starts):
UserService → imports → SQLAlchemy model
UserService → imports → FastAPI Request
UserService → imports → SendGrid client
RIGHT (both architectures enforce this):
UserService → imports → UserRepository interface (abstract)
SQLAlchemy model → implements → UserRepository interface
FastAPI handler → calls → UserService
The arrow flips. Infrastructure depends on business logic,
not the other way around.
Both architectures achieve this flip. They just name the layers differently and visualize them differently.
Part 2: Clean Architecture — The Four Rings¶
Robert C. Martin (Uncle Bob) published Clean Architecture in 2017. The visual model is four concentric circles, each representing a layer, with the dependency rule enforced strictly: outer rings depend on inner rings, never the reverse.
┌────────────────────────────────┐
│ Presentation (Frameworks/UI) │ ← outermost: FastAPI, Django, CLI
│ ┌──────────────────────────┐ │
│ │ Infrastructure │ │ ← DB, external APIs, email, storage
│ │ ┌────────────────────┐ │ │
│ │ │ Application │ │ │ ← use cases, orchestration
│ │ │ ┌──────────────┐ │ │ │
│ │ │ │ Domain │ │ │ │ ← entities, business rules, events
│ │ │ └──────────────┘ │ │ │
│ │ └────────────────────┘ │ │
│ └──────────────────────────┘ │
└────────────────────────────────┘
Dependencies: Presentation → Infrastructure → Application → Domain
Domain knows nothing about anyone else.
The Four Layers in Detail¶
Domain — the innermost ring. Pure business logic. No imports from external libraries. Only Python standard library at most. - Entities (objects with identity and lifecycle) - Value Objects (objects defined only by their attributes) - Domain Events (things that happened) - Business rules and invariants
Application — orchestrates domain objects to fulfill use cases. Defines interfaces (abstract classes / protocols) for things it needs but doesn't implement. - Use cases / command handlers / query handlers - Abstract repository interfaces - Abstract service interfaces (email sender, payment processor)
Infrastructure — concrete implementations of the interfaces defined in Application. - SQLAlchemy repository implementations - External API clients - Email senders - JWT token providers
Presentation — the entry point. Accepts input from the outside world, calls Application use cases, formats output. - FastAPI / Django route handlers - CLI commands - GraphQL resolvers - gRPC handlers
The Clean Architecture Project Structure¶
Based on Milan Jovanović's widely-adopted layout (shown in the image), here is the canonical folder structure translated to Python:
my_app/
│
├── domain/ # Ring 1: Innermost
│ ├── todos/
│ │ ├── todo_item.py # Entity
│ │ ├── priority.py # Value Object
│ │ ├── todo_errors.py # Domain errors
│ │ ├── todo_created_event.py # Domain Event
│ │ ├── todo_completed_event.py # Domain Event
│ │ └── todo_deleted_event.py # Domain Event
│ └── users/
│ ├── user.py # Entity
│ ├── user_errors.py
│ └── user_registered_event.py
│
├── application/ # Ring 2
│ ├── abstractions/
│ │ ├── repositories.py # Abstract interfaces (Protocols)
│ │ ├── messaging.py # Abstract event bus
│ │ └── authentication.py # Abstract auth interface
│ ├── behaviors/
│ │ ├── logging_decorator.py # Cross-cutting concern
│ │ └── validation_decorator.py # Cross-cutting concern
│ ├── todos/
│ │ ├── create_todo.py # Use case
│ │ ├── complete_todo.py # Use case
│ │ ├── delete_todo.py # Use case
│ │ ├── get_todos.py # Query
│ │ └── get_todo_by_id.py # Query
│ └── users/
│ ├── register_user.py # Use case
│ ├── login_user.py # Use case
│ ├── get_user_by_id.py # Query
│ └── get_user_by_email.py # Query
│
├── infrastructure/ # Ring 3
│ ├── database/
│ │ ├── db_context.py # SQLAlchemy session factory
│ │ ├── migrations/
│ │ ├── todos/
│ │ │ └── todo_configuration.py # ORM mapping
│ │ └── users/
│ │ └── user_configuration.py
│ ├── authentication/
│ │ ├── token_provider.py # JWT implementation
│ │ ├── password_hasher.py # bcrypt implementation
│ │ └── user_context.py # current user from JWT
│ ├── authorization/
│ │ └── permission_handler.py
│ └── domain_events/
│ └── domain_events_dispatcher.py
│
└── presentation/ # Ring 4: Outermost
├── api/
│ ├── endpoints/
│ │ ├── todos.py # FastAPI router
│ │ └── users.py # FastAPI router
│ └── middleware/
│ └── error_handler.py
├── dependency_injection.py # Wires everything together
└── main.py # Application entry point
Part 3: Hexagonal Architecture — Ports and Adapters¶
Alistair Cockburn introduced Hexagonal Architecture in 2005 — over a decade before Clean Architecture. The visual metaphor is a hexagon (the shape is arbitrary; Cockburn picked it because it has enough sides to draw multiple adapters). The official name is Ports and Adapters.
The mental model is different from Clean Architecture's rings:
┌──────────────────────────────────────────┐
│ OUTSIDE WORLD │
│ │
HTTP → │ [HTTP Adapter] [CLI Adapter] │
CLI → │ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ PRIMARY PORTS │ │
│ │ (driving ports — inputs) │ │
│ │ ┌───────────────────────────────┐ │ │
│ │ │ APPLICATION CORE │ │ │
│ │ │ (pure business logic) │ │ │
│ │ └───────────────────────────────┘ │ │
│ │ SECONDARY PORTS │ │
│ │ (driven ports — outputs) │ │
│ └─────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ [DB Adapter] [Email Adapter] │
│ │
└──────────────────────────────────────────┘
The key vocabulary:
Port — an interface (a Python Protocol or abstract class). Ports are defined inside the application core and represent the boundary between the core and the world.
Adapter — a concrete implementation of a port. Adapters live outside the application core and connect it to real technology (a database, an HTTP framework, a message queue).
Primary (Driving) Ports — the application's API to the outside world. The outside world calls these. Examples: TodoService.create_todo(), UserService.login().
Secondary (Driven) Ports — the application's requirements from the outside world. The application calls these. Examples: TodoRepository.save(), EmailSender.send_welcome().
The Hexagonal Project Structure¶
my_app/
│
├── core/ # The hexagon itself
│ ├── domain/
│ │ ├── todo.py # Entity
│ │ ├── user.py # Entity
│ │ └── events.py # Domain Events
│ │
│ ├── ports/
│ │ ├── primary/ # Driving ports (interfaces the adapters call INTO)
│ │ │ ├── todo_service_port.py
│ │ │ └── user_service_port.py
│ │ └── secondary/ # Driven ports (interfaces the core calls OUT TO)
│ │ ├── todo_repository_port.py
│ │ ├── user_repository_port.py
│ │ └── email_sender_port.py
│ │
│ └── services/ # Application logic (implements primary ports)
│ ├── todo_service.py
│ └── user_service.py
│
└── adapters/
├── primary/ # Driving adapters (call into the core)
│ ├── http/
│ │ ├── todo_router.py # FastAPI routes
│ │ └── user_router.py
│ └── cli/
│ └── seed_data.py
└── secondary/ # Driven adapters (called by the core)
├── persistence/
│ ├── sql_todo_repository.py # implements TodoRepositoryPort
│ └── sql_user_repository.py # implements UserRepositoryPort
└── notifications/
└── sendgrid_email_sender.py # implements EmailSenderPort
Part 4: Side-by-Side Comparison¶
| Dimension | Clean Architecture | Hexagonal Architecture |
|---|---|---|
| Metaphor | Concentric circles (layers) | Hexagon (inside vs outside) |
| Origin | Robert C. Martin, 2017 | Alistair Cockburn, 2005 |
| Primary concept | Dependency Rule across rings | Ports (interfaces) and Adapters (implementations) |
| Vocabulary | Domain, Application, Infrastructure, Presentation | Core, Primary Ports, Secondary Ports, Adapters |
| Layer count | 4 named layers | 2 zones: inside (core) and outside (adapters) |
| Use case location | Application ring | Core services |
| Interface location | Application ring (abstractions) | Core ports (primary/secondary split) |
| Test strategy | Test each ring in isolation | Test core with in-memory adapters |
| DDD compatibility | Excellent (Domain ring = DDD aggregate) | Excellent (Core domain = DDD aggregate) |
| Best for | Complex domains with many use cases | Systems with many integration points |
The honest truth: they are solving the same problem with different emphasis. Clean Architecture puts more structure on the inside (4 rings). Hexagonal Architecture puts more structure on the boundary (primary vs secondary ports). Many mature codebases use both vocabularies together.
Part 5: Complete Code — Clean Architecture in Python¶
Let's build a minimal working Todo system in Clean Architecture.
Domain Layer — Pure Business Logic¶
# domain/todos/todo_item.py
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
@dataclass
class TodoItem:
title: str
priority: Priority
id: UUID = field(default_factory=uuid4)
completed_at: Optional[datetime] = None
created_at: datetime = field(default_factory=datetime.utcnow)
# Business rule lives HERE, not in a service
def complete(self) -> None:
if self.completed_at is not None:
raise ValueError("Todo is already completed.")
self.completed_at = datetime.utcnow()
@property
def is_completed(self) -> bool:
return self.completed_at is not None
# domain/todos/todo_errors.py
class TodoNotFoundError(Exception):
def __init__(self, todo_id):
super().__init__(f"Todo {todo_id} not found.")
class TodoAlreadyCompletedError(Exception):
pass
Application Layer — Use Cases + Interfaces¶
# application/abstractions/repositories.py
from abc import ABC, abstractmethod
from typing import Optional
from uuid import UUID
from domain.todos.todo_item import TodoItem
class TodoRepository(ABC):
"""Abstract interface — defined here, implemented in Infrastructure."""
@abstractmethod
def get_by_id(self, todo_id: UUID) -> Optional[TodoItem]: ...
@abstractmethod
def save(self, todo: TodoItem) -> None: ...
@abstractmethod
def delete(self, todo_id: UUID) -> None: ...
# application/todos/create_todo.py
from dataclasses import dataclass
from uuid import UUID
from domain.todos.todo_item import TodoItem, Priority
from application.abstractions.repositories import TodoRepository
@dataclass
class CreateTodoCommand:
title: str
priority: Priority
@dataclass
class CreateTodoResult:
todo_id: UUID
class CreateTodoHandler:
def __init__(self, repository: TodoRepository) -> None:
self._repository = repository
def handle(self, command: CreateTodoCommand) -> CreateTodoResult:
todo = TodoItem(title=command.title, priority=command.priority)
self._repository.save(todo)
return CreateTodoResult(todo_id=todo.id)
# application/todos/complete_todo.py
from dataclasses import dataclass
from uuid import UUID
from application.abstractions.repositories import TodoRepository
from domain.todos.todo_errors import TodoNotFoundError
@dataclass
class CompleteTodoCommand:
todo_id: UUID
class CompleteTodoHandler:
def __init__(self, repository: TodoRepository) -> None:
self._repository = repository
def handle(self, command: CompleteTodoCommand) -> None:
todo = self._repository.get_by_id(command.todo_id)
if todo is None:
raise TodoNotFoundError(command.todo_id)
todo.complete() # business rule enforced by the entity
self._repository.save(todo)
Infrastructure Layer — Concrete Implementations¶
# infrastructure/database/sql_todo_repository.py
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from application.abstractions.repositories import TodoRepository
from domain.todos.todo_item import TodoItem, Priority
from infrastructure.database.models import TodoModel # ORM model
class SqlTodoRepository(TodoRepository):
"""Concrete implementation — depends on SQLAlchemy. Application layer never imports this."""
def __init__(self, session: Session) -> None:
self._session = session
def get_by_id(self, todo_id: UUID) -> Optional[TodoItem]:
model = self._session.get(TodoModel, str(todo_id))
if model is None:
return None
return TodoItem(
id=UUID(model.id),
title=model.title,
priority=Priority(model.priority),
completed_at=model.completed_at,
created_at=model.created_at,
)
def save(self, todo: TodoItem) -> None:
model = self._session.get(TodoModel, str(todo.id))
if model is None:
model = TodoModel(id=str(todo.id))
self._session.add(model)
model.title = todo.title
model.priority = todo.priority.value
model.completed_at = todo.completed_at
self._session.flush()
def delete(self, todo_id: UUID) -> None:
model = self._session.get(TodoModel, str(todo_id))
if model:
self._session.delete(model)
Presentation Layer — FastAPI Endpoints¶
# presentation/api/endpoints/todos.py
from fastapi import APIRouter, Depends, HTTPException, status
from uuid import UUID
from pydantic import BaseModel
from application.todos.create_todo import CreateTodoCommand, CreateTodoHandler
from application.todos.complete_todo import CompleteTodoCommand, CompleteTodoHandler
from domain.todos.todo_item import Priority
from domain.todos.todo_errors import TodoNotFoundError
router = APIRouter(prefix="/todos", tags=["Todos"])
class CreateTodoRequest(BaseModel):
title: str
priority: Priority = Priority.MEDIUM
class CreateTodoResponse(BaseModel):
todo_id: UUID
@router.post("/", response_model=CreateTodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(
request: CreateTodoRequest,
handler: CreateTodoHandler = Depends(), # injected by DI container
):
command = CreateTodoCommand(title=request.title, priority=request.priority)
result = handler.handle(command)
return CreateTodoResponse(todo_id=result.todo_id)
@router.patch("/{todo_id}/complete", status_code=status.HTTP_204_NO_CONTENT)
def complete_todo(
todo_id: UUID,
handler: CompleteTodoHandler = Depends(),
):
try:
handler.handle(CompleteTodoCommand(todo_id=todo_id))
except TodoNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
Dependency Injection — Wiring It All Together¶
# presentation/dependency_injection.py
from functools import lru_cache
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from fastapi import Depends
from application.todos.create_todo import CreateTodoHandler
from application.todos.complete_todo import CompleteTodoHandler
from infrastructure.database.sql_todo_repository import SqlTodoRepository
engine = create_engine("postgresql://user:pass@localhost/myapp")
SessionLocal = sessionmaker(bind=engine)
def get_db() -> Session:
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_todo_repository(db: Session = Depends(get_db)) -> SqlTodoRepository:
return SqlTodoRepository(db)
def get_create_todo_handler(
repo: SqlTodoRepository = Depends(get_todo_repository),
) -> CreateTodoHandler:
return CreateTodoHandler(repository=repo)
def get_complete_todo_handler(
repo: SqlTodoRepository = Depends(get_todo_repository),
) -> CompleteTodoHandler:
return CompleteTodoHandler(repository=repo)
Part 6: Complete Code — Hexagonal Architecture in Python¶
The same Todo system, now in Hexagonal style. The logic is identical — the structure is different.
Core: Domain + Ports¶
# core/ports/secondary/todo_repository_port.py
from typing import Protocol, Optional
from uuid import UUID
from core.domain.todo import TodoItem
class TodoRepositoryPort(Protocol):
"""Secondary port — the core's requirement from storage. Any adapter can implement this."""
def get_by_id(self, todo_id: UUID) -> Optional[TodoItem]: ...
def save(self, todo: TodoItem) -> None: ...
def delete(self, todo_id: UUID) -> None: ...
# core/ports/primary/todo_service_port.py
from typing import Protocol
from uuid import UUID
from core.domain.todo import Priority
class TodoServicePort(Protocol):
"""Primary port — what the outside world can call on this application."""
def create_todo(self, title: str, priority: Priority) -> UUID: ...
def complete_todo(self, todo_id: UUID) -> None: ...
def delete_todo(self, todo_id: UUID) -> None: ...
# core/services/todo_service.py
from uuid import UUID
from core.domain.todo import TodoItem, Priority
from core.ports.secondary.todo_repository_port import TodoRepositoryPort
class TodoService:
"""Implements the primary port. Depends only on secondary ports."""
def __init__(self, repository: TodoRepositoryPort) -> None:
self._repository = repository
def create_todo(self, title: str, priority: Priority) -> UUID:
todo = TodoItem(title=title, priority=priority)
self._repository.save(todo)
return todo.id
def complete_todo(self, todo_id: UUID) -> None:
todo = self._repository.get_by_id(todo_id)
if todo is None:
raise ValueError(f"Todo {todo_id} not found.")
todo.complete()
self._repository.save(todo)
Adapters: Primary (HTTP) and Secondary (Database)¶
# adapters/primary/http/todo_router.py
from fastapi import APIRouter, HTTPException
from uuid import UUID
from pydantic import BaseModel
from core.domain.todo import Priority
from core.ports.primary.todo_service_port import TodoServicePort
router = APIRouter(prefix="/todos", tags=["Todos"])
class CreateTodoRequest(BaseModel):
title: str
priority: Priority = Priority.MEDIUM
# The adapter calls INTO the core through the primary port
def make_todo_router(service: TodoServicePort) -> APIRouter:
@router.post("/")
def create(req: CreateTodoRequest):
todo_id = service.create_todo(req.title, req.priority)
return {"todo_id": str(todo_id)}
@router.patch("/{todo_id}/complete")
def complete(todo_id: UUID):
try:
service.complete_todo(todo_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return router
# adapters/secondary/persistence/sql_todo_repository.py
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from core.domain.todo import TodoItem, Priority
from core.ports.secondary.todo_repository_port import TodoRepositoryPort
from adapters.secondary.persistence.models import TodoModel
class SqlTodoRepository:
"""Secondary adapter — implements the core's TodoRepositoryPort."""
def __init__(self, session: Session) -> None:
self._session = session
def get_by_id(self, todo_id: UUID) -> Optional[TodoItem]:
model = self._session.get(TodoModel, str(todo_id))
if model is None:
return None
return TodoItem(id=UUID(model.id), title=model.title,
priority=Priority(model.priority))
def save(self, todo: TodoItem) -> None:
model = self._session.merge(TodoModel(
id=str(todo.id), title=todo.title,
priority=todo.priority.value, completed_at=todo.completed_at,
))
self._session.flush()
def delete(self, todo_id: UUID) -> None:
model = self._session.get(TodoModel, str(todo_id))
if model:
self._session.delete(model)
In-Memory Adapter — The Testing Superpower¶
This is the biggest practical win of both architectures:
# tests/fakes/in_memory_todo_repository.py
from typing import Optional
from uuid import UUID
from core.domain.todo import TodoItem
class InMemoryTodoRepository:
"""A test double that satisfies TodoRepositoryPort with no database needed."""
def __init__(self):
self._store: dict[UUID, TodoItem] = {}
def get_by_id(self, todo_id: UUID) -> Optional[TodoItem]:
return self._store.get(todo_id)
def save(self, todo: TodoItem) -> None:
self._store[todo.id] = todo
def delete(self, todo_id: UUID) -> None:
self._store.pop(todo_id, None)
# test_todo_service.py
def test_completing_a_todo_marks_it_as_done():
repo = InMemoryTodoRepository()
service = TodoService(repository=repo)
todo_id = service.create_todo("Write unit tests", Priority.HIGH)
service.complete_todo(todo_id)
todo = repo.get_by_id(todo_id)
assert todo.is_completed is True
# No database. No FastAPI. No HTTP. Runs in milliseconds.
Part 7: What Most People Get Wrong¶
The diagram's subtitle — "And what most people get wrong" — deserves its own section. These mistakes are universal.
Mistake 1: Importing Infrastructure from Application¶
# WRONG — Application use case importing SQLAlchemy directly
from sqlalchemy.orm import Session # ← Infrastructure bleeds into Application
class CreateTodoHandler:
def __init__(self, session: Session): # ← Application now depends on SQLAlchemy
self._session = session
Fix: The Application layer defines an abstract TodoRepository. The Session lives only in the Infrastructure layer.
Mistake 2: Anemic Domain — Logic in Services, Not Entities¶
# WRONG — business rule in the Application layer
class CompleteTodoHandler:
def handle(self, command):
todo = self._repository.get_by_id(command.todo_id)
todo.completed_at = datetime.utcnow() # ← sets field directly, no validation
self._repository.save(todo)
# RIGHT — business rule in the Domain entity
class TodoItem:
def complete(self) -> None:
if self.completed_at is not None:
raise TodoAlreadyCompletedError() # ← invariant enforced here
self.completed_at = datetime.utcnow()
The entity knows its own rules. A service that bypasses the entity's methods violates the domain model.
Mistake 3: One Giant Repository¶
# WRONG
class TodoRepository:
def get_by_id(self): ...
def get_all(self): ...
def get_by_user_id(self): ...
def get_overdue(self): ...
def get_completed_between(self): ...
def get_priority_stats(self): ... # now this is a reporting query, not a repository
Fix: Split read-heavy queries into a separate Query Service or use the CQRS pattern — Commands (write) through the Domain, Queries (read) go straight to the database with raw SQL or a query builder. Forcing every query through domain entities is expensive and unnecessary.
Mistake 4: Skipping the Dependency Injection Container¶
Both architectures require that the concrete implementations get injected from outside. Without a DI container, you either end up with a massive main.py wiring function, or — worse — you start importing concrete classes from inside use cases.
Use FastAPI's Depends, Python's dependency-injector library, or a simple factory pattern. The point is: no use case or domain object should ever call SqlTodoRepository() directly.
Mistake 5: Using Clean Architecture for a Simple CRUD App¶
Clean Architecture overhead is real:
Simple CRUD endpoint: 3 files in Flask
Same endpoint in Clean Architecture: 7+ files across 4 layers
If your app is: simple CRUD, <5 entities, single developer
Then: Flask/FastAPI with a thin service layer is perfectly correct
If your app is: complex domain, multiple teams, long-lived codebase
Then: Clean Architecture pays for itself quickly
Part 8: Choosing Between Them¶
"I want a clear, ring-based structure with a strong convention for
every type of object — and I'm comfortable with 4 named layers."
→ Clean Architecture
"I want to maximize testability and focus on the boundary between
my application and external systems — I have many integrations."
→ Hexagonal Architecture (Ports & Adapters)
"I want both — a domain-rich core AND explicit port/adapter naming
for every integration point."
→ Use them together. Most production systems do.
→ The rings come from Clean; the port/adapter naming at the
boundary comes from Hexagonal.
A mature team often uses Clean Architecture's folder structure (domain / application / infrastructure / presentation) and names their interfaces "ports" and their implementations "adapters" — getting the benefits of both vocabularies.
Summary¶
Both Clean Architecture and Hexagonal Architecture are implementations of a single truth: your business logic must never depend on infrastructure. Dependencies point inward. Interfaces are defined by the application; concrete implementations live outside.
Clean Architecture gives you four named concentric rings — Domain, Application, Infrastructure, Presentation — with an explicit rule: outer rings depend on inner, never the reverse. It excels when you have a rich domain with complex business rules and many use cases to organize.
Hexagonal Architecture (Ports and Adapters) gives you two zones — the application core and the outside world — connected by interfaces called ports and implementations called adapters. It excels when you have many integration points and want to maximize the testability of the core in total isolation.
The five mistakes that kill most implementations: importing infrastructure into the application layer, putting business logic in services instead of entities, building one monolithic repository, skipping dependency injection, and applying either pattern to a simple CRUD app where it adds cost without benefit.
The practical starting point for any new project: use Clean Architecture's four-ring folder structure, name your interfaces in the Application layer explicitly, and write in-memory adapter fakes for every secondary port on day one. Your test suite will thank you for years.
Want a full working GitHub repository with this exact structure, Docker Compose, Alembic migrations, and pytest setup? Drop a comment below.
Questions or discussion? Connect on LinkedIn, X or reach out via email.
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.