Files
orion/docs/architecture/cross-module-import-rules.md
Samir Boulahtit f95db7c0b1
Some checks failed
CI / ruff (push) Successful in 9s
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
feat(roles): add admin store roles page, permission i18n, and menu integration
- Add admin store roles page with merchant→store cascading for superadmin
  and store-only selection for platform admin
- Add permission catalog API with translated labels/descriptions (en/fr/de/lb)
- Add permission translations to all 15 module locale files (60 files total)
- Add info icon tooltips for permission descriptions in role editor
- Add store roles menu item and admin menu item in module definition
- Fix store-selector.js URL construction bug when apiEndpoint has query params
- Add admin store roles API (CRUD + platform scoping)
- Add integration tests for admin store roles and permission catalog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:31:27 +01:00

16 KiB

Cross-Module Import Rules

This document defines the strict import rules that ensure the module system remains decoupled, testable, and resilient. These rules are critical for maintaining a truly modular architecture.

Core Principles

Principle 1: Core modules NEVER import from optional modules

This is the fundamental rule that enables optional modules to be truly optional. When a core module imports from an optional module:

  • The app crashes if that module is disabled
  • You can't test core functionality in isolation
  • You create a hidden dependency that violates the architecture

Principle 2: Services over models — NEVER import another module's models

If module A needs data from module B, it MUST call module B's service methods.

Modules must NEVER import and query another module's SQLAlchemy models directly. This applies to ALL cross-module interactions — core-to-core, optional-to-core, and optional-to-optional.

When a module imports another module's models, it:

  • Couples to the internal schema (column names, relationships, table structure)
  • Bypasses business logic, validation, and access control in the owning service
  • Makes refactoring the model owner's schema a breaking change for all consumers
  • Scatters query logic across multiple modules instead of centralizing it

The owning module's service is the ONLY authorized gateway to its data.

Module Classification

Core Modules (Always Enabled)

  • contracts - Protocols and interfaces
  • core - Dashboard, settings, profile
  • tenancy - Platform, merchant, store, admin user management
  • cms - Content pages, media library
  • customers - Customer database
  • billing - Subscriptions, tier limits
  • payments - Payment gateway integrations
  • messaging - Email, notifications

Optional Modules (Per-Platform)

  • analytics - Reports, dashboards
  • cart - Shopping cart
  • catalog - Product browsing
  • checkout - Cart-to-order conversion
  • inventory - Stock management
  • loyalty - Loyalty programs
  • marketplace - Letzshop integration
  • orders - Order management

Import Rules Matrix

From \ To Core Services Core Models Optional Services Optional Models Contracts
Core Use services FORBIDDEN FORBIDDEN
Optional Use services ⚠️ With care Use services
Contracts

Explanation

  1. Any → Any Services: Allowed (with core→optional restriction). Import the service, call its methods.

  2. Any → Any Models: FORBIDDEN. Never import another module's SQLAlchemy models. Use that module's service instead.

  3. Core → Optional: FORBIDDEN (both services and models). Use provider protocols instead.

  4. Optional → Core Services: Allowed. Optional modules can call core service methods.

  5. Optional → Optional Services: Allowed with care. Declare dependency in definition.py.

  6. Any → Contracts: Allowed. Contracts define shared protocols and data structures.

  7. Contracts → Anything: Contracts should only depend on stdlib/typing/Protocol. No module imports.

Anti-Patterns (DO NOT DO)

Cross-Module Model Import (MOD-025)

# app/modules/orders/services/order_service.py

# BAD: Importing and querying another module's models
from app.modules.catalog.models import Product

class OrderService:
    def get_order_with_products(self, db, order_id):
        order = db.query(Order).filter_by(id=order_id).first()
        # BAD: Direct query on catalog's model
        products = db.query(Product).filter(Product.id.in_(product_ids)).all()
        return order, products
# GOOD: Call the owning module's service
from app.modules.catalog.services import product_service

class OrderService:
    def get_order_with_products(self, db, order_id):
        order = db.query(Order).filter_by(id=order_id).first()
        # GOOD: Catalog service owns Product data access
        products = product_service.get_products_by_ids(db, product_ids)
        return order, products

