How PostHog's Product Isolation Architecture Works: Facades and Contracts Explained

PostHog's product isolation architecture enforces strict boundaries between features by organizing each product in products/<name>/ with frozen dataclass contracts and facade APIs that are the only permissible import surface for cross-product communication.

PostHog implements a rigorous product isolation architecture in its open-source monorepo to eliminate tight coupling between feature areas. Each product resides in its own directory under products/<name>/ and communicates with others exclusively through immutable contracts and thin facade layers. This design guarantees that internal implementation details—Django models, business logic, and database schemas—remain private while providing a stable, versioned public interface.

The Four-Layer Product Structure

Every product in the PostHog monorepo follows a strict layered layout that separates concerns and controls visibility. According to the architecture documentation in products/architecture.md, each layer has a specific responsibility and import restrictions.

Layer Directory Purpose
Models backend/models.py Django ORM definitions that are never exported outside the product.
Business Logic backend/logic.py Core rules, calculations, and ORM queries kept internal to the product.
Facade backend/facade/api.py (public) backend/facade/contracts.py (stable data) The only importable surface for other products. Accepts and returns frozen dataclasses while delegating internally to logic.py.
Presentation backend/presentation/* DRF serializers, views, and URLs. Calls only the facade and never touches models or logic directly.

Contracts as Immutable Boundaries

Contracts are immutable data structures defined as @dataclass(frozen=True) in backend/facade/contracts.py. They serve as the sole objects that cross product boundaries, ensuring callers cannot depend on internal ORM fields or implementation details.

As specified in lines 15‑24 of products/architecture.md, contracts must follow strict rules:

  • No Django or DRF imports allowed in contract files.
  • Must be hashable, small, and stable to prevent breaking changes.
  • Reuse the same dataclass when input‑output shapes match to minimize duplication.

These frozen dataclasses guarantee that downstream products receive data snapshots rather than live ORM instances, preventing accidental database queries and coupling.

Facades as Import Boundaries

Facades provide thin, stable APIs through backend/facade/api.py. Their sole responsibility is translating between internal implementation and public contracts.

Per lines 49‑66 of the architecture documentation, facades must:

  • Validate contract arguments before processing.
  • Call the product’s backend/logic.py to execute business rules.
  • Convert ORM objects to contracts before returning data.

Facades must not contain business logic, import DRF components, or expose ORM instances. This restriction creates a clean transformation point where internal model changes can be mapped to stable contract fields without affecting consumers.

Enforcement with Tach and Import-Liner

PostHog automates architectural compliance through two linting tools configured in tach.toml and pyproject.toml.

Forbidden imports (enforced by Tach):

  • Direct imports of another product’s models.py.
  • Direct imports of another product’s logic.py or internal utilities.
  • Direct imports of another product’s views or presentation layers.

Allowed imports:

  • Importing another product’s backend.facade module.
  • Using frozen dataclasses from another product’s contracts.py.

Intra-product layering is protected by import-linter (configured in pyproject.toml), which ensures that presentation layers only import facades, and facades only import logic, preventing circular dependencies within a single product.

Architectural Benefits

This isolation pattern delivers four critical advantages for monorepo maintenance:

Explicit Boundary – Contracts define exactly what a caller receives, making public APIs discoverable and documented in code.

Transformation Point – Facades map internal models to contracts, allowing computed fields, renaming, or schema migrations without breaking downstream products.

Drift Absorption – When models evolve, only the facade mapper changes; downstream products remain untouched because they depend on stable contracts rather than database columns.

Selective Testing – Turbo caches test runs based on contract changes only. Downstream product tests are skipped if contracts remain unchanged, significantly reducing CI duration (see lines 71‑76 of architecture.md).

Code Implementation Examples

Defining a Contract in contracts.py

Contracts live in backend/facade/contracts.py and use frozen dataclasses to guarantee immutability:


# products/visual_review/backend/facade/contracts.py

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from uuid import UUID

@dataclass(frozen=True)
class Artifact:
    """Public, immutable representation of an artifact."""
    id: UUID
    project_id: int
    content_hash: str
    storage_path: str
    width: int
    height: int
    size_bytes: int
    created_at: datetime

This contract acts as the canonical data structure for the Visual Review product's public interface.

Implementing the Facade API in api.py

The facade imports contracts and logic, serving as the translation layer:


# products/visual_review/backend/facade/api.py

from . import contracts
from .. import logic


class ArtifactAPI:
    @staticmethod
    def create(params: contracts.CreateArtifact) -> contracts.Artifact:
        """Public entry point – thin wrapper around business logic."""
        instance = logic.create_artifact(params)          # business logic

        return _to_artifact(instance)                    # mapper → contract

    @staticmethod
    def list(team_id: int) -> list[contracts.Artifact]:
        qs = logic.get_artifacts_for_team(team_id)
        return [_to_artifact(obj) for obj in qs]


def _to_artifact(instance) -> contracts.Artifact:
    """Single source of truth for model → contract conversion."""
    return contracts.Artifact(
        id=instance.id,
        project_id=instance.project_id,
        content_hash=instance.content_hash,
        storage_path=instance.storage_path,
        width=instance.width,
        height=instance.height,
        size_bytes=instance.size_bytes,
        created_at=instance.created_at,
    )

The _to_artifact function centralizes the mapping logic, ensuring that any model changes require updates only in this single location.

Consuming the Facade in the Presentation Layer

Views and serializers in backend/presentation/ import only the facade and contracts, never touching models or logic directly:


# products/visual_review/backend/presentation/views.py

from rest_framework import viewsets, status
from rest_framework.response import Response

from ..facade import api as artifact_api
from ..facade import contracts
from .serializers import CreateArtifactSerializer, ArtifactSerializer


class ArtifactViewSet(viewsets.ViewSet):
    def create(self, request):
        ser = CreateArtifactSerializer(data=request.data)
        ser.is_valid(raise_exception=True)
        contract = contracts.CreateArtifact(**ser.validated_data)

        result = artifact_api.ArtifactAPI.create(contract)
        return Response(ArtifactSerializer(result).data, status=status.HTTP_201_CREATED)

    def list(self, request):
        team_id = request.user.team_id
        contracts = artifact_api.ArtifactAPI.list(team_id)
        return Response(ArtifactSerializer(contracts, many=True).data)

This strict separation ensures that HTTP concerns remain isolated from business rules.

Cross-Product Integration via Facade Imports

Products communicate by importing facades and receiving frozen contracts:


# products/revenue_analytics/backend/logic.py

from products.visual_review.backend.facade import api as vr_api

def compute_revenue_from_artifacts(team_id: int) -> float:
    # Calls the other product’s public facade; receives frozen contracts.

    artifacts = vr_api.ArtifactAPI.list(team_id)
    total = sum(a.size_bytes for a in artifacts)
    return total * 0.0001  # example conversion

Because revenue_analytics imports only the visual_review facade, it remains insulated from any changes to Visual Review's database schema or internal business logic.

Summary

  • PostHog's architecture isolates products in products/<name>/ directories with four distinct layers: models, logic, facade, and presentation.
  • Contracts are frozen dataclasses defined in backend/facade/contracts.py that serve as the only objects permitted to cross product boundaries.
  • Facades in backend/facade/api.py provide thin translation layers between internal ORM models and public contracts, enforcing validation and mapping.
  • Import boundaries enforced by Tach (tach.toml) and import-linter prevent direct access to models or logic from other products, maintaining strict isolation.
  • This pattern enables safe refactoring through drift absorption and selective testing based on contract changes, significantly improving CI performance.

Frequently Asked Questions

What is the difference between a contract and a facade in PostHog's architecture?

Contracts are immutable data structures (frozen dataclasses) that define the shape of data passed between products. Facades are Python classes or modules in backend/facade/api.py that contain methods accepting and returning these contracts. While contracts describe the data, facades provide the operations that produce or consume that data, acting as the callable interface for cross-product communication.

Why does PostHog use frozen dataclasses for contracts instead of Django models?

Frozen dataclasses enforce immutability and prohibit Django ORM imports, preventing downstream products from accidentally triggering database queries or depending on internal schema details. Unlike Django models, frozen dataclasses are hashable, lightweight, and stable, making them safe to cache, compare, and pass across service boundaries without coupling to the underlying database structure.

How does PostHog prevent developers from importing internal modules from other products?

PostHog uses Tach (configured in tach.toml) to enforce inter-product import boundaries, blocking any import of another product's models.py, logic.py, or internal utilities. Additionally, import-linter (configured in pyproject.toml) validates intra-product layering, ensuring presentation layers only import facades and facades only import logic. These tools run in CI to catch architectural violations before merge.

Can presentation layers access business logic directly, or must they use facades?

Presentation layers must use facades and are strictly forbidden from importing logic.py or models.py directly. According to the architecture rules in products/architecture.md, views and serializers in backend/presentation/ may only import from backend/facade/api.py and backend/facade/contracts.py. This restriction ensures that HTTP handling remains decoupled from business rules and database access.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →