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

> Understand PostHog's product isolation architecture. Learn how frozen dataclass contracts and facade APIs create strict boundaries for seamless cross-product communication.

- Repository: [PostHog/posthog](https://github.com/PostHog/posthog)
- Tags: architecture
- Published: 2026-04-25

---

**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`](https://github.com/PostHog/posthog/blob/main/products/architecture.md), each layer has a specific responsibility and import restrictions.

| Layer | Directory | Purpose |
|-------|-----------|---------|
| **Models** | [`backend/models.py`](https://github.com/PostHog/posthog/blob/main/backend/models.py) | Django ORM definitions that are never exported outside the product. |
| **Business Logic** | [`backend/logic.py`](https://github.com/PostHog/posthog/blob/main/backend/logic.py) | Core rules, calculations, and ORM queries kept internal to the product. |
| **Facade** | [`backend/facade/api.py`](https://github.com/PostHog/posthog/blob/main/backend/facade/api.py) (public) <br> [`backend/facade/contracts.py`](https://github.com/PostHog/posthog/blob/main/backend/facade/contracts.py) (stable data) | The *only* importable surface for other products. Accepts and returns frozen dataclasses while delegating internally to [`logic.py`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/tach.toml) and [`pyproject.toml`](https://github.com/PostHog/posthog/blob/main/pyproject.toml).

**Forbidden imports** (enforced by Tach):
- Direct imports of another product’s [`models.py`](https://github.com/PostHog/posthog/blob/main/models.py).
- Direct imports of another product’s [`logic.py`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/contracts.py).

**Intra-product layering** is protected by `import-linter` (configured in [`pyproject.toml`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/architecture.md)).

## Code Implementation Examples

### Defining a Contract in [`contracts.py`](https://github.com/PostHog/posthog/blob/main/contracts.py)

Contracts live in [`backend/facade/contracts.py`](https://github.com/PostHog/posthog/blob/main/backend/facade/contracts.py) and use frozen dataclasses to guarantee immutability:

```python

# 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`](https://github.com/PostHog/posthog/blob/main/api.py)

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

```python

# 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:

```python

# 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:

```python

# 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`](https://github.com/PostHog/posthog/blob/main/backend/facade/contracts.py) that serve as the only objects permitted to cross product boundaries.
- **Facades** in [`backend/facade/api.py`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/tach.toml)) to enforce inter-product import boundaries, blocking any import of another product's [`models.py`](https://github.com/PostHog/posthog/blob/main/models.py), [`logic.py`](https://github.com/PostHog/posthog/blob/main/logic.py), or internal utilities. Additionally, **import-linter** (configured in [`pyproject.toml`](https://github.com/PostHog/posthog/blob/main/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`](https://github.com/PostHog/posthog/blob/main/logic.py) or [`models.py`](https://github.com/PostHog/posthog/blob/main/models.py) directly. According to the architecture rules in [`products/architecture.md`](https://github.com/PostHog/posthog/blob/main/products/architecture.md), views and serializers in `backend/presentation/` may only import from [`backend/facade/api.py`](https://github.com/PostHog/posthog/blob/main/backend/facade/api.py) and [`backend/facade/contracts.py`](https://github.com/PostHog/posthog/blob/main/backend/facade/contracts.py). This restriction ensures that HTTP handling remains decoupled from business rules and database access.