Isolating a product with facade and contracts
Use this skill to migrate an existing product to the isolated architecture used by Visual review. Keep migrations incremental, with narrow PRs that avoid broad breakage.
Prerequisite: the product must already live under products/<name>/. This skill does not cover moving code out of posthog/, ee/, or other shared directories — do that first.
Core docs to load first
Read these before changing code:
- products/architecture.md
- products/README.md
- docs/internal/monorepo-layout.md
- posthog/models/team/README.md (team extension model rule)
- docs/published/handbook/engineering/type-system.md (serializer/OpenAPI type flow)
- docs/published/handbook/engineering/ai/implementing-mcp-tools.md (schema quality and team isolation expectations)
- .agents/security.md (SQL/HogQL security guidelines)
Use Visual review as the concrete reference implementation:
- products/visual_review/backend/facade/contracts.py
- products/visual_review/backend/facade/api.py
- products/visual_review/backend/presentation/views.py
- products/visual_review/backend/presentation/serializers.py
- products/visual_review/backend/logic.py
- products/visual_review/backend/tests/test_api.py
- products/visual_review/backend/tests/test_presentation.py
Before changing code, get the baseline:
hogli product:maturity <name> # scores models, facade, presentation, boundaries, codegen
hogli product:lint <name> # structural lint + isolation chain (strict if facade/contracts.py exists)
rg -n "from products\.<name>\.backend\.(models|logic|presentation|tasks|storage)" .
The rg output is your import map: every line is a caller that needs to migrate to the facade.
Guardrails
- Keep facades thin; put business rules in
logic.py. - Transaction boundaries belong in the facade (or logic), not in views.
- Never return ORM models across product boundaries.
- Keep contracts pure (no Django/DRF imports).
- Filter by
team_idin querysets. - Do not add product-specific fields to
Team; use a Team Extension model. - Add request/response schema annotations on viewset endpoints (
@validated_requestor@extend_schema). - Regenerate OpenAPI/types (
hogli build:openapi) when serializer/view changes affect API schema. - Presentation may only reach internals via the facade — enforced by the
presentation must use facadeimport-linter contract inpyproject.toml(tool.importlinter). Any new internal module (cache.py,helpers.py, …) is auto-covered; there is no blocklist to maintain. New cross-cutting imports must either go through the facade or be temporarily allowlisted there.
Required migration workflow
- Build an import map for the target product.
- Find cross-product imports into target internals (
models,logic,presentation, non-facade modules). - Classify each usage by capability (read/list, detail/read, create/update/delete, async/task, webhook/event).
- Find cross-product imports into target internals (
- Define the minimal contract surface.
- Start from currently consumed fields only.
- Create frozen dataclasses in
backend/facade/contracts.pyusingpydantic.dataclasses.dataclass— same shape as the stdlib variant but with runtime type validation on construction.
- Introduce a thin facade in
backend/facade/api.py.- Map ORM instances to contracts with explicit mapper functions.
- Keep method names capability-oriented and stable.
- Migrate callers in small batches.
- Replace one caller cluster at a time (single endpoint, single task, or single service area).
- Keep compatibility shims only when needed; remove promptly.
- Move presentation to consume the facade.
- Serializers convert JSON <-> contracts.
- Views call facade methods only.
- Enforce boundaries and verify. This is a four-step chain — each step depends
on the previous one, and
hogli product:lint(viaIsolationChainCheck) fails if any step is skipped:- Real facade —
backend/facade/api.pymust have actual function defs, not just re-exports fromlogic. - Tach interfaces — preferred path is to add the product name to the
regex in the existing shared
[[interfaces]]block intach.tomlthat exposesbackend\.facade.*andbackend\.presentation\.views.*. Only add a new dedicated block if the product needs a non-standard expose pattern. backend:contract-checkscript — add topackage.jsonso turbo-discover treats the product as isolated.- Narrowed
turbo.jsoninputs — restrictbackend:contract-checkinputs tobackend/facade/**andbackend/presentation/**so the Django suite is only re-run on facade/presentation changes (seeproducts/visual_review/turbo.json).
- Verify with
tach check --dependencies --interfaces,lint-imports(import-linter contract for presentation → facade), andhogli product:lint <name>. - Use
hogli product:maturity <name>for a detailed breakdown of remaining isolation work scored across models, facade, presentation, boundaries, codegen. - Run focused tests for changed files, then product-level backend tests.
- Real facade —
Legacy leaks during migration
If posthog/ or ee/ still imports product internals (backend.models,
backend.oauth, …) when you cut the first isolation PR, add a second
[[interfaces]] block under the "Legacy leaks" section of tach.toml
allow-listing exactly the modules core still touches. This keeps the build
green while you migrate callers in subsequent PRs. Shrink and delete that
block as imports move behind the facade — the final PR removes it entirely.
hogli product:lint flags any product that still has legacy leak interfaces
with a ⚠ has legacy interface leaks warning.
PR slicing strategy
Default to several PRs instead of one big migration:
- PR 1: Add contracts + facade methods, with no caller behavior changes. For a small product this PR can also flip
api.py/webhooks.pyintopresentation/and enable the 4-step chain (seeuser_interviewsPR #59132 for that combined shape). - PR 2-N: Migrate caller clusters one-by-one to the facade.
- Final PR: Remove deprecated internal import paths, drop "Legacy leaks"
[[interfaces]]blocks andignore_importsTODOs, and clean dead adapters.
If a product has many endpoints, migrate in this order:
- Read-only list/detail APIs (lowest risk)
- Internal service-to-service call sites
- Write paths (create/update/delete)
- Background tasks / async entrypoints
- Remaining edge endpoints and cleanup
Done criteria
Treat migration as complete only when:
- Cross-product imports use
backend/facadeonly. - Facade returns/accepts contracts, not ORM.
- Presentation layer no longer encodes business logic.
- Tests cover facade and presentation boundaries.
- The product is listed in the shared
[[interfaces]]block intach.tomlexposingbackend.facade.*andbackend.presentation.views.*— no legacy leak block remains. tach check --dependencies --interfacespasses with no violations for this product.lint-importspasses (import-linter verifies presentation doesn't bypass the facade internally).hogli product:lint <name>shows no legacy leak warning and the isolation chain is intact.backend:contract-checkis present inpackage.jsonwithturbo.jsoninputs narrowed tobackend/facade/**andbackend/presentation/**(enables isolated testing in CI).