refactor(docs): update architecture to reflect repository migration

- Rename CRUD layer to Repository layer throughout architecture documentation.
- Update dependency injection examples to use repository classes.
- Add async SQLAlchemy pattern for Repository methods (`select()` and transactions).
- Replace CRUD references in FEATURE_EXAMPLE.md with Repository-focused implementation details.
- Highlight repository class responsibilities and remove outdated CRUD patterns.
This commit is contained in:
2026-03-01 11:13:51 +01:00
parent 80d2dc0cb2
commit 68275b1dd3
4 changed files with 349 additions and 773 deletions

View File

@@ -117,7 +117,8 @@ backend/
│ ├── api/ # API layer │ ├── api/ # API layer
│ │ ├── dependencies/ # Dependency injection │ │ ├── dependencies/ # Dependency injection
│ │ │ ├── auth.py # Authentication dependencies │ │ │ ├── auth.py # Authentication dependencies
│ │ │ ── permissions.py # Authorization dependencies │ │ │ ── permissions.py # Authorization dependencies
│ │ │ └── services.py # Service singleton injection
│ │ ├── routes/ # API endpoints │ │ ├── routes/ # API endpoints
│ │ │ ├── auth.py # Authentication routes │ │ │ ├── auth.py # Authentication routes
│ │ │ ├── users.py # User management routes │ │ │ ├── users.py # User management routes
@@ -131,13 +132,14 @@ backend/
│ │ ├── config.py # Application configuration │ │ ├── config.py # Application configuration
│ │ ├── database.py # Database connection │ │ ├── database.py # Database connection
│ │ ├── exceptions.py # Custom exception classes │ │ ├── exceptions.py # Custom exception classes
│ │ ├── repository_exceptions.py # Repository-level exception hierarchy
│ │ └── middleware.py # Custom middleware │ │ └── middleware.py # Custom middleware
│ │ │ │
│ ├── crud/ # Database operations │ ├── repositories/ # Data access layer
│ │ ├── base.py # Generic CRUD base class │ │ ├── base.py # Generic repository base class
│ │ ├── user.py # User CRUD operations │ │ ├── user.py # User repository
│ │ ├── session.py # Session CRUD operations │ │ ├── session.py # Session repository
│ │ └── organization.py # Organization CRUD │ │ └── organization.py # Organization repository
│ │ │ │
│ ├── models/ # SQLAlchemy models │ ├── models/ # SQLAlchemy models
│ │ ├── base.py # Base model with mixins │ │ ├── base.py # Base model with mixins
@@ -153,8 +155,11 @@ backend/
│ │ ├── sessions.py # Session schemas │ │ ├── sessions.py # Session schemas
│ │ └── organizations.py # Organization schemas │ │ └── organizations.py # Organization schemas
│ │ │ │
│ ├── services/ # Business logic │ ├── services/ # Business logic layer
│ │ ├── auth_service.py # Authentication service │ │ ├── auth_service.py # Authentication service
│ │ ├── user_service.py # User management service
│ │ ├── session_service.py # Session management service
│ │ ├── organization_service.py # Organization service
│ │ ├── email_service.py # Email service │ │ ├── email_service.py # Email service
│ │ └── session_cleanup.py # Background cleanup │ │ └── session_cleanup.py # Background cleanup
│ │ │ │
@@ -168,9 +173,9 @@ backend/
├── tests/ # Test suite ├── tests/ # Test suite
│ ├── api/ # Integration tests │ ├── api/ # Integration tests
│ ├── crud/ # CRUD tests │ ├── repositories/ # Repository unit tests
│ ├── services/ # Service unit tests
│ ├── models/ # Model tests │ ├── models/ # Model tests
│ ├── services/ # Service tests
│ └── conftest.py # Test configuration │ └── conftest.py # Test configuration
├── docs/ # Documentation ├── docs/ # Documentation
@@ -214,11 +219,11 @@ The application follows a strict 5-layer architecture:
└──────────────────────────┬──────────────────────────────────┘ └──────────────────────────┬──────────────────────────────────┘
│ calls │ calls
┌──────────────────────────▼──────────────────────────────────┐ ┌──────────────────────────▼──────────────────────────────────┐
CRUD Layer (crud/) Repository Layer (repositories/)
│ - Database operations │ │ - Database operations │
│ - Query building │ │ - Query building │
│ - Transaction management │ - Custom repository exceptions
│ - Error handling │ - No business logic
└──────────────────────────┬──────────────────────────────────┘ └──────────────────────────┬──────────────────────────────────┘
│ uses │ uses
┌──────────────────────────▼──────────────────────────────────┐ ┌──────────────────────────▼──────────────────────────────────┐
@@ -262,7 +267,7 @@ async def get_current_user_info(
**Rules**: **Rules**:
- Should NOT contain business logic - Should NOT contain business logic
- Should NOT directly perform database operations (use CRUD or services) - Should NOT directly call repositories (use services injected via `dependencies/services.py`)
- Must validate all input via Pydantic schemas - Must validate all input via Pydantic schemas
- Must specify response models - Must specify response models
- Should apply appropriate rate limits - Should apply appropriate rate limits
@@ -279,9 +284,9 @@ async def get_current_user_info(
**Example**: **Example**:
```python ```python
def get_current_user( async def get_current_user(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
) -> User: ) -> User:
""" """
Extract and validate user from JWT token. Extract and validate user from JWT token.
@@ -295,7 +300,7 @@ def get_current_user(
except Exception: except Exception:
raise AuthenticationError("Invalid authentication credentials") raise AuthenticationError("Invalid authentication credentials")
user = user_crud.get(db, id=user_id) user = await user_repo.get(db, id=user_id)
if not user: if not user:
raise AuthenticationError("User not found") raise AuthenticationError("User not found")
@@ -313,7 +318,7 @@ def get_current_user(
**Responsibility**: Implement complex business logic **Responsibility**: Implement complex business logic
**Key Functions**: **Key Functions**:
- Orchestrate multiple CRUD operations - Orchestrate multiple repository operations
- Implement business rules - Implement business rules
- Handle external service integration - Handle external service integration
- Coordinate transactions - Coordinate transactions
@@ -323,9 +328,9 @@ def get_current_user(
class AuthService: class AuthService:
"""Authentication service with business logic.""" """Authentication service with business logic."""
def login( async def login(
self, self,
db: Session, db: AsyncSession,
email: str, email: str,
password: str, password: str,
request: Request request: Request
@@ -339,8 +344,8 @@ class AuthService:
3. Generate tokens 3. Generate tokens
4. Return tokens and user info 4. Return tokens and user info
""" """
# Validate credentials # Validate credentials via repository
user = user_crud.get_by_email(db, email=email) user = await user_repo.get_by_email(db, email=email)
if not user or not verify_password(password, user.hashed_password): if not user or not verify_password(password, user.hashed_password):
raise AuthenticationError("Invalid credentials") raise AuthenticationError("Invalid credentials")
@@ -350,11 +355,10 @@ class AuthService:
# Extract device info # Extract device info
device_info = extract_device_info(request) device_info = extract_device_info(request)
# Create session # Create session via repository
session = session_crud.create_session( session = await session_repo.create(
db, db,
user_id=user.id, obj_in=SessionCreate(user_id=user.id, **device_info)
device_info=device_info
) )
# Generate tokens # Generate tokens
@@ -373,75 +377,60 @@ class AuthService:
**Rules**: **Rules**:
- Contains business logic, not just data operations - Contains business logic, not just data operations
- Can call multiple CRUD operations - Can call multiple repository operations
- Should handle complex workflows - Should handle complex workflows
- Must maintain data consistency - Must maintain data consistency
- Should use transactions when needed - Should use transactions when needed
#### 4. CRUD Layer (`app/crud/`) #### 4. Repository Layer (`app/repositories/`)
**Responsibility**: Database operations and queries **Responsibility**: Database operations and queries — no business logic
**Key Functions**: **Key Functions**:
- Create, read, update, delete operations - Create, read, update, delete operations
- Build database queries - Build database queries
- Handle database errors - Raise custom repository exceptions (`DuplicateEntryError`, `IntegrityConstraintError`)
- Manage soft deletes - Manage soft deletes
- Implement pagination and filtering - Implement pagination and filtering
**Example**: **Example**:
```python ```python
class CRUDSession(CRUDBase[UserSession, SessionCreate, SessionUpdate]): class SessionRepository(RepositoryBase[UserSession, SessionCreate, SessionUpdate]):
"""CRUD operations for user sessions.""" """Repository for user sessions — database operations only."""
def get_by_jti(self, db: Session, jti: UUID) -> Optional[UserSession]: async def get_by_jti(self, db: AsyncSession, *, jti: str) -> UserSession | None:
"""Get session by refresh token JTI.""" """Get session by refresh token JTI."""
try: result = await db.execute(
return ( select(UserSession).where(UserSession.refresh_token_jti == jti)
db.query(UserSession) )
.filter(UserSession.refresh_token_jti == jti) return result.scalar_one_or_none()
.first()
)
except Exception as e:
logger.error(f"Error getting session by JTI: {str(e)}")
return None
def get_active_by_jti( async def deactivate(self, db: AsyncSession, *, session_id: UUID) -> bool:
self,
db: Session,
jti: UUID
) -> Optional[UserSession]:
"""Get active session by refresh token JTI."""
session = self.get_by_jti(db, jti=jti)
if session and session.is_active and not session.is_expired:
return session
return None
def deactivate(self, db: Session, session_id: UUID) -> bool:
"""Deactivate a session (logout).""" """Deactivate a session (logout)."""
try: try:
session = self.get(db, id=session_id) session = await self.get(db, id=session_id)
if not session: if not session:
return False return False
session.is_active = False session.is_active = False
db.commit() await db.commit()
logger.info(f"Session {session_id} deactivated") logger.info(f"Session {session_id} deactivated")
return True return True
except Exception as e: except Exception as e:
db.rollback() await db.rollback()
logger.error(f"Error deactivating session: {str(e)}") logger.error(f"Error deactivating session: {str(e)}")
return False return False
``` ```
**Rules**: **Rules**:
- Should NOT contain business logic - Should NOT contain business logic
- Must handle database exceptions - Must raise custom repository exceptions (not raw `ValueError`/`IntegrityError`)
- Must use parameterized queries (SQLAlchemy does this) - Must use async SQLAlchemy 2.0 `select()` API (never `db.query()`)
- Should log all database errors - Should log all database errors
- Must rollback on errors - Must rollback on errors
- Should use soft deletes when possible - Should use soft deletes when possible
- **Never imported directly by routes** — always called through services
#### 5. Data Layer (`app/models/` + `app/schemas/`) #### 5. Data Layer (`app/models/` + `app/schemas/`)
@@ -546,51 +535,23 @@ SessionLocal = sessionmaker(
#### Dependency Injection Pattern #### Dependency Injection Pattern
```python ```python
def get_db() -> Generator[Session, None, None]: async def get_db() -> AsyncGenerator[AsyncSession, None]:
""" """
Database session dependency for FastAPI routes. Async database session dependency for FastAPI routes.
Automatically commits on success, rolls back on error. The session is passed to service methods; commit/rollback is
managed inside service or repository methods.
""" """
db = SessionLocal() async with AsyncSessionLocal() as db:
try:
yield db yield db
finally:
db.close()
# Usage in routes # Usage in routes — always through a service, never direct repository
@router.get("/users") @router.get("/users")
def list_users(db: Session = Depends(get_db)): async def list_users(
return user_crud.get_multi(db) user_service: UserService = Depends(get_user_service),
``` db: AsyncSession = Depends(get_db),
):
#### Context Manager Pattern return await user_service.get_users(db)
```python
@contextmanager
def transaction_scope() -> Generator[Session, None, None]:
"""
Context manager for database transactions.
Use for complex operations requiring multiple steps.
Automatically commits on success, rolls back on error.
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
# Usage in services
def complex_operation():
with transaction_scope() as db:
user = user_crud.create(db, obj_in=user_data)
session = session_crud.create(db, session_data)
return user, session
``` ```
### Model Mixins ### Model Mixins
@@ -782,22 +743,15 @@ def get_profile(
```python ```python
@router.delete("/sessions/{session_id}") @router.delete("/sessions/{session_id}")
def revoke_session( async def revoke_session(
session_id: UUID, session_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) session_service: SessionService = Depends(get_session_service),
db: AsyncSession = Depends(get_db),
): ):
"""Users can only revoke their own sessions.""" """Users can only revoke their own sessions."""
session = session_crud.get(db, id=session_id) # SessionService verifies ownership and raises NotFoundError / AuthorizationError
await session_service.revoke_session(db, session_id=session_id, user_id=current_user.id)
if not session:
raise NotFoundError("Session not found")
# Check ownership
if session.user_id != current_user.id:
raise AuthorizationError("You can only revoke your own sessions")
session_crud.deactivate(db, session_id=session_id)
return MessageResponse(success=True, message="Session revoked") return MessageResponse(success=True, message="Session revoked")
``` ```
@@ -1092,8 +1046,8 @@ async def cleanup_expired_sessions():
Runs daily at 2 AM. Removes sessions expired for more than 30 days. Runs daily at 2 AM. Removes sessions expired for more than 30 days.
""" """
try: try:
with transaction_scope() as db: async with AsyncSessionLocal() as db:
count = session_crud.cleanup_expired(db, keep_days=30) count = await session_repo.cleanup_expired(db, keep_days=30)
logger.info(f"Cleaned up {count} expired sessions") logger.info(f"Cleaned up {count} expired sessions")
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up sessions: {str(e)}", exc_info=True) logger.error(f"Error cleaning up sessions: {str(e)}", exc_info=True)
@@ -1110,7 +1064,7 @@ async def cleanup_expired_sessions():
│Integration │ ← API endpoint tests │Integration │ ← API endpoint tests
│ Tests │ │ Tests │
├─────────────┤ ├─────────────┤
│ Unit │ ← CRUD, services, utilities │ Unit │ ← repositories, services, utilities
│ Tests │ │ Tests │
└─────────────┘ └─────────────┘
``` ```

View File

@@ -75,15 +75,14 @@ def create_user(db: Session, user_in: UserCreate) -> User:
### 4. Code Formatting ### 4. Code Formatting
Use automated formatters: Use automated formatters:
- **Black**: Code formatting - **Ruff**: Code formatting and linting (replaces Black, isort, flake8)
- **isort**: Import sorting - **pyright**: Static type checking
- **flake8**: Linting
Run before committing: Run before committing (or use `make validate`):
```bash ```bash
black app tests uv run ruff format app tests
isort app tests uv run ruff check app tests
flake8 app tests uv run pyright app
``` ```
## Code Organization ## Code Organization
@@ -94,19 +93,17 @@ Follow the 5-layer architecture strictly:
``` ```
API Layer (routes/) API Layer (routes/)
↓ calls ↓ calls (via service injected from dependencies/services.py)
Dependencies (dependencies/)
↓ injects
Service Layer (services/) Service Layer (services/)
↓ calls ↓ calls
CRUD Layer (crud/) Repository Layer (repositories/)
↓ uses ↓ uses
Models & Schemas (models/, schemas/) Models & Schemas (models/, schemas/)
``` ```
**Rules:** **Rules:**
- Routes should NOT directly call CRUD operations (use services when business logic is needed) - Routes must NEVER import repositories directly — always use a service
- CRUD operations should NOT contain business logic - Services call repositories; repositories contain only database operations
- Models should NOT import from higher layers - Models should NOT import from higher layers
- Each layer should only depend on the layer directly below it - Each layer should only depend on the layer directly below it
@@ -125,7 +122,7 @@ from sqlalchemy.orm import Session
# 3. Local application imports # 3. Local application imports
from app.api.dependencies.auth import get_current_user from app.api.dependencies.auth import get_current_user
from app.crud import user_crud from app.api.dependencies.services import get_user_service
from app.models.user import User from app.models.user import User
from app.schemas.users import UserResponse, UserCreate from app.schemas.users import UserResponse, UserCreate
``` ```
@@ -442,19 +439,19 @@ backend/app/alembic/versions/
4. **Testability**: Easy to mock and test 4. **Testability**: Easy to mock and test
5. **Consistent Ordering**: Always order queries for pagination 5. **Consistent Ordering**: Always order queries for pagination
### Use the Async CRUD Base Class ### Use the Async Repository Base Class
Always inherit from `CRUDBase` for database operations: Always inherit from `RepositoryBase` for database operations:
```python ```python
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.crud.base import CRUDBase from app.repositories.base import RepositoryBase
from app.models.user import User from app.models.user import User
from app.schemas.users import UserCreate, UserUpdate from app.schemas.users import UserCreate, UserUpdate
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): class UserRepository(RepositoryBase[User, UserCreate, UserUpdate]):
"""CRUD operations for User model.""" """Repository for User model — database operations only."""
async def get_by_email( async def get_by_email(
self, self,
@@ -467,7 +464,7 @@ class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
user_crud = CRUDUser(User) user_repo = UserRepository(User)
``` ```
**Key Points:** **Key Points:**
@@ -476,6 +473,7 @@ user_crud = CRUDUser(User)
- Use `await db.execute()` for queries - Use `await db.execute()` for queries
- Use `.scalar_one_or_none()` instead of `.first()` - Use `.scalar_one_or_none()` instead of `.first()`
- Use `T | None` instead of `Optional[T]` - Use `T | None` instead of `Optional[T]`
- Repository instances are used internally by services — never import them in routes
### Modern SQLAlchemy Patterns ### Modern SQLAlchemy Patterns
@@ -563,7 +561,7 @@ async def create_user(
The database session is automatically managed by FastAPI. The database session is automatically managed by FastAPI.
Commit on success, rollback on error. Commit on success, rollback on error.
""" """
return await user_crud.create(db, obj_in=user_in) return await user_service.create_user(db, obj_in=user_in)
``` ```
**Key Points:** **Key Points:**
@@ -582,12 +580,11 @@ async def complex_operation(
""" """
Perform multiple database operations atomically. Perform multiple database operations atomically.
The session automatically commits on success or rolls back on error. Services call repositories; commit/rollback is handled inside
each repository method.
""" """
user = await user_crud.create(db, obj_in=user_data) user = await user_repo.create(db, obj_in=user_data)
session = await session_crud.create(db, obj_in=session_data) session = await session_repo.create(db, obj_in=session_data)
# Commit is handled by the route's dependency
return user, session return user, session
``` ```
@@ -597,10 +594,10 @@ Prefer soft deletes over hard deletes for audit trails:
```python ```python
# Good - Soft delete (sets deleted_at) # Good - Soft delete (sets deleted_at)
await user_crud.soft_delete(db, id=user_id) await user_repo.soft_delete(db, id=user_id)
# Acceptable only when required - Hard delete # Acceptable only when required - Hard delete
user_crud.remove(db, id=user_id) await user_repo.remove(db, id=user_id)
``` ```
### Query Patterns ### Query Patterns
@@ -740,9 +737,10 @@ Always implement pagination for list endpoints:
from app.schemas.common import PaginationParams, PaginatedResponse from app.schemas.common import PaginationParams, PaginatedResponse
@router.get("/users", response_model=PaginatedResponse[UserResponse]) @router.get("/users", response_model=PaginatedResponse[UserResponse])
def list_users( async def list_users(
pagination: PaginationParams = Depends(), pagination: PaginationParams = Depends(),
db: Session = Depends(get_db) user_service: UserService = Depends(get_user_service),
db: AsyncSession = Depends(get_db),
): ):
""" """
List all users with pagination. List all users with pagination.
@@ -750,10 +748,8 @@ def list_users(
Default page size: 20 Default page size: 20
Maximum page size: 100 Maximum page size: 100
""" """
users, total = user_crud.get_multi_with_total( users, total = await user_service.get_users(
db, db, skip=pagination.offset, limit=pagination.limit
skip=pagination.offset,
limit=pagination.limit
) )
return PaginatedResponse(data=users, pagination=pagination.create_meta(total)) return PaginatedResponse(data=users, pagination=pagination.create_meta(total))
``` ```
@@ -816,19 +812,17 @@ def admin_route(
pass pass
# Check ownership # Check ownership
def delete_resource( async def delete_resource(
resource_id: UUID, resource_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) resource_service: ResourceService = Depends(get_resource_service),
db: AsyncSession = Depends(get_db),
): ):
resource = resource_crud.get(db, id=resource_id) # Service handles ownership check and raises appropriate errors
if not resource: await resource_service.delete_resource(
raise NotFoundError("Resource not found") db, resource_id=resource_id, user_id=current_user.id,
is_superuser=current_user.is_superuser,
if resource.user_id != current_user.id and not current_user.is_superuser: )
raise AuthorizationError("You can only delete your own resources")
resource_crud.remove(db, id=resource_id)
``` ```
### Input Validation ### Input Validation
@@ -862,9 +856,9 @@ tests/
├── api/ # Integration tests ├── api/ # Integration tests
│ ├── test_users.py │ ├── test_users.py
│ └── test_auth.py │ └── test_auth.py
├── crud/ # Unit tests for CRUD ├── repositories/ # Unit tests for repositories
├── models/ # Model tests ├── services/ # Unit tests for services
└── services/ # Service tests └── models/ # Model tests
``` ```
### Async Testing with pytest-asyncio ### Async Testing with pytest-asyncio
@@ -927,7 +921,7 @@ async def test_user(db_session: AsyncSession) -> User:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user(db_session: AsyncSession, test_user: User): async def test_get_user(db_session: AsyncSession, test_user: User):
"""Test retrieving a user by ID.""" """Test retrieving a user by ID."""
user = await user_crud.get(db_session, id=test_user.id) user = await user_repo.get(db_session, id=test_user.id)
assert user is not None assert user is not None
assert user.email == test_user.email assert user.email == test_user.email
``` ```

View File

@@ -616,7 +616,43 @@ def create_user(
return user return user
``` ```
**Rule**: Add type hints to ALL functions. Use `mypy` to enforce type checking. **Rule**: Add type hints to ALL functions. Use `pyright` to enforce type checking (`make type-check`).
---
---
### ❌ PITFALL #19: Importing Repositories Directly in Routes
**Issue**: Routes should never call repositories directly. The layered architecture requires all business operations to go through the service layer.
```python
# ❌ WRONG - Route bypasses service layer
from app.repositories.session import session_repo
@router.get("/sessions/me")
async def list_sessions(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
return await session_repo.get_user_sessions(db, user_id=current_user.id)
```
```python
# ✅ CORRECT - Route calls service injected via dependency
from app.api.dependencies.services import get_session_service
from app.services.session_service import SessionService
@router.get("/sessions/me")
async def list_sessions(
current_user: User = Depends(get_current_active_user),
session_service: SessionService = Depends(get_session_service),
db: AsyncSession = Depends(get_db),
):
return await session_service.get_user_sessions(db, user_id=current_user.id)
```
**Rule**: Routes import from `app.api.dependencies.services`, never from `app.repositories.*`. Services are the only callers of repositories.
--- ---
@@ -649,6 +685,11 @@ Use this checklist to catch issues before code review:
- [ ] Resource ownership verification - [ ] Resource ownership verification
- [ ] CORS configured (no wildcards in production) - [ ] CORS configured (no wildcards in production)
### Architecture
- [ ] Routes never import repositories directly (only services)
- [ ] Services call repositories; repositories call database only
- [ ] New service registered in `app/api/dependencies/services.py`
### Python ### Python
- [ ] Use `==` not `is` for value comparison - [ ] Use `==` not `is` for value comparison
- [ ] No mutable default arguments - [ ] No mutable default arguments
@@ -661,21 +702,18 @@ Use this checklist to catch issues before code review:
### Pre-commit Checks ### Pre-commit Checks
Add these to your development workflow: Add these to your development workflow (or use `make validate`):
```bash ```bash
# Format code # Format + lint (Ruff replaces Black, isort, flake8)
black app tests uv run ruff format app tests
isort app tests uv run ruff check app tests
# Type checking # Type checking
mypy app --strict uv run pyright app
# Linting
flake8 app tests
# Run tests # Run tests
pytest --cov=app --cov-report=term-missing IS_TEST=True uv run pytest --cov=app --cov-report=term-missing
# Check coverage (should be 80%+) # Check coverage (should be 80%+)
coverage report --fail-under=80 coverage report --fail-under=80
@@ -693,6 +731,6 @@ Add new entries when:
--- ---
**Last Updated**: 2025-10-31 **Last Updated**: 2026-02-28
**Issues Cataloged**: 18 common pitfalls **Issues Cataloged**: 19 common pitfalls
**Remember**: This document exists because these issues HAVE occurred. Don't skip it. **Remember**: This document exists because these issues HAVE occurred. Don't skip it.

File diff suppressed because it is too large Load Diff