Files
orion/docs/architecture/cross-module-import-rules.md
Samir Boulahtit 86e85a98b8
Some checks failed
CI / ruff (push) Successful in 9s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
refactor(arch): eliminate all cross-module model imports in service layer
Enforce MOD-025/MOD-026 rules: zero top-level cross-module model imports
remain in any service file. All 66 files migrated using deferred import
patterns (method-body, _get_model() helpers, instance-cached self._Model)
and new cross-module service methods in tenancy. Documentation updated
with Pattern 6 (deferred imports), migration plan marked complete, and
violations status reflects 84→0 service-layer violations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 06:13:15 +01:00

20 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. Pair with from __future__ import annotations to avoid quoting type hints:

from __future__ import annotations

from typing import TYPE_CHECKING

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

# With `from __future__ import annotations`, no string quoting needed
def process_import(job: MarketplaceImportJob | None = None) -> None:
    # Works at runtime even if marketplace is disabled
    ...

Note: Without from __future__ import annotations, you must quote the type hint as "MarketplaceImportJob" to prevent a NameError at class/function definition time.

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

6. Method-Body Deferred Imports

When a service needs to query another module's models directly (e.g., the model's owning service doesn't exist yet, or the service IS the gateway for a misplaced model), defer the import into the method body. This moves the import from module-load time to first-call time, breaking circular import chains and keeping the module boundary clean.

Choose the sub-pattern based on how many methods use the model:

6a. Simple method-body import (1-2 methods)

When only one or two methods need the model, import directly inside the method:

# app/modules/catalog/services/product_service.py

class ProductService:
    def create_product(self, db, data):
        # Deferred: only this method needs MarketplaceProduct
        from app.modules.marketplace.models import MarketplaceProduct

        mp = db.query(MarketplaceProduct).filter(
            MarketplaceProduct.id == data.marketplace_product_id
        ).first()
        ...

6b. Module-level _get_model() helper (3+ methods, same model)

When many methods in one file use the same cross-module model, create a module-level deferred helper to avoid repeating the import:

# app/modules/monitoring/services/log_service.py

def _get_application_log_model():
    """Deferred import for ApplicationLog (lives in tenancy, consumed by monitoring)."""
    from app.modules.tenancy.models import ApplicationLog
    return ApplicationLog


class LogService:
    def get_database_logs(self, db, filters):
        ApplicationLog = _get_application_log_model()
        return db.query(ApplicationLog).filter(...).all()

    def get_log_statistics(self, db, days=7):
        ApplicationLog = _get_application_log_model()
        return db.query(func.count(ApplicationLog.id)).scalar()

    def cleanup_old_logs(self, db, retention_days):
        ApplicationLog = _get_application_log_model()
        db.query(ApplicationLog).filter(...).delete()

6c. Instance-cached models (pervasive usage across class)

When a model is used in nearly every method of a class, cache it on the instance at init time to avoid repetitive local assignments:

# app/modules/marketplace/services/letzshop/order_service.py

def _get_order_models():
    """Deferred import for Order/OrderItem models."""
    from app.modules.orders.models import Order, OrderItem
    return Order, OrderItem


class LetzshopOrderService:
    def __init__(self, db):
        self.db = db
        self._Order, self._OrderItem = _get_order_models()

    def get_order(self, store_id, order_id):
        return self.db.query(self._Order).filter(
            self._Order.id == order_id,
            self._Order.store_id == store_id,
        ).first()

    def get_order_items(self, order):
        return self.db.query(self._OrderItem).filter(
            self._OrderItem.order_id == order.id
        ).all()

When to use each sub-pattern

Model usage in file Pattern Example
1-2 methods 6a: method-body import product_media_service.pyMediaFile
3+ methods, same model 6b: _get_model() helper log_service.pyApplicationLog
Nearly every method 6c: instance-cached letzshop/order_service.pyOrder

When NOT to use these patterns: If the owning module already has a service method that returns the data you need, call that service instead of querying the model. Deferred imports are for cases where the model's service doesn't expose the needed query, or the service IS the canonical gateway for a misplaced infrastructure model.

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