Move 9 init/seed scripts into scripts/seed/ and 7 validation scripts (+ validators/ subfolder) into scripts/validate/ to reduce clutter in the root scripts/ directory. Update all references across Makefile, CI/CD configs, pre-commit hooks, docs (~40 files), and Python imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
707 lines
21 KiB
Markdown
707 lines
21 KiB
Markdown
# Architecture Patterns & Design Decisions
|
|
|
|
This document describes the architectural patterns and design decisions that must be followed throughout the codebase.
|
|
|
|
> **Note:** These patterns are enforced automatically by `scripts/validate/validate_architecture.py`. Run the validator before committing code.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Core Principles](#core-principles)
|
|
2. [Layered Architecture](#layered-architecture)
|
|
3. [API Endpoint Patterns](#api-endpoint-patterns)
|
|
4. [Service Layer Patterns](#service-layer-patterns)
|
|
5. [Model Patterns](#model-patterns)
|
|
6. [Exception Handling](#exception-handling)
|
|
7. [JavaScript Patterns](#javascript-patterns)
|
|
8. [Validation](#validation)
|
|
|
|
---
|
|
|
|
## Core Principles
|
|
|
|
### 1. Separation of Concerns
|
|
|
|
**Each layer has specific responsibilities:**
|
|
|
|
- **Routes/Endpoints**: HTTP handling, validation, authentication, response formatting
|
|
- **Services**: Business logic, data processing, orchestration
|
|
- **Models**: Data structure and persistence
|
|
|
|
**❌ Bad Example - Business logic in endpoint:**
|
|
|
|
```python
|
|
@router.post("/stores")
|
|
async def create_store(store: StoreCreate, db: Session = Depends(get_db)):
|
|
# ❌ BAD: Business logic in endpoint
|
|
if db.query(Store).filter(Store.subdomain == store.subdomain).first():
|
|
raise HTTPException(status_code=409, detail="Store exists")
|
|
|
|
db_store = Store(**store.dict())
|
|
db.add(db_store)
|
|
db.commit()
|
|
db.refresh(db_store)
|
|
return db_store
|
|
```
|
|
|
|
**✅ Good Example - Delegated to service:**
|
|
|
|
```python
|
|
@router.post("/stores", response_model=StoreResponse)
|
|
async def create_store(
|
|
store: StoreCreate,
|
|
current_user: User = Depends(get_current_admin),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
try:
|
|
# ✅ GOOD: Delegate to service
|
|
result = store_service.create_store(db, store)
|
|
return result
|
|
except StoreAlreadyExistsError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to create store: {e}")
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
```
|
|
|
|
### 2. Type Safety
|
|
|
|
Use Pydantic for API validation, SQLAlchemy for database models.
|
|
|
|
### 3. Proper Exception Handling
|
|
|
|
Services throw domain exceptions, routes convert to HTTP responses.
|
|
|
|
---
|
|
|
|
## Layered Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ API Layer │
|
|
│ (app/api/v1/**/*.py, app/routes/**/*.py) │
|
|
│ │
|
|
│ Responsibilities: │
|
|
│ - HTTP request/response handling │
|
|
│ - Authentication/Authorization │
|
|
│ - Input validation (Pydantic models) │
|
|
│ - Exception handling (domain → HTTP) │
|
|
│ │
|
|
│ ❌ Should NOT: │
|
|
│ - Contain business logic │
|
|
│ - Directly access database (except via services) │
|
|
│ - Raise domain exceptions │
|
|
└─────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Service Layer │
|
|
│ (app/services/**/*.py) │
|
|
│ │
|
|
│ Responsibilities: │
|
|
│ - Business logic │
|
|
│ - Data validation (business rules) │
|
|
│ - Database operations │
|
|
│ - Orchestration of multiple operations │
|
|
│ │
|
|
│ ❌ Should NOT: │
|
|
│ - Know about HTTP (no HTTPException) │
|
|
│ - Create database sessions (accept as parameter) │
|
|
│ - Handle HTTP-specific concerns │
|
|
└─────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Data Layer │
|
|
│ (app/models/**/*.py) │
|
|
│ │
|
|
│ Responsibilities: │
|
|
│ - Database schema (SQLAlchemy models) │
|
|
│ - API schemas (Pydantic models) │
|
|
│ - Data structure definitions │
|
|
│ │
|
|
│ ❌ Should NOT: │
|
|
│ - Mix SQLAlchemy and Pydantic in same class │
|
|
│ - Contain business logic │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## API Endpoint Patterns
|
|
|
|
### Rule API-001: Use Pydantic Models
|
|
|
|
**All endpoints MUST use Pydantic models for request/response.**
|
|
|
|
```python
|
|
# ✅ GOOD: Pydantic models for type safety
|
|
class StoreCreate(BaseModel):
|
|
name: str = Field(..., max_length=200)
|
|
subdomain: str = Field(..., max_length=100)
|
|
is_active: bool = True
|
|
|
|
class StoreResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
subdomain: str
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True # For SQLAlchemy compatibility
|
|
|
|
@router.post("/stores", response_model=StoreResponse)
|
|
async def create_store(store: StoreCreate, db: Session = Depends(get_db)):
|
|
result = store_service.create_store(db, store)
|
|
return result
|
|
```
|
|
|
|
```python
|
|
# ❌ BAD: Raw dict, no validation
|
|
@router.post("/stores")
|
|
async def create_store(data: dict):
|
|
return {"name": data["name"]} # No type safety!
|
|
```
|
|
|
|
### Rule API-002: No Business Logic in Endpoints
|
|
|
|
**Endpoints should only handle HTTP concerns.**
|
|
|
|
```python
|
|
# ✅ GOOD: Delegate to service
|
|
@router.post("/stores")
|
|
async def create_store(store: StoreCreate, db: Session = Depends(get_db)):
|
|
result = store_service.create_store(db, store)
|
|
return result
|
|
```
|
|
|
|
```python
|
|
# ❌ BAD: Business logic in endpoint
|
|
@router.post("/stores")
|
|
async def create_store(store: StoreCreate, db: Session = Depends(get_db)):
|
|
# ❌ Database operations belong in service!
|
|
db_store = Store(**store.dict())
|
|
db.add(db_store)
|
|
db.commit()
|
|
return db_store
|
|
```
|
|
|
|
### Rule API-003: Proper Exception Handling
|
|
|
|
**Catch service exceptions and convert to HTTPException.**
|
|
|
|
```python
|
|
# ✅ GOOD: Proper exception handling
|
|
@router.post("/stores", response_model=StoreResponse)
|
|
async def create_store(store: StoreCreate, db: Session = Depends(get_db)):
|
|
try:
|
|
result = store_service.create_store(db, store)
|
|
return result
|
|
except StoreAlreadyExistsError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error creating store: {e}")
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|
|
```
|
|
|
|
### Rule API-004: Authentication
|
|
|
|
**Protected endpoints must use dependency injection for auth.**
|
|
|
|
```python
|
|
# ✅ GOOD: Use Depends for auth
|
|
@router.post("/stores")
|
|
async def create_store(
|
|
store: StoreCreate,
|
|
current_user: User = Depends(get_current_admin), # ✅ Auth required
|
|
db: Session = Depends(get_db)
|
|
):
|
|
result = store_service.create_store(db, store)
|
|
return result
|
|
```
|
|
|
|
### Rule API-005: Store Context from Token (Not URL)
|
|
|
|
**Store API endpoints MUST extract store context from JWT token, NOT from URL.**
|
|
|
|
> **Rationale:** Embedding store context in JWT tokens enables clean RESTful API endpoints, eliminates URL-based store detection issues, and improves security by cryptographically signing store access.
|
|
|
|
**❌ BAD: URL-based store detection**
|
|
|
|
```python
|
|
from middleware.store_context import require_store_context
|
|
|
|
@router.get("/products")
|
|
def get_products(
|
|
store: Store = Depends(require_store_context()), # ❌ Requires store in URL
|
|
current_user: User = Depends(get_current_store_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
# This fails on /api/v1/store/products (no store in URL)
|
|
products = product_service.get_store_products(db, store.id)
|
|
return products
|
|
```
|
|
|
|
**Issues with URL-based approach:**
|
|
- ❌ Only works with routes like `/store/{store_code}/dashboard`
|
|
- ❌ Fails on API routes like `/api/v1/store/products` (no store in URL)
|
|
- ❌ Inconsistent between page routes and API routes
|
|
- ❌ Violates RESTful API design
|
|
- ❌ Requires database lookup on every request
|
|
|
|
**✅ GOOD: Token-based store context**
|
|
|
|
```python
|
|
@router.get("/products")
|
|
def get_products(
|
|
current_user: User = Depends(get_current_store_api), # ✅ Store in token
|
|
db: Session = Depends(get_db),
|
|
):
|
|
# Extract store from JWT token
|
|
if not hasattr(current_user, "token_store_id"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Token missing store information. Please login again.",
|
|
)
|
|
|
|
store_id = current_user.token_store_id
|
|
|
|
# Use store_id from token
|
|
products = product_service.get_store_products(db, store_id)
|
|
return products
|
|
```
|
|
|
|
**Benefits of token-based approach:**
|
|
- ✅ Works on all routes (page and API)
|
|
- ✅ Clean RESTful API endpoints
|
|
- ✅ Store context cryptographically signed in JWT
|
|
- ✅ No database lookup needed for store detection
|
|
- ✅ Consistent authentication mechanism
|
|
- ✅ Security: Cannot be tampered with by client
|
|
|
|
**Token structure:**
|
|
```json
|
|
{
|
|
"sub": "user_id",
|
|
"username": "john.doe",
|
|
"store_id": 123, ← Store context
|
|
"store_code": "WIZAMART", ← Store code
|
|
"store_role": "Owner" ← Store role
|
|
}
|
|
```
|
|
|
|
**Available token attributes:**
|
|
- `current_user.token_store_id` - Store ID (use for database queries)
|
|
- `current_user.token_store_code` - Store code (use for logging)
|
|
- `current_user.token_store_role` - Store role (Owner, Manager, etc.)
|
|
|
|
**Migration checklist:**
|
|
1. Remove `store: Store = Depends(require_store_context())`
|
|
2. Remove unused imports: `from middleware.store_context import require_store_context`
|
|
3. Extract store from token: `store_id = current_user.token_store_id`
|
|
4. Add token validation check (see example above)
|
|
5. Update logging to use `current_user.token_store_code`
|
|
|
|
**See also:** `docs/backend/store-in-token-architecture.md` for complete migration guide
|
|
|
|
**Files requiring migration:**
|
|
- `app/api/v1/store/customers.py`
|
|
- `app/api/v1/store/notifications.py`
|
|
- `app/api/v1/store/media.py`
|
|
- `app/api/v1/store/marketplace.py`
|
|
- `app/api/v1/store/inventory.py`
|
|
- `app/api/v1/store/settings.py`
|
|
- `app/api/v1/store/analytics.py`
|
|
- `app/api/v1/store/payments.py`
|
|
- `app/api/v1/store/profile.py`
|
|
|
|
---
|
|
|
|
## Service Layer Patterns
|
|
|
|
### Rule SVC-001: No HTTPException in Services
|
|
|
|
**Services should NOT know about HTTP. Raise domain exceptions instead.**
|
|
|
|
```python
|
|
# ✅ GOOD: Domain exception
|
|
class StoreAlreadyExistsError(Exception):
|
|
"""Raised when store with same subdomain already exists"""
|
|
pass
|
|
|
|
class StoreService:
|
|
def create_store(self, db: Session, store_data: StoreCreate):
|
|
if self._store_exists(db, store_data.subdomain):
|
|
raise StoreAlreadyExistsError(
|
|
f"Store with subdomain '{store_data.subdomain}' already exists"
|
|
)
|
|
|
|
# Business logic...
|
|
store = Store(**store_data.dict())
|
|
db.add(store)
|
|
db.commit()
|
|
return store
|
|
```
|
|
|
|
```python
|
|
# ❌ BAD: HTTPException in service
|
|
class StoreService:
|
|
def create_store(self, db: Session, store_data: StoreCreate):
|
|
if self._store_exists(db, store_data.subdomain):
|
|
# ❌ Service shouldn't know about HTTP!
|
|
raise HTTPException(status_code=409, detail="Store exists")
|
|
```
|
|
|
|
### Rule SVC-002: Create Custom Exception Classes
|
|
|
|
**Don't use generic `Exception`. Create specific domain exceptions.**
|
|
|
|
```python
|
|
# ✅ GOOD: Specific exceptions
|
|
class StoreError(Exception):
|
|
"""Base exception for store-related errors"""
|
|
pass
|
|
|
|
class StoreNotFoundError(StoreError):
|
|
"""Raised when store is not found"""
|
|
pass
|
|
|
|
class StoreAlreadyExistsError(StoreError):
|
|
"""Raised when store already exists"""
|
|
pass
|
|
|
|
class StoreService:
|
|
def get_store(self, db: Session, store_code: str):
|
|
store = db.query(Store).filter(Store.store_code == store_code).first()
|
|
if not store:
|
|
raise StoreNotFoundError(f"Store '{store_code}' not found")
|
|
return store
|
|
```
|
|
|
|
```python
|
|
# ❌ BAD: Generic Exception
|
|
class StoreService:
|
|
def get_store(self, db: Session, store_code: str):
|
|
store = db.query(Store).filter(Store.store_code == store_code).first()
|
|
if not store:
|
|
raise Exception("Store not found") # ❌ Too generic!
|
|
return store
|
|
```
|
|
|
|
### Rule SVC-003: Database Session as Parameter
|
|
|
|
**Services should receive database session as parameter, not create it internally.**
|
|
|
|
```python
|
|
# ✅ GOOD: db session as parameter
|
|
class StoreService:
|
|
def create_store(self, db: Session, store_data: StoreCreate):
|
|
store = Store(**store_data.dict())
|
|
db.add(store)
|
|
db.commit()
|
|
db.refresh(store)
|
|
return store
|
|
|
|
def _store_exists(self, db: Session, subdomain: str) -> bool:
|
|
return db.query(Store).filter(Store.subdomain == subdomain).first() is not None
|
|
```
|
|
|
|
```python
|
|
# ❌ BAD: Creating session internally
|
|
class StoreService:
|
|
def create_store(self, store_data: StoreCreate):
|
|
# ❌ Don't create session here - makes testing hard
|
|
db = SessionLocal()
|
|
store = Store(**store_data.dict())
|
|
db.add(store)
|
|
db.commit()
|
|
return store
|
|
```
|
|
|
|
**Benefits:**
|
|
|
|
- Testability (can inject mock session)
|
|
- Transaction control (caller controls commit/rollback)
|
|
- Resource management (caller handles session lifecycle)
|
|
|
|
### Rule SVC-004: Use Pydantic for Input Validation
|
|
|
|
**Service methods should accept Pydantic models for complex inputs.**
|
|
|
|
```python
|
|
# ✅ GOOD: Pydantic model ensures validation
|
|
class StoreService:
|
|
def create_store(self, db: Session, store_data: StoreCreate):
|
|
# store_data is already validated by Pydantic
|
|
store = Store(**store_data.dict())
|
|
db.add(store)
|
|
db.commit()
|
|
return store
|
|
```
|
|
|
|
---
|
|
|
|
## Model Patterns
|
|
|
|
### Rule MDL-001: SQLAlchemy for Database Models
|
|
|
|
```python
|
|
# ✅ GOOD: SQLAlchemy model
|
|
from sqlalchemy import Column, Integer, String, Boolean
|
|
from app.database import Base
|
|
|
|
class Store(Base):
|
|
__tablename__ = "stores"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(200), nullable=False)
|
|
subdomain = Column(String(100), unique=True, nullable=False)
|
|
is_active = Column(Boolean, default=True)
|
|
```
|
|
|
|
### Rule MDL-002: Separate Pydantic from SQLAlchemy
|
|
|
|
**NEVER mix SQLAlchemy and Pydantic in the same class.**
|
|
|
|
```python
|
|
# ❌ BAD: Mixing SQLAlchemy and Pydantic
|
|
class Store(Base, BaseModel): # ❌ Don't do this!
|
|
__tablename__ = "stores"
|
|
name: str = Column(String(200))
|
|
```
|
|
|
|
```python
|
|
# ✅ GOOD: Separate models
|
|
# Database model (app/models/store.py)
|
|
class Store(Base):
|
|
__tablename__ = "stores"
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String(200), nullable=False)
|
|
|
|
# API model (app/api/v1/admin/stores.py)
|
|
class StoreCreate(BaseModel):
|
|
name: str = Field(..., max_length=200)
|
|
|
|
class StoreResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
```
|
|
|
|
---
|
|
|
|
## Exception Handling
|
|
|
|
### Rule EXC-001: Domain-Specific Exceptions
|
|
|
|
**Create exception hierarchy in `app/exceptions/`**
|
|
|
|
```python
|
|
# app/exceptions/store_exceptions.py
|
|
|
|
class StoreError(Exception):
|
|
"""Base exception for store domain"""
|
|
pass
|
|
|
|
class StoreNotFoundError(StoreError):
|
|
"""Store does not exist"""
|
|
pass
|
|
|
|
class StoreAlreadyExistsError(StoreError):
|
|
"""Store already exists"""
|
|
pass
|
|
|
|
class StoreValidationError(StoreError):
|
|
"""Store data validation failed"""
|
|
pass
|
|
```
|
|
|
|
### Rule EXC-002: Never Use Bare Except
|
|
|
|
```python
|
|
# ❌ BAD: Bare except
|
|
try:
|
|
result = do_something()
|
|
except: # ❌ Catches EVERYTHING including KeyboardInterrupt!
|
|
pass
|
|
```
|
|
|
|
```python
|
|
# ✅ GOOD: Specific exceptions
|
|
try:
|
|
result = do_something()
|
|
except ValueError as e:
|
|
logger.error(f"Validation error: {e}")
|
|
except DatabaseError as e:
|
|
logger.error(f"Database error: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error: {e}")
|
|
raise
|
|
```
|
|
|
|
---
|
|
|
|
## JavaScript Patterns
|
|
|
|
### Rule JS-001: Use apiClient Directly
|
|
|
|
```javascript
|
|
// ✅ GOOD
|
|
const stores = await apiClient.get('/api/v1/stores');
|
|
```
|
|
|
|
```javascript
|
|
// ❌ BAD
|
|
const stores = await window.apiClient.get('/api/v1/stores');
|
|
```
|
|
|
|
### Rule JS-002: Use Centralized Logger
|
|
|
|
```javascript
|
|
// ✅ GOOD: Centralized logger
|
|
const storeLog = window.LogConfig.createLogger('stores');
|
|
storeLog.info('Loading stores...');
|
|
storeLog.error('Failed to load stores:', error);
|
|
```
|
|
|
|
```javascript
|
|
// ❌ BAD: console
|
|
console.log('Loading stores...'); // ❌ Use logger instead
|
|
```
|
|
|
|
### Rule JS-003: Alpine Components Pattern
|
|
|
|
```javascript
|
|
// ✅ GOOD: Proper Alpine component
|
|
function storesManager() {
|
|
return {
|
|
// ✅ Inherit base layout functionality
|
|
...data(),
|
|
|
|
// ✅ Set page identifier for sidebar
|
|
currentPage: 'stores',
|
|
|
|
// Component state
|
|
stores: [],
|
|
loading: false,
|
|
|
|
// ✅ Init with guard
|
|
async init() {
|
|
if (window._storesInitialized) {
|
|
return;
|
|
}
|
|
window._storesInitialized = true;
|
|
|
|
await this.loadStores();
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Validation
|
|
|
|
### Running the Validator
|
|
|
|
```bash
|
|
# Validate entire codebase
|
|
python scripts/validate/validate_architecture.py
|
|
|
|
# Validate specific directory
|
|
python scripts/validate/validate_architecture.py app/api/
|
|
|
|
# Verbose output with context
|
|
python scripts/validate/validate_architecture.py --verbose
|
|
|
|
# Errors only (suppress warnings)
|
|
python scripts/validate/validate_architecture.py --errors-only
|
|
```
|
|
|
|
### Pre-commit Hook
|
|
|
|
Install pre-commit to validate automatically before commits:
|
|
|
|
```bash
|
|
# Install pre-commit
|
|
pip install pre-commit
|
|
|
|
# Setup hooks
|
|
pre-commit install
|
|
|
|
# Run manually
|
|
pre-commit run --all-files
|
|
```
|
|
|
|
### CI/CD Integration
|
|
|
|
Add to your CI pipeline:
|
|
|
|
```yaml
|
|
# .github/workflows/ci.yml
|
|
- name: Validate Architecture
|
|
run: |
|
|
python scripts/validate/validate_architecture.py
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
| Layer | Responsibility | Can Use | Cannot Use |
|
|
|-------|---------------|---------|------------|
|
|
| **API Endpoints** | HTTP handling, auth, validation | Pydantic, HTTPException, Depends | Direct DB access, business logic |
|
|
| **Services** | Business logic, orchestration | DB session, domain exceptions | HTTPException, HTTP concepts |
|
|
| **Models** | Data structure | SQLAlchemy OR Pydantic | Mixing both in same class |
|
|
|
|
---
|
|
|
|
## Common Violations and Fixes
|
|
|
|
### Violation: Business logic in endpoint
|
|
|
|
```python
|
|
# Before
|
|
@router.post("/stores")
|
|
async def create_store(store: StoreCreate, db: Session = Depends(get_db)):
|
|
db_store = Store(**store.dict())
|
|
db.add(db_store)
|
|
db.commit()
|
|
return db_store
|
|
```
|
|
|
|
```python
|
|
# After ✅
|
|
@router.post("/stores")
|
|
async def create_store(store: StoreCreate, db: Session = Depends(get_db)):
|
|
try:
|
|
return store_service.create_store(db, store)
|
|
except StoreAlreadyExistsError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
```
|
|
|
|
### Violation: HTTPException in service
|
|
|
|
```python
|
|
# Before
|
|
class StoreService:
|
|
def create_store(self, db: Session, store_data):
|
|
if exists:
|
|
raise HTTPException(status_code=409, detail="Exists")
|
|
```
|
|
|
|
```python
|
|
# After ✅
|
|
class StoreService:
|
|
def create_store(self, db: Session, store_data):
|
|
if exists:
|
|
raise StoreAlreadyExistsError("Store already exists")
|
|
```
|
|
|
|
---
|
|
|
|
**Remember:** These patterns are enforced automatically. Run `python scripts/validate/validate_architecture.py` before committing!
|