Files
orion/docs/proposals/decouple-modules-implementation-plan.md
Samir Boulahtit 7a9dda282d refactor(scripts): reorganize scripts/ into seed/ and validate/ subfolders
Move 9 init/seed scripts into scripts/seed/ and 7 validation scripts
(+ validators/ subfolder) into scripts/validate/ to reduce clutter in
the root scripts/ directory. Update all references across Makefile,
CI/CD configs, pre-commit hooks, docs (~40 files), and Python imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:35:53 +01:00

20 KiB

Implementation Plan: Decouple Core Modules from Optional Modules

Executive Summary

This plan addresses the remaining architecture violations where core modules have hard dependencies on optional modules. The goal is to ensure the app can run even if optional modules are removed.

Current Status:

  • Dashboard statistics (core → analytics) - FIXED via MetricsProvider pattern
  • Tenancy → marketplace, catalog, billing, analytics - 6 violations
  • CMS → catalog, billing, messaging - 5 violations (1 misplaced service)
  • Customers → orders - 2 violations

Part 1: Tenancy Module Violations

Violation T1: MarketplaceImportJob in models/init.py

Current Code:

# tenancy/models/__init__.py:22
from app.modules.marketplace.models.marketplace_import_job import MarketplaceImportJob  # noqa: F401

Purpose: SQLAlchemy relationship resolution for User.marketplace_import_jobs and Store.marketplace_import_jobs

Solution: Remove relationships from core models

The relationships User.marketplace_import_jobs and Store.marketplace_import_jobs should be defined ONLY in the MarketplaceImportJob model using backref, not on the User/Store models. This is a one-way relationship that optional modules add to core models.

Implementation:

  1. Remove the import from tenancy/models/__init__.py
  2. Remove marketplace_import_jobs relationship from User model
  3. Remove marketplace_import_jobs relationship from Store model
  4. Ensure MarketplaceImportJob already has the relationship defined with backref
  5. Access pattern changes: user.marketplace_import_jobs → query MarketplaceImportJob.filter(user_id=user.id)

Impact: Low - This is internal data access, not a public API


Violation T2: MarketplaceImportJob in admin_service.py

Current Code:

# tenancy/services/admin_service.py:36,40
from app.modules.marketplace.models import MarketplaceImportJob
from app.modules.marketplace.schemas import MarketplaceImportJobResponse

Used In:

  • get_marketplace_import_jobs() - Admin endpoint for listing import jobs
  • get_recent_import_jobs() - Dashboard recent imports widget
  • _convert_job_to_response() - Response conversion

Solution: Move functionality to marketplace module

These methods belong in the marketplace module, not tenancy. The admin dashboard should call marketplace service methods.

Implementation:

  1. Create app/modules/marketplace/services/import_job_service.py (if not exists)
  2. Move get_marketplace_import_jobs() logic to marketplace module
  3. Move get_recent_import_jobs() logic to marketplace module
  4. Update admin_service to use lazy imports with try/except:
    def get_recent_import_jobs(self, db: Session, limit: int = 10) -> list:
        try:
            from app.modules.marketplace.services import import_job_service
            return import_job_service.get_recent_jobs(db, limit)
        except ImportError:
            return []  # Marketplace module not installed
    
  5. Update admin dashboard route to handle empty list gracefully

Impact: Medium - Admin UI shows "No imports" if marketplace disabled


Violation T3: Catalog/Marketplace in store_service.py

Current Code:

# tenancy/services/store_service.py:18-30
from app.modules.catalog.exceptions import ProductAlreadyExistsException
from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException
from app.modules.marketplace.models import MarketplaceProduct
from app.modules.catalog.models import Product
from app.modules.catalog.schemas import ProductCreate

Used In:

  • add_product_to_catalog() - Adds marketplace product to store catalog

Solution: Move product management to catalog module

Product management is catalog functionality, not tenancy functionality. The add_product_to_catalog() method should live in the catalog module.

Implementation:

  1. Create app/modules/catalog/services/product_catalog_service.py
  2. Move add_product_to_catalog() to catalog module
  3. Move helper methods _get_product_by_id_or_raise() and _product_in_catalog()
  4. store_service delegates to catalog service with lazy import:
    def add_product_to_catalog(self, db: Session, store_id: int, product_data: dict):
        try:
            from app.modules.catalog.services import product_catalog_service
            return product_catalog_service.add_product(db, store_id, product_data)
        except ImportError:
            raise ModuleNotEnabledException("catalog")
    

