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