
はじめに
こんにちは、ZOZOTOWN開発1部iOSブロックの@kitasukeです。
前回の記事「ZOZOTOWN iOS のアーキテクチャとチームの進化」では、MVCからMVVM、そしてMVVM + Repositoryへのアーキテクチャ進化を取り上げました。あわせて、レビュー文化をチームに根づかせてきた3年間も振り返っています。
ただ、アーキテクチャを文章で定義しても、書き手によって命名や責務分割はぶれが生じますし、AIに任せると過去の望ましくない実装パターンまで律儀に再現されます。ドキュメントによる「努力目標」では、アーキテクチャは守りきれません。
そこで発想を逆にしました。アーキテクチャを「守るべきルール」ではなく、構造化されたスキーマとして定義し、人間とAIの双方がそれに従うしかない形にします。Swiftの型システムがコンパイル時に不正を弾くのと同じ発想を、アーキテクチャのレイヤーにスキーマという形で持ち込みます。それが本記事で紹介する「スキーマでアーキテクチャを縛る」アプローチです。副産物として、設計からコードを自動生成するパイプラインも動いています。
目次
どんなスキーマを定義したのか
全体像はこうなっています。
仕様書 (Confluence) / デザイン (Figma) / 既存コード
│
▼ /architecture
┌─────────────────────┐
│ 設計 YAML │ ←── AI / Codegen 向け
│ Human Doc (Markdown) │ ←── 人間向けレビュー資料
└─────────────────────┘
│
▼(人間がレビュー・編集)
│
▼ /codegen
Swift コード一式
↑ 全工程でガイドラインとテンプレートが参照される
土台となっているのが、チームで整備した 2つのドキュメント です。
architecture-guidelines.md— 各コンポーネントのスキーマ(何が正しいか)architecture-templates.md— スキーマからSwiftを導出するテンプレート(どう書くか)
architecture-guidelines.md — コンポーネントをスキーマで縛る
各コンポーネント(ViewModel、Repository、Translatorなど)を、型・依存・命名・必須ルール・禁止パターンなどのフィールドで厳密に定義しています。たとえばViewModelのスキーマは次のとおりです。
### ViewModel - type: `@MainActor final class` - imports: [Foundation, Combine] - imports_forbidden: [APIModule] - depends_on: [RepositoryProtocol, UIModelTranslator, DataModel, UIModel] - nested_types: [ViewState, Router] - naming: {Feature}ViewModel - required: - ViewState enum で画面状態を管理(複数 Bool 禁止) - @Published private(set) で外部からの直接変更を防止 - 1 ユーザーアクション = 1 input メソッド(did{Verb}{Noun}) - forbidden: - キャッシュロジック(Repository の責務) - ログ送信の直接呼び出し(UseCase/別 Repository に分離)
自由に書ける余地を意図的に潰しているのがポイントです。ViewModelがAPIモジュールをimportした時点でアウトです。@Published を private(set) にしなかった場合もアウトです。自己流のMVVM解釈を許さない設計になっています。
architecture-templates.md — スキーマから Swift を導出するルール
スキーマだけではSwiftコードの具体的な書き方までは決まりません。命名規則、ファイルの生成順序、各レイヤーのSwiftコードテンプレートなどを、もう一段別のドキュメントで固めています。ガイドラインがスキーマで、テンプレートが導出規則です。この2つが揃うことで、アーキテクチャのスキーマから具体的なSwiftコードが一意で決まる状態になりました。
どうやって縛っているのか
人間・AI・ツールの全員が、同じスキーマで動くようになっています。順に見ていきます。
画面ごとの設計を YAML で表現する
コンポーネントのスキーマが決まっても、画面ごとの実装は別物です。そこで、画面ごとの設計を1枚のYAMLで記述します。
feature: ProductList domain: Product api: - id: fetchProducts method: GET path: /products response: items: [Product] actions: - trigger: didAppear api: fetchProducts - trigger: didTapRetry api: fetchProducts condition: "state == .error" models: data: - name: Product fields: id: String name: String brandName: String price: Int imageURL: URL ui: - name: ProductListUIModel fields: nameText: String brandText: String priceText: String
このYAMLは、ガイドラインが定めたスキーマの「値」にあたります。画面のAPI、アクション、データモデルが構造化されて並んでいるだけで、曖昧さの入り込む余地はありません。
/architectureと/codegen — 実際の運用
この縛りを日々の開発で実行しているのが2つのスラッシュコマンドです。
/architecture: 仕様書やデザインから YAML を起こす
重要なのは、このYAMLを人間がゼロから書いているわけではないという点です。Confluenceの仕様書やFigmaのデザインを入力にすると、/architectureコマンドが設計YAMLと人間向けMarkdownの大部分を自動生成します。
人間の作業は「書く」ではなく「判断する」に寄っています。生成されたYAMLを読み、責務分割やエッジケースの扱いなど設計判断が必要な箇所だけに手を入れます。スキーマが縛ってくれているので、AIが起こしたYAMLも標準から外れた形にはなりません。
/codegen: YAMLからSwiftのコードを生成する
レビューが終わったYAMLを /codegen に渡すと、Swiftコード一式が出力されます。具体的には、View / ViewModel / Repository / プロトコル / モック / ユニットテストの雛形 / 依存注入のコードです。
たとえば先ほどの ProductList.yaml のうち、以下の部分に注目します。
actions: - trigger: didAppear api: fetchProducts - trigger: didTapRetry api: fetchProducts condition: "state == .error"
この部分を/codegenに流すと、ViewModelは次のように生成されます。
@MainActor final class ProductListViewModel: ObservableObject { enum ViewState { case loading case loaded(ProductListUIModel) case error(Error) } @Published private(set) var state: ViewState = .loading private let repository: ProductRepositoryProtocol func didAppear() async { await fetchProducts() } func didTapRetry() async { guard case .error = state else { return } await fetchProducts() } private func fetchProducts() async { state = .loading do { let products = try await repository.fetchProducts() state = .loaded(ProductListUIModelTranslator.translate(from: products)) } catch { state = .error(error) } } }
ガイドラインで定義した制約がそのまま反映されているのが分かります。たとえば@MainActor final class、@Published private(set)、ViewState enumでの状態管理、did{Verb}{Noun}命名規則などです。YAMLのactionsはそのままViewModelのメソッドに、conditionはguard文に対応しています。コード生成は仕組みの主役ではなく、スキーマで縛った結果として得られる副産物です。
何が変わったのか
AIの書くコードがレビューを通る水準になった
スキーマで縛ったことで、実際にAIの出力が目に見えて安定しました。命名・配置・レイヤー構成がプロジェクト標準に揃い、ハルシネーションもほぼ消え、同じYAMLを何度通してもほぼ同じコードが出てきます。AIの生成するコードは、そのままレビューを通る水準に達しました。これが縛りの直接的な見返りです。
レビューで「プロダクト品質」の話ができるようになった
コード品質(命名、配置、責務分割)はスキーマが自動的に揃えるので、レビューで議論する必要がなくなりました。その分、UXが成立しているか、エッジケースの仕様が妥当か、ビジネスゴールに沿っているか、といったプロダクトとしての品質に時間を使えるようになっています。コードの良し悪しではなく、プロダクトの良し悪しを議論できるようになったのは、狙い通りの大きな変化でした。
まとめ
アーキテクチャは「努力目標」ではなく「スキーマ」で守ります。Swiftの型システムが不正を弾くのと同じ発想を、設計レイヤーにも持ち込みます。人間とAIを同じスキーマで動かすことで、チームのアーキテクチャを長く保てる状態を目指しています。
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。