Files
orion/docs/architecture/module-system.md
Samir Boulahtit c2c0e3c740
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
refactor: rename platform_domain → main_domain to avoid confusion with platform.domain
The setting `settings.platform_domain` (the global/main domain like "wizard.lu")
was easily confused with `platform.domain` (per-platform domain like "rewardflow.lu").
Renamed to `settings.main_domain` / `MAIN_DOMAIN` env var across the entire codebase.

Also updated docs to reflect the refactored store detection logic with
`is_platform_domain` / `is_subdomain_of_platform` guards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 04:45:28 +01:00

47 KiB
Raw Blame History

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)

# 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:

# 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)
widget_provider Callable Factory function returning DashboardWidgetProviderProtocol (see Widget Provider Pattern)

Route Auto-Discovery

Routes in routes/api/ and routes/pages/ are automatically discovered and registered.

API Routes (routes/api/store.py)

# 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)

# 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)
  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:

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:

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:

# 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:

# 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:

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:

# 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:

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

# 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)
# 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:

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:

<!-- Module-specific JS -->
<script src="{{ url_for('orders_static', path='store/js/orders.js') }}"></script>
<script src="{{ url_for('billing_static', path='shared/js/feature-store.js') }}"></script>

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 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.

# 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:

# 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:

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

# 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:

# 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:

# 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:

# 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:

# 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:

# 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:

# 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:

# 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:

# 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:

<script src="{{ url_for('{module}_static', path='store/js/{module}.js') }}"></script>

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:

{
  "orders.title": "Orders",
  "orders.status.pending": "Pending",
  "orders.status.fulfilled": "Fulfilled"
}

Usage:

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:

# 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:

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:

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