products/architecture.md
This document defines the future architectural direction for our Django monolith, focusing on:
This is a forward-looking design document, not a migration guide.
Different tools use different names for the same concept:
products/<name>/. This is the unit of isolation, ownership, and selective testing.products/<name>/backend/). Registered in INSTALLED_APPS via AppConfig.package.json. One product = one Turbo package.tach.toml. Maps 1:1 to a product (core code like posthog and ee are also tach modules).This document uses "product" when talking about boundaries and architecture, and "Django app" only for Django-specific mechanics (models, migrations, apps.py).
As the codebase grows, running all tests for every change becomes expensive, and startup time of the dev server grows. Our goal:
Turbo provides task-level caching so that:
tach enforces Python import boundaries, ensuring dependencies are explicitly declared in tach.toml.
To benefit from selective testing, we must introduce architectural boundaries inside the Django monolith.
We will begin by wiring up one product to:
Focus:
backend:test; isolated products also declare backend:contract-checkfacade/api.py) will define the public interfaceEventually this grows into:
But this document is about foundational structure, not full rollout.
Each product adopts the following structure:
myproduct/
backend/
__init__.py
apps.py
models.py # Django ORM only
logic.py # Business logic
tasks/
__init__.py
tasks.py # Celery entrypoints (call facade)
schedules.py # Celery beat / periodic config (optional)
facade/
__init__.py
api.py # Facade (the only thing other products may import)
contracts.py # Frozen dataclasses (+ enums if small enough)
enums.py # Optional: exported enums/shared types when contracts.py grows
presentation/
__init__.py
serializers.py # DRF serializers (frozen dataclasses <-> JSON)
views.py # DRF views (HTTP endpoints)
urls.py # HTTP routing
tests/
test_models.py
test_logic.py
test_api.py # Facade tests
test_presentation.py # DRF integration tests
test_tasks.py
facade/)For the broader monorepo structure (products, services, platform), see monorepo-layout.md.
contracts.py)Each product defines its public interface as frozen dataclasses in backend/facade/contracts.py. These are the only data structures that cross product boundaries — facades accept and return them, and other products import them.
frozen=True)@dataclass(frozen=True)
class Artifact:
id: UUID
project_id: int
content_hash: str
storage_path: str
width: int
height: int
size_bytes: int
created_at: datetime
Contracts should not depend on:
If input and output shapes are identical, reuse the same dataclass.
Each product exposes a facade via backend/facade/api.py. This is the only file other products are allowed to import.
logic.py)logic.py)class ArtifactAPI:
@staticmethod
def create(params: CreateArtifact) -> Artifact:
instance = logic.create_artifact(params)
return _to_artifact(instance)
Facades convert ORM models to frozen dataclasses via mapper functions. These look repetitive when fields align 1:1:
def _to_artifact(instance) -> contracts.Artifact:
return contracts.Artifact(
id=instance.id,
content_hash=instance.content_hash,
# ... more fields
)
The value isn't the copying — it's having one place where "internal" becomes "external":
The alternative — returning ORM objects — works until it doesn't, then you're retrofitting isolation under pressure.
Business logic lives here: validation, calculations, business rules, ORM queries.
Examples:
Located in backend/presentation/.
Responsibilities:
No. Views only call facades, and facades only return frozen dataclasses. The presentation layer remains decoupled from internal details — when the facade hasn't changed, nothing outside the product is affected.
models.py directlylogic.pybackend.facade (the facade)Product A needs data from Product B — use the facade:
# products/revenue_analytics/backend/logic.py
from products.data_warehouse.backend.facade import DataWarehouseAPI
# OK: calling the facade, getting back frozen dataclasses
tables = DataWarehouseAPI.list_tables(team_id=team_id)
Not this:
# WRONG: importing models directly from another product
from products.data_warehouse.backend.models.table import DataWarehouseTable
tables = DataWarehouseTable.objects.filter(team_id=team_id)
Product exposing functionality — keep the facade thin:
# products/signals/backend/facade/api.py — real example from the codebase
async def emit_signal(team_id, source_product, source_type, source_id, description, weight):
"""Other products call this. They never touch signals' models or internals."""
...
Using contracts from another product:
# products/other_product/backend/logic.py
from products.visual_review.backend.facade.contracts import Artifact
def process_artifact(artifact: Artifact) -> None:
# artifact is a frozen dataclass, not an ORM object
...
The interfaces setting in tach.toml controls which paths inside a product other products can import. This is machine-enforced — tach will reject any import that doesn't go through the declared interfaces.
During migration, existing cross-product model imports are tracked in tach.toml depends_on. The goal is to replace them with facade calls over time.
Django allows ForeignKey relationships across products. This is still allowed, but ForeignKey relations create implicit reverse dependencies, even if you never use them:
# visual_review/backend/models.py
project = models.ForeignKey(Project, ...)
Django will auto-generate reverse relations (project.visualreview_set), migration dependencies, and app loading order dependencies — all of which violate isolation.
Rule: a product may have ForeignKeys to core models, but other products must not reference models inside this product. Use related_name='+' to disable reverse relations. If you need reverse access, use explicit facade calls rather than ORM traversal.
Each product is a Turborepo package with tasks defined in its package.json.
Turbo uses file-based inputs to determine cache validity. The key distinction:
Contract inputs (used by backend:contract-check):
backend/facade/contracts.py — frozen dataclasses (enums can live here too)backend/facade/enums.py — optional, for exported enums/constants/shared types when contracts.py growsImplementation inputs (used by backend:test):
backend/**/*.py filesOther products depend on a product's contract files only. When contract files haven't changed, downstream products don't need retesting.
Import boundaries are enforced by tach via tach.toml. This ensures products don't accidentally import each other's internals, which would break the contract-based isolation model.
Dependency rules for contract files (keep them pure):
from django.*)from rest_framework.*)django.core.exceptionsfrom_model() methods — put conversion in implementation codeother_product tests
| depends on
visual_review contracts (facade/contracts.py, facade/enums.py)
| does NOT depend on
visual_review impl (logic.py, models.py)
Scenario: Change visual_review/logic.py
visual_review backend:test → reruns (impl files changed)visual_review backend:contract-check → cache hit (contract files unchanged)other_product backend:test → skipped (depends only on contracts, which didn't change)# Run all product tests
pnpm turbo run backend:test
# Run specific product tests
pnpm turbo run backend:test --filter=@posthog/products-visual_review
# Run contract checks
pnpm turbo run backend:contract-check
This document outlines the future direction of our codebase:
This architecture reduces coupling, enables selective testing, and keeps the system maintainable as we grow.