@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.
Files changed (46) hide show
  1. package/.claude/agents/csharp-code-writer.md +32 -0
  2. package/.claude/agents/diagrammer.md +49 -0
  3. package/.claude/agents/frontend-code-writer.md +36 -0
  4. package/.claude/agents/prompt-writer.md +38 -0
  5. package/.claude/agents/python-code-writer.md +32 -0
  6. package/.claude/agents/swift-code-writer.md +32 -0
  7. package/.claude/agents/typescript-code-writer.md +32 -0
  8. package/.claude/commands/git-sync.md +96 -0
  9. package/.claude/commands/project-docs.md +287 -0
  10. package/.claude/settings.json +112 -0
  11. package/.claude/settings.json.default +15 -0
  12. package/.claude/skills/csharp-coding/SKILL.md +368 -0
  13. package/.claude/skills/ddd-architecture-python/SKILL.md +288 -0
  14. package/.claude/skills/feature-driven-architecture-python/SKILL.md +302 -0
  15. package/.claude/skills/gemini-3-prompting/SKILL.md +483 -0
  16. package/.claude/skills/gpt-5-2-prompting/SKILL.md +295 -0
  17. package/.claude/skills/opus-4-6-prompting/SKILL.md +315 -0
  18. package/.claude/skills/plantuml-diagramming/SKILL.md +758 -0
  19. package/.claude/skills/python-coding/SKILL.md +293 -0
  20. package/.claude/skills/react-coding/SKILL.md +264 -0
  21. package/.claude/skills/rest-api/SKILL.md +421 -0
  22. package/.claude/skills/shadcn-ui-coding/SKILL.md +454 -0
  23. package/.claude/skills/swift-coding/SKILL.md +401 -0
  24. package/.claude/skills/tailwind-css-coding/SKILL.md +268 -0
  25. package/.claude/skills/typescript-coding/SKILL.md +464 -0
  26. package/.claude/statusline-command.sh +34 -0
  27. package/.codex/prompts/plan-reviewer.md +162 -0
  28. package/.codex/prompts/project-docs.md +287 -0
  29. package/.codex/skills/ddd-architecture-python/SKILL.md +288 -0
  30. package/.codex/skills/feature-driven-architecture-python/SKILL.md +302 -0
  31. package/.codex/skills/gemini-3-prompting/SKILL.md +483 -0
  32. package/.codex/skills/gpt-5-2-prompting/SKILL.md +295 -0
  33. package/.codex/skills/opus-4-6-prompting/SKILL.md +315 -0
  34. package/.codex/skills/plantuml-diagramming/SKILL.md +758 -0
  35. package/.codex/skills/python-coding/SKILL.md +293 -0
  36. package/.codex/skills/react-coding/SKILL.md +264 -0
  37. package/.codex/skills/rest-api/SKILL.md +421 -0
  38. package/.codex/skills/shadcn-ui-coding/SKILL.md +454 -0
  39. package/.codex/skills/tailwind-css-coding/SKILL.md +268 -0
  40. package/.codex/skills/typescript-coding/SKILL.md +464 -0
  41. package/AGENTS.md +57 -0
  42. package/CLAUDE.md +98 -0
  43. package/LICENSE +201 -0
  44. package/README.md +114 -0
  45. package/bin/cli.mjs +291 -0
  46. 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.