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>
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 interfacescore- Dashboard, settings, profiletenancy- Platform, merchant, store, admin user managementcms- Content pages, media librarycustomers- Customer databasebilling- Subscriptions, tier limitspayments- Payment gateway integrationsmessaging- Email, notifications
Optional Modules (Per-Platform)
analytics- Reports, dashboardscart- Shopping cartcatalog- Product browsingcheckout- Cart-to-order conversioninventory- Stock managementloyalty- Loyalty programsmarketplace- Letzshop integrationorders- 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
-
Any → Any Services: Allowed (with core→optional restriction). Import the service, call its methods.
-
Any → Any Models: FORBIDDEN. Never import another module's SQLAlchemy models. Use that module's service instead.
-
Core → Optional: FORBIDDEN (both services and models). Use provider protocols instead.
-
Optional → Core Services: Allowed. Optional modules can call core service methods.
-
Optional → Optional Services: Allowed with care. Declare dependency in
definition.py. -
Any → Contracts: Allowed. Contracts define shared protocols and data structures.
-
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 aNameErrorat 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.py → MediaFile |
| 3+ methods, same model | 6b: _get_model() helper |
log_service.py → ApplicationLog |
| Nearly every method | 6c: instance-cached | letzshop/order_service.py → Order |
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
- Import Error: App fails to start if module code has syntax errors
- Runtime Crash: App crashes when disabled module is accessed
- Hidden Dependency: Module appears optional but isn't
- Testing Difficulty: Can't test core without all modules
- 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
Related Documentation
- Module System Architecture - Module structure and auto-discovery
- Metrics Provider Pattern - Numeric statistics architecture
- Widget Provider Pattern - Dashboard widgets architecture
- Architecture Violations Status - Current violation tracking
- Cross-Module Migration Plan - Migration plan for resolving all cross-module violations