Cross-Module Aggregation Query

# BAD: Counting another module's models directly
from app.modules.orders.models import Order
count = db.query(func.count(Order.id)).filter_by(store_id=store_id).scalar()

# GOOD: Ask the owning service
from app.modules.orders.services import order_service
count = order_service.get_order_count(db, store_id=store_id)

Direct Import from Optional Module

# app/modules/core/routes/api/admin_dashboard.py

# BAD: Core importing from optional module
from app.modules.marketplace.services import marketplace_service
from app.modules.analytics.services import stats_service

@router.get("/dashboard")
def get_dashboard():
    # This crashes if marketplace is disabled!
    imports = marketplace_service.get_recent_imports()

Conditional Import with ismodule_enabled Check

# BAD: Still creates import-time dependency
from app.modules.service import module_service

if module_service.is_module_enabled(db, platform_id, "marketplace"):
    from app.modules.marketplace.services import marketplace_service  # Still loaded!
    imports = marketplace_service.get_recent_imports()

Type Hints from Optional Modules

# BAD: Type hint forces import
from app.modules.marketplace.models import MarketplaceImportJob

def process_import(job: MarketplaceImportJob) -> None:  # Crashes if disabled
    ...

Approved Patterns

0. Cross-Module Service Calls (Primary Pattern)

The default way to access another module's data is through its service layer:

# app/modules/inventory/services/inventory_service.py

# GOOD: Import the service, not the model
from app.modules.catalog.services import product_service

class InventoryService:
    def get_stock_for_product(self, db, product_id):
        # Verify product exists via catalog service
        product = product_service.get_product_by_id(db, product_id)
        if not product:
            raise InventoryError("Product not found")
        # Query own models
        return db.query(StockLevel).filter_by(product_id=product_id).first()

Each module should expose these standard service methods for external consumers:

Method Pattern Purpose
get_{entity}_by_id(db, id) Single entity lookup
list_{entities}(db, **filters) Filtered list
get_{entity}_count(db, **filters) Count query
search_{entities}(db, query, **filters) Text search
get_{entities}_by_ids(db, ids) Batch lookup

1. Provider Protocol Pattern (Metrics & Widgets)

Use the provider protocol pattern for cross-module data:

# app/modules/core/services/stats_aggregator.py

# GOOD: Import only protocols and contracts
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue

class StatsAggregatorService:
    def _get_enabled_providers(self, db, platform_id):
        # Discover providers from MODULES registry
        from app.modules.registry import MODULES

        for module in MODULES.values():
            if module.has_metrics_provider():
                # Provider is called through protocol, not direct import
                provider = module.get_metrics_provider_instance()
                yield provider

2. Context Provider Pattern

Modules contribute context without direct imports:

# app/modules/core/utils.py

def get_context_for_frontend(frontend_type, request, db, platform):
    # GOOD: Discover and call providers through registry
    for module in get_enabled_modules(db, platform.id):
        if module.has_context_provider(frontend_type):
            context.update(module.get_context_contribution(...))

3. Lazy Factory Functions

Use factory functions in module definitions:

# app/modules/marketplace/definition.py

def _get_widget_provider():
    """Lazy import to avoid circular imports."""
    from app.modules.marketplace.services.marketplace_widgets import marketplace_widget_provider
    return marketplace_widget_provider

marketplace_module = ModuleDefinition(
    code="marketplace",
    widget_provider=_get_widget_provider,  # Called lazily when needed
)

4. Type Checking Only Imports

Use TYPE_CHECKING for type hints without runtime dependency:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from app.modules.marketplace.models import MarketplaceImportJob

def process_import(job: "MarketplaceImportJob") -> None:
    # Works at runtime even if marketplace is disabled
    ...

5. Registry-Based Discovery

Discover modules through the registry, not imports:

# GOOD: Discovery through registry
from app.modules.registry import MODULES

def get_module_if_enabled(db, platform_id, module_code):
    module = MODULES.get(module_code)
    if module and module_service.is_module_enabled(db, platform_id, module_code):
        return module
    return None

