@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,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.
|