Impact: Medium - Feature requires both catalog and marketplace modules


Violation T4: TierLimitExceededException in store_team_service.py

Current Code:

# tenancy/services/store_team_service.py:34
from app.modules.billing.exceptions import TierLimitExceededException

# Line 78 (lazy import inside method)
from app.modules.billing.services import subscription_service
subscription_service.check_team_limit(db, store.id)

Used In:

  • invite_team_member() - Validates team size against subscription tier

Solution: Protocol-based limit checking

Define a TierLimitChecker protocol in contracts module. Billing implements it, tenancy uses it optionally.

Implementation:

  1. Add to app/modules/contracts/billing.py:

    @runtime_checkable
    class TierLimitCheckerProtocol(Protocol):
        def check_team_limit(self, db: Session, store_id: int) -> None:
            """Raises TierLimitExceededException if limit exceeded."""
            ...
    
  2. Add generic exception to tenancy module:

    # tenancy/exceptions.py
    class TeamSizeLimitExceededException(WizamartException):
        """Team size limit exceeded (billing module provides specific limits)."""
    
  3. Update store_team_service.py:

    def invite_team_member(self, ...):
        # Check tier limits if billing module available
        try:
            from app.modules.billing.services import subscription_service
            subscription_service.check_team_limit(db, store.id)
        except ImportError:
            pass  # No billing module - no tier limits
        except Exception as e:
            # Convert billing exception to tenancy exception
            if "limit" in str(e).lower():
                raise TeamSizeLimitExceededException(str(e))
            raise
    

Impact: Low - Without billing, team size is unlimited


Violation T5: Analytics/Marketplace in admin_stores.py

Current Code:

# tenancy/routes/api/admin_stores.py:20,23
from app.modules.analytics.services.stats_service import stats_service
from app.modules.analytics.schemas import StoreStatsResponse

# Lines 348, 399 (lazy imports)
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service

Used In:

  • get_store_statistics() - Returns store counts
  • export_store_products_letzshop() - CSV export
  • export_store_products_letzshop_to_folder() - Batch export

Solution A (Analytics): Use MetricsProvider pattern (already implemented!)

The stats endpoint should use our new StatsAggregatorService:

@router.get("/stores/stats")
def get_store_statistics(db: Session = Depends(get_db), ...):
    from app.modules.core.services.stats_aggregator import stats_aggregator
    metrics = stats_aggregator.get_admin_dashboard_stats(db, platform_id)
    # Extract tenancy metrics
    tenancy_metrics = metrics.get("tenancy", [])
    return _build_store_stats_response(tenancy_metrics)

Solution B (Marketplace Export): Already uses lazy imports - wrap in try/except

@router.get("/stores/{store_id}/export/letzshop")
def export_store_products_letzshop(...):
    try:
        from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
        return letzshop_export_service.export_store_products(...)
    except ImportError:
        raise ModuleNotEnabledException("marketplace")

Impact: Low - Features gracefully degrade


Violation T6: Analytics in admin_platform_users.py

Current Code:

# tenancy/routes/api/admin_platform_users.py:18
from app.modules.analytics.services.stats_service import stats_service

Used In:

  • get_user_statistics() - Returns user counts

Solution: Same as T5 - use StatsAggregator

@router.get("/users/stats")
def get_user_statistics(db: Session = Depends(get_db), ...):
    from app.modules.core.services.stats_aggregator import stats_aggregator
    metrics = stats_aggregator.get_admin_dashboard_stats(db, platform_id)
    tenancy_metrics = metrics.get("tenancy", [])
    return _build_user_stats_response(tenancy_metrics)

Impact: None - Already have user metrics in tenancy_metrics


Part 2: CMS Module Violations

Violation C1: ProductMedia in media.py and media_service.py

Current Code:

# cms/models/media.py:77-80
product_associations = relationship("ProductMedia", back_populates="media", ...)

# cms/services/media_service.py:31
from app.modules.catalog.models import ProductMedia

Used In:

  • attach_to_product() - Creates product-media association
  • detach_from_product() - Removes product-media association
  • get_media_usage() - Lists where media is used

Solution: Move product-media logic to catalog module

The CMS media service shouldn't know about products. Product-media associations are catalog concerns.

