A Polylith-inspired, Clean Architecture–influenced approach for structured Python systems.
PolyClean is a structured architectural approach built on two ideas:
- Polylith for modular workspace structure
- Clean Architecture for inward-facing dependency rules
This document is:
- A teaching guide
- A concrete worked example
- A proposal for team discussion
It is intentionally opinionated, but not yet a rigid standard.
As systems grow, two problems tend to appear:
- Business logic becomes tangled with infrastructure.
- Dependencies become unclear and fragile.
PolyClean addresses this by introducing:
- Clear directional dependencies
- Explicit layer responsibilities
- Strict separation between business logic and technical implementation
The goals are:
- Predictable structure
- Replaceable infrastructure
- Testable business logic
- Clear onboarding for new developers
- Clear reasoning boundaries for both humans and AI systems
Modern projects are no longer read only by humans.
They are increasingly read, modified, and reasoned about by:
- AI coding assistants
- Static analysis tools
- Automated refactoring systems
As systems grow, cognitive load becomes the main constraint.
PolyClean intentionally structures projects so they can be understood in digestible chunks.
Each brick represents a single, clear responsibility:
- A contract defines concepts and rules.
- A flow defines a single business process.
- An adapter defines one technical integration.
- A base defines one delivery entry point.
This allows a reader (human or AI) to:
- Open a single flow brick.
- Follow its imports to the contracts it depends on.
- Understand the full behaviour of that use case without reading unrelated infrastructure.
In PolyClean, the execution path of a request is explicit:
Base -> Flow -> Contract
If persistence or integration is required:
Flow -> Contract -> Adapter (implementation chosen by Base)
This means that to understand a feature, you typically only need to read:
- The relevant base brick
- The specific flow brick
- The contracts referenced by that flow
You do not need to read:
- Other flows
- Other adapters
- Unrelated parts of the system
As more features are added:
- New flows are added as separate bricks.
- New adapters are added without modifying existing flows.
- Contracts evolve in controlled, explicit ways.
This prevents the "everything is connected to everything" problem.
Each feature remains locally understandable.
This locality of reasoning is what makes the system:
- Maintainable for humans
- Navigable for new team members
- Safer for AI-assisted modification
PolyClean is therefore not only about architectural purity —
It is about preserving comprehensibility at scale.
PolyClean organises code into four layers:
- Contract
- Flow
- Adapter
- Base
Each layer has a single responsibility.
Dependencies always point inward:
Base -> Flow -> Contract
Adapter -> Contract
Contracts sit at the centre.
%%{init: {'theme': 'dark', "flowchart" : { "curve" : "basis" } } }%%
graph TD
style C fill:#446cc2,stroke:#0d4da1,stroke-width:3px,color:#000000
style F fill:#d5d964,stroke:#e1e817,stroke-width:2px,color:#000000
style A fill:#58b853,stroke:#1B5E20,stroke-width:2px,color:#000000
style B fill:#AF7062,stroke:#BF360C,stroke-width:2px,color:#000000
C(📝 Contract Layer<br/>posts_contract<br/>instagram_contract)
F(⚙️ Flow Layer<br/>create_post_flow<br/>publish_post_flow)
A(🔌 Adapter Layer<br/>sqlite_post_adapter<br/>instagram_publish_adapter)
B(🚀 Base Layer<br/>publishing_api)
E1[(🗄️ SQLite)]
E2[(📱 Instagram API)]
C -->|depended by| F
C -->|depended by| A
F -->|depended by| B
A -->|depended by| B
A -.->|uses| E1
A -.->|calls| E2
classDef x fill:#f0cd92,stroke:#cc8316,stroke-width:2px,stroke-dasharray: 5 5,color:#000000
class E1,E2 x;
| Layer | Purpose | Contains | May Depend On |
|---|---|---|---|
| Contract | Define what exists and what services do | Entities, Protocols, Invariants | Python stdlib only |
| Flow | Orchestrate business processes | Workflows, decision logic | Contracts only |
| Adapter | Implement technical details | DB, HTTP, external integrations | Contracts + external libs |
| Base | Composition root | Wiring, DI, framework setup | Everything |
We will use a simple example throughout:
- Create a post
- Store it
- Publish it to Instagram
This allows us to clearly see where each piece of code belongs.
PolyClean assumes a standard Python Polylith workspace.
Top-level folders:
bases/- Application entry points (APIs, CLIs)components/- Reusable building blocks (business logic, contracts, adapters)projects/- Deployable units (service packaging)development/- Scratchpad code for exploration
The projects/ folder contains deployable units. Each subdirectory is a separate Python package with its own pyproject.toml that defines:
- Service-specific dependencies
- Which components and bases to include
- Build and packaging configuration
Projects answer: "What gets deployed?"
Components and bases answer: "What code makes up the service?"
This separation allows:
- Multiple services from shared code (e.g., REST API + background worker)
- Independent deployment and versioning per service
- Service-specific dependency lists
Example workspace.toml:
[tool.polylith]
namespace = "polyclean"
[tool.polylith.structure]
theme = "loose".
├── workspace.toml
├── pyproject.toml
├── bases/
│ └── polyclean/
│ └── publishing_api/
│ ├── __init__.py
│ └── main.py
├── components/
│ └── polyclean/
│ ├── posts_contract/
│ ├── instagram_contract/
│ ├── create_post_flow/
│ ├── publish_post_flow/
│ ├── rest_adapter_lib/
│ ├── sqlite_post_adapter/
│ └── instagram_publish_adapter/
├── projects/
│ └── publishing_service/ # Deployable service
└── development/
└── scratch.py
With namespace = "polyclean", imports look like:
from polyclean.posts_contract import Postfrom polyclean.publish_post_flow import PublishPostFlow
Contracts define:
- What things are (entities)
- What services do (ports)
- Business invariants
Contracts contain no infrastructure code.
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Post:
id: Optional[int]
content: str
image_url: str
created_at: datetime
instagram_post_id: Optional[str]
posted: bool = False
def mark_as_posted(self, instagram_id: str) -> None:
self.posted = True
self.instagram_post_id = instagram_idfrom typing import Protocol, Optional, List
from .entities import Post
class PostStoragePort(Protocol):
async def save(self, post: Post) -> Post: ...
async def get_by_id(self, post_id: int) -> Optional[Post]: ...
async def get_unposted(self) -> List[Post]: ...
async def update(self, post: Post) -> Post: ...
class InstagramPort(Protocol):
async def publish_post(self, image_url: str, caption: str) -> str: ...
async def validate_connection(self) -> bool: ...Ports describe behaviour without specifying implementation.
They define what the system requires — not how it is implemented.
- SQL queries
- HTTP requests
- Framework code
- Workflow orchestration
- Implementation details
Flows orchestrate business processes.
They:
- Coordinate entities
- Use ports
- Contain decision logic
- Do not implement infrastructure
from polyclean.posts_contract import PostStoragePort
from polyclean.instagram_contract import InstagramPort
class PublishPostFlow:
def __init__(self, storage: PostStoragePort, instagram: InstagramPort):
self._storage = storage
self._instagram = instagram
async def flow(self, post_id: int) -> dict:
post = await self._storage.get_by_id(post_id)
if not post:
return {"success": False, "message": "Post not found"}
if post.posted:
return {"success": False, "message": "Already published"}
if not await self._instagram.validate_connection():
return {"success": False, "message": "Instagram unavailable"}
instagram_id = await self._instagram.publish_post(
image_url=post.image_url,
caption=post.content
)
post.mark_as_posted(instagram_id)
await self._storage.update(post)
return {"success": True, "instagram_post_id": instagram_id}- SQL queries
- API client implementations
- Framework route decorators
- Imports from adapters
- Imports from other flows
Adapters implement the ports defined in contracts.
They contain:
- SQL
- HTTP calls
- External libraries
- Infrastructure details
import aiosqlite
from polyclean.posts_contract import Post, PostStoragePort
class SQLitePostAdapter(PostStoragePort):
async def save(self, post: Post) -> Post:
...import httpx
from polyclean.instagram_contract import InstagramPort
class InstagramGraphAdapter(InstagramPort):
async def publish_post(self, image_url: str, caption: str) -> str:
...- Business orchestration logic
- Flow imports
- Other adapter imports
The composition root.
The base layer wires everything together.
from polyclean.create_post_flow import CreatePostFlow
from polyclean.publish_post_flow import PublishPostFlow
from polyclean.sqlite_post_adapter import SQLitePostAdapter
from polyclean.instagram_publish_adapter import InstagramGraphAdapter
storage = SQLitePostAdapter()
instagram = InstagramGraphAdapter(...)
create_flow = CreatePostFlow(storage)
publish_flow = PublishPostFlow(storage, instagram)The base layer:
- Knows about flows
- Knows about adapters
- Knows about the framework
- Contains no business logic
Allowed:
- Flow -> Contract
- Adapter -> Contract
- Base -> Flow + Adapter
Forbidden:
- Contract -> anything else
- Flow -> Adapter
- Adapter -> Flow
- Adapter -> Adapter
- Flow -> Flow
All arrows point inward toward contracts.
Layer libraries are reusable utilities shared within a single layer.
They exist to prevent duplication without breaking architectural boundaries.
When multiple adapters need similar infrastructure code — HTTP clients, message queue helpers, retry logic — the instinct is to create a shared adapter. But adapters cannot depend on other adapters.
Layer libraries solve this by providing a home for shared utilities that:
- Do not belong in contracts (too implementation-specific)
- Would cause duplication if copied into each adapter/flow
- Must remain accessible to all bricks in the same layer
Layer libraries follow the same naming pattern as other bricks, with a _lib suffix:
*_contract_lib— shared by contracts*_flow_lib— shared by flows*_adapter_lib— shared by adapters
Examples:
rest_adapter_libworkflow_flow_libvalidation_contract_lib
A layer library may only be imported by bricks in the same layer (or by base, the composition root).
| Layer | May Import | May NOT Import |
|---|---|---|
| Contract | *_contract_lib |
*_flow_lib, *_adapter_lib |
| Flow | *_flow_lib |
*_adapter_lib |
| Adapter | *_adapter_lib |
*_flow_lib |
| Base | all _lib types |
nothing |
A layer library should be a pure, layer-agnostic utility. It exists precisely for code that is "too implementation-specific" to belong in the layer proper. Therefore, libs should NOT depend on layer types:
| Brick type | May import from |
|---|---|
*_contract_lib |
(nothing from polyclean — pure utilities) |
*_flow_lib |
(nothing from polyclean — pure utilities) |
*_adapter_lib |
(nothing from polyclean — pure utilities) |
If a lib needs to depend on layer types, that code likely belongs in the layer itself, not in the lib.
Same-layer lib dependencies are allowed (e.g., lib_a can import lib_b if both are *_adapter_lib). Python's import cycle detection catches any problematic cycles at runtime.
rest_adapter_lib provides a shared requests.Session with retry logic pre-configured. Any adapter that needs to make HTTP calls imports from it instead of building its own session.
# components/polyclean/rest_adapter_lib/session.py
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def build_session(
retries: int = 3,
backoff_factor: float = 0.5,
status_forcelist: tuple[int, ...] = (429, 500, 502, 503, 504),
) -> requests.Session:
"""Return a requests.Session pre-configured with retry logic."""
retry_policy = Retry(
total=retries,
backoff_factor=backoff_factor,
status_forcelist=status_forcelist,
allowed_methods={"GET", "POST"},
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry_policy)
session = requests.Session()
session.mount("https://", adapter)
session.mount("http://", adapter)
return sessionAn adapter using the library:
# components/polyclean/instagram_publish_adapter/adapter.py
from polyclean.instagram_contract import InstagramPort
from polyclean.rest_adapter_lib import build_session
class InstagramGraphAdapter(InstagramPort):
def __init__(self, access_token: str, business_account_id: str):
self._access_token = access_token
self._business_account_id = business_account_id
self._base_url = "https://graph.facebook.com/v18.0"
self._session = build_session(retries=3)
async def publish_post(self, image_url: str, caption: str) -> str:
create_response = self._session.post(
f"{self._base_url}/{self._business_account_id}/media",
params={"image_url": image_url, "caption": caption,
"access_token": self._access_token},
timeout=30,
)
create_response.raise_for_status()
media_id = create_response.json()["id"]
publish_response = self._session.post(
f"{self._base_url}/{self._business_account_id}/media_publish",
params={"creation_id": media_id, "access_token": self._access_token},
timeout=30,
)
publish_response.raise_for_status()
return publish_response.json()["id"]Layer libraries solve a specific anti-pattern: two adapters sharing code by having one import the other.
Without layer libraries:
- Adapter A creates a helper
- Adapter B needs the helper
- Adapter B imports from Adapter A
- This creates a forbidden adapter-to-adapter dependency
With layer libraries:
- Helper goes in
*_adapter_lib - Both Adapter A and Adapter B import from the library
- No forbidden dependency is created
The base layer may import from any library, as it is the composition root.
Architecture is only useful if it is enforced.
PolyClean is designed so that layer rules can be validated automatically.
The dependency rules should not rely on developer discipline alone.
We want tooling to guarantee:
- Contracts do not import from flows, adapters, or bases.
- Flows only import from contracts.
- Adapters only import from contracts (plus external libraries).
- Bases are the only bricks allowed to import flows and adapters together.
If these rules are violated, the build should fail.
Python Polylith already understands brick boundaries.
By running dependency checks across bricks, you can:
- Detect illegal cross-brick imports.
- Visualise dependency graphs.
- Ensure bases only depend on allowed components.
This provides structural guarantees at the workspace level.
In addition to Polylith checks, static analysis tools can reinforce boundaries:
rufforflake8for import rulesmypyfor type boundary validationimport-linterfor declarative dependency contracts
For example, import-linter can declare rules such as:
- "flows may only import contracts"
- "adapters may not import flows"
These rules become executable architecture.
Layer validation should run in CI.
This ensures:
- New contributors cannot accidentally break boundaries.
- AI-generated changes are automatically validated.
- Architectural drift is prevented over time.
PolyClean treats architecture as code.
If the dependency direction changes, it must be a deliberate decision — not an accident.
graph TB
subgraph "Contracts Layer"
PC[📝 posts_contract]
IC[📝 instagram_contract]
end
subgraph "Flow Layer"
CF[🌊 create_post_flow]
PF[🌊 publish_post_flow]
end
subgraph "Implementation Layer"
SA[🔌 sqlite_post_adapter]
IA[🔌 instagram_publish_adapter]
end
subgraph "Delivery Layer"
FA[🚀 publishing_api]
end
subgraph "External"
SQLITE[(🗄️ SQLite)]
IGAPI[(📱 Instagram API)]
end
CF -->|flows through| PC
PF -->|flows through| PC
PF -->|flows through| IC
SA -->|implements| PC
IA -->|implements| IC
FA -->|triggers| CF
FA -->|triggers| PF
FA -->|uses| SA
FA -->|uses| IA
SA -.->|stores in| SQLITE
IA -.->|calls| IGAPI
| Layer | What to Test | Strategy |
|---|---|---|
| Contract | Entity behaviour & invariants | Pure unit tests |
| Flow | Workflow orchestration | Mock ports |
| Adapter | Technical implementation | Integration tests |
| Base | API wiring | End-to-end tests |
- Contracts define what exists.
- Flows define how behaviour happens.
- Adapters implement technical details.
- Base wires everything together.
Dependencies always point inward.