@dynokostya/just-works 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/csharp-code-writer.md +32 -0
- package/.claude/agents/diagrammer.md +49 -0
- package/.claude/agents/frontend-code-writer.md +36 -0
- package/.claude/agents/prompt-writer.md +38 -0
- package/.claude/agents/python-code-writer.md +32 -0
- package/.claude/agents/swift-code-writer.md +32 -0
- package/.claude/agents/typescript-code-writer.md +32 -0
- package/.claude/commands/git-sync.md +96 -0
- package/.claude/commands/project-docs.md +287 -0
- package/.claude/settings.json +112 -0
- package/.claude/settings.json.default +15 -0
- package/.claude/skills/csharp-coding/SKILL.md +368 -0
- package/.claude/skills/ddd-architecture-python/SKILL.md +288 -0
- package/.claude/skills/feature-driven-architecture-python/SKILL.md +302 -0
- package/.claude/skills/gemini-3-prompting/SKILL.md +483 -0
- package/.claude/skills/gpt-5-2-prompting/SKILL.md +295 -0
- package/.claude/skills/opus-4-6-prompting/SKILL.md +315 -0
- package/.claude/skills/plantuml-diagramming/SKILL.md +758 -0
- package/.claude/skills/python-coding/SKILL.md +293 -0
- package/.claude/skills/react-coding/SKILL.md +264 -0
- package/.claude/skills/rest-api/SKILL.md +421 -0
- package/.claude/skills/shadcn-ui-coding/SKILL.md +454 -0
- package/.claude/skills/swift-coding/SKILL.md +401 -0
- package/.claude/skills/tailwind-css-coding/SKILL.md +268 -0
- package/.claude/skills/typescript-coding/SKILL.md +464 -0
- package/.claude/statusline-command.sh +34 -0
- package/.codex/prompts/plan-reviewer.md +162 -0
- package/.codex/prompts/project-docs.md +287 -0
- package/.codex/skills/ddd-architecture-python/SKILL.md +288 -0
- package/.codex/skills/feature-driven-architecture-python/SKILL.md +302 -0
- package/.codex/skills/gemini-3-prompting/SKILL.md +483 -0
- package/.codex/skills/gpt-5-2-prompting/SKILL.md +295 -0
- package/.codex/skills/opus-4-6-prompting/SKILL.md +315 -0
- package/.codex/skills/plantuml-diagramming/SKILL.md +758 -0
- package/.codex/skills/python-coding/SKILL.md +293 -0
- package/.codex/skills/react-coding/SKILL.md +264 -0
- package/.codex/skills/rest-api/SKILL.md +421 -0
- package/.codex/skills/shadcn-ui-coding/SKILL.md +454 -0
- package/.codex/skills/tailwind-css-coding/SKILL.md +268 -0
- package/.codex/skills/typescript-coding/SKILL.md +464 -0
- package/AGENTS.md +57 -0
- package/CLAUDE.md +98 -0
- package/LICENSE +201 -0
- package/README.md +114 -0
- package/bin/cli.mjs +291 -0
- package/package.json +39 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ddd-architecture-python
|
|
3
|
+
description: Apply when implementing Domain-Driven Design patterns in Python (.py) files. Covers tactical patterns (entities, value objects, aggregates, domain events, repositories), layered architecture with dependency inversion, persistence strategies, validation boundaries, and common DDD anti-patterns. Best suited for projects with complex business rules spanning multiple entities.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Domain-Driven Design in Python
|
|
7
|
+
|
|
8
|
+
Match the project's existing domain model conventions. When uncertain, read 2-3 existing aggregate or entity modules to infer the local style. Check for existing base classes, event infrastructure, and repository patterns before introducing new ones. These defaults apply only when the project has no established convention.
|
|
9
|
+
|
|
10
|
+
## Never rules
|
|
11
|
+
|
|
12
|
+
These are unconditional. They prevent structural defects regardless of project style.
|
|
13
|
+
|
|
14
|
+
- **Never put business logic in service layers while domain models are empty data bags.** This is the anemic domain model. If all methods live in services and entities are just data carriers, you have Transaction Scripts with extra mapping cost. Move behavior that enforces invariants into the entity or aggregate that owns the state.
|
|
15
|
+
- **Never create repositories for anything other than aggregate roots.** Repositories exist per aggregate root, not per entity. Accessing child entities bypassing the aggregate root breaks consistency boundaries. `OrderLineRepository` is always wrong if `OrderLine` belongs to an `Order` aggregate.
|
|
16
|
+
- **Never use `@dataclass(frozen=True)` for entities.** Frozen dataclasses enforce structural equality (compare all fields). Entities have identity -- two `User` objects with the same `id` are the same user even if `email` changed. Use `@dataclass(eq=False, slots=True)` and implement identity-based `__eq__` and `__hash__` on the id field.
|
|
17
|
+
- **Never use `unsafe_hash=True` on mutable dataclasses.** It makes mutable objects hashable, causing subtle bugs when attributes change after insertion into sets or dict keys. Use frozen for value objects, custom hash for entities.
|
|
18
|
+
- **Never let domain models import from infrastructure.** The dependency arrow points inward: infrastructure -> application -> domain. Domain models must not import SQLAlchemy, Pydantic, httpx, or any external framework.
|
|
19
|
+
- **Never duplicate validation between API layer and domain layer.** Pydantic validates input shape at the boundary (type coercion, required fields). Domain validates business invariants (order total can't be negative, user can't have more than 5 active subscriptions). These are different concerns.
|
|
20
|
+
- **Never apply tactical DDD patterns to CRUD-only modules.** If a bounded context has no business invariants beyond "save and retrieve," use plain service functions or direct ORM operations. Strategic DDD (bounded contexts, ubiquitous language) is almost always valuable; tactical DDD is conditional.
|
|
21
|
+
|
|
22
|
+
## Tactical patterns
|
|
23
|
+
|
|
24
|
+
| Pattern | Python Implementation | Use When | Skip When |
|
|
25
|
+
|---------|----------------------|----------|-----------|
|
|
26
|
+
| **Value Object** | `@dataclass(frozen=True, slots=True)` | Equality by value (Money, Email, DateRange) | Simple strings/ints with no validation |
|
|
27
|
+
| **Entity** | `@dataclass(eq=False, slots=True)` + custom `__eq__`/`__hash__` on id | Objects with lifecycle and identity | Lookup tables, config records |
|
|
28
|
+
| **Aggregate Root** | Entity + `_events: list[Event]` + invariant methods | Multi-entity consistency boundaries | Single-entity modules |
|
|
29
|
+
| **Domain Event** | `@dataclass(frozen=True)` inheriting from `Event` base | Side effects: notifications, indexing, cross-context sync | Simple CRUD with no cross-context effects |
|
|
30
|
+
| **Repository** | Protocol in domain, implementation in infrastructure | Aggregate root persistence abstraction | Simple modules -- use ORM directly |
|
|
31
|
+
| **Domain Service** | Plain function or class in domain layer | Logic spanning multiple aggregates | Logic that belongs on a single entity |
|
|
32
|
+
| **Application Service** | Orchestrates repositories, domain objects, UoW | Use case coordination | Don't mix with domain logic |
|
|
33
|
+
| **Factory** | `@classmethod` on entity or aggregate | Complex construction with invariants | Simple `__init__` suffices |
|
|
34
|
+
|
|
35
|
+
### Value object
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Money:
|
|
40
|
+
amount: Decimal
|
|
41
|
+
currency: str
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if self.amount < 0:
|
|
45
|
+
raise ValueError("Amount cannot be negative")
|
|
46
|
+
if len(self.currency) != 3:
|
|
47
|
+
raise ValueError("Currency must be ISO 4217 code")
|
|
48
|
+
|
|
49
|
+
def add(self, other: Money) -> Money:
|
|
50
|
+
if self.currency != other.currency:
|
|
51
|
+
raise ValueError(f"Cannot add {self.currency} to {other.currency}")
|
|
52
|
+
return Money(amount=self.amount + other.amount, currency=self.currency)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Entity with identity equality
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
@dataclass(eq=False, slots=True)
|
|
59
|
+
class Order:
|
|
60
|
+
id: int
|
|
61
|
+
customer_id: int
|
|
62
|
+
lines: list[OrderLine]
|
|
63
|
+
status: OrderStatus
|
|
64
|
+
_events: list[Event] = field(default_factory=list, repr=False)
|
|
65
|
+
|
|
66
|
+
def __eq__(self, other: object) -> bool:
|
|
67
|
+
if not isinstance(other, Order):
|
|
68
|
+
return NotImplemented
|
|
69
|
+
return self.id == other.id
|
|
70
|
+
|
|
71
|
+
def __hash__(self) -> int:
|
|
72
|
+
return hash(self.id)
|
|
73
|
+
|
|
74
|
+
def add_line(self, sku: str, qty: int, price: Money) -> None:
|
|
75
|
+
if self.status != OrderStatus.DRAFT:
|
|
76
|
+
raise OrderFinalizedError(self.id)
|
|
77
|
+
line = OrderLine(sku=sku, qty=qty, price=price)
|
|
78
|
+
self.lines.append(line)
|
|
79
|
+
|
|
80
|
+
def confirm(self) -> None:
|
|
81
|
+
if not self.lines:
|
|
82
|
+
raise EmptyOrderError(self.id)
|
|
83
|
+
self.status = OrderStatus.CONFIRMED
|
|
84
|
+
self._events.append(OrderConfirmed(order_id=self.id))
|
|
85
|
+
|
|
86
|
+
def collect_events(self) -> list[Event]:
|
|
87
|
+
events = self._events[:]
|
|
88
|
+
self._events.clear()
|
|
89
|
+
return events
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Wrong -- frozen dataclass for an entity:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# WRONG: frozen enforces structural equality, entities have identity
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class Order:
|
|
98
|
+
id: int
|
|
99
|
+
customer_id: int
|
|
100
|
+
status: OrderStatus # can't mutate status transitions
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Persistence strategy
|
|
104
|
+
|
|
105
|
+
Three approaches, ordered by coupling:
|
|
106
|
+
|
|
107
|
+
| Strategy | Tradeoff | Use When |
|
|
108
|
+
|----------|----------|----------|
|
|
109
|
+
| **Domain models = ORM models** | Coupling to SQLAlchemy, but zero mapping | <3 aggregates, team <3, domain is simple |
|
|
110
|
+
| **Imperative mapping** (`map_imperatively`) | Clean separation, moderate setup | Business logic complex enough to test without DB |
|
|
111
|
+
| **Separate models + manual mapping** | Full isolation, high boilerplate | Domain and persistence schemas diverge significantly |
|
|
112
|
+
|
|
113
|
+
Start with Strategy A. Move to Strategy B when you need to unit-test domain logic without touching the database. Move to Strategy C only when the persistence schema genuinely differs from the domain model.
|
|
114
|
+
|
|
115
|
+
### Imperative mapping
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# domain/model.py -- pure Python, no SQLAlchemy imports
|
|
119
|
+
@dataclass(eq=False, slots=True)
|
|
120
|
+
class Product:
|
|
121
|
+
id: int
|
|
122
|
+
sku: str
|
|
123
|
+
batches: list[Batch]
|
|
124
|
+
|
|
125
|
+
# infrastructure/orm.py -- SQLAlchemy mapping, called once at startup
|
|
126
|
+
from sqlalchemy import Table, Column, Integer, String
|
|
127
|
+
from sqlalchemy.orm import registry, relationship
|
|
128
|
+
|
|
129
|
+
mapper_registry = registry()
|
|
130
|
+
|
|
131
|
+
product_table = Table(
|
|
132
|
+
"products",
|
|
133
|
+
mapper_registry.metadata,
|
|
134
|
+
Column("id", Integer, primary_key=True),
|
|
135
|
+
Column("sku", String(255)),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def start_mappers() -> None:
|
|
139
|
+
mapper_registry.map_imperatively(Product, product_table, properties={
|
|
140
|
+
"batches": relationship(Batch),
|
|
141
|
+
})
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Dependency inversion
|
|
145
|
+
|
|
146
|
+
| Mechanism | Use When |
|
|
147
|
+
|-----------|----------|
|
|
148
|
+
| **Protocol** | Domain ports consumed by application/infrastructure. No inheritance required. |
|
|
149
|
+
| **ABC** | Infrastructure base classes where implementations share common behavior |
|
|
150
|
+
| **Constructor args** | Default for everything. Explicit, testable, no framework. |
|
|
151
|
+
| **FastAPI Depends** | Request-scoped injection in web handlers |
|
|
152
|
+
|
|
153
|
+
Protocol for domain ports, constructor injection for wiring:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# domain/ports.py
|
|
157
|
+
from typing import Protocol
|
|
158
|
+
|
|
159
|
+
class OrderRepository(Protocol):
|
|
160
|
+
async def get(self, order_id: int) -> Order: ...
|
|
161
|
+
async def save(self, order: Order) -> None: ...
|
|
162
|
+
|
|
163
|
+
# application/services.py
|
|
164
|
+
class ConfirmOrderHandler:
|
|
165
|
+
def __init__(self, repo: OrderRepository, bus: MessageBus) -> None:
|
|
166
|
+
self._repo = repo
|
|
167
|
+
self._bus = bus
|
|
168
|
+
|
|
169
|
+
async def handle(self, command: ConfirmOrder) -> None:
|
|
170
|
+
order = await self._repo.get(command.order_id)
|
|
171
|
+
order.confirm()
|
|
172
|
+
await self._repo.save(order)
|
|
173
|
+
for event in order.collect_events():
|
|
174
|
+
await self._bus.publish(event)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Wrong -- generic repository with leaked ORM abstractions:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
# WRONG: this is a leaked ORM, not a domain repository
|
|
181
|
+
class Repository[T]:
|
|
182
|
+
async def filter(self, **kwargs: Any) -> list[T]: ...
|
|
183
|
+
async def all(self) -> list[T]: ...
|
|
184
|
+
|
|
185
|
+
# RIGHT: domain-meaningful methods
|
|
186
|
+
class OrderRepository(Protocol):
|
|
187
|
+
async def get(self, order_id: int) -> Order: ...
|
|
188
|
+
async def get_pending_orders(self) -> list[Order]: ...
|
|
189
|
+
async def save(self, order: Order) -> None: ...
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Domain events
|
|
193
|
+
|
|
194
|
+
Use `@dataclass(frozen=True)` events collected on aggregates. Dispatch via a simple message bus.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
# domain/events.py
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class Event:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class OrderConfirmed(Event):
|
|
204
|
+
order_id: int
|
|
205
|
+
|
|
206
|
+
# infrastructure/messagebus.py
|
|
207
|
+
EVENT_HANDLERS: dict[type[Event], list[Callable]] = {
|
|
208
|
+
OrderConfirmed: [send_confirmation_email, update_inventory],
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async def handle(event: Event) -> None:
|
|
212
|
+
for handler in EVENT_HANDLERS.get(type(event), []):
|
|
213
|
+
await handler(event)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Domain events are in-process. Integration events cross service boundaries via message queues -- different concern, different infrastructure.
|
|
217
|
+
|
|
218
|
+
## Validation layers
|
|
219
|
+
|
|
220
|
+
| Layer | Responsibility | Tool |
|
|
221
|
+
|-------|---------------|------|
|
|
222
|
+
| **API boundary** | Shape, types, required fields | Pydantic `BaseModel` |
|
|
223
|
+
| **Domain** | Business invariants | Entity/aggregate methods, `__post_init__` |
|
|
224
|
+
| **Cross-aggregate** | Multi-aggregate rules | Domain services |
|
|
225
|
+
|
|
226
|
+
Don't validate the same thing twice. Pydantic checks "is this a valid email string." Domain checks "can this user register with this email given their account state."
|
|
227
|
+
|
|
228
|
+
## Project structure
|
|
229
|
+
|
|
230
|
+
### Pragmatic DDD (start here)
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
src/
|
|
234
|
+
├── ordering/ # Bounded context = package
|
|
235
|
+
│ ├── domain/
|
|
236
|
+
│ │ ├── model.py # Entities, VOs, aggregates
|
|
237
|
+
│ │ ├── events.py # Domain events
|
|
238
|
+
│ │ └── ports.py # Repository protocols
|
|
239
|
+
│ ├── application/
|
|
240
|
+
│ │ └── handlers.py # Command/query handlers (thin)
|
|
241
|
+
│ ├── infrastructure/
|
|
242
|
+
│ │ ├── orm.py # SQLAlchemy mapping
|
|
243
|
+
│ │ └── repository.py # Repository implementations
|
|
244
|
+
│ └── interface/
|
|
245
|
+
│ ├── router.py # FastAPI routes
|
|
246
|
+
│ └── schemas.py # Pydantic request/response
|
|
247
|
+
├── shared/
|
|
248
|
+
│ ├── messagebus.py # Event dispatch
|
|
249
|
+
│ └── uow.py # Unit of Work
|
|
250
|
+
└── main.py
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Import rules: interface -> application -> domain. Infrastructure -> domain (implements ports). Domain imports nothing from other layers.
|
|
254
|
+
|
|
255
|
+
### Simple DDD (CRUD-heavy bounded contexts)
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
src/
|
|
259
|
+
├── notifications/ # Simple context -- no tactical DDD
|
|
260
|
+
│ ├── router.py
|
|
261
|
+
│ ├── schemas.py
|
|
262
|
+
│ ├── service.py # Business logic (thin)
|
|
263
|
+
│ └── models.py # ORM models directly
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Not every bounded context needs full DDD. Apply tactical patterns where business rules are complex.
|
|
267
|
+
|
|
268
|
+
## When to use DDD
|
|
269
|
+
|
|
270
|
+
| Criterion | Use DDD | Skip DDD |
|
|
271
|
+
|-----------|---------|----------|
|
|
272
|
+
| Business invariants | Span multiple entities, enforced transactionally | Single-entity CRUD |
|
|
273
|
+
| Domain complexity | Domain experts disagree on rules | Rules fit on one page |
|
|
274
|
+
| Team size | 3+ developers, domain knowledge distributed | Solo developer, full context in head |
|
|
275
|
+
| Change frequency | Business rules change independently of tech | Schema-driven CRUD, logic is trivial |
|
|
276
|
+
| Bounded contexts | 2+ contexts with different models of same concept | Monolithic domain, single model |
|
|
277
|
+
|
|
278
|
+
## Anti-patterns
|
|
279
|
+
|
|
280
|
+
- **Anemic domain model.** All logic in services, entities are bags of data. You have Transaction Scripts with extra mapping cost.
|
|
281
|
+
- **Repository per entity.** Repositories exist only for aggregate roots. `UserRepository`, `OrderRepository` -- yes. `OrderLineRepository` -- no.
|
|
282
|
+
- **DDD theater.** Using vocabulary (aggregate, bounded context, ubiquitous language) without actually modeling the domain. If your "aggregates" don't enforce any invariants, they're just database records.
|
|
283
|
+
- **Over-abstraction.** `IUserRepositoryFactory`, `AbstractDomainServiceBase` -- Python doesn't need this. Protocol + constructor injection is the ceiling.
|
|
284
|
+
- **Premature event sourcing.** Event sourcing is a persistence strategy, not a default. Use it when you need a full audit log or temporal queries. For everything else, it adds rebuild complexity, eventual consistency headaches, and schema evolution pain.
|
|
285
|
+
- **Wrong aggregate boundaries.** If you load an aggregate and it pulls 50 related entities from the database, the boundary is too wide. If you can't enforce a business rule without loading two aggregates, the boundary is too narrow or the rule belongs in a domain service.
|
|
286
|
+
- **Generic repository.** `Repository[T]` with `.filter()` and `.all()` is a leaked ORM abstraction. Repositories expose domain-meaningful methods: `get_pending_orders()`, not `filter(status="pending")`.
|
|
287
|
+
- **Pydantic in domain layer.** Domain models should be pure Python (dataclasses). Pydantic belongs at system boundaries. Coupling domain logic to Pydantic makes unit testing slower and ties domain evolution to a serialization library.
|
|
288
|
+
- **Importing Java/C# patterns wholesale.** Python has no interfaces, no private fields, no explicit getters/setters. Use Protocol (not Interface), `_convention` (not private fields), properties only when computation is needed.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feature-driven-architecture-python
|
|
3
|
+
description: Apply when structuring Python projects by business capability (vertical slices). Covers directory layout, feature boundaries, inter-feature communication, database model ownership, migration strategies, boundary enforcement, testing across features, and migration from layered architectures. Best suited for FastAPI and Flask projects with 5+ distinct features.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Feature-Driven Architecture
|
|
7
|
+
|
|
8
|
+
Match the project's existing structure. When uncertain, read 3-5 existing feature directories to infer the local conventions. Check for import-linter or PyTestArch configuration in `pyproject.toml`. These defaults apply only when the project has no established convention.
|
|
9
|
+
|
|
10
|
+
## Never rules
|
|
11
|
+
|
|
12
|
+
These are unconditional. They prevent coupling and boundary erosion regardless of project style.
|
|
13
|
+
|
|
14
|
+
- **Never import another feature's ORM models directly.** Features own their models. Cross-feature data access goes through the owning feature's service layer or shared read models. `from src.billing.models import Invoice` inside `src.auth/` is always a defect.
|
|
15
|
+
- **Never create circular imports between features.** If Feature A imports from Feature B and Feature B imports from Feature A, the feature boundaries are drawn wrong. Refactor using events, a shared service, or merge the features.
|
|
16
|
+
- **Never put business logic in routers/views.** Routers handle HTTP concerns (status codes, response formatting). Business logic lives in `service.py` or domain functions within the feature.
|
|
17
|
+
- **Never share Pydantic request/response schemas across features.** Each feature defines its own schemas. Identical DTOs in two features today will diverge tomorrow — duplication is cheaper than the wrong shared abstraction.
|
|
18
|
+
- **Never skip boundary enforcement tooling.** Setup is 15 minutes — a single `independence` contract in `pyproject.toml` and `lint-imports` in CI. Kraken Technologies found that violations appear even on small teams under deadline pressure, and compound quickly. Code review catches logic issues; import-linter catches structural invariants that are tedious and error-prone to verify manually in diffs.
|
|
19
|
+
- **Never use ForeignKey across feature boundaries in new code.** Use plain integer ID fields between features. The referential integrity cost is real but coupling cost is worse. Shared read models are the escape hatch.
|
|
20
|
+
- **Never put feature-specific code in the shared layer.** `shared/` or `core/` contains only cross-cutting infrastructure: auth middleware, database session factory, base classes, pagination, response schemas. If it's specific to one feature, it belongs in that feature.
|
|
21
|
+
- **Never use `extend_existing=True` for cross-feature read models.** It silently merges column definitions into a shared `Table` object (SQLAlchemy issues #7366, #8925). Use database VIEWs mapped to separate ORM classes instead.
|
|
22
|
+
|
|
23
|
+
## Directory structure
|
|
24
|
+
|
|
25
|
+
Each feature is a self-contained Python package:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
src/
|
|
29
|
+
├── auth/
|
|
30
|
+
│ ├── __init__.py
|
|
31
|
+
│ ├── router.py # FastAPI APIRouter with endpoints
|
|
32
|
+
│ ├── schemas.py # Pydantic request/response models
|
|
33
|
+
│ ├── models.py # SQLAlchemy/ORM models
|
|
34
|
+
│ ├── service.py # Business logic
|
|
35
|
+
│ ├── dependencies.py # FastAPI Depends() callables
|
|
36
|
+
│ ├── exceptions.py # Feature-specific errors
|
|
37
|
+
│ └── tests/
|
|
38
|
+
├── billing/
|
|
39
|
+
│ └── ... # Same structure per feature
|
|
40
|
+
├── shared/
|
|
41
|
+
│ ├── config.py # Pydantic BaseSettings
|
|
42
|
+
│ ├── database.py # Session factory, Base model
|
|
43
|
+
│ ├── middleware.py # CORS, logging, request tracing
|
|
44
|
+
│ └── exceptions.py # Application-wide exception base classes
|
|
45
|
+
└── main.py # App factory, router registration
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Not every feature needs every file. A simple CRUD feature might have only `router.py`, `schemas.py`, and `service.py`. A complex domain feature might add `domain.py`, `events.py`, `repository.py`. Let complexity emerge per-slice.
|
|
49
|
+
|
|
50
|
+
**Key pattern:** Routers delegate to service functions. Services import only their own feature's models and schemas. Wire features in the app factory via `app.include_router()`.
|
|
51
|
+
|
|
52
|
+
## Inter-feature communication
|
|
53
|
+
|
|
54
|
+
Four patterns, ordered by coupling (tightest to loosest):
|
|
55
|
+
|
|
56
|
+
| Pattern | When to use | Example |
|
|
57
|
+
|---------|-------------|---------|
|
|
58
|
+
| **Direct service import** | Read-only access, simple projects, <5 features | `from src.auth.service import get_user` |
|
|
59
|
+
| **FastAPI Depends()** | Request-scoped shared state, auth context | `user = Depends(get_current_user)` |
|
|
60
|
+
| **Shared read models** | Cross-feature queries without write coupling | Database VIEWs mapped to read-only ORM classes |
|
|
61
|
+
| **Events (pub/sub)** | Fire-and-forget: emails, analytics, indexing | `BackgroundTasks`, blinker `send_async()`, or task queue |
|
|
62
|
+
|
|
63
|
+
**Default to direct service imports** for small projects. Introduce events only when features genuinely don't need synchronous responses. Don't build event infrastructure speculatively.
|
|
64
|
+
|
|
65
|
+
**Batch service calls to avoid N+1.** When a feature lists entities that reference another feature's data, always provide a batch method alongside the single-item method:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# src/auth/service.py
|
|
69
|
+
async def get_users_by_ids(user_ids: list[int]) -> dict[int, User]:
|
|
70
|
+
"""Batch fetch — single query with WHERE id IN (...)."""
|
|
71
|
+
async with get_session() as session:
|
|
72
|
+
stmt = select(User).where(User.id.in_(user_ids))
|
|
73
|
+
result = await session.execute(stmt)
|
|
74
|
+
return {u.id: u for u in result.scalars().all()}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# src/billing/service.py — WRONG: N+1 service calls
|
|
79
|
+
async def list_invoices_with_users() -> list[InvoiceDetail]:
|
|
80
|
+
invoices = await get_invoices()
|
|
81
|
+
return [
|
|
82
|
+
InvoiceDetail(invoice=inv, user_name=(await get_user_by_id(inv.user_id)).name)
|
|
83
|
+
for inv in invoices # 1 query per invoice
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# RIGHT: batch fetch — 2 queries total regardless of N
|
|
87
|
+
async def list_invoices_with_users() -> list[InvoiceDetail]:
|
|
88
|
+
invoices = await get_invoices()
|
|
89
|
+
users_map = await get_users_by_ids(list({inv.user_id for inv in invoices}))
|
|
90
|
+
return [
|
|
91
|
+
InvoiceDetail(invoice=inv, user_name=users_map[inv.user_id].name)
|
|
92
|
+
for inv in invoices
|
|
93
|
+
]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**FastAPI dependency injection** for cross-feature auth context: define `get_current_user` in `src/auth/dependencies.py`, then inject via `Depends(get_current_user)` in other features' routers. Features import only `dependencies.py`, never auth internals.
|
|
97
|
+
|
|
98
|
+
**Event-driven decoupling** when synchronous coupling is unacceptable:
|
|
99
|
+
|
|
100
|
+
| Mechanism | Latency coupling | When to use |
|
|
101
|
+
|-----------|-----------------|-------------|
|
|
102
|
+
| `blinker send_async()` | Awaits all receivers | Receivers are fast, <50ms each |
|
|
103
|
+
| `BackgroundTasks` | None — runs post-response | Side effects the caller doesn't wait for |
|
|
104
|
+
| Task queue (Celery, ARQ) | None — separate worker | Retries, persistence, or heavy processing needed |
|
|
105
|
+
|
|
106
|
+
Prefer `BackgroundTasks` for simple fire-and-forget (emails, analytics). Use `blinker` signals only when you need a publish/subscribe pattern with multiple listeners. Note: `send_async()` still awaits all receivers — it's async but not decoupled. For true fire-and-forget, use `BackgroundTasks` or a task queue.
|
|
107
|
+
|
|
108
|
+
## Database model ownership
|
|
109
|
+
|
|
110
|
+
The hardest problem in feature-driven architecture. Three strategies:
|
|
111
|
+
|
|
112
|
+
| Strategy | Tradeoff | When to use |
|
|
113
|
+
|----------|----------|-------------|
|
|
114
|
+
| **Feature-private models** | Full isolation, no cross-feature FKs | Distinct data domains (auth, notifications) |
|
|
115
|
+
| **ID-based references** | Loses referential integrity, risks N+1 | Features that reference but don't own shared data |
|
|
116
|
+
| **Shared read layer** | Adds a shared dependency, but read-only | Heavy cross-feature reporting/querying |
|
|
117
|
+
|
|
118
|
+
Feature-private models (preferred) — use plain `int` for cross-feature IDs, never `ForeignKey`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# src/billing/models.py
|
|
122
|
+
class Invoice(Base):
|
|
123
|
+
__tablename__ = "invoices"
|
|
124
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
125
|
+
user_id: Mapped[int] = mapped_column() # plain int, NOT ForeignKey("users.id")
|
|
126
|
+
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For reporting that needs JOINs across feature boundaries, use database VIEWs with a write-protection mixin:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# src/shared/read_models.py
|
|
133
|
+
class ReadOnlyModel:
|
|
134
|
+
"""Mixin — prevents accidental writes to VIEW-backed models."""
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
@event.listens_for(Session, "before_flush")
|
|
138
|
+
def _prevent_readonly_flush(session, flush_context, instances):
|
|
139
|
+
for obj in session.new | session.dirty | session.deleted:
|
|
140
|
+
if isinstance(obj, ReadOnlyModel):
|
|
141
|
+
raise RuntimeError(f"Attempted to write to read-only model {type(obj).__name__}")
|
|
142
|
+
|
|
143
|
+
class InvoiceWithUser(ReadOnlyModel, Base):
|
|
144
|
+
__tablename__ = "v_invoices_with_users" # database VIEW, not a table
|
|
145
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
146
|
+
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
|
147
|
+
user_email: Mapped[str]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Create the VIEW in an Alembic migration with `op.execute("CREATE VIEW ...")` / `op.execute("DROP VIEW ...")`.
|
|
151
|
+
|
|
152
|
+
## Migration ownership
|
|
153
|
+
|
|
154
|
+
When features own their models, someone must own the migration files. Two practical strategies:
|
|
155
|
+
|
|
156
|
+
**Strategy A: Centralized migrations (start here).** Single `alembic/versions/` directory. Any developer runs `alembic revision --autogenerate`. Simple, linear history, easy CI. Tradeoff: merge conflicts when multiple teams change models simultaneously. Use when team size <5.
|
|
157
|
+
|
|
158
|
+
**Strategy B: Per-feature directories with branch labels.** Alembic supports `version_locations` for multiple migration directories. Each feature gets a branch label (`--head=base --branch-label=auth --version-path=src/auth/migrations`). Apply all branches with `alembic upgrade heads` (plural). Tradeoff: `autogenerate` cannot auto-detect which feature a model change belongs to. Use when 5+ developers and merge conflicts are frequent.
|
|
159
|
+
|
|
160
|
+
For strongest isolation (approaching microservice extraction), each feature can use its own PostgreSQL schema — but this has significant infrastructure overhead and is rarely needed.
|
|
161
|
+
|
|
162
|
+
**CI guard for all strategies:** Always run `alembic upgrade heads` against an empty database in CI.
|
|
163
|
+
|
|
164
|
+
## Cross-feature testing
|
|
165
|
+
|
|
166
|
+
Layered testing strategy — per-slice unit tests are straightforward; cross-feature integration is where teams get stuck.
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
tests/
|
|
170
|
+
conftest.py # Shared factory fixtures (db_session, make_user, make_invoice)
|
|
171
|
+
features/
|
|
172
|
+
auth/
|
|
173
|
+
conftest.py # Auth-specific fakes and helpers
|
|
174
|
+
test_service.py # Unit: test with fakes
|
|
175
|
+
billing/
|
|
176
|
+
conftest.py
|
|
177
|
+
test_service.py
|
|
178
|
+
integration/
|
|
179
|
+
test_billing_auth.py # Integration: billing + auth, real DB
|
|
180
|
+
workflows/
|
|
181
|
+
test_purchase_flow.py # E2E: full request chain via test client
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Layer 1: Unit tests with fakes** (per feature, fast). Each feature tests its service layer in isolation:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
# tests/features/billing/test_service.py
|
|
188
|
+
class FakeUserService:
|
|
189
|
+
def __init__(self, users: dict[int, User] | None = None):
|
|
190
|
+
self._users = users or {}
|
|
191
|
+
|
|
192
|
+
async def get_users_by_ids(self, user_ids: list[int]) -> dict[int, User]:
|
|
193
|
+
return {uid: self._users[uid] for uid in user_ids if uid in self._users}
|
|
194
|
+
|
|
195
|
+
async def test_list_invoices_includes_user_names(db_session):
|
|
196
|
+
fake_users = FakeUserService(users={1: User(id=1, name="Alice")})
|
|
197
|
+
svc = BillingService(session=db_session, user_service=fake_users)
|
|
198
|
+
invoices = await svc.list_invoices_with_users()
|
|
199
|
+
assert invoices[0].user_name == "Alice"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Layer 2: Integration tests with factory fixtures** (cross-feature, real DB). Shared factory fixtures in root `conftest.py` create real domain objects:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
# tests/conftest.py
|
|
206
|
+
@pytest.fixture
|
|
207
|
+
def make_user(db_session):
|
|
208
|
+
async def _make(email="test@example.com", **kwargs):
|
|
209
|
+
user = User(email=email, **kwargs)
|
|
210
|
+
db_session.add(user)
|
|
211
|
+
await db_session.flush()
|
|
212
|
+
return user
|
|
213
|
+
return _make
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Layer 3: Workflow tests** (E2E, real HTTP). Test full request chains through multiple features using the FastAPI test client. Keep these minimal — they're slow and brittle.
|
|
217
|
+
|
|
218
|
+
**Key rules:**
|
|
219
|
+
- Never import a feature's service directly from another feature's tests. Use factory fixtures or fakes.
|
|
220
|
+
- Keep feature-specific fixtures in feature-level `conftest.py`. Only cross-feature factories go in root `conftest.py`.
|
|
221
|
+
- Separate fast and slow tests with markers: `pytest -m "not integration"` for fast feedback, full suite in CI.
|
|
222
|
+
- If using fakes, test them against the same contract as the real implementation to prevent drift.
|
|
223
|
+
|
|
224
|
+
## Boundary enforcement
|
|
225
|
+
|
|
226
|
+
**import-linter** — the minimum viable boundary enforcement. Add to `pyproject.toml`:
|
|
227
|
+
|
|
228
|
+
```toml
|
|
229
|
+
[tool.importlinter]
|
|
230
|
+
root_package = "src"
|
|
231
|
+
|
|
232
|
+
[[tool.importlinter.contracts]]
|
|
233
|
+
name = "Features are independent"
|
|
234
|
+
type = "independence"
|
|
235
|
+
modules = [
|
|
236
|
+
"src.auth",
|
|
237
|
+
"src.billing",
|
|
238
|
+
"src.notifications",
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
[[tool.importlinter.contracts]]
|
|
242
|
+
name = "Shared layer does not depend on features"
|
|
243
|
+
type = "forbidden"
|
|
244
|
+
source_modules = ["src.shared"]
|
|
245
|
+
forbidden_modules = [
|
|
246
|
+
"src.auth",
|
|
247
|
+
"src.billing",
|
|
248
|
+
"src.notifications",
|
|
249
|
+
]
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Run in CI: `lint-imports`. Add as a pre-commit hook. Alternative: **PyTestArch** for teams that prefer executable architecture tests in pytest.
|
|
253
|
+
|
|
254
|
+
**Exception to independence:** Features MAY import another feature's `dependencies.py` for FastAPI `Depends()` injection. Configure via `ignore_imports` in the independence contract:
|
|
255
|
+
|
|
256
|
+
```toml
|
|
257
|
+
ignore_imports = [
|
|
258
|
+
"src.billing.router -> src.auth.dependencies",
|
|
259
|
+
"src.notifications.router -> src.auth.dependencies",
|
|
260
|
+
]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## When to use this pattern
|
|
264
|
+
|
|
265
|
+
| Use when | Don't use when |
|
|
266
|
+
|----------|----------------|
|
|
267
|
+
| 5+ distinct business capabilities | Primarily CRUD with minimal logic |
|
|
268
|
+
| 3+ developers with team ownership | 1-2 developers touching everything |
|
|
269
|
+
| Features change independently | Codebase under 5,000 lines |
|
|
270
|
+
| FastAPI or Flask project | Django with tight admin integration |
|
|
271
|
+
| Incremental monolith migration | Features share data via complex JOINs |
|
|
272
|
+
| Team comfortable with refactoring | Domain boundaries are unclear |
|
|
273
|
+
|
|
274
|
+
## Migration from layered architecture
|
|
275
|
+
|
|
276
|
+
Strangler Fig pattern — replace one feature at a time.
|
|
277
|
+
|
|
278
|
+
**Phase 1: Map.** Identify which files change together. Those clusters are your features. Add integration tests for the first feature to migrate.
|
|
279
|
+
|
|
280
|
+
**Phase 2: Extract shared.** Create `shared/` with cross-cutting concerns: database session factory, auth middleware, base model classes, response schemas.
|
|
281
|
+
|
|
282
|
+
**Phase 3: Migrate one feature.** Pick the most isolated feature. Create a new package with router, schemas, service, and models. Wire alongside the old code. Verify with tests. Delete the old code.
|
|
283
|
+
|
|
284
|
+
**Phase 4: Handle models.** Move models owned by a single feature into that feature's package. For shared models, decide: keep in `shared/data/`, or duplicate with ID-based references.
|
|
285
|
+
|
|
286
|
+
**Phase 5: Handle migrations.** Start with centralized migrations (Strategy A). Graduate to per-feature branches (Strategy B) when merge conflicts become frequent.
|
|
287
|
+
|
|
288
|
+
**Phase 6: Enforce.** Add import-linter contracts. Start descriptive, then tighten to enforced. Add to CI.
|
|
289
|
+
|
|
290
|
+
**Phase 7: Iterate.** Apply Rule of Three for shared logic extraction. Decompose features exceeding ~1,500 lines of business logic. Add events for cross-feature triggers that don't need synchronous responses.
|
|
291
|
+
|
|
292
|
+
## Anti-patterns
|
|
293
|
+
|
|
294
|
+
- **Folder-only refactoring.** Moving files into feature directories without restructuring imports and dependencies. "Shuffling folders and calling it VSA" changes nothing.
|
|
295
|
+
- **Premature abstraction.** Creating `IUserRepository`, `IOrderService` interfaces for one implementation. Python's Protocols provide structural typing where needed — don't add Java-style ceremony.
|
|
296
|
+
- **Per-endpoint slicing.** Creating a directory for every single endpoint (`create_user/`, `get_user/`, `delete_user/`). This mirrors .NET MediatR patterns but is not idiomatic Python. Group by feature, not by operation.
|
|
297
|
+
- **Event bus for everything.** Building event infrastructure before you have a single cross-feature trigger that doesn't need a synchronous response. Direct service calls are simpler and debuggable.
|
|
298
|
+
- **God feature.** One slice absorbs most business logic. Start with procedural service functions, extract domain classes only when complexity warrants it.
|
|
299
|
+
- **Shared model coupling.** Putting all ORM models in a central `models/` directory "for convenience." This recreates the layered architecture you were trying to escape.
|
|
300
|
+
- **DRY over independence.** Extracting shared utilities after seeing the same pattern in two features. Wait for three. Duplication is cheaper than the wrong abstraction.
|
|
301
|
+
- **N+1 service calls.** Calling `get_user_by_id` in a loop instead of `get_users_by_ids` with `WHERE id IN (...)`. Always provide batch methods for cross-feature data access.
|
|
302
|
+
- **Synchronous side effects in signal handlers.** Using blinker `send()` with I/O listeners — this blocks the caller. Use `BackgroundTasks` or a task queue.
|