# Module System Architecture
The Orion platform uses a **plug-and-play modular architecture** where modules are fully self-contained and automatically discovered. Simply create a module directory with the required structure, and the framework handles registration, routing, and resource loading automatically.
## Key Features
- **Auto-Discovery**: Modules are automatically discovered from `app/modules/*/definition.py`
- **Zero Configuration**: No changes to `main.py`, `registry.py`, or other framework files needed
- **Self-Contained**: Each module owns its routes, services, models, templates, and translations
- **Hot-Pluggable**: Add or remove modules by simply adding/removing directories
## Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ FRAMEWORK LAYER │
│ (Infrastructure that modules depend on - not modules themselves) │
│ │
│ Config │ Database │ Auth │ Permissions │ Observability │ Celery │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ AUTO-DISCOVERED MODULE LAYER │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CORE MODULES (Always Enabled) │ │
│ │ contracts │ core │ tenancy │ cms │ customers │ billing │ │ │
│ │ payments │ messaging │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ OPTIONAL MODULES (Per-Platform) │ │
│ │ analytics │ inventory │ catalog │ cart │ checkout │ ... │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ INTERNAL MODULES (Admin Only) │ │
│ │ dev-tools │ monitoring │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## Auto-Discovery System
All module components are automatically discovered by the framework:
| Component | Discovery Location | Auto-Loaded By |
|-----------|-------------------|----------------|
| **Registry** | `*/definition.py` | `app/modules/discovery.py` |
| **Configuration** | `*/config.py` | `app/modules/config.py` |
| **API Routes** | `*/routes/api/*.py` | `app/modules/routes.py` |
| **Page Routes** | `*/routes/pages/*.py` | `app/modules/routes.py` |
| **Tasks** | `*/tasks/__init__.py` | `app/modules/tasks.py` |
| **Templates** | `*/templates/` | `app/templates_config.py` |
| **Static Files** | `*/static/` | `main.py` |
| **Locales** | `*/locales/*.json` | `app/utils/i18n.py` |
| **Migrations** | `*/migrations/versions/` | `app/modules/migrations.py` |
### Creating a New Module (Zero Framework Changes)
```bash
# 1. Create module directory
mkdir -p app/modules/mymodule/{routes/{api,pages},services,models,schemas,templates/mymodule/store,static/store/js,locales,tasks}
# 2. Create required files
touch app/modules/mymodule/__init__.py
touch app/modules/mymodule/definition.py
touch app/modules/mymodule/exceptions.py
# 3. That's it! The framework auto-discovers and registers everything.
```
## Three-Tier Classification
### Core Modules (8)
Core modules are **always enabled** and cannot be disabled. They provide fundamental platform functionality.
| Module | Description | Key Features | Permissions |
|--------|-------------|--------------|-------------|
| `contracts` | Cross-module protocols and interfaces | Service protocols, type-safe interfaces | - |
| `core` | Dashboard, settings, profile | Basic platform operation | 5 |
| `cms` | Content pages, media library, themes | Content management | 5 |
| `customers` | Customer database, profiles, segmentation | Customer data management | 4 |
| `tenancy` | Platform, merchant, store, admin user management | Multi-tenant infrastructure | 4 |
| `billing` | Platform subscriptions, tier limits, store invoices | Subscription management, tier-based feature gating | 5 |
| `payments` | Payment gateway integrations (Stripe, PayPal, etc.) | Payment processing, required for billing | 3 |
| `messaging` | Messages, notifications, email templates | Email for registration, password reset, notifications | 3 |
**Why these are core:**
- **billing**: Tier limits affect many features (team size, product limits, email providers). Subscription management is fundamental.
- **payments**: Required by billing for subscription payment processing.
- **messaging**: Email is required for user registration, password reset, and team invitations.
### Optional Modules (8)
Optional modules can be **enabled or disabled per platform**. They provide additional functionality that may not be needed by all platforms.
| Module | Dependencies | Description | Permissions |
|--------|--------------|-------------|-------------|
| `analytics` | - | Reports, dashboards, advanced statistics | 3 |
| `cart` | `inventory` | Shopping cart management, session-based carts | 2 |
| `catalog` | `inventory` | Customer-facing product browsing | 6 |
| `checkout` | `cart`, `orders`, `customers` | Cart-to-order conversion, checkout flow | 2 |
| `inventory` | - | Stock management, locations | 3 |
| `loyalty` | `customers` | Stamp/points loyalty programs, wallet integration | 4 |
| `marketplace` | `inventory` | Letzshop integration, product import/export | 3 |
| `orders` | - | Order management, customer checkout | 4 |
### Internal Modules (2)
Internal modules are **admin-only tools** not exposed to customers or stores.
| Module | Description |
|--------|-------------|
| `dev-tools` | Component library, icon browser |
| `monitoring` | Logs, background tasks, Flower, Grafana integration |
## Self-Contained Module Structure
Every module follows this standardized structure:
```
app/modules/analytics/
├── __init__.py # Module package marker
├── definition.py # ModuleDefinition (REQUIRED for auto-discovery)
├── config.py # Environment config (auto-discovered)
├── exceptions.py # Module-specific exceptions
├── docs/ # Module documentation (source of truth)
│ ├── index.md # Module overview (REQUIRED)
│ ├── data-model.md # Entity relationships (optional)
│ ├── api.md # API reference (optional)
│ └── business-logic.md # Complex logic docs (optional)
├── routes/
│ ├── __init__.py
│ ├── api/ # API endpoints (auto-discovered)
│ │ ├── __init__.py
│ │ ├── admin.py # Must export: router = APIRouter()
│ │ └── store.py # Must export: router = APIRouter()
│ └── pages/ # HTML page routes (auto-discovered)
│ ├── __init__.py
│ └── store.py # Must export: router = APIRouter()
├── services/
│ ├── __init__.py
│ └── stats_service.py
├── models/
│ ├── __init__.py
│ └── report.py
├── schemas/
│ ├── __init__.py
│ └── stats.py
├── templates/ # Auto-discovered by Jinja2
│ └── analytics/
│ └── store/
│ └── analytics.html
├── static/ # Auto-mounted at /static/modules/analytics/
│ ├── admin/js/ # Admin-facing JS for this module
│ ├── store/js/ # Store-facing JS for this module
│ │ └── analytics.js
│ └── shared/js/ # Shared JS (used by both admin and store)
├── locales/ # Auto-loaded translations
│ ├── en.json
│ ├── de.json
│ ├── fr.json
│ └── lb.json
├── tasks/ # Auto-discovered by Celery
│ ├── __init__.py # REQUIRED for Celery discovery
│ └── reports.py
└── migrations/ # Auto-discovered by Alembic
├── __init__.py # REQUIRED for discovery
└── versions/
├── __init__.py # REQUIRED for discovery
└── analytics_001_create_reports.py
```
## Module Definition
Each module must have a `definition.py` with a `ModuleDefinition` instance:
```python
# app/modules/analytics/definition.py
from app.modules.base import ModuleDefinition, PermissionDefinition
from app.modules.enums import FrontendType
analytics_module = ModuleDefinition(
# Identity
code="analytics",
name="Analytics & Reporting",
description="Dashboard analytics, custom reports, and data exports.",
version="1.0.0",
# Classification (determines tier)
is_core=False, # Set True for core modules
is_internal=False, # Set True for admin-only modules
# Dependencies
requires=[], # List other module codes this depends on
# Features (for tier-based gating)
features=[
"basic_reports",
"analytics_dashboard",
"custom_reports",
],
# Module-driven permissions (RBAC)
permissions=[
PermissionDefinition(
id="analytics.view",
label_key="analytics.permissions.view",
description_key="analytics.permissions.view_desc",
category="analytics",
),
PermissionDefinition(
id="analytics.export",
label_key="analytics.permissions.export",
description_key="analytics.permissions.export_desc",
category="analytics",
),
],
# Menu items per frontend
menu_items={
FrontendType.ADMIN: [], # Analytics uses dashboard
FrontendType.STORE: ["analytics"],
},
# Self-contained module configuration
is_self_contained=True,
services_path="app.modules.analytics.services",
models_path="app.modules.analytics.models",
schemas_path="app.modules.analytics.schemas",
exceptions_path="app.modules.analytics.exceptions",
templates_path="templates",
locales_path="locales",
)
```
### ModuleDefinition Fields
| Field | Type | Description |
|-------|------|-------------|
| `code` | `str` | Unique identifier (e.g., "billing") |
| `name` | `str` | Display name |
| `description` | `str` | What the module provides |
| `version` | `str` | Semantic version (default: "1.0.0") |
| `requires` | `list[str]` | Module codes this depends on |
| `features` | `list[str]` | Feature codes for tier gating |
| `permissions` | `list[PermissionDefinition]` | RBAC permission definitions |
| `menu_items` | `dict` | Menu items per frontend type |
| `context_providers` | `dict[FrontendType, Callable]` | Functions that provide template context per frontend |
| `is_core` | `bool` | Cannot be disabled if True |
| `is_internal` | `bool` | Admin-only if True |
| `is_self_contained` | `bool` | Uses self-contained structure |
| `metrics_provider` | `Callable` | Factory function returning MetricsProviderProtocol (see [Metrics Provider Pattern](metrics-provider-pattern.md)) |
| `widget_provider` | `Callable` | Factory function returning DashboardWidgetProviderProtocol (see [Widget Provider Pattern](widget-provider-pattern.md)) |
## Route Auto-Discovery
Routes in `routes/api/` and `routes/pages/` are automatically discovered and registered.
### API Routes (`routes/api/store.py`)
```python
# app/modules/analytics/routes/api/store.py
from fastapi import APIRouter, Depends
from app.api.deps import get_current_store_api, get_db
router = APIRouter() # MUST be named 'router' for auto-discovery
@router.get("")
def get_analytics(
current_user = Depends(get_current_store_api),
db = Depends(get_db),
):
"""Get store analytics."""
pass
```
**Auto-registered at:** `/api/v1/store/analytics`
### Page Routes (`routes/pages/store.py`)
```python
# app/modules/analytics/routes/pages/store.py
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
router = APIRouter() # MUST be named 'router' for auto-discovery
@router.get("/{store_code}/analytics", response_class=HTMLResponse)
async def analytics_page(request: Request, store_code: str):
"""Render analytics page."""
pass
```
**Auto-registered at:** `/store/{store_code}/analytics`
## Framework Layer
The Framework Layer provides infrastructure that modules depend on. These are **not modules** - they're always available and cannot be disabled.
| Component | Location | Purpose |
|-----------|----------|---------|
| Config | `app/core/config.py` | Settings management |
| Database | `app/core/database.py` | SQLAlchemy sessions |
| Logging | `app/core/logging.py` | Structured logging |
| Permissions | `app/core/permissions.py` | RBAC definitions |
| Feature Gate | `app/core/feature_gate.py` | Tier-based access |
| Celery | `app/core/celery_config.py` | Task queue |
| Observability | `app/core/observability.py` | Health checks, metrics, Sentry |
| Auth Middleware | `middleware/auth.py` | JWT authentication |
| Context Middleware | `middleware/platform_context.py` | Multi-tenancy |
| Dependencies | `app/api/deps.py` | FastAPI DI |
| Base Exceptions | `app/exceptions/base.py` | Exception hierarchy |
## Module Dependencies
Modules can depend on other modules. When enabling a module, its dependencies are automatically enabled.
```
CORE MODULES (always enabled):
┌─────────────────────────────────────────────────────────┐
│ contracts core tenancy cms customers │
│ billing ← payments messaging │
└─────────────────────────────────────────────────────────┘
OPTIONAL MODULES (dependencies shown):
inventory
↙ ↓ ↘
catalog cart marketplace
↓
checkout ← orders
```
**Dependency Rules:**
1. **Core modules NEVER import from optional modules** (see [Cross-Module Import Rules](cross-module-import-rules.md))
2. Enabling a module auto-enables its dependencies
3. Disabling a module auto-disables modules that depend on it
4. Circular dependencies are not allowed
5. Use protocol patterns (Metrics/Widget Provider) for cross-module data
## Module Registry
The registry auto-discovers all modules:
```python
from app.modules.registry import (
MODULES, # All modules (auto-discovered)
CORE_MODULES, # Core only
OPTIONAL_MODULES, # Optional only
INTERNAL_MODULES, # Internal only
get_module,
get_core_module_codes,
get_module_tier,
)
# Get a specific module
billing = get_module("billing")
# Check module tier
tier = get_module_tier("billing") # Returns "optional"
# Get all core module codes
core_codes = get_core_module_codes() # {"contracts", "core", "tenancy", "cms", "customers", "billing", "payments", "messaging"}
```
## Module Service
The `ModuleService` manages module enablement per platform:
```python
from app.modules.service import module_service
# Check if module is enabled
if module_service.is_module_enabled(db, platform_id, "billing"):
pass
# Get all enabled modules for a platform
modules = module_service.get_platform_modules(db, platform_id)
# Enable a module (auto-enables dependencies)
module_service.enable_module(db, platform_id, "billing", user_id=current_user.id)
# Disable a module (auto-disables dependents)
module_service.disable_module(db, platform_id, "billing", user_id=current_user.id)
```
## Context Providers (Module-Driven Page Context)
**Context providers** enable modules to dynamically contribute template context variables without hardcoding module imports. This is a core architectural pattern that ensures the platform remains modular and extensible.
### Problem Solved
Without context providers, the platform would need hardcoded imports like:
```python
# BAD: Hardcoded module imports
from app.modules.billing.models import TIER_LIMITS # What if billing is disabled?
from app.modules.cms.services import content_page_service # What if cms is disabled?
```
This breaks when modules are disabled and creates tight coupling.
### Solution: Module-Driven Context
Each module can register **context provider functions** in its `definition.py`. The framework automatically calls providers for enabled modules only.
```
┌─────────────────────────────────────────────────────────────────────┐
│ Page Request (e.g., /pricing) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ get_context_for_frontend(FrontendType.PLATFORM) │
└─────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ CMS Module │ │Billing Module │ │ Other Module │
│ (enabled) │ │ (enabled) │ │ (disabled) │
└───────┬───────┘ └───────┬───────┘ └───────────────┘
│ │ │
▼ ▼ × (skipped)
┌───────────────┐ ┌───────────────┐
│ header_pages │ │ tiers │
│ footer_pages │ │ trial_days │
└───────────────┘ └───────────────┘
│ │
└───────────┬───────────┘
▼
┌─────────────────────────────────┐
│ Merged Context Dict │
│ {header_pages, tiers, ...} │
└─────────────────────────────────┘
```
### Frontend Types
Context providers are registered per frontend type:
| Frontend Type | Description | Use Case |
|--------------|-------------|----------|
| `PLATFORM` | Marketing/public pages | Homepage, pricing, signup |
| `ADMIN` | Platform admin dashboard | Admin user management, platform settings |
| `STORE` | Store/merchant dashboard | Store settings, product management |
| `STOREFRONT` | Customer-facing shop | Product browsing, cart, checkout |
### Registering a Context Provider
In your module's `definition.py`:
```python
# app/modules/billing/definition.py
from typing import Any
from app.modules.base import ModuleDefinition
from app.modules.enums import FrontendType
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
"""
Provide billing context for platform/marketing pages.
This function is ONLY called when billing module is enabled.
Imports are done inside to avoid loading when module is disabled.
"""
from app.core.config import settings
from app.modules.billing.models import TIER_LIMITS, TierCode
tiers = []
for tier_code, limits in TIER_LIMITS.items():
tiers.append({
"code": tier_code.value,
"name": limits["name"],
"price_monthly": limits["price_monthly_cents"] / 100,
# ... more tier data
})
return {
"tiers": tiers,
"trial_days": settings.stripe_trial_days,
"stripe_publishable_key": settings.stripe_publishable_key,
}
billing_module = ModuleDefinition(
code="billing",
name="Billing & Subscriptions",
# ... other fields ...
# Register context providers
context_providers={
FrontendType.PLATFORM: _get_platform_context,
},
)
```
### Context Provider Signature
All context providers must follow this signature:
```python
def provider_function(
request: Any, # FastAPI Request object
db: Any, # SQLAlchemy Session
platform: Any, # Platform model (may be None)
) -> dict[str, Any]:
"""Return a dict of context variables to merge into the template context."""
return {"key": "value"}
```
### Using Context in Routes
Route handlers use convenience functions to build context:
```python
# app/modules/cms/routes/pages/platform.py
from fastapi import APIRouter, Request, Depends
from app.modules.core.utils import get_platform_context
from app.api.deps import get_db
router = APIRouter()
@router.get("/pricing")
async def pricing_page(request: Request, db = Depends(get_db)):
# Context automatically includes contributions from all enabled modules
context = get_platform_context(request, db, page_title="Pricing")
# If billing module is enabled, context includes:
# - tiers, trial_days, stripe_publishable_key
# If CMS module is enabled, context includes:
# - header_pages, footer_pages
return templates.TemplateResponse(
request=request,
name="platform/pricing.html",
context=context,
)
```
### Available Context Functions
Import from `app.modules.core.utils`:
```python
from app.modules.core.utils import (
get_context_for_frontend, # Generic - specify FrontendType
get_platform_context, # For PLATFORM pages
get_admin_context, # For ADMIN pages
get_store_context, # For STORE pages
get_storefront_context, # For STOREFRONT pages
)
```
### Base Context (Always Available)
Every context includes these base variables regardless of modules:
| Variable | Description |
|----------|-------------|
| `request` | FastAPI Request object |
| `platform` | Platform model (may be None) |
| `platform_name` | From settings.project_name |
| `main_domain` | From settings.main_domain |
| `_` | Translation function (gettext style) |
| `t` | Translation function (key-value style) |
| `current_language` | Current language code |
| `SUPPORTED_LANGUAGES` | List of available languages |
### Example: CMS Module Context Provider
```python
# app/modules/cms/definition.py
def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
"""Provide CMS context for platform/marketing pages."""
from app.modules.cms.services import content_page_service
platform_id = platform.id if platform else None
header_pages = content_page_service.list_platform_pages(
db, platform_id=platform_id, header_only=True, include_unpublished=False
)
footer_pages = content_page_service.list_platform_pages(
db, platform_id=platform_id, footer_only=True, include_unpublished=False
)
return {
"header_pages": header_pages,
"footer_pages": footer_pages,
"legal_pages": [],
}
def _get_storefront_context(request: Any, db: Any, platform: Any) -> dict[str, Any]:
"""Provide CMS context for storefront (customer shop) pages."""
from app.modules.cms.services import content_page_service
store = getattr(request.state, "store", None)
if not store:
return {"header_pages": [], "footer_pages": []}
header_pages = content_page_service.list_pages_for_store(
db, platform_id=platform.id, store_id=store.id, header_only=True
)
footer_pages = content_page_service.list_pages_for_store(
db, platform_id=platform.id, store_id=store.id, footer_only=True
)
return {"header_pages": header_pages, "footer_pages": footer_pages}
cms_module = ModuleDefinition(
code="cms",
# ... other fields ...
context_providers={
FrontendType.PLATFORM: _get_platform_context,
FrontendType.STOREFRONT: _get_storefront_context,
},
)
```
### How Modules Are Selected
The context builder determines which modules to query:
1. **With platform**: Queries `PlatformModule` table for enabled modules
2. **Without platform**: Only includes core modules (`is_core=True`)
```python
# Simplified logic from get_context_for_frontend()
if platform:
enabled_module_codes = module_service.get_enabled_module_codes(db, platform.id)
else:
# No platform context - only core modules
enabled_module_codes = {
code for code, module in MODULES.items() if module.is_core
}
for code in enabled_module_codes:
module = MODULES.get(code)
if module and module.has_context_provider(frontend_type):
contribution = module.get_context_contribution(frontend_type, request, db, platform)
context.update(contribution)
```
### Error Handling
Context providers are wrapped in try/except to prevent one module from breaking the entire page:
```python
try:
contribution = module.get_context_contribution(...)
if contribution:
context.update(contribution)
except Exception as e:
logger.warning(f"[CONTEXT] Module '{code}' context provider failed: {e}")
# Continue with other modules - page still renders
```
### Benefits
1. **Zero coupling**: Adding/removing modules requires no changes to route handlers
2. **Lazy loading**: Module code only imported when that module is enabled
3. **Per-platform customization**: Each platform loads only what it needs
4. **Graceful degradation**: One failing module doesn't break the entire page
5. **Testability**: Providers are pure functions that can be unit tested
## Module Static Files
Each module can have its own static assets (JavaScript, CSS, images) in the `static/` directory. These are automatically mounted at `/static/modules/{module_name}/`.
### Static File Structure
```
app/modules/{module}/static/
├── admin/js/ # Admin pages for this module
├── store/js/ # Store pages for this module
├── shared/js/ # Shared across admin/store (e.g., feature-store.js)
└── shop/js/ # Shop pages (if module has storefront UI)
```
### Referencing in Templates
Use the `{module}_static` URL name:
```html
```
### Module vs. Platform Static Files
| Put in Module | Put in Platform (`static/`) |
|---------------|----------------------------|
| Module-specific features | Platform-level admin (dashboard, login, platforms, stores) |
| Order management → `orders` module | Store core (profile, settings, team) |
| Product catalog → `catalog` module | Shared utilities (api-client, utils, icons) |
| Billing/subscriptions → `billing` module | Admin user management |
| Analytics dashboards → `analytics` module | Platform user management |
**Key distinction:** Platform users (admin-users.js, users.js) manage internal platform access. Shop customers (customers.js in customers module) are end-users who purchase from stores.
See [Frontend Structure](frontend-structure.md) for detailed JS file organization.
## Module Configuration
Modules can have environment-based configuration using Pydantic Settings. The `config.py` file is auto-discovered by `app/modules/config.py`.
```python
# app/modules/marketplace/config.py
from pydantic import Field
from pydantic_settings import BaseSettings
class MarketplaceConfig(BaseSettings):
"""Configuration for marketplace module."""
# Settings loaded from env vars with MARKETPLACE_ prefix
api_timeout: int = Field(default=30, description="API timeout in seconds")
batch_size: int = Field(default=100, description="Import batch size")
max_retries: int = Field(default=3, description="Max retry attempts")
model_config = {"env_prefix": "MARKETPLACE_"}
# Export for auto-discovery
config_class = MarketplaceConfig
config = MarketplaceConfig()
```
**Usage:**
```python
# Direct import
from app.modules.marketplace.config import config
timeout = config.api_timeout
# Via discovery
from app.modules.config import get_module_config
config = get_module_config("marketplace")
```
**Environment variables:**
```bash
MARKETPLACE_API_TIMEOUT=60
MARKETPLACE_BATCH_SIZE=500
```
## Module Migrations
Each module owns its database migrations in the `migrations/versions/` directory. Alembic auto-discovers these via `app/modules/migrations.py`.
### Migration Structure
```
app/modules/cms/migrations/
├── __init__.py # REQUIRED for discovery
└── versions/
├── __init__.py # REQUIRED for discovery
├── cms_001_create_content_pages.py
├── cms_002_add_sections.py
└── cms_003_add_media_library.py
```
### Naming Convention
```
{module_code}_{sequence}_{description}.py
```
### Migration Template
```python
# app/modules/cms/migrations/versions/cms_001_create_content_pages.py
"""Create content_pages table.
Revision ID: cms_001
Create Date: 2026-01-28
"""
from alembic import op
import sqlalchemy as sa
revision = "cms_001"
down_revision = None
branch_labels = ("cms",) # Module-specific branch
def upgrade() -> None:
op.create_table(
"content_pages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id")),
sa.Column("slug", sa.String(100), nullable=False),
sa.Column("title", sa.String(200), nullable=False),
)
def downgrade() -> None:
op.drop_table("content_pages")
```
### Running Migrations
Module migrations are automatically discovered:
```bash
# Run all migrations (core + modules)
alembic upgrade head
# View migration history
alembic history
```
### Current State
Currently, all migrations reside in central `alembic/versions/`. The module-specific directories are in place for:
- **New modules**: Should create migrations in their own `migrations/versions/`
- **Future reorganization**: Existing migrations will be moved to modules pre-production
## Entity Auto-Discovery Reference
This section details the auto-discovery requirements for each entity type. **All entities must be in modules** - legacy locations are deprecated and will trigger architecture validation errors.
### Routes
Routes define API and page endpoints. They are auto-discovered from module directories.
| Type | Location | Discovery | Router Name |
|------|----------|-----------|-------------|
| Admin API | `routes/api/admin.py` | `app/modules/routes.py` | `router` |
| Store API | `routes/api/store.py` | `app/modules/routes.py` | `router` |
| Storefront API | `routes/api/storefront.py` | `app/modules/routes.py` | `router` |
| Admin Pages | `routes/pages/admin.py` | `app/modules/routes.py` | `router` |
| Store Pages | `routes/pages/store.py` | `app/modules/routes.py` | `router` |
All route files export `router`. The file location (`admin.py` vs `store.py`) determines the context. Consumer code (definition.py, `__init__.py`) re-exports as `admin_router`/`store_router` where distinction is needed.
**Structure:**
```
app/modules/{module}/routes/
├── __init__.py
├── api/
│ ├── __init__.py
│ ├── admin.py # Must export router
│ ├── store.py # Must export router
│ ├── storefront.py # Must export router
│ └── admin_{feature}.py # Sub-routers aggregated in admin.py
└── pages/
├── __init__.py
└── store.py # Must export router
```
**Example - Aggregating Sub-Routers:**
```python
# app/modules/billing/routes/api/store.py
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
router = APIRouter(
prefix="/billing",
dependencies=[Depends(require_module_access("billing"))],
)
# Aggregate sub-routers
from .store_checkout import store_checkout_router
from .store_usage import store_usage_router
router.include_router(store_checkout_router)
router.include_router(store_usage_router)
```
**Legacy Locations (DEPRECATED - will cause errors):**
- `app/api/v1/store/*.py` - Move to module `routes/api/store.py`
- `app/api/v1/admin/*.py` - Move to module `routes/api/admin.py`
---
### Services
Services contain business logic. They are not auto-discovered but should be in modules for organization.
| Location | Import Pattern |
|----------|----------------|
| `services/*.py` | `from app.modules.{module}.services import service_name` |
| `services/__init__.py` | Re-exports all public services |
**Structure:**
```
app/modules/{module}/services/
├── __init__.py # Re-exports: from .order_service import order_service
├── order_service.py # OrderService class + order_service singleton
└── fulfillment_service.py # Related services
```
**Example:**
```python
# app/modules/orders/services/order_service.py
from sqlalchemy.orm import Session
from app.modules.orders.models import Order
class OrderService:
def get_order(self, db: Session, order_id: int) -> Order:
return db.query(Order).filter(Order.id == order_id).first()
order_service = OrderService()
# app/modules/orders/services/__init__.py
from .order_service import order_service, OrderService
__all__ = ["order_service", "OrderService"]
```
**Legacy Locations (DEPRECATED - will cause errors):**
- `app/services/*.py` - Move to module `services/`
- `app/services/{module}/` - Move to `app/modules/{module}/services/`
---
### Models
Database models (SQLAlchemy). Currently in `models/database/`, migrating to modules.
| Location | Base Class | Discovery |
|----------|------------|-----------|
| `models/*.py` | `Base` from `models.base` | Alembic autogenerate |
**Structure:**
```
app/modules/{module}/models/
├── __init__.py # Re-exports: from .order import Order, OrderItem
├── order.py # Order model
└── order_item.py # Related models
```
**Example:**
```python
# app/modules/orders/models/order.py
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from models.base import Base, TimestampMixin
class Order(Base, TimestampMixin):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
status = Column(String(50), default="pending")
items = relationship("OrderItem", back_populates="order")
```
**Legacy Locations (being migrated):**
- `models/database/*.py` - Core models remain here, domain models move to modules
---
### Schemas
Pydantic schemas for request/response validation.
| Location | Base Class | Usage |
|----------|------------|-------|
| `schemas/*.py` | `BaseModel` from Pydantic | API routes, validation |
**Structure:**
```
app/modules/{module}/schemas/
├── __init__.py # Re-exports all schemas
├── order.py # Order request/response schemas
└── order_item.py # Related schemas
```
**Example:**
```python
# app/modules/orders/schemas/order.py
from pydantic import BaseModel
from datetime import datetime
class OrderResponse(BaseModel):
id: int
store_id: int
status: str
created_at: datetime
class Config:
from_attributes = True
class OrderCreateRequest(BaseModel):
customer_id: int
items: list[OrderItemRequest]
```
**Legacy Locations (DEPRECATED - will cause errors):**
- `models/schema/*.py` - Move to module `schemas/`
---
### Tasks (Celery)
Background tasks are auto-discovered by Celery from module `tasks/` directories.
| Location | Discovery | Registration |
|----------|-----------|--------------|
| `tasks/*.py` | `app/modules/tasks.py` | Celery autodiscover |
**Structure:**
```
app/modules/{module}/tasks/
├── __init__.py # REQUIRED - imports task functions
├── import_tasks.py # Task definitions
└── export_tasks.py # Related tasks
```
**Example:**
```python
# app/modules/marketplace/tasks/import_tasks.py
from celery import shared_task
from app.core.database import SessionLocal
@shared_task(bind=True)
def process_import(self, job_id: int, store_id: int):
db = SessionLocal()
try:
# Process import
pass
finally:
db.close()
# app/modules/marketplace/tasks/__init__.py
from .import_tasks import process_import
from .export_tasks import export_products
__all__ = ["process_import", "export_products"]
```
**Legacy Locations (DEPRECATED - will cause errors):**
- `app/tasks/*.py` - Move to module `tasks/`
---
### Exceptions
Module-specific exceptions inherit from `OrionException`.
| Location | Base Class | Usage |
|----------|------------|-------|
| `exceptions.py` | `OrionException` | Domain errors |
**Structure:**
```
app/modules/{module}/
└── exceptions.py # All module exceptions
```
**Example:**
```python
# app/modules/orders/exceptions.py
from app.exceptions import OrionException
class OrderException(OrionException):
"""Base exception for orders module."""
pass
class OrderNotFoundError(OrderException):
"""Order not found."""
def __init__(self, order_id: int):
super().__init__(f"Order {order_id} not found")
self.order_id = order_id
class OrderAlreadyFulfilledError(OrderException):
"""Order has already been fulfilled."""
pass
```
---
### Templates
Jinja2 templates are auto-discovered from module `templates/` directories. The template loader searches `app/templates/` first (for shared templates), then each module's `templates/` directory.
| Location | URL Pattern | Discovery |
|----------|-------------|-----------|
| `templates/{module}/store/*.html` | `/store/{store}/...` | Jinja2 loader |
| `templates/{module}/admin/*.html` | `/admin/...` | Jinja2 loader |
| `templates/{module}/storefront/*.html` | `/storefront/...` | Jinja2 loader |
| `templates/{module}/public/*.html` | `/...` (platform pages) | Jinja2 loader |
**Module Template Structure:**
```
app/modules/{module}/templates/
└── {module}/
├── admin/
│ ├── list.html
│ └── partials/ # Module-specific partials
│ └── my-partial.html
├── store/
│ ├── index.html
│ └── detail.html
├── storefront/ # Customer-facing shop pages
│ └── products.html
└── public/ # Platform marketing pages
└── pricing.html
```
**Template Reference:**
```python
# In route
return templates.TemplateResponse(
request=request,
name="{module}/store/index.html",
context={"items": items}
)
```
**Shared Templates (in `app/templates/`):**
Some templates remain in `app/templates/` because they are used across all modules:
| Directory | Contents | Purpose |
|-----------|----------|---------|
| `admin/base.html` | Admin layout | Parent template all admin pages extend |
| `store/base.html` | Store layout | Parent template all store pages extend |
| `storefront/base.html` | Shop layout | Parent template all storefront pages extend |
| `platform/base.html` | Public layout | Parent template all public pages extend |
| `admin/errors/` | Error pages | HTTP error templates (404, 500, etc.) |
| `store/errors/` | Error pages | HTTP error templates for store |
| `storefront/errors/` | Error pages | HTTP error templates for storefront |
| `admin/partials/` | Shared partials | Header, sidebar used across admin |
| `store/partials/` | Shared partials | Header, sidebar used across store |
| `shared/macros/` | Jinja2 macros | Reusable UI components (buttons, forms, tables) |
| `shared/includes/` | Includes | Common HTML snippets |
| `invoices/` | PDF templates | Invoice PDF generation |
These shared templates provide the "framework" that module templates build upon. Module templates extend base layouts and import shared macros.
---
### Static Files
JavaScript, CSS, and images are auto-mounted from module `static/` directories.
| Location | URL | Discovery |
|----------|-----|-----------|
| `static/store/js/*.js` | `/static/modules/{module}/store/js/*.js` | `main.py` |
| `static/admin/js/*.js` | `/static/modules/{module}/admin/js/*.js` | `main.py` |
**Structure:**
```
app/modules/{module}/static/
├── store/js/
│ └── {module}.js
├── admin/js/
│ └── {module}.js
└── shared/js/
└── common.js
```
**Template Reference:**
```html
```
---
### Locales (i18n)
Translation files are auto-discovered from module `locales/` directories.
| Location | Format | Discovery |
|----------|--------|-----------|
| `locales/*.json` | JSON key-value | `app/utils/i18n.py` |
**Structure:**
```
app/modules/{module}/locales/
├── en.json
├── de.json
├── fr.json
└── lb.json
```
**Example:**
```json
{
"orders.title": "Orders",
"orders.status.pending": "Pending",
"orders.status.fulfilled": "Fulfilled"
}
```
**Usage:**
```python
from app.utils.i18n import t
message = t("orders.title", locale="en") # "Orders"
```
---
### Configuration
Module-specific environment configuration.
| Location | Base Class | Discovery |
|----------|------------|-----------|
| `config.py` | `BaseSettings` | `app/modules/config.py` |
**Example:**
```python
# app/modules/marketplace/config.py
from pydantic_settings import BaseSettings
class MarketplaceConfig(BaseSettings):
api_timeout: int = 30
batch_size: int = 100
model_config = {"env_prefix": "MARKETPLACE_"}
config = MarketplaceConfig()
```
**Environment Variables:**
```bash
MARKETPLACE_API_TIMEOUT=60
MARKETPLACE_BATCH_SIZE=500
```
## Architecture Validation Rules
The architecture validator (`scripts/validate/validate_architecture.py`) enforces module structure:
| Rule | Severity | Description |
|------|----------|-------------|
| MOD-001 | ERROR | Self-contained modules must have required directories |
| MOD-002 | WARNING | Services must contain actual code, not re-exports |
| MOD-003 | WARNING | Schemas must contain actual code, not re-exports |
| MOD-004 | WARNING | Routes must import from module, not legacy locations |
| MOD-005 | WARNING | Modules with UI must have templates and static |
| MOD-006 | INFO | Modules should have locales for i18n |
| MOD-007 | ERROR | Definition paths must match directory structure |
| MOD-008 | WARNING | Self-contained modules must have exceptions.py |
| MOD-009 | ERROR | Modules must have definition.py for auto-discovery |
| MOD-010 | WARNING | Route files must export `router` variable |
| MOD-011 | WARNING | Tasks directory must have `__init__.py` |
| MOD-012 | INFO | Locales should have all language files |
| MOD-013 | INFO | config.py should export `config` or `config_class` |
| MOD-014 | WARNING | Migrations must follow naming convention |
| MOD-015 | WARNING | Migrations directory must have `__init__.py` files |
| MOD-016 | ERROR | Routes must be in modules, not `app/api/v1/` |
| MOD-017 | ERROR | Services must be in modules, not `app/services/` |
| MOD-018 | ERROR | Tasks must be in modules, not `app/tasks/` |
| MOD-019 | ERROR | Schemas must be in modules, not `models/schema/` |
| MOD-020 | WARNING | Module definition must have required attributes (code, name, description, version, features) |
| MOD-021 | WARNING | Modules with menus should have features defined |
| MOD-022 | INFO | Feature modules should have permissions (unless internal or storefront-only) |
| MOD-023 | INFO | Modules with routers should use `get_*_with_routers` pattern |
Run validation:
```bash
python scripts/validate/validate_architecture.py
```
## Best Practices
### Do
- Keep modules focused on a single domain
- Use `requires` for hard dependencies
- Provide `health_check` for critical modules
- Use events for cross-module communication
- Follow the standard directory structure
- Export `router` variable in route files
- Include all supported languages in locales
### Don't
- Create circular dependencies
- **Make core modules import from optional modules** (use provider patterns instead)
- Put framework-level code in modules
- Skip migration naming conventions
- Forget `__init__.py` in tasks directory
- Manually register modules in registry.py (use auto-discovery)
- Import optional modules at the top of core module files
- Use direct imports when a protocol pattern exists
## Related Documentation
- [Creating Modules](../development/creating-modules.md) - Step-by-step guide
- [Cross-Module Import Rules](cross-module-import-rules.md) - Import constraints and patterns
- [Metrics Provider Pattern](metrics-provider-pattern.md) - Dashboard statistics architecture
- [Widget Provider Pattern](widget-provider-pattern.md) - Dashboard widgets architecture
- [Menu Management](menu-management.md) - Sidebar configuration
- [Observability](observability.md) - Health checks integration
- [Feature Gating](../implementation/feature-gating-system.md) - Tier-based access