Skip to content
Open
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
31 changes: 31 additions & 0 deletions ai-assisted-coding-architecture/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Orders service: instructions for coding agents

## Layout

- `orders/domain/`: pure business rules. No I/O, no framework imports.
- `orders/services/`: use cases. Depend on `domain` and on the `Protocol`
classes in `services/ports.py`, never on `adapters`.
- `orders/adapters/`: databases and network. May import `services` and
`domain`.

## Invariants

1. Imports point inward only: adapters -> services -> domain.
2. Domain dataclasses are frozen.
3. Absolute imports only (`from orders.domain.models import Order`).
4. Use `datetime.now(UTC)`, never `datetime.utcnow()`.
5. Use `pathlib.Path`, not `os.path`.
6. Before writing a helper, search `orders/` for an existing one.

## Checks that must pass before you finish

Run each of these from the project root:

- `lint-imports`
- `ruff check . && ruff format --check .`
- `pytest`
- `pylint --disable=all --enable=duplicate-code orders`
- `vulture`

Decisions live in `docs/adr/`. Read the accepted ADRs before changing a
boundary, and propose a new ADR instead of editing an accepted one.
1 change: 1 addition & 0 deletions ai-assisted-coding-architecture/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
34 changes: 34 additions & 0 deletions ai-assisted-coding-architecture/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# AI-Assisted Coding Architecture: Keep Python Code Coherent

Sample code for the Real Python article
[AI-Assisted Coding Architecture: Keep Python Code Coherent](https://realpython.com/ai-assisted-coding-architecture/).

A small layered `orders` package, with the guardrails that keep its
architecture intact: an `AGENTS.md` file, an import-linter contract, Ruff's
`banned-api` rule, an AST-based architecture test, duplicate-code detection
with Pylint, dead-code detection with Vulture, and an architecture decision
record.

## Setup

Python 3.11 or later is required.

```console
$ python -m venv venv
$ source venv/bin/activate
(venv) $ python -m pip install -r requirements.txt
```

## Run the checks

```console
(venv) $ lint-imports
(venv) $ ruff check . && ruff format --check .
(venv) $ pytest
(venv) $ pylint --disable=all --enable=duplicate-code orders
(venv) $ vulture
```

## License

Distributed under the MIT license.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 1. Use a layered architecture with enforced import direction

Date: 2026-10-26

## Status

Accepted

## Context

Contributors, human and automated, kept importing database code from the
domain layer. Reviews caught it late, and each fix touched several modules.

## Decision

Code lives in three layers: `orders.adapters`, `orders.services`, and
`orders.domain`. A layer may import only from layers below it. The rule is
enforced by an import-linter `layers` contract that runs in CI.

## Consequences

- Domain code can be tested without a database.
- New integrations go in `adapters`, so they can't leak into business rules.
- A cross-layer shortcut now fails CI instead of waiting for review.
Empty file.
Empty file.
35 changes: 35 additions & 0 deletions ai-assisted-coding-architecture/orders/adapters/sqlite_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import json
import sqlite3
from decimal import Decimal

from orders.domain.models import Order, OrderLine


class SqliteOrderRepository:
def __init__(self, connection: sqlite3.Connection) -> None:
self._db = connection
self._db.execute(
"CREATE TABLE IF NOT EXISTS orders "
"(id TEXT PRIMARY KEY, body TEXT)"
)

def save(self, order: Order) -> None:
body = json.dumps(
[[ln.sku, ln.quantity, str(ln.unit_price)] for ln in order.lines]
)
self._db.execute(
"INSERT OR REPLACE INTO orders VALUES (?, ?)",
(order.order_id, body),
)

def get(self, order_id: str) -> Order | None:
row = self._db.execute(
"SELECT body FROM orders WHERE id = ?", (order_id,)
).fetchone()
if row is None:
return None
lines = tuple(
OrderLine(sku, qty, Decimal(price))
for sku, qty, price in json.loads(row[0])
)
return Order(order_id, lines)
Empty file.
23 changes: 23 additions & 0 deletions ai-assisted-coding-architecture/orders/domain/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class OrderLine:
sku: str
quantity: int
unit_price: Decimal

@property
def total(self) -> Decimal:
return self.unit_price * self.quantity


@dataclass(frozen=True)
class Order:
order_id: str
lines: tuple[OrderLine, ...]

@property
def total(self) -> Decimal:
return sum((line.total for line in self.lines), Decimal(0))
12 changes: 12 additions & 0 deletions ai-assisted-coding-architecture/orders/domain/pricing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from decimal import Decimal

from orders.domain.models import Order

BULK_THRESHOLD = Decimal(100)
BULK_DISCOUNT = Decimal("0.10")


def discounted_total(order: Order) -> Decimal:
if order.total >= BULK_THRESHOLD:
return order.total * (1 - BULK_DISCOUNT)
return order.total
Empty file.
12 changes: 12 additions & 0 deletions ai-assisted-coding-architecture/orders/services/checkout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from datetime import UTC, datetime

from orders.domain.models import Order
from orders.domain.pricing import discounted_total
from orders.services.ports import OrderRepository


def place_order(order: Order, repo: OrderRepository) -> str:
"""Save the order and return a receipt line."""
repo.save(order)
placed_at = datetime.now(UTC).isoformat(timespec="seconds")
return f"{order.order_id}: {discounted_total(order)} at {placed_at}"
9 changes: 9 additions & 0 deletions ai-assisted-coding-architecture/orders/services/ports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Protocol

from orders.domain.models import Order


class OrderRepository(Protocol):
def save(self, order: Order) -> None: ...

def get(self, order_id: str) -> Order | None: ...
49 changes: 49 additions & 0 deletions ai-assisted-coding-architecture/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[tool.ruff]
line-length = 79
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "PTH", "UP", "TID"]

