Files
orion/docs/architecture/architecture-patterns.md
Samir Boulahtit e9253fbd84 refactor: rename Wizamart to Orion across entire codebase
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>
2026-02-14 16:46:56 +01:00

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!