AP blueprints (activitypub.py, ap_social.py) were querying federation tables (ap_actor_profiles etc.) on g.s which points to the app's own DB after the per-app split. Now uses g._ap_s backed by get_federation_session() for non-federation apps. Also hardens Ghost sync before_app_serving to catch/rollback on failure instead of crashing the Hypercorn worker. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
22 KiB
Phase 6: Full Cross-App Decoupling via Glue Services
Context
Phases 1-5 are complete. All cross-domain FK constraints have been dropped (except OrderItem.product_id and CartItem.product_id/market_place_id/user_id, kept as pragmatic exceptions). Cross-domain writes go through glue services.
However, 25+ cross-app model imports remain — apps still from blog.models.ghost_content import Post, from market.models.market import CartItem, etc. This means every app needs every other app's code on disk to start, making separate databases or independent deployment impossible.
Goal: Eliminate all cross-app model imports. Every app only imports from its own models/, from shared/, and from glue/. Cross-domain access goes through glue services. After this phase, each app could theoretically run against its own database.
Inventory of Cross-App Imports to Eliminate
Cart app imports (9 files, 4 foreign models):
| File | Import | Usage |
|---|---|---|
cart/bp/cart/api.py |
market.models.market.CartItem |
Query cart items |
cart/bp/cart/api.py |
market.models.market_place.MarketPlace |
Filter by container |
cart/bp/cart/api.py |
events.models.calendars.CalendarEntry, Calendar |
Query pending entries |
cart/bp/cart/api.py |
blog.models.ghost_content.Post |
Resolve page slug |
cart/bp/cart/services/checkout.py |
market.models.market.Product, CartItem |
Find cart items, validate products |
cart/bp/cart/services/checkout.py |
events.models.calendars.CalendarEntry, Calendar |
Resolve page containers |
cart/bp/cart/services/checkout.py |
market.models.market_place.MarketPlace |
Get container_id |
cart/bp/cart/services/page_cart.py |
market.models.market.CartItem |
Query page cart |
cart/bp/cart/services/page_cart.py |
market.models.market_place.MarketPlace |
Join for container |
cart/bp/cart/services/page_cart.py |
events.models.calendars.CalendarEntry, Calendar |
Query page entries |
cart/bp/cart/services/page_cart.py |
blog.models.ghost_content.Post |
Batch-load posts |
cart/bp/cart/services/get_cart.py |
market.models.market.CartItem |
Query cart items |
cart/bp/cart/services/calendar_cart.py |
events.models.calendars.CalendarEntry |
Query pending entries |
cart/bp/cart/services/clear_cart_for_order.py |
market.models.market.CartItem |
Soft-delete items |
cart/bp/cart/services/clear_cart_for_order.py |
market.models.market_place.MarketPlace |
Filter by page |
cart/bp/orders/routes.py |
market.models.market.Product |
Join for search |
cart/bp/order/routes.py |
market.models.market.Product |
Load product details |
cart/app.py |
blog.models.ghost_content.Post |
Page slug hydration |
Blog app imports (8 files, 3 foreign models):
| File | Import | Usage |
|---|---|---|
blog/bp/post/admin/routes.py |
cart.models.page_config.PageConfig (3 places) |
Load/update page config |
blog/bp/post/admin/routes.py |
events.models.calendars.Calendar (3 places) |
Query calendars |
blog/bp/post/admin/routes.py |
market.models.market_place.MarketPlace (3 places) |
Query/create/delete markets |
blog/bp/post/services/markets.py |
market.models.market_place.MarketPlace |
Create/delete markets |
blog/bp/post/services/markets.py |
cart.models.page_config.PageConfig |
Check feature flag |
blog/bp/post/services/entry_associations.py |
events.models.calendars.CalendarEntry, CalendarEntryPost, Calendar |
Post-entry associations |
blog/bp/post/routes.py |
events.models.calendars.Calendar |
Page context |
blog/bp/post/routes.py |
market.models.market_place.MarketPlace |
Page context |
blog/bp/blog/ghost_db.py |
cart.models.page_config.PageConfig |
Query page configs |
blog/bp/blog/ghost/ghost_sync.py |
cart.models.page_config.PageConfig |
Sync page config |
blog/bp/blog/services/posts_data.py |
events.models.calendars.CalendarEntry, CalendarEntryPost |
Fetch associated entries |
Events app imports (5 files, 3 foreign models):
| File | Import | Usage |
|---|---|---|
events/app.py |
blog.models.ghost_content.Post |
Page slug hydration |
events/app.py |
market.models.market_place.MarketPlace |
Context processor |
events/bp/markets/services/markets.py |
market.models.market_place.MarketPlace |
Create/delete markets |
events/bp/markets/services/markets.py |
blog.models.ghost_content.Post |
Validate post exists |
events/bp/markets/routes.py |
market.models.market_place.MarketPlace |
Query/delete markets |
events/bp/calendars/services/calendars.py |
blog.models.ghost_content.Post |
Validate post exists |
events/bp/calendar_entry/services/post_associations.py |
blog.models.ghost_content.Post |
Manage post-entry assocs |
events/bp/payments/routes.py |
cart.models.page_config.PageConfig |
Load/update SumUp config |
Market app imports (1 file):
| File | Import | Usage |
|---|---|---|
market/app.py |
blog.models.ghost_content.Post |
Page slug hydration |
Glue layer imports (2 files):
| File | Import | Usage |
|---|---|---|
glue/services/cart_adoption.py |
market.models.market.CartItem |
Adopt cart items |
glue/services/cart_adoption.py |
events.models.calendars.CalendarEntry |
Adopt entries |
glue/services/order_lifecycle.py |
events.models.calendars.CalendarEntry, Calendar |
Claim/confirm entries |
Design Decisions
-
Glue services return ORM objects (not dicts) when the model is standalone — PageConfig, MarketPlace, Calendar, CalendarEntry. This avoids template changes and keeps SQLAlchemy lazy-load working.
-
Glue services for Post return dicts — other apps only need
{id, slug, title, is_page, feature_image}. Returning the full ORM object would couple them to the blog schema. -
CartItem stays in
market/models/market.py— it has FKs toproducts.id,market_places.id, andusers.id, plus relationships toProduct,MarketPlace, andUser. Moving it to cart/ would just reverse the cross-app import direction. Instead, cart reads CartItem through glue. -
OrderItem.product relationship uses string forward-ref — already works via SQLAlchemy string resolution as long as Product is registered in the mapper. Glue setup handles this.
-
Glue services are allowed to import from any app's models — that's the glue layer's job. Apps call glue; glue touches models.
-
blog/bp/post/services/markets.py and entry_associations.py move to glue — these are pure cross-domain CRUD (blog writes to MarketPlace, blog reads CalendarEntry). They belong in glue.
Step 1: Glue service for pages (Post access)
New file: glue/services/pages.py
Provides dict-based Post access for non-blog apps:
async def get_page_by_slug(session, slug) -> dict | None:
"""Return {id, slug, title, is_page, feature_image, ...} or None."""
async def get_page_by_id(session, post_id) -> dict | None:
"""Return page dict by id."""
async def get_pages_by_ids(session, post_ids) -> dict[int, dict]:
"""Batch-load pages. Returns {id: page_dict}."""
async def page_exists(session, post_id) -> bool:
"""Check if post exists (for validation before creating calendars/markets)."""
async def is_page(session, post_id) -> bool:
"""Check if post exists and is_page=True."""
async def search_posts(session, query, page=1, per_page=10) -> tuple[list[dict], int]:
"""Search posts by title (for events post_associations)."""
All functions import from blog.models.ghost_content import Post internally.
Files changed:
market/app.py— replacefrom blog.models.ghost_content import Postwithfrom glue.services.pages import get_page_by_slugevents/app.py— samecart/app.py— samecart/bp/cart/api.py— replace Post import withfrom glue.services.pages import get_page_by_slugcart/bp/cart/services/page_cart.py— replace Post import withfrom glue.services.pages import get_pages_by_idsevents/bp/calendars/services/calendars.py— replacefrom blog.models.ghost_content import Postwithfrom glue.services.pages import page_exists, is_pageevents/bp/markets/services/markets.py— replacefrom blog.models.ghost_content import Postwithfrom glue.services.pages import page_exists, is_page
Step 2: Glue service for page config
New file: glue/services/page_config.py
async def get_page_config(session, post_id) -> PageConfig | None:
"""Load PageConfig for a page."""
async def get_or_create_page_config(session, post_id) -> PageConfig:
"""Load or create PageConfig. Emits container.child_attached if created."""
async def get_page_configs_by_ids(session, post_ids) -> dict[int, PageConfig]:
"""Batch-load PageConfigs by container_id."""
Imports from cart.models.page_config import PageConfig internally.
Files changed:
blog/bp/post/admin/routes.py— replacefrom cart.models.page_config import PageConfigwith glue service callsblog/bp/post/services/markets.py— replace PageConfig importblog/bp/blog/ghost_db.py— replace PageConfig importblog/bp/blog/ghost/ghost_sync.py— replace PageConfig importevents/bp/payments/routes.py— replace PageConfig importcart/bp/cart/services/checkout.py— replacefrom models.page_config import PageConfigstays (same app)
Step 3: Glue service for calendars (events access from blog)
New file: glue/services/calendars.py
async def get_calendars_for_page(session, post_id) -> list[Calendar]:
"""Return active calendars for a page."""
async def get_calendar_entries_for_posts(session, post_ids) -> dict[int, list]:
"""Fetch confirmed CalendarEntries associated with posts (via CalendarEntryPost).
Returns {post_id: [entry, ...]}."""
Move and adapt from blog/bp/post/services/entry_associations.py:
async def toggle_entry_association(session, post_id, entry_id) -> tuple[bool, str | None]:
async def get_post_entry_ids(session, post_id) -> set[int]:
async def get_associated_entries(session, post_id, page=1, per_page=10) -> dict:
These functions import from events.models.calendars internally.
Files changed:
blog/bp/post/routes.py— replacefrom events.models.calendars import Calendar+from market.models.market_place import MarketPlacewith glue service callsblog/bp/post/admin/routes.py— replace Calendar imports with glue service callsblog/bp/post/services/entry_associations.py— delete file, moved to glueblog/bp/blog/services/posts_data.py— replacefrom events.models.calendars import CalendarEntry, CalendarEntryPostwith glue service call
Step 4: Glue service for marketplaces
New file: glue/services/marketplaces.py
async def get_marketplaces_for_page(session, post_id) -> list[MarketPlace]:
"""Return active marketplaces for a page."""
async def create_marketplace(session, post_id, name) -> MarketPlace:
"""Create marketplace (validates page exists via pages service)."""
async def soft_delete_marketplace(session, post_slug, market_slug) -> bool:
"""Soft-delete a marketplace."""
Move the logic from blog/bp/post/services/markets.py and events/bp/markets/services/markets.py (they're nearly identical).
Files changed:
blog/bp/post/services/markets.py— delete file, moved to glueblog/bp/post/admin/routes.py— replace MarketPlace imports + service calls with glueblog/bp/post/routes.py— replace MarketPlace import with glue serviceevents/bp/markets/services/markets.py— delete file, moved to glueevents/bp/markets/routes.py— replace MarketPlace import, use glueevents/app.py— replace MarketPlace import with glue service
Step 5: Glue service for cart items (market model access from cart)
New file: glue/services/cart_items.py
async def get_cart_items(session, user_id=None, session_id=None, *, page_post_id=None) -> list[CartItem]:
"""Get cart items for identity, optionally scoped to page."""
async def find_or_create_cart_item(session, product_id, user_id, session_id) -> CartItem | None:
"""Find existing or create new cart item. Returns None if product missing."""
async def clear_cart_for_order(session, order, *, page_post_id=None) -> None:
"""Soft-delete cart items for order identity."""
async def get_calendar_cart_entries(session, user_id=None, session_id=None, *, page_post_id=None) -> list[CalendarEntry]:
"""Get pending calendar entries for identity, optionally scoped to page."""
Imports CartItem, Product, MarketPlace from market, CalendarEntry, Calendar from events internally.
Files changed:
cart/bp/cart/services/get_cart.py— replace CartItem import with glue callcart/bp/cart/services/calendar_cart.py— replace CalendarEntry import with glue callcart/bp/cart/services/clear_cart_for_order.py— replace CartItem/MarketPlace imports with glue callcart/bp/cart/services/checkout.py— replace CartItem/Product/MarketPlace/CalendarEntry/Calendar imports with glue callscart/bp/cart/api.py— replace CartItem/MarketPlace/CalendarEntry/Calendar imports with glue callscart/bp/cart/services/page_cart.py— replace CartItem/MarketPlace/CalendarEntry/Calendar imports with glue calls
Step 6: Glue service for products (market access from cart orders)
New file: glue/services/products.py
async def get_product(session, product_id) -> Product | None:
"""Get product by ID."""
This is minimal — only needed by cart/bp/order/routes.py and cart/bp/orders/routes.py for search/display. However, OrderItem.product relationship already resolves via string forward-ref. We only need Product for the join-based search in orders listing.
Files changed:
cart/bp/orders/routes.py— replacefrom market.models.market import Productwith glue import or useOrderItem.productrelationshipcart/bp/order/routes.py— replacefrom market.models.market import Product(already uses OrderItem.product relationship for display)
Step 7: Glue service for post associations (events-side)
Move events/bp/calendar_entry/services/post_associations.py into glue:
New additions to glue/services/pages.py (or separate file glue/services/post_associations.py):
async def add_post_to_entry(session, entry_id, post_id) -> tuple[bool, str | None]:
async def remove_post_from_entry(session, entry_id, post_id) -> tuple[bool, str | None]:
async def get_entry_posts(session, entry_id) -> list[dict]:
async def search_posts_for_entry(session, query, page=1, per_page=10) -> tuple[list[dict], int]:
Files changed:
events/bp/calendar_entry/services/post_associations.py— delete file, moved to glue- Update any routes in events that call this service to use glue instead
Step 8: Update glue model registration
glue/setup.py needs to ensure all models from all apps are registered in SQLAlchemy's mapper when starting any app. This is because string-based relationship references (like OrderItem.product → "Product") need the target model class registered.
def register_models():
"""Import all model modules to register them with SQLAlchemy mapper."""
# These are already imported by each app, but ensure completeness:
try:
import blog.models.ghost_content # noqa
except ImportError:
pass
try:
import market.models.market # noqa
import market.models.market_place # noqa
except ImportError:
pass
try:
import cart.models.order # noqa
import cart.models.page_config # noqa
except ImportError:
pass
try:
import events.models.calendars # noqa
except ImportError:
pass
Each app's app.py calls register_models() at startup. The try/except guards handle Docker where only one app's code is present — but since all apps share glue/ and the DB, all model files need to be importable.
Note: In Docker, each container only has its own app + shared + glue. For glue services that import from other apps' models, those models must be available. This means either:
- (a) Include all model files in each container (symlinks or copies), or
- (b) Have glue services that import other apps' models use try/except at import time
Since all apps already share one DB and all model files are available in development, option (a) is cleaner for production. Alternatively, the current Docker setup could be extended to include cross-app model files in each image.
Step 9: Update existing glue services
glue/services/cart_adoption.py — already imports from market and events (correct — this is glue's job). No change needed.
glue/services/order_lifecycle.py — already imports from events. No change needed.
Step 10: Clean up dead imports and update app.py files
After all glue services are wired:
cart/app.py— removefrom blog.models.ghost_content import Post, usefrom glue.services.pages import get_page_by_slugmarket/app.py— removefrom blog.models.ghost_content import Post, usefrom glue.services.pages import get_page_by_slugevents/app.py— removefrom blog.models.ghost_content import Postandfrom market.models.market_place import MarketPlace- Remove any now-empty cross-app model directories if they exist
Files Summary
| Repo | File | Change |
|---|---|---|
| glue | services/pages.py |
NEW — Post access (slug, id, exists, search) |
| glue | services/page_config.py |
NEW — PageConfig CRUD |
| glue | services/calendars.py |
NEW — Calendar queries + entry associations (from blog) |
| glue | services/marketplaces.py |
NEW — MarketPlace CRUD (from blog+events) |
| glue | services/cart_items.py |
NEW — CartItem/CalendarEntry queries for cart |
| glue | services/products.py |
NEW — Product access for cart orders |
| glue | services/post_associations.py |
NEW — Post-CalendarEntry associations (from events) |
| glue | setup.py |
Add register_models() |
| cart | app.py |
Replace Post import with glue |
| cart | bp/cart/api.py |
Replace all 4 cross-app imports with glue |
| cart | bp/cart/services/checkout.py |
Replace cross-app imports with glue |
| cart | bp/cart/services/page_cart.py |
Replace all cross-app imports with glue |
| cart | bp/cart/services/get_cart.py |
Replace CartItem import with glue |
| cart | bp/cart/services/calendar_cart.py |
Replace CalendarEntry import with glue |
| cart | bp/cart/services/clear_cart_for_order.py |
Replace CartItem/MarketPlace with glue |
| cart | bp/orders/routes.py |
Replace Product import with glue |
| cart | bp/order/routes.py |
Replace Product import with glue |
| blog | bp/post/admin/routes.py |
Replace PageConfig/Calendar/MarketPlace with glue |
| blog | bp/post/routes.py |
Replace Calendar/MarketPlace with glue |
| blog | bp/post/services/entry_associations.py |
DELETE — moved to glue/services/calendars.py |
| blog | bp/post/services/markets.py |
DELETE — moved to glue/services/marketplaces.py |
| blog | bp/blog/ghost_db.py |
Replace PageConfig import with glue |
| blog | bp/blog/ghost/ghost_sync.py |
Replace PageConfig import with glue |
| blog | bp/blog/services/posts_data.py |
Replace CalendarEntry/CalendarEntryPost with glue |
| events | app.py |
Replace Post + MarketPlace imports with glue |
| events | bp/markets/services/markets.py |
DELETE — moved to glue/services/marketplaces.py |
| events | bp/markets/routes.py |
Replace MarketPlace import, use glue |
| events | bp/calendars/services/calendars.py |
Replace Post import with glue |
| events | bp/calendar_entry/services/post_associations.py |
DELETE — moved to glue/services/post_associations.py |
| events | bp/payments/routes.py |
Replace PageConfig import with glue |
| market | app.py |
Replace Post import with glue |
Implementation Order
- Step 1 (pages.py) — unlocks Steps 2-4 which depend on page validation
- Step 2 (page_config.py) — independent after Step 1
- Steps 3-4 (calendars.py, marketplaces.py) — can be done in parallel, both use pages.py
- Step 5 (cart_items.py) — depends on steps 1, 3 for calendar queries
- Step 6 (products.py) — independent
- Step 7 (post_associations.py) — independent, uses pages.py
- Steps 8-10 (registration, cleanup) — after all services exist
What's NOT changing
- CartItem stays in
market/models/market.py— moving it creates equal or worse coupling - OrderItem stays in
cart/models/order.pywithproduct_idFK — pragmatic exception - OrderItem.product_id FK — kept, denormalized
product_titlemakes it non-critical - CartItem.product_id FK — kept, same DB
- CartItem.market_place_id FK — kept, same DB
- CartItem.user_id FK — kept, shared model
- Internal HTTP APIs (cart/summary, coop/, events/) — not changing
shared/models (User, MagicLink, etc.) — shared across all apps by design
Docker Consideration
For glue services to work in Docker (single app per container), model files from other apps must be importable. Options:
- Copy model files into each Docker image during build (just the
models/dirs) - Use try/except in glue services at import time (degrade gracefully)
- Mount shared volume with all model files
Recommend option 2 for now — glue services that can't import a model simply raise ImportError at call time, which only happens if the service is called from the wrong app (shouldn't happen in practice).
Verification
grep -r "from blog\.models" cart/ market/ events/ glue/— should return zero results (only in blog/ itself)grep -r "from market\.models" blog/ cart/ events/— should return zero results (only in market/ and glue/)grep -r "from cart\.models" blog/ market/ events/— should return zero results (only in cart/ and glue/)grep -r "from events\.models" blog/ cart/ market/— should return zero results (only in events/ and glue/)- All 4 apps start without import errors
- Checkout flow works end-to-end
- Blog admin: can toggle features, create/delete markets, manage calendar entries
- Events admin: can create calendars, manage markets, configure payments
- Market app: markets listing page loads correctly