Each product in PostHog is a vertical slice: it contains its backend (Django app), frontend (React/TypeScript), and optionally shared code. This structure ensures product features are self-contained and can evolve independently.
The entire product folder (products/<product_name>/) is treated as a Turborepo package.
Backend and frontend are sub-parts of that package.
This is the (future) home for all PostHog products (RFC).
For the detailed architecture rationale (frozen dataclasses, facades, isolated testing), see architecture.md.
products/
__init__.py
<product_name>/ # Turborepo package boundary
__init__.py # allows imports like products.<product>.backend.*
manifest.tsx # describes the product's features
package.json # defines the product package in Turborepo
backend/ # Django app
__init__.py
apps.py
models.py
logic.py # business logic
routes.py # API routes: register_routes(routers), auto-discovered from INSTALLED_APPS
migrations/
facade/ # cross-product Python interface
__init__.py
api.py # facade methods
contracts.py # frozen dataclasses (+ enums)
enums.py # optional: exported enums/shared types
presentation/ # DRF views/serializers
__init__.py
views.py
serializers.py
urls.py
tasks/ # Celery tasks
__init__.py
tasks.py
tests/
conftest.py
test_*.py
frontend/
components/
scenes/
hooks/
logics/
generated/ # OpenAPI-generated TypeScript types
mcp/ # MCP tool definitions (tools.yaml) and UI apps — most products
skills/ # agent skills for the product — many products
services/ # optional: a service this product deploys
packages/ # optional: a library/CLI this product ownsUse bin/hogli product:bootstrap <name> to scaffold a new product with this structure.
-
Each
backend/folder is a real Django app. -
Register it in
INSTALLED_APPSviaAppConfig:# products/feature_flags/backend/apps.py from django.apps import AppConfig class FeatureFlagsConfig(AppConfig): name = "products.feature_flags.backend" label = "feature_flags" verbose_name = "Feature Flags"
-
✅ Always use the real Python path for imports:
from products.feature_flags.backend.models import FeatureFlag
-
✅ For relations, use string app labels:
class Experiment(models.Model): feature_flag = models.ForeignKey( "feature_flags.FeatureFlag", on_delete=models.CASCADE, )
-
❌ Do not import models from
posthog.modelsor create re-exports likeproducts.feature_flags.models.
This avoids circular imports and keeps migrations/app labels stable.
- Each
frontend/directory contains the frontend app for the product. - It lives under the same package as the backend.
- Backend and frontend tooling can be independent (
requirements.txtvs.package.json) but remain in the same Turborepo package. - Jest unit tests for frontend code live inside
frontend/tests/(or alongside the file as*.test.ts/*.spec.ts). - Playwright end-to-end tests for a product live inside
frontend/e2e/as*.spec.ts— these are discovered byplaywright.config.tsand run by the E2E CI workflow. Import shared fixtures via the@playwright-utils/*and@playwright-pages/*aliases. Seeplaywright/README.mdfor details.
If backend and frontend need shared schemas, validators, or constants, put them in a shared/ directory under the product.
Keep shared code minimal to avoid tight coupling.
backend/ and frontend/ are the core, but a product owns more than that.
Beyond backend/ + frontend/ + manifest.tsx + package.json, most products also carry:
mcp/— MCP tool definitions (tools.yaml) and UI apps (the majority of products expose these)skills/— agent skills for the product (many products)
And anything else attributable to a single product — nest it here rather than in a top-level dir:
services/<svc>/— a service or worker the product deployspackages/<lib>/— a library or CLI the product owns- dev/CI/backfill scripts, benchmarks, audits, fixtures and dummy-data generators
Co-locating keeps tooling boundaries (CODEOWNERS, CI filters, lint) on the products/<product>/** path instead of hand-synced <product>-* prefixes.
Reserve top-level tools/, services/, packages/, and cli/ for things no single product owns.
See monorepo-layout.md → "What a product can own" for the full rationale and the nest-then-promote rule for shared packages.
- Each high level product should have its own folder.
- Please keep the top level folders
under_scorecased, as dashes make it hard to import files in some languages (e.g. Python).
- Please keep the top level folders
- Each product has a few required files / folders:
manifest.tsx- describes the product's features. All manifest files are combined intofrontend/src/products.tsxandfrontend/src/products.jsonon build.package.json- describes the frontend dependencies. Ideally they should all bepeerDependenciesof whatever is infrontend/package.json__init__.py- allows imports likeproducts.<product>.backend.*(only if backend exists)backend/__init__.py- marks the backend directory as a Python package/Django app (only if backend exists).frontend/- React frontend code. We run oxfmt/eslint only on files in thefrontendfolder on commit.backend/- Python backend code. It's treated as a separate django app.
The easiest way is to use hogli:
bin/hogli product:bootstrap your_product_nameThis creates the full structure with apps.py, package.json, etc.
To check your product structure follows conventions:
bin/hogli product:lint your_product_nameThe lint command validates:
- Presence:
backend:testmust exist; isolated products must also havebackend:contract-check - Absence: products must NOT have
backend:contract-checkif they are not isolated or have legacy interface leaks (where core still imports internals) — turbo-discover uses this key to classify products as isolated, which causes the full Django test suite to be skipped when that product changes - Legacy leaks: products with TODO legacy leak blocks in
tach.tomlshow a⚠warning in the tach boundaries check - Script content (for
backend:test):- No
|| trueor|| exit 0— these swallow test failures in CI - No no-op scripts (e.g.,
echo 'No backend tests') whenbackend/contains actual test files - Pytest paths referenced in the command must exist on disk and contain discoverable tests
- No
Note
To migrate a product to full isolation (facade + contracts + selective testing), use the isolating-product-facade-contracts skill. See products/architecture.md for the target architecture.
- Create a new folder
products/your_product_name, keep it underscore-cased. - Create a
manifest.tsxfile- Describe the product's frontend
scenes,routes,urls, file system types, and project tree (navbar) items. - All manifest files are combined into a single
frontend/src/products.tsxfile on build. - NOTE: we don't copy imports into
products.tsx. If you add new icons, update the imports manually infrontend/src/products.tsx. It only needs to be done once. - NOTE: if you want to add a link to the old pre-project-tree navbar, do so manually in
frontend/src/layout/navigation-3000/navigationLogic.tsx
- Describe the product's frontend
- Create a
package.jsonfile:- Keep the package name as
@posthog/products-your-product-name. Include@posthog/products-in the name. - Update the global
frontend/package.json: add your new npm package underdependencies. - If your scenes are linked up with the right paths, things should just work.
- Each scene can either export a React component as its default export, or define a
export const scene: SceneExport = { logic, component }object to export both a logic and a component. This way the logic stays mounted when you move away from the page. This is useful if you don't want to reload everything each time the scene is loaded.
- Keep the package name as
- Create
__init__.pyandbackend/__init__.pyfiles if your product has python backend code.__init__.pyallows imports likeproducts.<name>.backend.*backend/__init__.pymarks the backend directory as a Python package / Django app.- Register the backend as a Django app with an
AppConfigthat setslabel = "<name>"(notproducts.<name>). - Modify
posthog/settings/web.pyand add your new product underPRODUCTS_APPS. - Modify
tach.tomland add a new block for your product. We usetachto track cross-dependencies between python apps. - Add your API routes in
backend/routes.pywith aregister_routes(routers)function (e.g.routers.projects.register(r"my_thing", MyThingViewSet, "project_my_thing", ["team_id"])). It is auto-discovered — once the product is inPRODUCTS_APPS,posthog/api/__init__.pyfinds and callsregister_routes(routers)with no edit to core. Seeposthog/api/routing.py:RouterRegistryfor the available router handles (projects/environments/organizations/root). - NOTE: we will automate some of these steps in the future, but for now, please do them manually.
- Create or move your backend models under the product's
backend/folder. - Use direct imports from the product location (e.g.,
from products.experiments.backend.models import Experiment) - Use string-based foreign key references to avoid circular imports (e.g.,
models.ForeignKey("posthog.Team", on_delete=models.CASCADE))- A
ForeignKeytargeting a hot table (posthog.Team,posthog.User,posthog.Organization,posthog.Project, includingsettings.AUTH_USER_MODEL) is unsafe even within the same database, and even for a brand-newCreateModel: building the FK constraint takes aSHARE ROW EXCLUSIVElock on the referenced parent table, which can stall a deploy under write traffic.HotTableAlterPolicyblocks this in CI. Either declare the FK withdb_constraint=False(no parent lock at all, app-level enforcement only) or add it as a real constraint with the two-phaseAddForeignKeyNotValid/ValidateForeignKeyhelpers. See Foreign Keys to Hot Tables. (This is a different problem from the cross-database FK limitation below — that one is about FKs across separate product databases.)
- A
- Create a
products/your_product_name/backend/migrationsfolder. - Run
python manage.py makemigrations your_product_name -n initial_migration - If this is a brand-new model, you're done.
- If you're moving a model from the old
posthog/models/folder, there are more things to do:- Make sure the model's
Metaclass hasdb_table = 'old_table_name'set along withmanaged = True. - Run
python manage.py makemigrations posthog -n remove_old_product_name - The generated migrations will want to
DROP TABLEyour old model, andCREATE TABLEthe new one. This is not what we want. - Instead, we want to run
migrations.SeparateDatabaseAndStatein both migrations. - Follow the example in
posthog/migrations/0548_migrate_early_access_features.pyandproducts/early_access_features/migrations/0001_initial_migration.py. - Move all operations into
state_operations = []and keep thedatabase_operations = []empty in both migrations. - Run and test this a few times before merging. Data loss is irreversible.
- Make sure the model's
Database isolation is part of the broader product isolation architecture (see architecture.md). Products communicate through facades and frozen dataclass contracts — never through shared ORM queries or cross-product joins. Separate databases enforce this at the infrastructure level: if your product can't reach another product's tables, you can't accidentally couple to them.
New products get their own Postgres database by default (hogli product:bootstrap adds a route automatically).
Opting out: Remove the product's entry from products/db_routing.yaml and everything falls back to default. This weakens isolation — a bad migration or traffic spike in your product can impact the entire app, and nothing prevents accidental cross-product ORM queries. Acceptable reasons to opt out: the product has no models, or it's in early prototyping and not yet following the facade pattern.
A route in products/db_routing.yaml declares which app label gets its own database:
routes:
- app_label: visual_review
database: visual_reviewThis automatically:
- Registers
visual_review_db_writerandvisual_review_db_readeras Django database aliases - Routes all reads/writes for the
visual_reviewapp throughProductDBRouter - Runs migrations via
bin/migrate(callsmigrate_product_databasesmanagement command) - Creates the database in local Docker via the Postgres init script
Locally (DEBUG=1), it auto-connects to posthog_visual_review on localhost. In prod, the infrastructure handles env vars and connections automatically. If the env var is absent, the route is silently skipped.
- Add a route in
products/db_routing.yaml(this repo) - Ask
#team-infrastructureto provision the database — they'll handle the cluster, credentials, and connection plumbing
Every model that stores tenant data must have a team_id field. This is PostHog's primary tenant isolation boundary — without it, there's no way to enforce that one team can't see another's data.
For product databases, use ProductTeamModel as your base class. It provides team_id plus a fail-closed manager that raises TeamScopeError when queries run without team context. See posthog/models/scoping/README.md for the full API.
from posthog.models.scoping.product_mixin import ProductTeamModel
class MyModel(ProductTeamModel):
name = models.CharField(max_length=255)
# team_id inherited — BigIntegerField, indexed, no FKPostgres doesn't support foreign keys across databases. Models on a product database must not use ForeignKey to models in the main database (Team, User, etc.). Use plain integer fields instead:
# Do this — plain integer, no FK constraint
team_id = models.BigIntegerField(db_index=True)
# Not this — can't reference a table in another database
team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE)ForeignKeys between models within the same product database are fine.
This aligns with the facade pattern: if your product needs data from Team or User, fetch it through the facade using IDs — don't join to the table directly. Consequences:
- No
select_related/prefetch_relatedacross databases — use the facade or manual batch fetching - No
ON DELETE CASCADEfrom the main DB — handle cleanup in application code or via background tasks - No
transaction.atomic()spanning both databases — design for eventual consistency across boundaries
Separate databases only isolate failures if your product's outage can't drag the rest of the app down with it. Two layers provide that:
connect_timeout=3on every product alias — a connection to an unreachable host fails in 3s instead of blocking on the OS TCP default (60-120s).- A fail-fast circuit breaker (
posthog/db_circuit_breaker.py) on a custom database backend (posthog.db_backends.failopen). When a product DB is unreachable, the breaker opens after a few connection failures and then raises immediately on connect — in microseconds, instead of waiting on the timeout. This frees the worker to serve other requests, so one product database going down can't exhaust the shared worker pool and take the whole app offline.
Breaker state lives in Redis, so one worker tripping the breaker is seen by all pods at once. The breaker is per product alias: while it's open, only that product's endpoints fail (fast, with an OperationalError); everything else is unaffected. After a cooldown, a single probe request tests recovery and closes the breaker on success. If Redis itself is unavailable the breaker fails safe (stays closed) so it can never be what takes a healthy database offline.
There is no fail-open redirect to default — product tables don't exist there, so a redirect would only produce a different error. The isolation win is fast, contained failure, not silent degradation. Tune via PRODUCT_DB_CIRCUIT_BREAKER_* env vars; disabled in tests by default.
Products use Turborepo for selective testing. Only tests affected by your changes run.
# Run all product tests
pnpm turbo run backend:test
# Run specific product tests
pnpm turbo run backend:test --filter=@posthog/products-visual_review
# Dry-run to see what would execute
pnpm turbo run backend:test --dry-run=jsonSee architecture.md for how selective testing works.