# Architecture Rules - Module Structure Rules # Rules for app/modules/*/ directories module_rules: - id: "MOD-001" name: "Self-contained modules must have required directories" severity: "error" description: | When a module declares is_self_contained=True in its definition, it must have the following directory structure: Required directories: - services/ - Business logic services (actual code, not re-exports) - models/ - Database models (actual code, not re-exports) - schemas/ - Pydantic schemas (actual code, not re-exports) - routes/ - API and page routes - routes/api/ - API endpoints - routes/pages/ - Page endpoints (if module has UI) Optional directories (based on module needs): - templates/ - Jinja templates (if module has UI) - static/ - Static assets (JS/CSS) - locales/ - Translation files - tasks/ - Celery tasks - exceptions.py - Module-specific exceptions WHY THIS MATTERS: - Consistency: All modules follow the same structure - Discoverability: Developers know where to find code - Encapsulation: Module owns all its components - Testability: Clear boundaries for unit tests pattern: directory_pattern: "app/modules/*/" required_when: "is_self_contained=True" required_dirs: - "services/" - "models/" - "schemas/" - "routes/" - "routes/api/" - id: "MOD-002" name: "Module services must contain actual code, not re-exports" severity: "warning" description: | Self-contained module services should contain actual business logic, not just re-exports from legacy locations. If a module's services/ directory only contains re-exports (from ...), the module is not truly self-contained. WRONG (re-export only): # app/modules/analytics/services/stats_service.py from app.services.stats_service import stats_service, StatsService __all__ = ["stats_service", "StatsService"] RIGHT (actual code): # app/modules/analytics/services/stats_service.py class StatsService: def get_stats(self, db: Session, vendor_id: int): # Actual implementation here ... pattern: file_pattern: "app/modules/*/services/*.py" anti_patterns: - "^from app\\.services\\." - "^from app\\/services\\/" - id: "MOD-003" name: "Module schemas must contain actual code, not re-exports" severity: "warning" description: | Self-contained module schemas should contain actual Pydantic models, not just re-exports from legacy models/schema/ location. WRONG (re-export only): # app/modules/analytics/schemas/stats.py from models.schema.stats import StatsResponse, ... __all__ = ["StatsResponse", ...] RIGHT (actual code): # app/modules/analytics/schemas/stats.py class StatsResponse(BaseModel): total: int pending: int ... pattern: file_pattern: "app/modules/*/schemas/*.py" anti_patterns: - "^from models\\.schema\\." - "^from models\\/schema\\/" - id: "MOD-004" name: "Module routes must use module-internal implementations" severity: "warning" description: | Module routes should import services from within the module, not from legacy app/services/ locations. WRONG: from app.services.stats_service import stats_service RIGHT: from app.modules.analytics.services import stats_service # or from ..services import stats_service pattern: file_pattern: "app/modules/*/routes/**/*.py" anti_patterns: - "from app\\.services\\." - id: "MOD-005" name: "Modules with UI must have templates and static directories" severity: "warning" description: | Modules that define menu_items (have UI pages) should have: - templates/{module_code}/admin/ or templates/{module_code}/vendor/ - static/admin/js/ or static/vendor/js/ This ensures: - UI is self-contained within the module - Templates are namespaced to avoid conflicts - JavaScript can be module-specific pattern: directory_pattern: "app/modules/*/" required_when: "has_menu_items=True" required_dirs: - "templates/" - "static/" - id: "MOD-006" name: "Module locales should exist for internationalization" severity: "info" description: | Self-contained modules should have a locales/ directory with translation files for internationalization. Structure: app/modules/{module}/locales/ en.json de.json fr.json lb.json Translation keys are namespaced as {module}.key_name pattern: directory_pattern: "app/modules/*/" suggested_dirs: - "locales/" - id: "MOD-008" name: "Self-contained modules must have exceptions.py" severity: "warning" description: | Self-contained modules should have an exceptions.py file defining module-specific exceptions that inherit from OrionException. Structure: app/modules/{module}/exceptions.py Example: # app/modules/analytics/exceptions.py from app.exceptions import OrionException class AnalyticsException(OrionException): """Base exception for analytics module.""" pass class ReportGenerationError(AnalyticsException): """Error generating analytics report.""" pass WHY THIS MATTERS: - Encapsulation: Module owns its exception hierarchy - Clarity: Clear which exceptions belong to which module - Testability: Easy to mock module-specific exceptions pattern: directory_pattern: "app/modules/*/" required_when: "is_self_contained=True" required_files: - "exceptions.py" # ========================================================================= # Auto-Discovery Rules # ========================================================================= - id: "MOD-009" name: "Module must have definition.py for auto-discovery" severity: "error" description: | Every module directory must have a definition.py file containing a ModuleDefinition instance for auto-discovery. The framework auto-discovers modules from app/modules/*/definition.py. Without this file, the module won't be registered. Required: app/modules//definition.py The definition.py must export a ModuleDefinition instance: # app/modules/analytics/definition.py from app.modules.base import ModuleDefinition analytics_module = ModuleDefinition( code="analytics", name="Analytics & Reporting", ... ) pattern: directory_pattern: "app/modules/*/" required_files: - "definition.py" - "__init__.py" - id: "MOD-010" name: "Module routes must export router variable for auto-discovery" severity: "warning" description: | Route files (admin.py, vendor.py, shop.py) in routes/api/ and routes/pages/ must export a 'router' variable for auto-discovery. The route discovery system looks for: - routes/api/admin.py with 'router' variable - routes/api/vendor.py with 'router' variable - routes/pages/vendor.py with 'router' variable Example: # app/modules/analytics/routes/pages/vendor.py from fastapi import APIRouter router = APIRouter() # Must be named 'router' @router.get("/analytics") def analytics_page(): ... pattern: file_pattern: "app/modules/*/routes/*/*.py" required_exports: - "router" - id: "MOD-011" name: "Module tasks must have __init__.py for Celery discovery" severity: "warning" description: | If a module has a tasks/ directory, it must have __init__.py for Celery task auto-discovery to work. Celery uses autodiscover_tasks() which requires the tasks package to be importable. Required structure: app/modules//tasks/ ├── __init__.py <- Required for discovery └── some_task.py The __init__.py should import task functions: # app/modules/billing/tasks/__init__.py from app.modules.billing.tasks.subscription import reset_period_counters pattern: directory_pattern: "app/modules/*/tasks/" required_files: - "__init__.py" - id: "MOD-012" name: "Module locales should have all supported language files" severity: "info" description: | Module locales/ directory should have translation files for all supported languages to ensure consistent i18n. Supported languages: en, de, fr, lb Structure: app/modules//locales/ ├── en.json ├── de.json ├── fr.json └── lb.json Missing translations will fall back to English, but it's better to have all languages covered. pattern: directory_pattern: "app/modules/*/locales/" suggested_files: - "en.json" - "de.json" - "fr.json" - "lb.json" - id: "MOD-007" name: "Module definition must match directory structure" severity: "error" description: | If a module definition specifies paths like services_path, models_path, etc., those directories must exist and contain __init__.py files. Example: If definition.py has: services_path="app.modules.analytics.services" Then these must exist: app/modules/analytics/services/ app/modules/analytics/services/__init__.py pattern: file_pattern: "app/modules/*/definition.py" validates: - "services_path -> services/__init__.py" - "models_path -> models/__init__.py" - "schemas_path -> schemas/__init__.py" - "exceptions_path -> exceptions.py or exceptions/__init__.py" - "templates_path -> templates/" - "locales_path -> locales/" # ========================================================================= # Self-Contained Config & Migrations Rules # ========================================================================= - id: "MOD-013" name: "Module config.py for environment-based configuration" severity: "info" description: | Self-contained modules can have a config.py file for environment-based configuration using Pydantic Settings. Structure: app/modules//config.py Pattern: # app/modules/marketplace/config.py from pydantic import Field from pydantic_settings import BaseSettings class MarketplaceConfig(BaseSettings): '''Configuration for marketplace module.''' # Settings prefixed with MARKETPLACE_ in environment api_timeout: int = Field(default=30, description="API timeout") batch_size: int = Field(default=100, description="Import batch size") model_config = {"env_prefix": "MARKETPLACE_"} # Export for auto-discovery config_class = MarketplaceConfig config = MarketplaceConfig() The config is auto-discovered by app/modules/config.py and can be accessed via get_module_config("marketplace"). WHY THIS MATTERS: - Encapsulation: Module owns its configuration - Environment: Settings loaded from environment variables - Validation: Pydantic validates configuration on startup - Defaults: Sensible defaults in code, overridable via env pattern: directory_pattern: "app/modules/*/" suggested_files: - "config.py" - id: "MOD-014" name: "Module migrations must follow naming convention" severity: "warning" description: | If a module has migrations, they must: 1. Be located in migrations/versions/ directory 2. Follow the naming convention: {module_code}_{sequence}_{description}.py Structure: app/modules//migrations/ ├── __init__.py └── versions/ ├── __init__.py ├── _001_initial.py ├── _002_add_feature.py └── ... Example for cms module: app/modules/cms/migrations/versions/ ├── cms_001_create_content_pages.py ├── cms_002_add_sections.py └── cms_003_add_media_library.py Migration file must include: revision = "cms_001" down_revision = None # or previous revision branch_labels = ("cms",) # Module-specific branch WHY THIS MATTERS: - Isolation: Module migrations don't conflict with core - Ordering: Sequence numbers ensure correct order - Traceability: Clear which module owns each migration - Rollback: Can rollback module migrations independently pattern: directory_pattern: "app/modules/*/migrations/versions/" file_pattern: "{module_code}_*.py" - id: "MOD-015" name: "Module migrations directory must have __init__.py files" severity: "warning" description: | If a module has a migrations/ directory, both the migrations/ and migrations/versions/ directories must have __init__.py files for proper Python package discovery. Required structure: app/modules//migrations/ ├── __init__.py <- Required └── versions/ └── __init__.py <- Required The __init__.py files can be empty or contain docstrings: # migrations/__init__.py '''Module migrations.''' WHY THIS MATTERS: - Alembic needs to import migration scripts as Python modules - Without __init__.py, the directories are not Python packages pattern: directory_pattern: "app/modules/*/migrations/" required_files: - "__init__.py" - "versions/__init__.py" # ========================================================================= # Legacy Location Rules (Auto-Discovery Enforcement) # ========================================================================= - id: "MOD-016" name: "Routes must be in modules, not app/api/v1/" severity: "error" description: | All API routes must be defined in module directories, not in legacy app/api/v1/vendor/ or app/api/v1/admin/ locations. WRONG (legacy location): app/api/v1/vendor/orders.py app/api/v1/admin/orders.py RIGHT (module location): app/modules/orders/routes/api/vendor.py app/modules/orders/routes/api/admin.py Routes in modules are auto-discovered and registered. Legacy routes require manual registration and don't follow module patterns. EXCEPTIONS (allowed in legacy): - __init__.py (router aggregation) - auth.py (core authentication - will move to tenancy) - Files with # noqa: mod-016 comment WHY THIS MATTERS: - Auto-discovery: Module routes are automatically registered - Encapsulation: Routes belong with their domain logic - Consistency: All modules follow the same pattern - Maintainability: Easier to understand module boundaries pattern: prohibited_locations: - "app/api/v1/vendor/*.py" - "app/api/v1/admin/*.py" exceptions: - "__init__.py" - "auth.py" - id: "MOD-017" name: "Services must be in modules, not app/services/" severity: "error" description: | All business logic services must be defined in module directories, not in the legacy app/services/ location. WRONG (legacy location): app/services/order_service.py RIGHT (module location): app/modules/orders/services/order_service.py EXCEPTIONS (allowed in legacy): - __init__.py (re-exports for backwards compatibility) - Files that are pure re-exports from modules - Files with # noqa: mod-017 comment WHY THIS MATTERS: - Encapsulation: Services belong with their domain - Clear boundaries: Know which module owns which service - Testability: Can test modules in isolation - Refactoring: Easier to move/rename modules pattern: prohibited_locations: - "app/services/*.py" exceptions: - "__init__.py" - id: "MOD-018" name: "Tasks must be in modules, not app/tasks/" severity: "error" description: | All Celery background tasks must be defined in module directories, not in the legacy app/tasks/ location. WRONG (legacy location): app/tasks/subscription_tasks.py RIGHT (module location): app/modules/billing/tasks/subscription.py The module tasks/ directory must have __init__.py for Celery autodiscovery to work. EXCEPTIONS (allowed in legacy): - __init__.py (Celery app configuration) - dispatcher.py (task routing infrastructure) - Files with # noqa: mod-018 comment WHY THIS MATTERS: - Auto-discovery: Celery finds tasks from module directories - Encapsulation: Tasks belong with their domain logic - Consistency: All async operations in one place per module pattern: prohibited_locations: - "app/tasks/*.py" exceptions: - "__init__.py" - "dispatcher.py" - id: "MOD-019" name: "Schemas must be in modules, not models/schema/" severity: "error" description: | All Pydantic schemas must be defined in module directories, not in the legacy models/schema/ location. WRONG (legacy location): models/schema/order.py RIGHT (module location): app/modules/orders/schemas/order.py EXCEPTIONS (allowed in legacy): - __init__.py (re-exports for backwards compatibility) - base.py (base schema classes - infrastructure) - auth.py (core authentication schemas - cross-cutting) - Files with # noqa: mod-019 comment WHY THIS MATTERS: - Encapsulation: Schemas belong with their domain - Co-location: Request/response schemas near route handlers - Clear ownership: Know which module owns which schema pattern: prohibited_locations: - "models/schema/*.py" exceptions: - "__init__.py" - "base.py" - "auth.py" # ========================================================================= # Module Definition Completeness Rules # ========================================================================= - id: "MOD-020" name: "Module definition must have required attributes" severity: "warning" description: | Module definitions should include at minimum: - code: Module identifier - name: Human-readable name - description: What the module does - version: Semantic version - features: List of features (unless infrastructure module) - permissions: Access control definitions (unless internal or storefront-only) EXAMPLES (incomplete): module = ModuleDefinition( code="cart", name="Shopping Cart", description="...", version="1.0.0", # Missing features and permissions ) EXAMPLES (complete): module = ModuleDefinition( code="orders", name="Order Management", description="...", version="1.0.0", features=["order_management", "fulfillment_tracking"], permissions=[ PermissionDefinition(id="orders.view", ...), ], ) EXCEPTIONS: - is_internal=True modules may skip permissions - Infrastructure modules (is_core=True with no UI) may skip features - Storefront-only modules (session-based, no admin UI) may have minimal permissions WHY THIS MATTERS: - Consistency: All modules follow the same definition pattern - RBAC: Permissions enable proper role-based access control - Feature flags: Features enable selective module functionality pattern: file_pattern: "app/modules/*/definition.py" required_attributes: - "code" - "name" - "description" - "version" - id: "MOD-021" name: "Modules with menus should have features" severity: "warning" description: | If a module defines menu items or menu sections, it should also define features to describe what functionality it provides. Menus indicate the module has UI and user-facing functionality, which should be documented as features. WRONG: module = ModuleDefinition( code="billing", menus={ FrontendType.ADMIN: [...], }, # Missing features list ) RIGHT: module = ModuleDefinition( code="billing", features=[ "subscription_management", "billing_history", "invoice_generation", ], menus={ FrontendType.ADMIN: [...], }, ) WHY THIS MATTERS: - Documentation: Features describe what the module does - Feature flags: Enables/disables specific functionality - Consistency: All UI modules describe their capabilities pattern: file_pattern: "app/modules/*/definition.py" validates: - "menus -> features" - id: "MOD-022" name: "Feature modules should have permissions" severity: "info" description: | Modules with features should define permissions unless: - is_internal=True (internal tools like dev_tools) - Storefront-only module (session-based, no authentication) Permissions enable role-based access control (RBAC) for module functionality. EXCEPTIONS: - is_internal=True modules (internal tooling) - Modules with only storefront features (cart, checkout without admin UI) - Infrastructure modules (contracts, core utilities) EXAMPLE: module = ModuleDefinition( code="billing", features=["subscription_management", ...], permissions=[ PermissionDefinition( id="billing.view_subscriptions", label_key="billing.permissions.view_subscriptions", description_key="billing.permissions.view_subscriptions_desc", category="billing", ), ], ) WHY THIS MATTERS: - RBAC: Permissions enable proper access control - Security: Restrict who can access module features - Consistency: All feature modules define their access rules pattern: file_pattern: "app/modules/*/definition.py" validates: - "features -> permissions" exceptions: - "is_internal=True" - id: "MOD-023" name: "Modules with routers should use get_*_with_routers pattern" severity: "info" description: | Modules that define routers should follow the lazy import pattern with a dedicated function. Route files use `router` as the variable name; consumer code distinguishes via `admin_router`/`store_router`. def get_{module}_module_with_routers() -> ModuleDefinition: This pattern: 1. Avoids circular imports during module initialization 2. Ensures routers are attached at the right time 3. Provides a consistent API for router registration WRONG: # Direct router assignment at module level module.admin_router = router RIGHT: def _get_admin_router(): from app.modules.orders.routes.api.admin import router return router def get_orders_module_with_routers() -> ModuleDefinition: orders_module.admin_router = _get_admin_router() return orders_module WHY THIS MATTERS: - Prevents circular imports - Consistent pattern across all modules - Clear API for module registration pattern: file_pattern: "app/modules/*/definition.py" validates: - "router imports -> get_*_with_routers function" # ========================================================================= # Static File Mounting Rules # ========================================================================= - id: "MOD-024" name: "Module static file mount order - specific paths first" severity: "error" description: | When mounting module static files in main.py, more specific paths must be mounted BEFORE less specific paths. FastAPI processes mounts in registration order. WRONG ORDER (locales 404): # Less specific first - intercepts /static/modules/X/locales/* app.mount("/static/modules/X", StaticFiles(...)) # More specific second - never reached! app.mount("/static/modules/X/locales", StaticFiles(...)) RIGHT ORDER (locales work): # More specific first app.mount("/static/modules/X/locales", StaticFiles(...)) # Less specific second - catches everything else app.mount("/static/modules/X", StaticFiles(...)) This applies to all nested static file mounts: - locales/ must be mounted before static/ - img/ or css/ subdirectories must be mounted before parent SYMPTOMS OF WRONG ORDER: - 404 errors for nested paths like /static/modules/tenancy/locales/en.json - Requests to subdirectories served as 404 instead of finding files See: docs/architecture/user-context-pattern.md (Static File Mount Order section) pattern: file_pattern: "main.py" validates: - "module_locales mount BEFORE module_static mount" # ========================================================================= # Cross-Module Boundary Rules # ========================================================================= - id: "MOD-025" name: "Modules must NOT import models from other modules" severity: "error" description: | Modules must access data from other modules through their SERVICE layer, never by importing and querying their models directly. This is the "services over models" principle: if module A needs data from module B, it MUST call module B's service methods. WRONG (direct model import): # app/modules/orders/services/order_service.py from app.modules.catalog.models import Product # FORBIDDEN class OrderService: def get_order_details(self, db, order_id): product = db.query(Product).filter_by(id=pid).first() RIGHT (service call): # app/modules/orders/services/order_service.py from app.modules.catalog.services import product_service class OrderService: def get_order_details(self, db, order_id): product = product_service.get_product_by_id(db, pid) ALSO RIGHT (provider protocol for core→optional): # app/modules/core/services/stats_aggregator.py from app.modules.contracts.metrics import MetricsProviderProtocol # Discover providers through registry, no direct imports EXCEPTIONS: - Test fixtures may create models from other modules for setup - TYPE_CHECKING imports for type hints are allowed - Tenancy models (User, Store, Merchant, Platform) may be imported as type hints in route signatures where FastAPI requires it, but queries must go through tenancy services WHY THIS MATTERS: - Encapsulation: Modules own their data access patterns - Refactoring: Module B can change its schema without breaking A - Testability: Mock services, not database queries - Consistency: Clear API boundaries between modules - Decoupling: Modules can evolve independently pattern: file_pattern: "app/modules/*/services/**/*.py" anti_patterns: - "from app\\.modules\\.(?!)\\.models import" exceptions: - "TYPE_CHECKING" - "tests/" - id: "MOD-026" name: "Cross-module data access must use service methods" severity: "error" description: | When a module needs data from another module, it must use that module's public service API. Each module should expose service methods for common data access patterns. Service methods a module should expose: - get_{entity}_by_id(db, id) -> Entity or None - list_{entities}(db, filters) -> list[Entity] - get_{entity}_count(db, filters) -> int - search_{entities}(db, query, filters) -> list[Entity] WRONG (direct query across module boundary): # In orders module count = db.query(func.count(Product.id)).scalar() RIGHT (call catalog service): # In orders module count = product_service.get_product_count(db, store_id=store_id) This applies to: - Simple lookups (get by ID) - List/search queries - Aggregation queries (count, sum) - Join queries (should be decomposed into service calls) WHY THIS MATTERS: - Single source of truth for data access logic - Easier to add caching, validation, or access control - Clear contract between modules - Simpler testing with service mocks pattern: file_pattern: "app/modules/*/services/**/*.py" check: "cross_module_service_usage"