Isolating a product with facade and contracts

SkillAI & models

Plan and execute product isolation migrations to a facade plus contract layer in PostHog, following the Visual review architecture. Use when a product still exposes internals (models/logic/views) across boundaries and needs migration toward contracts.py + facade/api.py + presentation separation, with a PR strategy that minimizes review latency and conflicts with parallel work.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Isolating a product with facade and contracts skill

What this skill tells your AI

The instructions your AI receives, as published by posthog/posthog-foss in .agents/skills/isolating-product-facade-contracts/SKILL.md and read by ahel’s review.

Before you choose a product to isolate, check things already tried. It records which product was the first candidate, and why the field test moved to another one.

Use this skill to migrate an existing product to the isolated architecture used by Visual review. Optimize for short calendar exposure, not small diffs: authoring is cheap and the verification chain catches mechanical breakage, while human review latency and a fast-moving master are the real bottlenecks. Default to one PR structured in reviewable commits; a separate facade-first PR exists only as the merge base for team-sliced sweeps — per the PR strategy below.

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:

  1. products/architecture.md
  2. products/README.md
  3. docs/internal/monorepo-layout.md
  4. posthog/models/team/README.md (team extension model rule)
  5. docs/published/handbook/engineering/type-system.md (serializer/OpenAPI type flow)
  6. docs/published/handbook/engineering/ai/implementing-mcp-tools.md (schema quality and team isolation expectations)
  7. .agents/security.md (SQL/HogQL security guidelines)

Use Visual review as the concrete reference implementation:

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)
hogli product:isolate:scan <name>   # the recon: import map, coupling gate, preflight (see below)

First, read the baseline to learn which migration phase you're in — you don't start knowing whether this is untouched or mid-flight, and that decides where you pick up. The signals are all in the output above; there is no status file to consult (a hand-kept one would drift — the maturity score plus the ignore_imports TODO set is the status, and lint-imports prunes the latter as modules finish):

  • Fresh — facade/presentation score zero, no backend/facade/, no ignore_imports entries for the product. Start at step 1.
  • Mid-sweepfacade/contracts.py + facade/api.py exist but core still imports internals (scan still lists model-access/etc., or product:lint warns has legacy interface leaks). The facade design is settled; continue migrating callers (step 4) — don't redesign it.
  • Mid presentation-wave — the chain is mostly intact but pyproject.toml still has ignore_imports TODO entries for the product. The sweep is done; resume the presentation wave on exactly those deferred modules.
  • Effectively done — chain complete, no ignore_imports entries left. Verify against the done criteria instead of changing anything.

The scan does the recon that would otherwise cost a dozen ad-hoc greps, and is the source of truth for the rest of the flow:

  • References by kind — every cross-boundary reference to the product's internals, classified (model-access, query-runner, celery-task, temporal-wiring, test-fixture, string-reference) with the facade pattern each kind maps to. This is the sweep checklist; string references (@patch(...) mock paths, dotted names in config) are included, which import-oriented grepping misses.
  • Core-coupling count — picks the PR strategy below: near zero means the whole migration fits the single-PR default; triple digits means the caller sweep needs slicing by owning team.
  • Strict-lint preflightproduct:lint switches from lenient to strict the moment facade/contracts.py exists; the scan runs the structural checks in forced-strict mode so those demands (root tsconfig.json, tasks.py at tasks/tasks.py, required root files in place) surface up front instead of mid-migration. Strict lint checks the required root files only. It does not restrict internal directory names: the enforced shape is the import chain — routes.py imports only presentation/, and presentation/ imports only facade/ — so a product may add other internal packages (services/, reviewer/, …) as long as they stay behind the facade.
  • Thin/thick signal per view module — the future ignore_imports allowlist size.
  • Blind spots — the scan reads backend.* imports only. A coupling count of zero is necessary, not sufficient: it can't see product-root packages core imports (dags/) or non-import channels (config dotted strings, hogql system tables, in-process API test dispatch). See "Clearing coupling the scan won't show" below before declaring a product done.

--json emits the machine-readable recipe — keep it with the PR; regenerating the migration against fresh master starts from a fresh scan.

When the tooling doesn't fit, fix the tooling

Don't quietly hand-work-around it. If isolate:scan/:move misses your product's shape — a layout it doesn't detect, a coupling channel it can't see — and other products share that shape, extend the tool and update this skill so the next migration inherits the fix. That's how backend/api/-subpackage support and the Dagster-assets channel got added. Reserve a manual work-around (called out in the PR) for a true one-off; a silent hand-hack just leaves the next product to hit the same wall.

