@@ -74,383 +74,7 @@ The review checks:
7474
7575Findings are reported by severity (Critical / Important / Suggestions) with file/line references and fix snippets.
7676
77- ** Example output** (from reviewing a test ` bookstore-api/ ` project). See the [ color-coded version] ( https://mktoronto.github.io/python-clean-architecture/ ) on the landing page.
78-
79- ``` text
80- $ /review-architecture bookstore-api/
81-
82- Architecture Summary
83- ----------------------
84-
85- Layers
86- ------
87- API: routers/books.py, routers/orders.py
88- Logic: services/book_service.py, services/order_service.py
89- Database: db/database.py (models + session factory)
90- Models: models/book.py, models/order.py (Pydantic)
91- Unclear: utils/helpers.py (formatting + DB queries -- mixed concerns)
92-
93- Dependency Flows
94- ----------------
95- routers/books.py -> services/book_service.py -> db/database.py [ok] (no abstraction)
96- routers/orders.py -> services/order_service.py -> db/database.py [ok] (no abstraction)
97- routers/orders.py -> db/database.py [!] layer skip
98- utils/helpers.py -> db/database.py [!] mixed concerns
99-
100- Layer Violations
101- ----------------
102- [!] routers/orders.py:2 imports SessionLocal + OrderModel directly
103- [!] routers/orders.py:21 performs raw DB query inside an endpoint
104- [!] utils/helpers.py:1 imports DB layer -- utility module should not touch persistence
105- [!] No DataInterface Protocol exists -- all services hardcode SessionLocal()
106-
107- What Works Well
108- ----------------
109- [ok] Good Pydantic model separation -- BookCreate / Book and OrderCreate / Order
110- properly separate input from output (models/book.py, models/order.py)
111- [ok] Clean main.py -- simple composition with tagged routers, no business logic
112- [ok] Consistent router structure -- proper APIRouter() with prefix/tag organization
113- [ok] Book service functions are individually small -- each under 10 lines
114-
115- Findings by Severity
116- ----------------------
117-
118- Critical
119-
120- 1. Layer skip -- router queries DB directly
121- routers/orders.py:2,19-32
122- Principles: P3 Depend on Abstractions, P2 Low Coupling
123-
124- The read_order endpoint imports SessionLocal and OrderModel, then runs
125- raw SQLAlchemy queries inside the router. Bypasses the service layer.
126-
127- # Before (routers/orders.py:19-32)
128- from db.database import SessionLocal, OrderModel
129-
130- @router.get("/{order_id}")
131- def read_order(order_id: int):
132- db = SessionLocal()
133- order = db.query(OrderModel).filter(OrderModel.id == order_id).first()
134- db.close()
135- ...
136-
137- # After -- delegate to service, raise proper HTTP errors
138- from fastapi import HTTPException
139-
140- @router.get("/{order_id}", response_model=Order)
141- def read_order(order_id: int):
142- order = order_service.get_order(order_id)
143- if not order:
144- raise HTTPException(status_code=404, detail="Order not found")
145- return order
146-
147- 2. No dependency injection -- all services hardcode DB access
148- services/book_service.py:1, services/order_service.py:3
149- Principle: P3 Depend on Abstractions
150-
151- Every function creates SessionLocal() internally. No DataInterface
152- Protocol exists. Testing requires a real database.
153-
154- # Before (services/book_service.py:1,5-6)
155- from db.database import SessionLocal, BookModel
156-
157- def create_book(data: BookCreate) -> Book:
158- db = SessionLocal()
159- book = BookModel(**data.dict())
160- ...
161-
162- # After -- define a Protocol, inject it
163- class BookDataInterface(Protocol):
164- def create(self, data: dict) -> dict: ...
165- def get_all(self) -> list[dict]: ...
166- def get_by_id(self, book_id: int) -> dict | None: ...
167-
168- def create_book(data: BookCreate, data_interface: BookDataInterface) -> Book:
169- result = data_interface.create(data.model_dump())
170- return Book(**result)
171-
172- 3. No resource management -- leaked DB connections on exceptions
173- services/book_service.py:6-11, services/order_service.py (every method)
174- Principle: P7 Keep Things Simple | Rule: #13 Use Context Managers
175-
176- Every function does db = SessionLocal() ... db.close(). If any line
177- between them raises, the connection leaks. OrderService methods have
178- multiple early return paths each needing their own db.close().
179-
180- # Before (services/order_service.py:10-14)
181- db = SessionLocal()
182- book = db.query(BookModel).filter(BookModel.id == book_id).first()
183- if not book:
184- db.close()
185- return None
186-
187- # After -- context manager guarantees cleanup
188- @contextmanager
189- def get_session():
190- session = SessionLocal()
191- try:
192- yield session
193- finally:
194- session.close()
195-
196- def get_order(self, order_id: int) -> Order | None:
197- with get_session() as db:
198- order = db.query(OrderModel).filter(OrderModel.id == order_id).first()
199- return Order(**order.__dict__) if order else None
200-
201- Important
202-
203- 4. God class -- OrderService has 7 responsibilities
204- services/order_service.py:6 -- 177 lines
205- Principle: P1 High Cohesion
206-
207- Handles: order CRUD, discount calculation, email notifications,
208- inventory management, logging, refund processing, and shipping
209- calculation. The docstring says it: "Handles orders, payments,
210- inventory, notifications, discounts, and shipping."
211-
212- Fix: Extract into focused collaborators --
213- DiscountCalculator, NotificationService, ShippingCalculator,
214- RefundProcessor. Keep OrderOperations for pure CRUD only.
215-
216- 5. Broad exception catching -- except Exception: pass
217- services/order_service.py:163-164
218- Rule: #12 No Broad Exception Catching
219-
220- _send_email catches Exception and silently discards it. A typo inside
221- the method, a TypeError, even a KeyboardInterrupt would be swallowed.
222- Order appears to succeed but email silently fails with no trace.
223-
224- # Before
225- def _send_email(self, to, subject, body):
226- try:
227- ...
228- except Exception:
229- pass
230-
231- # After -- catch specific errors, log failures
232- def _send_email(self, to: str, subject: str, body: str) -> None:
233- try:
234- ...
235- except (smtplib.SMTPException, ConnectionError) as e:
236- logger.warning("Failed to send email to %s: %s", to, e)
237-
238- 6. Missing type hints on 15+ functions
239- Multiple files
240- Rule: Type hints on all function parameters and return types
241-
242- File Function Missing
243- ------------------------------ ------------------------------- ---------------------
244- services/book_service.py:15 get_all_books() return type
245- services/book_service.py:25 get_book(book_id) param + return
246- services/book_service.py:34 search_books(query) param + return
247- services/order_service.py:9 create_order(self, ...) all params + return
248- services/order_service.py:66 get_order(self, order_id) param + return
249- services/order_service.py:80 update_status(self, ...) all params + return
250- services/order_service.py:105 cancel_order(self, order_id) param + return
251- services/order_service.py:133 get_order_history(self, ...) param + return
252- services/order_service.py:139 calculate_shipping(self, ...) param + return
253- services/order_service.py:154 _send_email(self, ...) all params + return
254- utils/helpers.py:4 format_price(amount, currency) all params + return
255- utils/helpers.py:15 get_bestsellers(limit) param + return
256- utils/helpers.py:22 validate_isbn(isbn) param + return
257- utils/helpers.py:30 generate_report() return
258-
259- 7. Error responses return 200 with error body instead of HTTPException
260- routers/books.py:21-22, routers/orders.py:13-14,24-25
261-
262- Returning {"error": "Book not found"} sends HTTP 200 with an error
263- body. Clients cannot distinguish success from failure by status code.
264-
265- # Before (routers/books.py:20-23) -- returns HTTP 200 with error!
266- book = get_book(book_id)
267- if not book:
268- return {"error": "Book not found"}
269- return book
270-
271- # After -- proper HTTP semantics
272- from fastapi import HTTPException
273-
274- book = get_book(book_id)
275- if not book:
276- raise HTTPException(status_code=404, detail="Book not found")
277- return book
278-
279- 8. Missing HTTP status codes on POST endpoints
280- routers/books.py:8, routers/orders.py:10
281-
282- POST endpoints default to 200 instead of 201 Created.
283-
284- # Before
285- @router.post("/")
286- def add_book(data: BookCreate):
287-
288- # After
289- @router.post("/", status_code=201, response_model=Book)
290- def add_book(data: BookCreate):
291-
292- 9. Returning raw dicts instead of Pydantic models
293- services/book_service.py:20-22, services/order_service.py:58-64,71-77
294- Principle: P6 Start with the Data
295-
296- Functions return hand-built {"id": ..., "title": ...} dicts instead
297- of using the Pydantic models already defined in models/. Loses type
298- safety, validation, serialization control, and IDE support.
299-
300- # Before (services/book_service.py:19-22)
301- result = []
302- for b in books:
303- result.append({"id": b.id, "title": b.title, "author": b.author,
304- "isbn": b.isbn, "price": b.price})
305- return result
306-
307- # After -- use the model you already defined
308- return [Book(id=b.id, title=b.title, author=b.author,
309- isbn=b.isbn, price=b.price) for b in books]
310-
311- 10. Deprecated Pydantic v1 API
312- services/book_service.py:7 -- data.dict()
313-
314- Pydantic v2 renamed .dict() to .model_dump(). The old method
315- still works but is deprecated and will be removed.
316-
317- # Before (Pydantic v1)
318- book = BookModel(**data.dict())
319-
320- # After (Pydantic v2)
321- book = BookModel(**data.model_dump())
322-
323- Suggestions
324-
325- 11. if/elif chain for discount logic -- Strategy pattern
326- services/order_service.py:19-31
327- Pattern: Strategy -- Callable type alias + dict mapping
328-
329- # Before (services/order_service.py:19-31)
330- if quantity >= 10:
331- discount_type = "bulk"
332- elif quantity >= 5:
333- discount_type = "medium"
334- else:
335- discount_type = "none"
336-
337- if discount_type == "bulk":
338- total = total * 0.8
339- elif discount_type == "medium":
340- total = total * 0.9
341- elif discount_type == "none":
342- pass
343-
344- # After -- discount strategies as functions
345- from typing import Callable
346-
347- DiscountFn = Callable[[float, int], float]
348-
349- def bulk_discount(total: float, quantity: int) -> float:
350- return total * 0.8
351-
352- def medium_discount(total: float, quantity: int) -> float:
353- return total * 0.9
354-
355- def no_discount(total: float, quantity: int) -> float:
356- return total
357-
358- def get_discount(quantity: int) -> DiscountFn:
359- if quantity >= 10:
360- return bulk_discount
361- elif quantity >= 5:
362- return medium_discount
363- return no_discount
364-
365- # Usage
366- discount = get_discount(quantity)
367- total = discount(total, quantity)
368-
369- 12. if/elif chain for currency formatting -- dict mapping
370- utils/helpers.py:4-12
371- Pattern: Registry -- dict mapping replaces if/elif
372-
373- # Before
374- def format_price(amount, currency):
375- if currency == "USD":
376- return f"${amount:.2f}"
377- elif currency == "EUR":
378- return f"E{amount:.2f}"
379- elif currency == "GBP":
380- return f"L{amount:.2f}"
381- else:
382- return f"{amount:.2f} {currency}"
383-
384- # After
385- CURRENCY_SYMBOLS: dict[str, str] = {
386- "USD": "$", "EUR": "E", "GBP": "L",
387- }
388-
389- def format_price(amount: float, currency: str) -> str:
390- symbol = CURRENCY_SYMBOLS.get(currency)
391- if symbol:
392- return f"{symbol}{amount:.2f}"
393- return f"{amount:.2f} {currency}"
394-
395- 13. Bare string constants for order status -- use StrEnum
396- services/order_service.py:38,86,89,95,111,114
397-
398- # Before -- typo-prone strings scattered across the codebase
399- status="pending"
400- if order.status != "pending":
401- if new_status == "shipped":
402-
403- # After
404- class OrderStatus(StrEnum):
405- PENDING = "pending"
406- SHIPPED = "shipped"
407- DELIVERED = "delivered"
408- CANCELLED = "cancelled"
409-
410- 14. Mixed concerns in utils/helpers.py -- split by domain
411- utils/helpers.py:1-38
412- Principle: P1 High Cohesion | Rule: #16 Avoid Generic Package Names
413-
414- Contains formatting (format_price), validation (validate_isbn),
415- DB queries (get_bestsellers), and reporting (generate_report).
416- Four unrelated responsibilities. The utils/ name is a dumping ground.
417-
418- Fix: Move get_bestsellers/generate_report into services/.
419- Move format_price into formatting.py. Move validate_isbn into models/.
420-
421- 15. Notification side effects interleaved with business logic -- Pub/Sub
422- services/order_service.py:44-49,89-100,121-125
423- Pattern: Pub/Sub
424-
425- _send_email calls are scattered throughout create_order, update_status,
426- and cancel_order. Each new side effect (Slack notification, analytics
427- event, audit log) requires modifying every method.
428-
429- # After -- event-based notification
430- EventHandler = Callable[[dict], None]
431- subscribers: dict[str, list[EventHandler]] = {}
432-
433- def subscribe(event: str, handler: EventHandler) -> None:
434- subscribers.setdefault(event, []).append(handler)
435-
436- def post_event(event: str, data: dict) -> None:
437- for handler in subscribers.get(event, []):
438- handler(data)
439-
440- # In create_order:
441- post_event("order.created", {"order_id": order.id, "email": customer_email})
442-
443- # Wire handlers in composition root:
444- subscribe("order.created", send_confirmation_email)
445- subscribe("order.created", log_order)
446-
447- ---
448- Summary: 3 critical (layer skip, no DI, no resource management),
449- 7 important (god class, broad catch, missing types, wrong HTTP responses,
450- missing status codes, raw dicts, deprecated API), 5 suggestions (Strategy,
451- Registry, StrEnum, split utils, Pub/Sub). The biggest wins would be
452- introducing a DataInterface Protocol and splitting OrderService.
453- ```
77+ ** Example output:** [ Full review of a test bookstore-api project] ( docs/example-review.md ) (15 findings with before/after code). See the [ color-coded version] ( https://mktoronto.github.io/python-clean-architecture/ ) on the landing page.
45478
45579### All Commands
45680
0 commit comments