Isolating a product with facade and contracts
SkillAI & modelsPlan 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.
No other account needed.
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 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:
- 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/
- 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)
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/, noignore_importsentries for the product. Start at step 1. - Mid-sweep —
facade/contracts.py+facade/api.pyexist but core still imports internals (scan still lists model-access/etc., orproduct:lintwarnshas 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.tomlstill hasignore_importsTODO entries for the product. The sweep is done; resume the presentation wave on exactly those deferred modules. - Effectively done — chain complete, no
ignore_importsentries 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 preflight —
product:lintswitches from lenient to strict the momentfacade/contracts.pyexists; the scan runs the structural checks in forced-strict mode so those demands (roottsconfig.json,tasks.pyattasks/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.pyimports onlypresentation/, andpresentation/imports onlyfacade/— 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_importsallowlist 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_signalshape), not a raw@receiveron a foreign sender. The hook registry is the inventory. - Either direction — never do slow or external work in a receiver body. Use
transaction.on_committo 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.pypatchesexecute_hogql_queryon each runner module). Re-export the module object through the facade (facade/queries.pyre-exportsweb_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 afacade/testing.pysubmodule: the tach interface already exposesbackend.facade.*, and any other exposed module counts as a legacy leak and blocks the isolated tests. Neverapps.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. ATYPE_CHECKINGimport 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_nodeinposthog/hogql/database/database.py). Any product-defined class in the catalog tree (aPostgresTable/LazyTablesubclass) 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 invalues/values_list/count/exists/aggregate, or a chain embedded inExists(...)/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_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
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 40k
- Forks
- 3k
- Last commit
- Sep 2026
Others that do the same job
Advanced
- Catalog kind
- skill
- Gateway key
isolating-product-facade-contracts- Source
- github.com/posthog/posthog