"No in-process callers, so no facade" is the wrong test

The core-coupling count sizes the sweep — zero importers means a single-PR migration with no caller migration — but it does not decide whether to isolate. The facade is also the structural seal that makes the CI skip sound, and that has nothing to do with importer count:

  • A product's HTTP API is exercised in-process by tests (the Django test client dispatches into the view stack in the same process, not over a real socket). Cross-cutting tests — permissions, schema, activity-log, "every viewset does X" — reach a product's endpoints by URL, coupling to its live behavior with zero imports. tach, lint-imports, and this scan all read the import graph, so none of them can see it. "No importers" is necessary, not sufficient.
  • Because the channel can't be enumerated, it is closed by construction, not audit: keep presentation thin and reaching internals only through the facade, so every observable behavior lives in the facade (tested in-product, inside the boundary) or in the serializer shape (the OpenAPI schema, whose changes already force the full suite); and keep behavior tests in-product.

So a product whose only consumers are over HTTP (node services, the TS/MCP codegen) is not facade-optional — there the facade's whole job is sealing its own presentation (step 2's second demand source). The genuine exception is a product with essentially no Django-side logic (a thin shim over an external service): it has nothing to seal, but it is then simply not isolated — no backend:contract-check, still paying the full suite. That is an accept-the-cost choice, not "isolated without a facade".

This is the third non-import coupling channel, alongside hogql system tables and dotted-string config (step 6.4) — and the least visible, since the other two at least leave a string to grep for.

Clearing coupling the scan won't show

product:isolate:scan walks the import graph of backend.*. Six kinds of coupling escape it — none is a dead end, each has a defined move. After the backend sweep, git grep "products.<name>" (not just .backend) and read the scan's string-reference section to find them.

Reverse accessors. A relation field (FK, O2O, M2M) that crosses a product boundary without related_name="+" adds a reverse accessor and a reverse query name to the target class. No import exists, so the scan cannot see it, but any caller can traverse it. The move: seal the relation with related_name="+", remove any explicit related_query_name (it keeps filter() traversal alive and shows as a query:<name> row), and delete its reverse-accessor(...) line from products/model_crossing_uses_baseline.txt in the same change (bin/hogli product:crossings --all --write-baseline); give a caller that needs reverse access a facade read function. The crossing ratchet blocks new unsealed relations. See products/architecture.md § Cross-product foreign keys.

Signal coupling. A receiver whose sender belongs to another boundary runs that boundary's code inside this one's save path, with no import edge when the sender is a string. Three moves, by case:

  • Core listens to a product model (string sender) — flip the direction: the product calls core's public function from its own save path (remote_config.mark_dirty(team_id)), a normal product→core import.
  • A product reacts to a core save — register through a core-owned hook (the register_team_extension_signal shape), not a raw @receiver on a foreign sender. The hook registry is the inventory.
  • Either direction — never do slow or external work in a receiver body. Use transaction.on_commit to dispatch a task instead.

Test-infrastructure coupling. Tests are in tach's interface graph but not in its dependency graph (hogli lint:tach runs the two passes CI runs). A test may import any product's public surface without a depends_on entry, and a test that imports another product's internals fails CI the same way production code does. Such a test gets one of the moves below. When the product cannot offer the move yet, a legacy-leak [[interfaces]] block that names the debt and the exit demotes the product until the block is drained; the block is never a facade module that hands the internals out under a sanctioned name. Core tests reach into the product's test helpers, which no facade re-export naturally covers:

  • Monkeypatch targets — a core test base patches a product module attribute (e.g. posthog/test/base.py patches execute_hogql_query on each runner module). Re-export the module object through the facade (facade/queries.py re-exports web_overview, not just its class) and point the patch at the facade path.
  • Shared fixtures / base classes — a core test subclasses the product's test base. Decide ownership: if the fixture is infrastructure for a core concern (a preaggregated-table test base, and the tables live in core), move it down into core and have both sides import it downward; if the core test is really exercising the product's behavior, relocate the test into the product. Either way the cross-boundary test import disappears.
  • Fixtures that need product rows — a core test seeds a product model (InsightViewed, Account) to set up its scenario. Seed through a facade write function, with a normal import. If none exists yet, add one; that is the same move a production caller makes. A helper that only fixtures need goes in a facade/testing.py submodule: the tach interface already exposes backend.facade.*, and any other exposed module counts as a legacy leak and blocks the isolated tests. Never apps.get_model("<label>", "<Model>") at module scope: it is the same dependency with the import edge removed. tach, mypy, and LSP stop seeing it, and snob (the PR test selector) does not select the test when the model changes, so the break lands on master. A TYPE_CHECKING import next to it does not restore the edge; tach ignores type-only imports, and so does snob.