[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"

[tool.ruff.lint.flake8-tidy-imports.banned-api]
"datetime.datetime.utcnow".msg = "Deprecated and naive. Use datetime.now(UTC)."
"datetime.datetime.utcfromtimestamp".msg = "Deprecated and naive. Use datetime.fromtimestamp(ts, timezone.utc)."
"random.seed".msg = "Inject a random.Random instance instead of seeding global state."

[tool.importlinter]
root_package = "orders"
include_external_packages = true

[[tool.importlinter.contracts]]
name = "Layers: adapters -> services -> domain"
type = "layers"
layers = ["orders.adapters", "orders.services", "orders.domain"]
broken_contract_guidance = """
Imports must point down the stack. Pass data into domain code instead of
fetching it there, and make services depend on a Protocol in
orders/services/ports.py, not on an adapter. Do not add an ignore_imports
entry without a new ADR.
See docs/adr/0001-layered-architecture.md.
"""

[[tool.importlinter.contracts]]
name = "Domain stays free of I/O libraries"
type = "forbidden"
source_modules = ["orders.domain"]
forbidden_modules = ["sqlite3", "json", "requests", "httpx"]
allow_indirect_imports = true
broken_contract_guidance = """
Domain code is pure business logic. Move the I/O into an adapter, and have
a service pass the data in.
See docs/adr/0001-layered-architecture.md.
"""

[tool.vulture]
paths = ["orders", "tests"]
min_confidence = 60

[tool.pytest.ini_options]
pythonpath = ["."]
5 changes: 5 additions & 0 deletions ai-assisted-coding-architecture/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import-linter==2.15
pylint==4.1.3
pytest==9.1.1
ruff==0.17.2
vulture==2.16
35 changes: 35 additions & 0 deletions ai-assisted-coding-architecture/tests/test_architecture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Invariants that import-linter and Ruff can't express."""

import ast
from pathlib import Path

DOMAIN = Path(__file__).parent.parent / "orders" / "domain"


def _is_frozen_dataclass(node: ast.ClassDef) -> bool | None:
"""Return None if not a dataclass, else whether it is frozen."""
for deco in node.decorator_list:
if isinstance(deco, ast.Name) and deco.id == "dataclass":
return False
if (
isinstance(deco, ast.Call)
and getattr(deco.func, "id", None) == "dataclass"
):
return any(
kw.arg == "frozen"
and isinstance(kw.value, ast.Constant)
and kw.value.value is True
for kw in deco.keywords
)
return None


def test_domain_dataclasses_are_frozen():
mutable = []
for path in sorted(DOMAIN.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
if _is_frozen_dataclass(node) is False:
mutable.append(f"{path.name}:{node.lineno} {node.name}")
assert not mutable, f"Domain dataclasses must be frozen: {mutable}"
14 changes: 14 additions & 0 deletions ai-assisted-coding-architecture/tests/test_checkout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import sqlite3
from decimal import Decimal

from orders.adapters.sqlite_repo import SqliteOrderRepository
from orders.domain.models import Order, OrderLine
from orders.services.checkout import place_order


def test_place_order_round_trip():
repo = SqliteOrderRepository(sqlite3.connect(":memory:"))
order = Order("A1", (OrderLine("pen", 20, Decimal("6.00")),))
receipt = place_order(order, repo)
assert receipt.startswith("A1: 108.0000 at ")
assert repo.get("A1") == order
51 changes: 51 additions & 0 deletions ai-assisted-coding-architecture/tests/test_duplicates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Fail when two functions differ only in names and docstrings."""

import ast
from pathlib import Path

MIN_NODES = 40
PACKAGE = Path(__file__).parent.parent / "orders"


def normalize(func: ast.FunctionDef) -> str:
"""Dump the body with local names replaced by numbered placeholders."""
params = [
arg.arg for arg in ast.walk(func.args) if isinstance(arg, ast.arg)
]
local = set(params) | {
n.id
for n in ast.walk(func)
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)
}
placeholders = {name: number for number, name in enumerate(params)}

class Rename(ast.NodeTransformer):
def visit_Name(self, node):
if node.id in local:
number = placeholders.setdefault(node.id, len(placeholders))
return ast.Name(id=f"_{number}", ctx=node.ctx)
return node

def visit_arg(self, node):
number = placeholders.setdefault(node.arg, len(placeholders))
return ast.arg(arg=f"_{number}")

body = func.body
if ast.get_docstring(func) is not None:
body = body[1:]
module = ast.Module(body=body, type_ignores=[])
return ast.dump(Rename().visit(module))


def test_no_renamed_duplicates():
seen = {}
for path in sorted(PACKAGE.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if isinstance(node, ast.FunctionDef):
key = normalize(node)
if key.count("(") >= MIN_NODES:
seen.setdefault(key, []).append(
f"{path.name}:{node.lineno} {node.name}"
)
clones = [places for places in seen.values() if len(places) > 1]
assert not clones, f"Same logic under different names: {clones}"
Loading