isolating-product-facade-contracts について
既存プロダクトを facade と contracts のアーキテクチャへ移行するためのリポジトリスキルです。移行段階の評価、境界設計、呼び出し元の移行、検証を案内します。
主な機能
- 01移行段階の評価
- 02Facade と contracts の指針
- 03結合スキャンと呼び出し元の移行
- 04隔離チェックと検証
活用シーン
- 01products/ 配下のプロダクトの隔離
- 02進行中の隔離移行の再開
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ posthog/ posthog --skill isolating-product-facade-contractsインストール後、Claude Code で/isolating-product-facade-contractsを入力して読み込まれたか確認します。
詳細
ファサードとコントラクトによるプロダクトの分離
分離するプロダクトを選ぶ前に、すでに試したことを確認してください。そこには、どのプロダクトが最初の候補だったのか、そしてなぜフィールドテストが別のプロダクトへ移ったのかが記録されています。
このスキルは、既存のプロダクトを Visual review で使われている分離アーキテクチャへ移行するために使用します。 小さな diff ではなく、カレンダー上の短い露出期間を最適化してください。作成は安価で、検証 チェーンが機械的な破損を検出しますが、実際のボトルネックは人間によるレビューのレイテンシーと 急速に動く master です。デフォルトでは、レビュー可能なコミットで構成された 1 つの PR とします。 ファサードを先に行う別の PR は、チーム単位で分割したスイープのマージベースとしてのみ存在します — 以下の PR 戦略に従ってください。
前提条件: プロダクトはすでに products/<name>/ 配下に存在している必要があります。このスキルは posthog/、ee/、その他の共有ディレクトリからコードを移動することをカバーしていません — それは先に行ってください。
最初に読み込むべき中核ドキュメント
コードを変更する前に、以下を読んでください:
- products/architecture.md
- products/README.md
- docs/internal/monorepo-layout.md
- posthog/models/team/README.md (チーム拡張モデルのルール)
- docs/published/handbook/engineering/type-system.md (シリアライザー/OpenAPI の型フロー)
- docs/published/handbook/engineering/ai/implementing-mcp-tools.md (スキーマ品質とチーム分離の要件)
- .agents/security.md (SQL/HogQL セキュリティガイドライン)
Visual review を具体的な参照実装として使用してください:
- 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
コードを変更する前に、ベースラインを取得してください:
hogli product:maturity <name> # scores models, facade, presentation, boundaries, codegen
hogli product:lint <name> # structural lint + isolation chain, prints the rung: Lenient → Strict → Sealed → Isolated
hogli product:isolate:scan <name> # the recon: import map, coupling gate, preflight (see below)
まず、ベースラインを読んで、どの移行フェーズにいるのかを把握してください — これが未着手なのか途中なのかは最初から分かっているわけではなく、それによってどこから再開するかが決まります。
シグナルはすべて上記の出力に含まれています。参照すべきステータスファイルはありません(手作業で
管理するものはずれてしまいます — 成熟度スコアと ignore_imports の TODO セットが まさに ステータスであり、
lint-imports はモジュールが完成するにつれて後者を削除します):
- 新規 — facade/presentation のスコアがゼロ、
backend/facade/がなく、そのプロダクト用のignore_importsエントリもない。ステップ 1 から開始します。 - スイープ途中 —
facade/contracts.py+facade/api.pyは存在するが、コアがまだ内部をインポート している(スキャンが依然として model-access/etc. をリストアップしている、またはproduct:lintがhas legacy interface leaksを警告している)。ファサードの設計は確定しているので、呼び出し元の移行を続けてください(ステップ 4)— 再設計はしないでください。 - プレゼンテーションウェーブの途中 — チェーンはほぼ無傷だが、
pyproject.tomlにそのプロダクト用のignore_importsTODO エントリがまだ残っている。スイープは完了しており、まさにそれらの 先送りされたモジュールに対してプレゼンテーションウェーブを再開します。 - 実質完了 — チェーンが完全で、
ignore_importsエントリが残っていない。何も変更するのではなく、 完了基準に照らして検証します。
このスキャンは、そうでなければ十数回のアドホックな grep を要する偵察を行い、フローの残りの部分における 信頼できる情報源となります:
- 種類別の参照 — 製品の内部に対する境界を越えたすべての参照を、
分類し(model-access、query-runner、celery-task、temporal-wiring、test-fixture、
string-reference)、それぞれの種類がマッピングされるファサードパターンとともに示します。これが網羅チェックリストです。文字列参照(
@patch(...)のモックパス、設定内のドット区切り名)も含まれており、インポート指向の grep では見落とされます。 - コア結合数 — 以下の PR 戦略を選びます。ほぼゼロなら移行全体が単一 PR のデフォルトに収まります。3 桁なら呼び出し元の網羅には所有チームごとの分割が必要です。
- 厳格 lint プリフライト —
product:lintはfacade/contracts.pyが存在した瞬間に緩い設定から厳格な設定へ切り替わります。スキャンは強制厳格モードで構造チェックを実行するため、それらの要求(ルートのtsconfig.json、tasks/tasks.pyにあるtasks.py、必須ルートファイルの配置)は移行の途中ではなく最初に表面化します。厳格 lint は必須ルートファイルのみをチェックします。内部ディレクトリ名は制限しません。強制される形はインポートチェーンです —routes.pyはpresentation/のみをインポートし、presentation/はfacade/のみをインポートします — したがって製品は、ファサードの背後に留まる限り、他の内部パッケージ(services/、reviewer/など)を追加してもかまいません。 - ビューモジュールごとの thin/thick シグナル — 将来の
ignore_imports許可リストのサイズです。 - 盲点 — スキャンは
backend.*のインポートのみを読み取ります。結合数がゼロであることは必要条件であり、十分条件ではありません。コアがインポートする製品ルートのパッケージ(dags/)や、インポート以外のチャネル(設定のドット区切り文字列、hogql システムテーブル、インプロセス API テストディスパッチ)は見えません。製品を完了と宣言する前に、以下の「スキャンでは見えない結合を解消する」を参照してください。
--json は機械可読なレシピを出力します — PR と一緒に保管してください。最新の master に対して移行を再生成すると、新しいスキャンから始まります。
ツールが適合しないときは、ツールを修正する
黙って手作業で回避しないでください。isolate:scan/:move が製品の形を見落とす場合 — 検出できないレイアウト、認識できない結合チャネル — そして他の製品も同じ形を共有しているなら、ツールを拡張し、このスキルを更新して次の移行が修正を引き継げるようにしてください。これが backend/api/ サブパッケージのサポートと Dagster アセットチャネルが追加された方法です。手作業での回避策は、真の一回限りのケース(PR で明示)のために取っておいてください。黙った手作業のハックは、次の製品が同じ壁にぶつかるだけです。
「プロセス内の呼び出し元が存在しない、ゆえにファサードも不要」は誤った判定基準です
コア結合のカウントはスイープの規模を測るものです — インポート元がゼロであれば、呼び出し元の移行を伴わない単一 PR での移行を意味します — しかし、分離すべきかどうかを決めるものではありません。ファサードはまた、CI スキップを 健全 たらしめる構造的な封止でもあり、それはインポート元の数とは何の関係もありません。
- プロダクトの HTTP API は、テストによってプロセス内で実行されます(Django のテストクライアントは実際のソケット経由ではなく、同じプロセス内のビュースタックへディスパッチします)。横断的なテスト — パーミッション、スキーマ、アクティビティログ、「すべてのビューセットが X を行う」 — は URL 経由でプロダクトのエンドポイントに到達し、インポートを一切伴わずにその生きた挙動に結合します。tach、
lint-imports、およびこのスキャンはいずれもインポートグラフを読むため、そのいずれもこれを検出できません。「インポート元がない」は必要条件であり、十分条件ではありません。 - このチャネルは列挙できないため、監査ではなく構造によって閉じられます。すなわち、プレゼンテーションを薄く保ち、内部にはファサードを通してのみ到達するようにし、観測可能な挙動はすべてファサード内(プロダクト内で、境界の内側でテストされる)か、シリアライザの形(OpenAPI スキーマ。その変更はすでにフルスイートを強制します)に存在するようにします。そして、挙動テストはプロダクト内に保ちます。
したがって、唯一の利用者が HTTP 経由のみであるプロダクト(ノードサービス、TS/MCP コード生成)は、ファサードが任意ではありません — そこではファサードの仕事そのものが、自身のプレゼンテーションを封止することです(ステップ 2 の第 2 の要求源)。真の例外は、Django 側のロジックを実質的に持たないプロダクト(外部サービスに対する薄いシム)です。封止するものが何もありませんが、その場合は単に 分離されていない だけです — backend:contract-check はなく、それでもフルスイートのコストを払い続けます。それはコストを受け入れる選択であり、「ファサードなしで分離する」ことではありません。
これは、hogql システムテーブルとドット文字列設定(ステップ 6.4)に並ぶ、3 つ目の非インポート結合チャネルです — そして最も目に見えにくいものです。他の 2 つは少なくとも grep できる文字列を残すからです。
スキャンでは表示されない結合を解消する
product:isolate:scan は backend.* のインポートグラフを走査します。6 種類の結合がこれをすり抜けます — いずれも行き止まりではなく、それぞれに定義された対処法があります。バックエンドのスイープ後、git grep "products.<name>"(.backend だけでなく)を実行し、スキャンの文字列参照セクションを読んでそれらを見つけてください。
逆アクセサ。 プロダクト境界を越えるリレーションフィールド(FK、O2O、M2M)が related_name="+" を伴わない場合、対象クラスに逆アクセサと逆クエリ名を追加します。インポートは存在しないためスキャンには見えませんが、どの呼び出し元もそれをたどることができます。対処法:リレーションを related_name="+" で封止し、明示的な related_query_name を削除し(これは filter() のたどりを生かし続け、query:<name> 行として表示されます)、同じ変更で
products/model_crossing_uses_baseline.txt からその reverse-accessor(...) 行を削除します
(bin/hogli product:crossings --all --write-baseline)。逆アクセスを必要とする呼び出し元には、ファサードの読み取り関数を与えてください。クロッシングのラチェットが、新たな未封止のリレーションをブロックします。products/architecture.md の § Cross-product foreign keys を参照してください。
シグナル結合。 送信元が別の境界に属するレシーバーは、その境界のコードをこの境界の保存パス内で実行します。送信元が文字列の場合、インポートのエッジは存在しません。ケース別に 3 つの対処法があります。
-
コアがプロダクトのモデルをリッスンする(文字列の送信元) — 方向を反転させます。プロダクトが自身の保存パスからコアの公開関数を呼び出します (
remote_config.mark_dirty(team_id))。これは通常のプロダクト→コアのインポートです。 -
プロダクトがコアの保存に反応する — 外部の送信元に対する生の
@receiverではなく、コアが所有するフック(register_team_extension_signalの形)を通じて登録します。フックレジストリが目録です。 -
どちらの方向でも — レシーバーの本体で遅い処理や外部処理を決して行わないでください。代わりに
transaction.on_commitを使ってタスクをディスパッチします。 テストインフラの結合。 テストは tach のインターフェースグラフには含まれますが、依存関係グラフには含まれません (hogli lint:tachは CI が実行する 2 つのパスを実行します)。テストはdepends_onエントリなしで任意のプロダクトの公開サーフェスをインポートでき、別のプロダクトの内部をインポートするテストは、本番コードと同じように CI で失敗します。そのようなテストには、以下のいずれかの対処を行います。プロダクトがまだその対処を提供できない場合、負債と終了条件を明示したレガシーリークの[[interfaces]]ブロックがそのプロダクトを降格させ、ブロックが解消されるまでその状態が続きます。このブロックは、内部を公認された名前で外に渡すファサードモジュールであってはなりません。コアのテストはプロダクトのテストヘルパーに踏み込んでおり、それをファサードの再エクスポートで自然にカバーすることはできません: -
Monkeypatch のターゲット — コアのテストベースがプロダクトのモジュール属性にパッチを当てます (例:
posthog/test/base.pyは各ランナーモジュールのexecute_hogql_queryにパッチを当てます)。モジュールオブジェクトをファサード経由で再エクスポートし (facade/queries.pyはクラスだけでなくweb_overviewを再エクスポートします)、パッチをファサードのパスに向けます。 -
共有フィクスチャ / ベースクラス — コアのテストがプロダクトのテストベースをサブクラス化します。所有権を決めます: フィクスチャがコアの関心事のためのインフラである場合 (事前集計テーブルのテストベースで、テーブルはコアに存在する)、それをコアに移し、両側が下方向にインポートするようにします。コアのテストが実際にプロダクトの動作を検証している場合は、テストをプロダクト側に移します。いずれの場合も、境界を越えたテストのインポートは消えます。
-
プロダクトの行を必要とするフィクスチャ — コアのテストがシナリオをセットアップするためにプロダクトのモデル (
InsightViewed、Account) をシードします。通常のインポートを使って、ファサードの書き込み関数を通じてシードします。まだ存在しない場合は追加します。それは本番の呼び出し元が行うのと同じ対処です。フィクスチャだけが必要とするヘルパーはfacade/testing.pyサブモジュールに置きます。tach のインターフェースはすでにbackend.facade.*を公開しており、その他の公開されたモジュールはレガシーリークと見なされ、分離されたテストをブロックします。モジュールスコープでapps.get_model("<label>", "<Model>")を決して使わないでください。それはインポートのエッジを取り除いた同じ依存関係です。 tach、mypy、LSP はそれを見なくなり、snob (PR テストセレクター) はモデルが変更されたときにそのテストを選択しないため、破壊は master に到達します。その隣のTYPE_CHECKINGインポートはエッジを復元しません。tach は型のみのインポートを無視し、snob も同様です。
backend/ の外側のサーフェス。 プロダクトはそのルートに非 Django のサーフェスを公開できます — たとえば products/<name>/dags/ 配下の Dagster アセットなど — そしてコアがそれらを直接インポートします。スキャンは backend.* のみを走査するため、それらを列挙しません。そして tach / product:lint は「not part of the public interface」で遅れて失敗します (直接のインターフェース公開もレガシーリークチェックに引っかかります)。ファサードのサブモジュール経由で再エクスポートし (facade/dags.py がアセットモジュールを再エクスポートします)、コアのインポーターの経路を変更します。temporal や query-runner の配線とまったく同じです。
外部データコンシューマーなし。 上で述べたとおりです — ファサードはプロダクト自身のビューにサービスを提供します。そのモデルに対するファサードの読み取り関数を提供し、安価なビューを配線し、高価なもの (ネストされたシリアライザーやトランザクションのビューセット) はプレゼンテーションウェーブ向けの名前付き ignore_imports として先送りします。ファサード関数を提供しつつその呼び出し元を先送りすることは、正当な中間状態であり、中途半端な移行ではありません。
プロダクト所有の HogQL システムテーブル。 コアがプロダクトのフェデレーテッドシステムテーブルをマウントする場合 (schema/system.py、lazy_join_registry.py)、2 つの独立した問いに答えます:
- 参照は通常のファサードインポートにできますか? はい — テーブル定義と遅延結合
関数は単なるモジュールレベルのオブジェクトです。それらをプロダクト内
(例:
facade/hogql.py) に移動し、他の配線と同様に core のインポートを 付け替えてください。 - 静的な pickled カタログにオブジェクトは入りますか? Core は
カタログを一度構築し、リクエストごとに制限付き unpickler
(
posthog/hogql/database/database.pyのbuild_database_root_node) を 通して再ロードします。カタログツリー内でプロダクトが定義した任意の クラス (PostgresTable/LazyTableのサブクラス) は、そのモジュールを_CATALOG_PICKLE_MODULESに追加する必要があります — プレフィックス単位では なく個別に許可リストへ登録します。エントリが欠けていると、core のカタログ テストがモジュール名を示すメッセージとともに失敗します。Warehouse 形式の チーム別テーブルはリクエスト時に構築され、静的カタログには決して入りません。 そのためほとんどのプロダクトはこれに遭遇しません。
web_analytics の移行はこの 3 つすべての実例です。preagg のテストベースは
core へ移動し、タイムゾーンの統合テストはプロダクト内へ移動し、Dagster アセットは
facade/dags.py を得て、フィルタープリセットの読み取りは viewset を延期したまま
facade/api.py に着地しました。
コンシューマーがすでに保持しているモデルクラス
一部のプロダクトは、監視対象モデルの許可 (MODEL_CROSSINGS) の下で依然として
モデルクラスを渡しています。この許可は、そのクラスがプロダクトを出てよいと
述べるだけであり、コンシューマーがそれで何をするかについては何も述べていません。
それをカバーする 2 つのルールがあります:
- デフォルト拒否。 クロッシングクラスは、チェックがインスタンスフリーと
みなす形でのみコンシューマーコードに現れてよい: アノテーション、
X.DoesNotExist、ネストされたクラス属性 (X.Status)、X._meta、values/values_list/count/exists/aggregateで終わるマネージャーチェーン、 またはExists(...)/Subquery(...)に埋め込まれたチェーン。それ以外は 許可されません。 - 許可するのではなく移動する。 モデルをクエリ、シリアライズ、書き込みする コードは、そのモデルのプロダクトに属します。許可されない使用に対する 是正措置は移動です。ファサード関数は移動が残していくものであり、 コンシューマーはオーケストレーションと id を保持します。
apps.get_model('label', 'Class') もカウントされ、しかも許可対象のモデルだけでなく
すべてのプロダクトモデルが対象です。これはインポートのエッジを残さないため、
tach はそれを拒否できません。テストモジュールはスキャン対象外のままであり、
これは盲点であって許可ではありません。この方法でモデルに到達する core の
テストフィクスチャはカウントされず、選択もされません (上記の「テスト
インフラストラクチャの結合」を参照)。マイグレーションも対象外です。履歴
レジストリが、マイグレーションがモデルに到達できる唯一の方法だからです。
プロダクションコードは呼び出しを追加してはいけません。
hogli product:crossings <product> は、プロダクトのクロッシングクラスを、
すべてのコンシューマーでの使用を種類別に分類して一覧表示し、許可されないものを
先頭に示します。許可されない使用は
products/model_crossing_uses_baseline.txt に凍結され、リポジトリ不変テストで
保護されています。カウントは減る方向にのみ進めることができ、
hogli product:crossings --all --write-baseline が減少を記録します。
products/architecture.md の § Wiring couplings を参照してください。
ガードレール
- ファサードは薄く保ち、ビジネスルールはファサードの背後、デフォルトでは
logic/に置きます。他の内部パッケージ(services/、reviewer/など)は、ファサードの背後に留まる限り問題ありません。 - トランザクション境界はビューではなく、ファサード(または logic)に属します。
- コントラクトを処理するビューは、
ObjectAccessRefを通じてオブジェクト RBAC をチェックします。safely_get_object()から返すか、self.check_object_permissions(request, ref)に渡してください。手動で、あるいはファサード内でチェックしないでください。products/architecture.mdの § Who owns RBAC? を参照してください。 - プロダクト境界を越えて ORM モデルを返さないでください。
hogli product:lintはファサードのシグネチャを、facade-returns、facade-accepts(<parameter>)、facade-logicの種類で読み取ります。products/model_crossing_uses_baseline.txtの一致する行は、現在ファサードが 行っていることを記録しており、縮小されるだけなので、新しい検出結果は lint に失敗します。bin/hogli product:crossings --all --write-baselineで再生成してください。 ルールはproducts/architecture.mdの § Facades: The Public Interface です。- プロダクト境界を越えるすべてのリレーションフィールドは、
related_name="+"で宣言してください — 逆アクセサのラチェットが、封印されていない 新しいものをブロックします。 - 別の境界の sender にシグナルレシーバーを登録しないでください。上記の 「Signal coupling」にあるムーブを使用してください。
- コントラクトファイルは純粋に保ってください(
facade/contracts.pyやfacade/enums.pyに Django/DRF のインポートは不可)。そこの choices enum はposthog/enums.pyのLabeledStrEnum/LabeledIntEnumです。 - クエリセットでは
team_idでフィルタリングしてください。 Teamにプロダクト固有のフィールドを追加しないでください。Team Extension モデルを使用してください。- viewset のエンドポイントにリクエスト/レスポンススキーマのアノテーションを追加してください(
@validated_requestまたは@extend_schema)。 - シリアライザー/ビューの変更が API スキーマに影響する場合は、OpenAPI/types を再生成してください(
hogli build:openapi)。 - プレゼンテーションはファサードを経由してのみ内部にアクセスできます — これは
pyproject.tomlのpresentation must use facadeインポートリンターコントラクト (tool.importlinter)によって強制されています。新しい内部モジュール (cache.py、helpers.pyなど)は自動的に対象になります。維持すべき ブロックリストはありません。新しい横断的なインポートは、ファサードを経由するか、 そこで一時的に許可リストに登録する必要があります。
必須のマイグレーションワークフロー
- 対象プロダクトのインポートマップを作成します。
- 対象の内部モジュール(
models、logic、presentation、ファサード以外のモジュール)へのプロダクト横断的なインポートを洗い出します。 - 各使用箇所をケイパビリティ(読み取り/一覧、詳細/読み取り、作成/更新/削除、非同期/タスク、Webhook/イベント)ごとに分類します。
- 対象の内部モジュール(
- 2 つの需要源の両方からコントラクト表面を定義します。
- 外部の利用者(core、その他のプロダクト):現在利用されているフィールドのみを出発点にします。
- プロダクト自身の presentation(ステップ 5):そのビューが呼び出すファサード関数。厚いビューの場合、この分担は 後回しにされることがあります — ただしその先送りを明示してください。それが、有効化する分離が 健全か楽観的かを決めます(ステップ 5 を参照)。
backend/facade/contracts.pyにpydantic.dataclasses.dataclassを使って凍結データクラスを作成します — 標準ライブラリ版と同じ形ですが、構築時に実行時の型検証が行われます。
backend/facade/api.pyに薄いファサードを導入します。- 明示的なマッパー関数で ORM インスタンスをコントラクトにマッピングします。
- メソッド名はケイパビリティ指向で安定させます。
- core が登録する配線(celery beat タスク、temporal ワークフロー/アクティビティ/メトリクス/
スケジュール定数、query-runner レジストリフック、Dagster アセット)は、境界を
データではなくオブジェクトとして越えます — それぞれに専用のファサードサブモジュール(
facade/tasks.py、facade/temporal.py、facade/queries.py、facade/dags.py)を与え、core が触れるものだけを 正確に再エクスポートします。レジストリ方式の利用者はクラス識別(isinstance)でディスパッチするため、 クラス自体を再エクスポートします。monkeypatch する利用者にはモジュールオブジェクトが必要なので、 それも再エクスポートします。facade/api.pyは重いインポート(HogQL、temporalio)を含まないようにして、 設定のみの利用者がそれらをdjango.setup()の経路に引き込まないようにします — そして同じ理由で、 軽量な共有テーブル(facade/hogql.py)を重い runner の再エクスポートから分離します。
- 呼び出し元はデフォルトで一括して移行します。
- 書き換えは機械的で(インポートの差し替え + 呼び出しのマッピング)、検証チェーンによって完全にチェックされます。 バッチに分けると、master がブランチの下でずれる期間が長引くだけです。
- プロダクトの行を必要とする core のテストフィクスチャは、ファサードの書き込み関数か
プロダクトのテスト用ドアを経由し、通常のインポートを使います —
apps.get_modelは使いません(その理由は上記の「テストインフラの結合」を参照)。 - 互換シムは連続する PR の間をつなぐために存在しますが、一括方式ではほとんど必要ありません。 どうしても避けられない場合、そのシムは「後で」ではなく最後のクリーンアップ PR で削除されます。
- 例外:トランザクション境界や書き込み経路のセマンティクスなど、微妙な挙動を持つ呼び出し元は、 独自の小さな PR で移動してもかまいません — 以下の PR 戦略を参照してください。
- presentation をファサードを利用する形に移行します。
- シリアライザは JSON <-> コントラクトを変換し、ビューはファサードのメソッドだけを呼び出します。
- ビューモジュールごとに、サイズ見積もりに基づいて、そのファサードリファクタリングを
この PR で行うか先送りするかを決めます。モジュールごとの作業量を見積もります:runner 実行の差し替えは
安価です(runner はすでに
posthog.schemaの型を返すため、新しいコントラクトは不要です)。 単純なモデル CRUD には小さなコントラクトといくつかの関数が必要です。シリアライザに埋め込まれた トランザクション処理やプロダクト横断的なロジックは高コストな階層です。作業量が安価な モジュールはすべて差し替え、高コストなものだけを先送りし、その理由と残るignore_importsエントリを PR の説明に明記します。 - 先送りされたモジュールは、
pyproject.tomlの TODO セクションに正確なペアのignore_importsエントリを 保持します。この負債はスキップをゲートします:エントリが 1 つでも残っている間、プロダクトはbackend:contract-check(ステップ 6)を有効化してはならず、それにより完全なスイートが実行され続け、 カバレッジの穴が生じません。これが健全性の境界です — スキップはファサード/presentation の変更時にのみ スイートを再実行するため、presentation が薄くなって初めて誠実になります。先送りされた厚いビューは 内部モジュールに直接到達するため、内部モジュールの変更がそれを経由して HTTP に流れるとスキップを すり抜けてしまいます(そして、インポートを一切使わずに URL 経由でプロダクトを実行する横断的な core テスト もすり抜けます)。hogli product:lintがこのゲートを強制します —ignore_importsエントリが存在する 状態での contract-check は lint に失敗します。スキップは、presentation ウェーブの PR がエントリを空にしたときに 解き放つ報酬です。 - 機械的な部分は 1 つのコマンドです:
hogli product:isolate:move <name>(まず--dry-runを実行してください) は ViewSet モジュールをpresentation/views/に移動し(自動検出、上書きには--views)、tasks.pyを celery 名を固定したtasks/パッケージに移動し(モジュールの公開サーフェスを再エクスポートし、 プライベート なタスク名がパッケージパス経由で到達された場合 — たとえば@patch("...backend.tasks._helper")のターゲット — に警告を出します。これらは...backend.tasks.tasks._helperへ手動で向け直す必要があるためです。自動書き換えは 固定されたname=文字列を破壊してしまいます)、リポジトリ全体のドット付きパスを書き換えます。backend/api/サブパッケージは丸ごと移動します:本番用のヘルパーサブパッケージ(例:destination_tests/)はpresentation/views/配下で構造を保ち、プレフィックスのリネームに追随します。一方、テスト用サブパッケージ (test/、tests/)はbackend/tests/api/に移転します — これらは api 名前空間から外れるため、 単純なプレフィックス書き換えでは追随できません。この移動は、api/モジュールがすでにpresentation/<stem>.pyにミラーされている場合は拒否されます — これは以前の手動移行が互換シムを 残したことを意味し、移動するとモジュールが重複してしまうためです。まずシムを削除して呼び出し元を 向け直し、その後で残りを再実行してください(error_tracking が実例です)。 - 書き換えは意図的に 2 ツールに分割されており、モデル移行ツール群を再発明するのではなく
それに倣っています:絶対完全修飾パスと文字列参照(
@patch(...)のモックパス)は ガード付きの単語境界正規表現を通します(字句的に曖昧さがないため)。相対インポート (from ..x import y、任意の深さ)は libcst(cst_helpers.py、プロダクトモデル移行のimport_rewriterから移植)を通します。これは解決にインポート元モジュールのパッケージが必要で、 行正規表現では知り得ないためです。
- 境界を強制し、検証します。これは 4 ステップのチェーンです — 各ステップは前のステップに依存し、
hogli product:lint(IsolationChainCheck経由)はいずれかのステップがスキップされると失敗します:- 実際のファサード —
backend/facade/api.pyには、logicからの再エクスポートだけでなく、 実際の関数定義が必要です。 - Tach インターフェース — 推奨される方法は、
tach.tomlの既存の共有[[interfaces]]ブロック内の 正規表現にプロダクト名を追加することです。これはbackend\.facade.*とbackend\.presentation\.views.*を公開します。プロダクトが非標準の公開パターンを必要とする場合にのみ、 新しい専用ブロックを追加してください。 backend:contract-checkスクリプト —package.jsonに追加して、turbo-discover がプロダクトを 分離済みとして扱うようにします。これは最後の要素です:プロダクトが完全に分離されたときにのみ 追加してください —ignore_importsエントリが残っておらず、presentation が薄い状態です。hogli product:lintは、先送りエントリが残っている間はこのスクリプトをブロックします。 そのときまでスキップは不健全だからです(ステップ 5 を参照)。- 絞り込まれた
turbo.jsonの inputs —backend:contract-checkの inputs をbackend/facade/**とbackend/presentation/**に制限し、Django スイートがファサード/presentation の 変更時にのみ再実行されるようにします(products/visual_review/turbo.jsonを参照)。inputs は モデル表面全体も監視する必要があります —backend/models.pyまたはbackend/models/**、 さらにbackend/migrations/**(最初のマイグレーションが入る前でも必須です):モデルはインポートなしでも 到達可能です(apps.get_model文字列、マイグレーション、admin、hogql システムテーブル、 ドット付き文字列設定)。そのため tach/import-linter は、外部がそれを観察していないことを証明できません。hogli product:lintは、これらのいずれかを欠いた絞り込みをブロックし、product:bootstrapは すでにそれをカバーした inputs を生成します。
- 恒久的インターフェースの例外(還元不能なインポート結合)。 一部のインポート結合は
本当に解消できません:ClickHouse DDL モジュール(
backend.sql、backend.embeddingなど)は、 core のposthog/clickhouse/schema.pyレジストリ、conftest.py、そしてインポートパスを 永久にハードコードする凍結された ClickHouse マイグレーションからインポートされます。凍結された マイグレーションを再ルーティングしたり、モジュールを移動したりすることはできません。このために、 それらのモジュールを公開する tach の[[interfaces]]ブロックのすぐ上の行に# isolation:permanent-interfaceコメントを付けてマークします。このマーカーは、 そのブロックが宣言済みで還元不能な公開 — レガシーリークではない — であることをhogli product:lintに伝え、それによりbackend:contract-checkを保留しなくなります。 健全性は turbo.json と組み合わせることで保たれます:恒久的に公開されるすべてのモジュールは、 契約チェックのinputs(例:backend/sql.py)に必ず現れなければならず、それにより その変更でも完全なスイートが再実行されます。IsolationChainCheckはその組み合わせを強制し、 マークされたモジュールが inputs に欠けていると失敗します。これは、HTTP 経由で動作しない結合であり、 かつ再ルーティングが不可能な場合(凍結マイグレーション / schema レジストリの DDL)にのみ 使用してください — まだ移行していないモデル/logic のインポートのための抜け道としてではありません。 この制限は様式上のものではなく構造上のものです:このマーカーが健全なのは、凍結により固定された すべてのモジュールがDDL のみを含む場合だけです。error_trackingが実例です (sql/embedding/indexed_embeddingは純粋な DDL モジュールです)。cohortsも該当します — マイグレーション 0010 は DDL のみのサブモジュールであるproducts.cohorts.backend.models.sqlを固定して いるため、マーカーはまさにそのサブモジュールに適用されます(backend.models.*ではありません)。event_definitionsは該当しません:マイグレーション 0120 はproducts.event_definitions.backend.models.property_definitionを固定しており、このモジュールは DDL 定数と並んでPropertyDefinitionモデルクラスを定義しているため、恒久的とマークすると モデルアクセスを公開してしまいます — まさにこの例外が禁じている抜け道です。この形のプロダクトは、 まず DDL を専用モジュールに抽出し、元のモジュール内の再エクスポートシムで凍結されたインポートパスを 保つ必要があります。シムの残存的な公開(凍結マイグレーションが依然としてモデルモジュールをインポートする) は、マーカーで覆い隠すのではなく、ブロックコメントで正直に文書化しなければなりません。 hogli lint:tach、lint-imports(presentation → facade の import-linter 契約)、hogli product:lint <name>で検証します。- 残っている分離作業を models、facade、presentation、boundaries、codegen にわたって
スコアリングした詳細な内訳には
hogli product:maturity <name>を使用します。 - 変更されたファイルのフォーカスしたテストを実行し、その後プロダクトレベルのバックエンドテストを実行します。
- 実際のファサード —
移行中のレガシーリーク
これは、呼び出し元のスイープがチームをまたいで分割されている高結合な製品向けの例外パスです(以下の PR 戦略を参照)— すべてのコアインポートが移動し終える前に tach の境界が有効になり、一方で backend:contract-check の CI スイッチはブロックがなくなるまで待たなければなりません(リークが残っている間は hogli product:lint がスクリプトを拒否します)。単一 PR のデフォルトでは、すべてのコア呼び出し元が同じ PR に含まれるため、このブロックは存在する必要がありません。
分離チェーンが有効になった時点で posthog/ または ee/ がまだ製品の内部(backend.models、backend.oauth など)をインポートしている場合は、tach.toml の「Legacy leaks」セクションの下に 2 つ目の [[interfaces]] ブロックを追加し、コアがまだ触れているモジュールだけを明示的に許可リストに登録します。これにより、残りのチーム分割 PR がマージされる間もビルドをグリーンに保てます。インポートがファサードの背後へ移動するにつれて、そのブロックを縮小して削除してください — 最終的なクリーンアップ PR で完全に削除します。hogli product:lint は、レガシーリークインターフェースがまだ残っている製品に対して ⚠ has legacy interface leaks という警告を表示します。
PR 戦略
希少なリソースはレビュアーの注意力とカレンダー時間であり、執筆の労力ではありません。 開いている移行ブランチは並行作業がマージされるたびに劣化し、追加の PR ごとに 直列化されたレビュー期間とフルスイートの CI 実行が加わります(製品はまだ分離されていないため、 各移行 PR はフルスイートのコストを支払います)。マージトポロジーが必要とする最小限の PR を使い、 PR の境界ではなくコミット構造でレビューを整理してください。
デフォルト:1 つの PR を、レビュー可能なコミットで構成する。
- コミット — ファサード。 コントラクト + ファサード(サブモジュールの配線を含む)+ 動作同等性テスト。純粋な追加であり、レビュアーはこのコミットを設計として読みます。
- コミット — スイープ。 すべての呼び出し元の移行。機械的であり、レビュアーはサンプリングし、 検証チェーンがそれを支えます。
- コミット — 構造 + チェーン。 プレゼンテーションの移動と書き換え
(
hogli product:isolate:move)、厳格な lint の修正、tach インターフェース。この PR で製品が 完全に分離されている場合に限り、ここでbackend:contract-check+ 絞り込んだturbo.jsonを 追加します — 遅延されたビューモジュールがない場合です。いずれかのモジュールが遅延されている場合 (ignore_importsのエントリが残っている場合)、スキップはまだ有効にできません(product:lintが それをブロックします)。そのため、プレゼンテーションウェーブの PR とともにリリースされます。 この PR はスキップなしで出荷され、フルスイートを支払い続けますが、それは妥当です。
ファサードをレビューしやすさのために独自の PR に分割しないでください。新しいファイルはどの PR に
置かれても競合し得ないため、独立した設計 PR は競合保護をもたらしません — それは 2 つのレビュー期間を
直列化し、移行の CI コストを倍増させるだけです。さらに悪いことに、呼び出し元のないファサードは
抽象的にしかレビューできません。1 つの PR では、呼び出し元の差分が設計の隣に位置し、それを正当化
(または告発)します。形については user_interviews の PR #59132 とログ移行(#63180 + #63184、
これが学ばれる前に 2 つとして行われた)を参照してください。
PR の説明には、動作を伴う編集を列挙してください — 書き込みパス、トランザクション境界、シリアライザーの 変更、名前変更されたものや互換性が固定されたもの(celery タスク名)。そのリストがレビューの注意力を 向ける場所であり、それ以外はすべてサンプリングされます。書き込みパスが本当に微妙な場合は、 例外としてそれらの少数の呼び出し元を独自の小さな PR に引き出してください。
PR は再ベース可能ではなく、再生成可能として扱ってください。インポートマップとファサードの設計が 書き換えを完全に決定するため、競合時には手動で解決するのではなく、新鮮な master に対してブランチを 再生成してください。レビュー期間のためだけに開いたままにしてください。
ファサードファーストの別 PR が存在するのは、まさに 1 つの理由からです:スイープをスライスする必要が あるときの共有マージベースになることです。 ベースラインからのコア結合数でゲートします:
- 低い(ゼロから低い 2 桁): 上記のように単一の PR。レガシーリークの仕組みは完全にスキップします。
- 高い(3 桁): スイープは 1 つの単位としてレビューされません。コントラクト + ファサード
- 同等性テストを最初にリリースします — その独立したレビューはここでは意味があります。なぜなら、
すべてのスライスがそれに対してコーディングしようとしているからです — その後、呼び出しコードを
所有する主体(消費チーム/領域であり、製品自身のオーナーではない)でスイープをスライスし、
並行してレビューし、能力によって直列化しないでください。スライスがリリースされる間に
レガシーリークの tach ブロック(上記)を追加しますが、
backend:contract-checkはまだ追加しないで ください —hogli product:lintはリークが残っている間そのスクリプトを拒否します。最終クリーンアップ PR は、ブロック、ignore_importsの TODO、デッドアダプター、シムを削除し、そのとき初めて contract-check + 絞り込んだturbo.json入力を有効にします — このパスの CI の見返りは最後に 訪れます。
- 同等性テストを最初にリリースします — その独立したレビューはここでは意味があります。なぜなら、
すべてのスライスがそれに対してコーディングしようとしているからです — その後、呼び出しコードを
所有する主体(消費チーム/領域であり、製品自身のオーナーではない)でスイープをスライスし、
並行してレビューし、能力によって直列化しないでください。スライスがリリースされる間に
レガシーリークの tach ブロック(上記)を追加しますが、
遅延されたビューモジュール(ステップ 5)は、どちらのパスでも 1 つのフォローアップを追加します: プレゼンテーションウェーブです — そのフローと出力については以下のセクションを参照してください。
受け入れるべきトレードオフ:1 つの PR は複数よりも粗く元に戻ります。これは問題ありません。なぜなら、 マッパーは動作を保持し、動作を伴う編集は事前に列挙され、PR 全体が再生成可能であるからです — ホットな製品での数週間の直列 PR は、時折の粗い元に戻しよりも、競合と放棄された分離 CI 時間において 多くのコストがかかります。
プレゼンテーションウェーブ
同じファサードに対する第2の需要ウェーブです。第1のウェーブが外部の利用者に提供していたのに対し、こちらはプロダクトの 自身のビュー が必要とする関数を対象とします。プロダクトごとに1回、マイグレーション PR がマージされた後に実行し、その PR に記載された保留中のモジュールを対象に作業します(スキャンのビューモジュールテーブルがワークリストです)。
保留中のモジュールごとに:
- そのビューが必要とするファサード関数を設計します。クエリエンドポイントには、
posthog.schemaのクエリオブジェクトを受け取り、ランナーのレスポンス型を返す run-function を持たせます。これらはすでにフレームワークに依存しないため、通常は新しいコントラクトは不要です。モデルを基盤とするエンドポイントには、コントラクトと CRUD/ケイパビリティ関数(visual_review のDataclassSerializerパターン)を持たせます。 - 境界線を引きます。実行とトランザクションに関する関心事(
ExecutionMode、クエリのタグ付け、select_for_updateの上限、プロダクト横断の副作用)はファサードの背後へ移動させ、HTTP に関する関心事(リクエストのバリデーション、レスポンスコード、report_user_actionのアナリティクス)はビューに残します。 - ビューを parse → facade → serialize へと薄くし、新しいファサード関数には第1のウェーブと同様にパリティテストを用意します。
- そのモジュールの
ignore_importsエントリーを削除します。lint-importsはそれらなしでパスする必要があります。この削除が、モジュールの完了を示す成果物です。
これは掃討作業ではなく設計 PR です。ファサードのコミットと同じレビューの重みを持ちます(コントラクトと関数の形は長期的に存続する API です)。そのため、機械的な作業に便乗させるのではなく、独自の PR とします。ついでに第1のウェーブの関数をリファクタリングする(例:ランナービルダーを run-function に包含する)のは問題ありません。その呼び出し元は少数であり、パリティテストが振る舞いを固定しているからです。以下に示すプロダクトの完了基準は、このウェーブが許可リストを空にして初めて完全に満たされます。そしてそれを空にすることがスキップを可能にします。最後の ignore_imports エントリーを削除する PR が、backend:contract-check と絞り込まれた turbo.json がようやく追加される場所です(それまでは product:lint がそれらをブロックします)。モジュールを保留にしたプロダクトにとって、ここが CI スキップを獲得する時です。
完了基準
マイグレーションが完了したとみなすのは、次の条件を満たしたときだけです:
- プロダクト横断のインポートが
backend/facadeのみを使用している。 - ファサードが ORM ではなくコントラクトを返し、受け取る。
- プレゼンテーション層がビジネスロジックをエンコードしなくなっている。
- テストがファサードとプレゼンテーションの境界をカバーしている。
- プロダクトが
tach.tomlの共有[[interfaces]]ブロックに記載され、backend.facade.*とbackend.presentation.views.*を公開している — レガシーのリークブロックが残っていない。 hogli lint:tachがこのプロダクトに対する違反なしでパスする。lint-importsが、pyproject.tomlの TODO セクションにこのプロダクト用のignore_importsエントリーが1つも残っていない状態でパスする — エントリーは保留中のビューモジュールに由来する追跡対象のアーキテクチャ上の負債であり、プレゼンテーションウェーブがそれらを削除します。hogli product:lint <name>がレガシーリークの警告を表示せず、分離チェーンが intact である。backend:contract-checkがpackage.jsonに存在し、turbo.jsonの inputs がbackend/facade/**とbackend/presentation/**に絞り込まれている(CI スキップを有効化します)。これは最後に追加されるものです —product:lintは上記のignore_importsエントリーがなくなるまでそれをブロックするため、スキップは完全に分離されたプロダクトに対してのみ有効になります。