Implementation:

  1. Create app/modules/catalog/services/product_media_service.py

  2. Move attach_to_product() and detach_from_product() to catalog

  3. CMS media_service uses lazy delegation:

    def attach_to_product(self, db: Session, media_id: int, product_id: int):
        try:
            from app.modules.catalog.services import product_media_service
            return product_media_service.attach_media(db, media_id, product_id)
        except ImportError:
            raise ModuleNotEnabledException("catalog")
    
  4. For get_media_usage(), optionally include product associations:

    def get_media_usage(self, db: Session, media_id: int) -> dict:
        usage = {"pages": [...], "products": []}
        try:
            from app.modules.catalog.services import product_media_service
            usage["products"] = product_media_service.get_media_products(db, media_id)
        except ImportError:
            pass  # No catalog module
        return usage
    

Impact: Medium - Product-media features require catalog module


Violation C2: TIER_LIMITS in platform.py (Homepage pricing)

Current Code:

# cms/routes/pages/platform.py:17
from app.modules.billing.models import TIER_LIMITS, TierCode

Used In:

  • _get_tiers_data() - Builds pricing tier display for homepage
  • Called by homepage() and content_page() handlers

Solution: Use Context Provider pattern

Billing module should provide tier data via context provider (already supported in module architecture).

Implementation:

  1. Add context provider to billing module definition:

    # billing/definition.py
    def _get_platform_context(request, db, platform) -> dict:
        from app.modules.billing.models import TIER_LIMITS, TierCode
        tiers = [...]  # Build tiers data
        return {"tiers": tiers, "has_billing": True}
    
    billing_module = ModuleDefinition(
        context_providers={
            FrontendType.PLATFORM: _get_platform_context,
        },
    )
    
  2. Remove _get_tiers_data() from platform.py

  3. Template checks {% if tiers %} before showing pricing section

  4. Homepage gracefully shows "Contact us for pricing" if billing disabled

Impact: Low - Pricing section hidden if billing disabled


Violation C3 & C4: MISPLACED SERVICE - store_email_settings_service.py

Current Code:

# cms/services/store_email_settings_service.py:28-33
from app.modules.messaging.models import StoreEmailSettings, EmailProvider, PREMIUM_EMAIL_PROVIDERS
from app.modules.billing.models import StoreSubscription, TierCode

Critical Finding: This service is in the WRONG MODULE!

The store_email_settings_service.py is a messaging service that manages email provider configuration. It belongs in the messaging module, not CMS.

Solution: Move service to messaging module

Implementation:

  1. Move cms/services/store_email_settings_service.pymessaging/services/store_email_settings_service.py

  2. Update all imports that reference it (search codebase)

  3. For billing tier checks, use lazy import with graceful fallback:

    # messaging/services/store_email_settings_service.py
    def _check_premium_tier(self, db: Session, store_id: int, provider: str) -> bool:
        if provider not in PREMIUM_EMAIL_PROVIDERS:
            return True  # Non-premium providers always allowed
    
        try:
            from app.modules.billing.services import subscription_service
            tier = subscription_service.get_store_tier(db, store_id)
            return tier in {TierCode.BUSINESS, TierCode.ENTERPRISE}
        except ImportError:
            return True  # No billing module - all providers allowed
    
  4. Update cms/services/__init__.py to remove the export

  5. Add backwards-compatibility alias if needed (deprecation warning)

Impact: Medium - Requires import path updates


Violation C5: Dead Code - Store import

Current Code:

# cms/services/store_email_settings_service.py:27
from app.modules.tenancy.models import Store  # UNUSED

Solution: Remove the unused import when moving the service.


Part 3: Customers Module Violations

Violation CU1 & CU2: Order imports in customer_service.py

Current Code:

# customers/services/customer_service.py:332-350 (lazy import)
from app.modules.orders.models import Order  # in get_customer_orders()

# customers/services/customer_service.py:365-396 (lazy import)
from app.modules.orders.models import Order  # in get_customer_statistics()

Used In:

  • get_customer_orders() - Lists orders for a customer
  • get_customer_statistics() - Calculates customer LTV metrics

Solution: Protocol-based customer order service

Define a CustomerOrdersProtocol in contracts. Orders module implements it.

