Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart with Orion/orion/ORION across 184 files. This includes database identifiers, email addresses, domain references, R2 bucket names, DNS prefixes, encryption salt, Celery app name, config defaults, Docker configs, CI configs, documentation, seed data, and templates. Renames homepage-wizamart.html template to homepage-orion.html. Fixes duplicate file_pattern key in api.yaml architecture rule. 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": "ORION", ← 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!
|