Architecture Validation

The architecture validator (scripts/validate/validate_architecture.py) includes rules to detect violations:

Rule Severity Description
IMPORT-001 ERROR Core module imports from optional module
IMPORT-002 WARNING Optional module imports from unrelated optional module
IMPORT-003 INFO Consider using protocol pattern instead of direct import
MOD-025 ERROR Module imports models from another module (use services)
MOD-026 ERROR Cross-module data access not going through service layer

Run validation:

python scripts/validate/validate_architecture.py

Provider Pattern Summary

When to Use Each Pattern

Need Pattern Location
Numeric statistics MetricsProvider contracts/metrics.py
Dashboard widgets WidgetProvider contracts/widgets.py
Page template context Context Provider definition.py
Service access Protocol + DI contracts/{module}.py

Provider Flow

┌─────────────────────────────────────────────────────────────────────┐
│                         CONTRACTS LAYER                              │
│                     (Shared protocol definitions)                    │
│                                                                      │
│   MetricsProviderProtocol    DashboardWidgetProviderProtocol        │
│   ContentServiceProtocol     ServiceProtocol                         │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                    ┌───────────────┼───────────────┐
                    ▼               ▼               ▼
        ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
        │  CORE MODULE     │ │ OPTIONAL MODULE 1│ │ OPTIONAL MODULE 2│
        │                  │ │                  │ │                  │
        │ StatsAggregator  │ │ OrderMetrics     │ │ CatalogMetrics   │
        │ WidgetAggregator │ │ (implements)     │ │ (implements)     │
        │ (discovers)      │ │                  │ │                  │
        └──────────────────┘ └──────────────────┘ └──────────────────┘
                │                       │                    │
                │                       │                    │
                ▼                       ▼                    ▼
        ┌──────────────────────────────────────────────────────────────┐
        │                      MODULE REGISTRY                          │
        │                                                               │
        │  MODULES = {                                                  │
        │      "orders": ModuleDefinition(metrics_provider=...),        │
        │      "catalog": ModuleDefinition(metrics_provider=...),       │
        │  }                                                            │
        └──────────────────────────────────────────────────────────────┘

Testing Without Dependencies

Because core doesn't depend on optional modules, you can test core in isolation:

# tests/unit/core/test_stats_aggregator.py

from unittest.mock import MagicMock
from app.modules.contracts.metrics import MetricValue, MetricsProviderProtocol

class MockMetricsProvider:
    """Mock that implements the protocol without needing real modules."""
    @property
    def metrics_category(self) -> str:
        return "test"

    def get_store_metrics(self, db, store_id, context=None):
        return [MetricValue(key="test.value", value=42, label="Test", category="test")]

def test_stats_aggregator_with_mock_provider():
    # Test core without any optional modules
    mock_provider = MockMetricsProvider()
    ...

Consequences of Violations

If Core Imports Optional Module

  1. Import Error: App fails to start if module code has syntax errors
  2. Runtime Crash: App crashes when disabled module is accessed
  3. Hidden Dependency: Module appears optional but isn't
  4. Testing Difficulty: Can't test core without all modules
  5. Deployment Issues: Can't deploy minimal configurations

Detection

Add these checks to CI:

# Check for forbidden imports
grep -r "from app.modules.marketplace" app/modules/core/ && exit 1
grep -r "from app.modules.analytics" app/modules/core/ && exit 1
grep -r "from app.modules.orders" app/modules/core/ && exit 1

Summary

Rule Enforcement
Core → Optional = FORBIDDEN Architecture validation, CI checks
Cross-module model imports = FORBIDDEN MOD-025 rule, code review
Use services for cross-module data MOD-026 rule, code review
Use Protocol pattern for core→optional Code review, documentation
Lazy factory functions Required for definition.py
TYPE_CHECKING imports Required for type hints across modules
Registry-based discovery Required for all cross-module access

Following these rules ensures:

  • Modules can be truly enabled/disabled per platform
  • Testing can be done in isolation
  • New modules can be added without modifying core
  • The app remains stable when modules fail
  • Module schemas can evolve independently
  • Data access logic is centralized in the owning service