Implementation:

  1. Add to app/modules/contracts/orders.py:

    @runtime_checkable
    class CustomerOrdersProtocol(Protocol):
        def get_customer_orders(
            self, db: Session, store_id: int, customer_id: int, skip: int, limit: int
        ) -> tuple[list, int]:
            ...
    
        def get_customer_statistics(
            self, db: Session, store_id: int, customer_id: int
        ) -> dict:
            ...
    
  2. Create app/modules/orders/services/customer_orders_service.py:

    class CustomerOrdersService:
        def get_customer_orders(self, db, store_id, customer_id, skip, limit):
            from app.modules.orders.models import Order
            # ... existing logic
    
        def get_customer_statistics(self, db, store_id, customer_id):
            from app.modules.orders.models import Order
            # ... existing logic
    
    customer_orders_service = CustomerOrdersService()
    
  3. Update customer_service.py to use lazy service discovery:

    def get_customer_orders(self, db, store_id, customer_id, skip=0, limit=50):
        try:
            from app.modules.orders.services import customer_orders_service
            return customer_orders_service.get_customer_orders(db, store_id, customer_id, skip, limit)
        except ImportError:
            return [], 0  # No orders module
    
    def get_customer_statistics(self, db, store_id, customer_id):
        customer = self.get_customer(db, store_id, customer_id)
        stats = {
            "customer_id": customer_id,
            "member_since": customer.created_at,
            "is_active": customer.is_active,
            "total_orders": 0,
            "total_spent": 0.0,
            "average_order_value": 0.0,
            "last_order_date": None,
        }
    
        try:
            from app.modules.orders.services import customer_orders_service
            order_stats = customer_orders_service.get_customer_statistics(db, store_id, customer_id)
            stats.update(order_stats)
        except ImportError:
            pass  # No orders module - return base stats
    
        return stats
    

Impact: Low - Customer details show "No orders" if orders disabled


Implementation Phases

Phase 1: Quick Wins (Low Risk)

  1. T6: Update admin_platform_users.py to use StatsAggregator
  2. T5a: Update admin_stores.py stats endpoint to use StatsAggregator
  3. C5: Remove dead Store import
  4. T4: Add try/except for billing tier check

Phase 2: Service Relocation (Medium Risk)

  1. C3/C4: Move store_email_settings_service to messaging module
  2. T2: Move import job methods to marketplace module
  3. C1: Move product-media logic to catalog module

Phase 3: Model Relationship Cleanup (Medium Risk)

  1. T1: Remove MarketplaceImportJob relationship from User/Store models
  2. T3: Move product catalog methods to catalog module

Phase 4: Protocol-Based Decoupling (Low Risk)

  1. CU1/CU2: Create customer orders service in orders module
  2. C2: Add billing context provider for pricing tiers

Phase 5: Testing & Verification

  1. Run full test suite with all modules enabled
  2. Test with each optional module disabled individually
  3. Update architecture validation script
  4. Update documentation

Summary Table

ID Module Violation Solution Risk Phase
T1 tenancy MarketplaceImportJob in models Remove relationship from core Medium 3
T2 tenancy ImportJob in admin_service Move to marketplace Medium 2
T3 tenancy Products in store_service Move to catalog Medium 3
T4 tenancy TierLimit in team_service Try/except wrapper Low 1
T5a tenancy Stats in admin_stores Use StatsAggregator Low 1
T5b tenancy Export in admin_stores Already lazy - add try/except Low 1
T6 tenancy Stats in admin_users Use StatsAggregator Low 1
C1 cms ProductMedia in media Move to catalog Medium 2
C2 cms TIER_LIMITS in platform Context provider Low 4
C3/C4 cms MISPLACED email service Move to messaging Medium 2
C5 cms Dead Store import Remove None 1
CU1/CU2 customers Order in customer_service Protocol + lazy Low 4

Verification Commands

After implementation, run these to verify:

# Check no core→optional imports remain
grep -r "from app.modules.analytics" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"

grep -r "from app.modules.marketplace" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"

grep -r "from app.modules.billing" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"

grep -r "from app.modules.catalog" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"

grep -r "from app.modules.orders" app/modules/core/ app/modules/tenancy/ app/modules/cms/ app/modules/customers/ 2>/dev/null | grep -v "# optional" || echo "Clean!"

# Run tests with each optional module "disabled" (move temporarily)
# This would be a more advanced test

# Run architecture validator
python scripts/validate/validate_architecture.py

Expected Outcome

After all phases complete:

  • App starts successfully even if optional modules are removed
  • Core modules have NO hard imports from optional modules
  • Features gracefully degrade when optional modules disabled
  • All cross-module communication via protocols or lazy imports with try/except