Surfaces outside backend/. A product can expose non-Django surfaces at its root — Dagster assets under products/<name>/dags/, for instance — that core imports directly. The scan only walks backend.*, so it won't list them, and tach / product:lint fail late with "not part of the public interface" (a direct interface exposure also trips the legacy-leak check). Re-export them through a facade submodule (facade/dags.py re-exporting the asset modules) and reroute the core importer, exactly like the temporal and query-runner wiring.

No external data consumers. Covered above — the facade serves the product's own views. Provide facade read functions for its models; wire the cheap views; defer the expensive ones (nested-serializer or transactional viewsets) as named ignore_imports for the presentation wave. Providing the facade function while deferring its caller is a legitimate intermediate state, not a half-migration.

Product-owned HogQL system tables. When core mounts a product's federated system tables (schema/system.py, lazy_join_registry.py), answer two independent questions:

  • Can the reference be a normal facade import? Yes — table defs and lazy-join functions are plain module-level objects; move them into the product (e.g. facade/hogql.py) and reroute core's import, like any other wiring.
  • Do the objects enter the static pickled catalog? Core builds the catalog once and reloads it per request through a restricted unpickler (build_database_root_node in posthog/hogql/database/database.py). Any product-defined class in the catalog tree (a PostgresTable/LazyTable subclass) needs its module added to _CATALOG_PICKLE_MODULES — allowlisted individually, not by prefix. A missing entry fails the core catalog tests with a message naming the module. Warehouse-style per-team tables are built at request time and never enter the static catalog, which is why most products never hit this.

The web_analytics migration is the worked example of all three: its preagg test base moved down to core, its timezone integration test moved into the product, its Dagster assets gained a facade/dags.py, and its filter-preset reads landed in facade/api.py with the viewset deferred.

Model classes a consumer already holds

Some products still hand out model classes under the watched-models allowance (MODEL_CROSSINGS). That allowance only says the class may leave the product; it says nothing about what the consumer does with it. Two rules cover that:

  • Default-deny. A crossing class may appear in consumer code only in a shape the check calls instance-free: an annotation, X.DoesNotExist, a nested class attribute (X.Status), X._meta, a manager chain ending in values/values_list/count/exists/aggregate, or a chain embedded in Exists(...)/Subquery(...). Anything else is disallowed.
  • Move, don't permit. Code that queries, serializes or writes a model belongs in that model's product. The remedy for a disallowed use is a move; the facade function is what the move leaves behind, and the consumer keeps orchestration and ids.

apps.get_model('label', 'Class') is counted too, and for every product model, not only the allowance ones. It leaves no import edge, so tach cannot refuse it. Test modules stay out of the scan, which is a blind spot, not permission: a core test fixture that reaches a model this way is uncounted and unselected (see "Test-infrastructure coupling" above). Migrations stay out too: the historical registry is the only way a migration can reach a model. Production code may not add a call.

hogli product:crossings <product> lists a product's crossing classes with every consumer use bucketed by kind, disallowed first. Disallowed uses are frozen in products/model_crossing_uses_baseline.txt and guarded by a repo-invariant test; counts may only go down, and hogli product:crossings --all --write-baseline records the decrease. See products/architecture.md § Wiring couplings.

Guardrails

  • Keep facades thin; put business rules behind the facade, in logic/ by default. Other internal packages (services/, reviewer/, …) are fine as long as they stay behind the facade.
  • Transaction boundaries belong in the facade (or logic), not in views.
  • Never return ORM models across product boundaries.
  • Declare every relation field that crosses a product boundary with related_name="+" — the reverse-accessor ratchet blocks new unsealed ones.
  • Do not register a signal receiver on another boundary's sender; use the moves in "Signal coupling" above.
  • Keep contracts pure (no Django/DRF imports).
  • Filter by team_id in querysets.
  • Do not add product-specific fields to Team; use a Team Extension model.
  • Add request/response schema annotations on viewset endpoints (@validated_request or @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 facade import-linter contract in pyproject.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

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
715
Forks
118
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
isolating-product-facade-contracts-posthog
Source
github.com/posthog/posthog-foss