Shopifyのクロスプラットフォームアプリ開発のためのReflexが参考になったから紹介したいがServiceを抽象化するべきかの疑問点もある

はじめに

2026年11月6日にShopifyのエンジニアリングのアカウントのツイートがあり、クロスプラットフォームのReflexというのが投稿されそれが参考になったということを書いておきます。ただ、コードは部分的にしか解説されておらず、自分にはServiceという抽象化レイヤーが必要とは思えないというメモに近いです。

ShopifyのReflexについての記事:

https://shopify.engineering/reflex

そんで、一旦自分の感想をツイートしたものもあります。そっちのほうが読みやすいかもしれません。

Shopifyのアプリ開発は何を課題としているか

前提を知っておかないと理解ができないと思うので記事に書かれてることを中心に整理します。

Shopifyとは

  • 175カ国以上で数百万のビジネスを支えるEコマースプラットフォーム
    • (おそらく)自分のWebページやWebサービスに組み込む
    • モバイルアプリはその管理画面的な役割もある(?)

もともとShopifyのモバイルアプリはReact Native使ってた

もともとShopifyはReact Nativeを使っていて、そこからSwift/Kotlin移行は別の記事もあります

https://shopify.engineering/back-to-native

それを読むと、まず1度書いたらそれが二つのプラットフォームで動いてくれるというメリットをかつては大きく受け取っていたらしい。で、そこから生成AIが発展して移行することになったと。

React Nativeを使う上での課題があった

  • フレームワーク固有のパフォーマンス問題への対応や、重要基盤のチューニングに労力がかかった
    • フレームワークのアップデートや外部依存関係(ライブラリなど)の維持管理、追従に常にコストがかかり続けていた
  • AIの登場で1度しか書かないで済むメリットよりデメリットが大きくなった
    • AIエージェントが実装、翻訳、テスト、レビューといった作業の多くを担えるようになった

React Native関係ないSwift/Kotlinにする場合の従来の課題もある

  • 従来のモバイルアプリのテスト手法についての制約
    • シミュレータ制御の重さと脆さ
      • テストのためにiOSシミュレータ/Androidエミュレータを起動する
    • フィードバックループの遅さ
      • 人間じゃないAIは画像解析してUIをテストする

なお、React NativeでHot Reloadがあったから人間が確認して作り直すフィードバックループは短くできるけど、AIはUIを検証して作り直そうとするとコストが高いということも課題でしょうね。それが解決しようとしている課題だったと。

予想

まずShopifyはお金を取り扱うし175カ国以上もシェアがあったらiOS/Androidで挙動が違うっていうのが大変なインシデントなんでしょうね。それだけでめちゃくちゃ怒られる。お金の出勤方法が違うとかね。

QAの人がテストしてないのー?とかも思うでしょうが、人間がテストをしてもそれでも見過ごすこともある。なぜならリリースをたくさんしていかないといけないからわけわかんなくなって見過ごすこともある。

さらにQAの人が手でテストするところまで開発者たちが気がつけないってのも問題で、早期に気がつかないともうすでに違うバグ修正や新機能を追加してたりしてそこからプラットフォームごとの違いの修正というタスクをやっていく必要がある。だるすぎる。それを開発者の時点で防ぐべきってことでしょうね。

Reflexはどのようにして課題を解決するのか

テストコードを共通化してクロスプラットフォームの互換性を守る

どのようにクロスプラットフォームをやっていくようにしたのかというと、テストコードのみを共通化し、共通化した仕様通りか検証する。共通化した仕様としてテストされる対象はCoreとする。

iOS/Android用のそれぞれのコードは共通化しない。

Coreのテストはヘッドレスで実行される

テストコードはTypeScriptで実装され、Coreの部分はTypeScriptから呼び出される。実行環境はmacOS(原則Linux)上で動作し、iOSシミュレータやAndroidエミュレータはそのテスト時には起動しない。

それによってテスト時間を短縮できる。

これはPackage.swiftでiOS依存部分をなくし、実行バイナリを出力するようにしてるようです。macOSやLinuxそれぞれでバイナリが出力できる。アプリのロジック部分を実行バイナリから呼び出せるような構成にしてることで実現できますよと。

CoreはCLIから呼び出せる

実行バイナリの入出力はCLIでできるので、それでテストコードからの入力と結果の出力にもできるし、AIが操作する対象にもできる。だからAIは共通のロジックが正しく動作するように検証するのにViewを介さずにすぐにフィードバックができるということです。

実装はどのようになっているのか

公開されている部分からの予想です。間違いがあるかもしれません。

階層

サンプルでは次のようにしてCoreを初期化してこれをAppで利用しているようです。

// ── The app: InventoryManagementMobile/InventoryManagementApp.swift ──────────
let core = InventoryManagementCore(
    storage: KeychainStorage()
)

// ── The CLI: InventoryManagementCLI/main.swift — same core, no platform/UI ─
let core = InventoryManagementCore(
    storage: InMemoryStorage()
)

つまり、`storage`をprotocolにし、アプリ用はKeychanStoreを使い、CLIやテスト用にはインメモリを使えるようにしている。

アプリ側

// アプリ側: InventoryManagementMobile/
struct KeychainStorage: Storage { }

Core側

// InventoryManagementSwiftCore/
public protocol Storage {

}

public struct InMemoryStorage: Storage {

}

細かい実装は抜きにして、

アプリ側(InventoryManagementMobile) はCoreのInventoryManagementSwiftCoreをimportしている側で、importされているCoreのほうでprotocolがありますよと。

App(実装) --[依存]--> InventoryManagementSwiftCore(protocol Storage)

となっていて依存関係逆転の原則がなりたってる。

Core側のPackageを想像で書くと次の通り。Packageが公開しているproducsは2つ。

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "InventoryPackage",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        // iOSのホストアプリ側で呼び出して利用する
        .library(
            name: "InventoryManagementSwiftCore",
            targets: ["InventoryManagementSwiftCore"]
        ),
        // CLIで操作するmacOS(Linux)のバイナリとして出力
        .executable(
            name: "InventoryManagementCLI",
            targets: ["InventoryManagementCLI"]
        )
    ],
    targets: [
        // InventoryManagementSwiftCoreは、ViewModel、
        // サービス、NavigationなどUI以外のロジックを担うモジュール
        .target(name: "InventoryManagementSwiftCore"),
        // IntrospectionKitは、ViewModelとCLIの間の契約を定義する別モジュール
        .target(
            name: "IntrospectionKit",
            dependencies: ["InventoryManagementSwiftCore"]
        ),
        // InventoryManagementCLIは、Coreを画面なしで動かし、
        // 標準入出力のJSONを通じて操作する実行ターゲット
        .executableTarget(
            name: "InventoryManagementCLI",
            dependencies: ["IntrospectionKit"]
        ),
    ]
)

間違いかもしれないけど解説

.executable にするのはCLIから操作するmacOS(Linux)のバイナリにするため。ただLinuxからビルドする時はplatformsにLinuxとは書かなくてもSwiftのみに依存して大丈夫なTargetsならビルドエラーにならずに実行バイナリがLinuxように出力されるはず。

platformsにmacOS(.v14)と書くのは、おそらくmacOS SDKのバージョンで使えるかをチェックしている。つまり、.targetで指定されたターゲットにたとえば@Observationを使っているならmacOS(v14)のような指定が必要。なぜなら、macOS SDKは Dynamic Linkされたターゲットを利用するため。

Linuxでバイナリ実行するのをビルドする際にはmacOS SDKなんてないので、Swift自体のObservationを使う。もちろんSwiftのみでビルドできない時はできない。なので、CLIのためにビルドして実行バイナリを出すためにもiOSに依存したFrameworkをimportして使うことはできない。

Linuxを使うメリットはどこにでも安価に用意されてるのでそれができると大きい。

App側からCoreの利用:

ここまでの情報から、Swiftからの利用は次のようにしてるんじゃないかと想像

import InventoryCore
import SwiftUI

@main
struct ReflexInventoryApp: App {
    @State private var core: InventoryManagementCore


    init() {
      _core = State(
          initialValue: InventoryManagementCore(
            storage: KeychainStorage()
          )
      )
    }

    var body: some Scene {
        WindowGroup {
            // ここはAppRootViewか、
            // もしくはInventoryManagementViewかはよくわからない
            AppRootView(core: core)
        }
    }
}

`Storage`の例でも使われたInventoryManagementCoreについて以降は考えます。

Coreを掘り下げる

Coreのモジュール内にAppRootがあり、プロトコルとしてServiceが存在するものと書かれてる。一旦 InventoryManagementCoreで考え、Storageは先ほど同様protocol、ただし、Serviceはprotocolではなくclassとした例にしてみます。

// InventoryManagementSwiftCore
public final class InventoryManagementCore {
    public let appRoot: AppRoot

    public init(storage: any Storage) {
        appRoot = .init(
            service: .init(
                storage: storage
            )
        )
    }
}

public final class InventoryService {
    pubic init(storage: any Storage)

    public func saveQuantity(
      productID: String,
      quantity: Int
    ) async throws -> Product {
      // ここで実際の保存処理を書いたりする
      try await storage.saveQuantity(
        productID: productID
        quantity: quantity
      )
    }
}

一旦例にしてみたんですが、理解し兼ねてるのがServiceってprotocolである必要性があるんだろうかってことです。Storageがすでに抽象化されてDIされているのに、それを使うServiceを抽象化してしまう必要があるのかがわからないなと。アプリ側はViewModelを呼び出すのでServiceを公開する必要もない気がします。もし必要性がわかったら https://x.com/yimajo へメンションなどしてくれると助かります。

ViewModelの実装

ViewModelはThe Elm Architectureを参考にしたとありますが、そのSwiftライブラリとして作られたTCAも参考にしているように思えます。

つまり、Action, Reducer, Stateを使う

ここまでのまとめ

ShopifyのReflexはいいアイデアだと思うんですが、それをそのままやる必要は全然なく、アイデアとして知ってると良いかなと思いました。共通の処理をActionによるStateの変化をみることで、処理の共通化ができると割り切ってる。もちろん他のE2EテストやUIテストもあるようですが、ある程度割り切ってロジックをテストすることでAIによるコード生成とテストコード生成が爆速な恩恵を最大限にし、デメリットを最小限にしようとしているのがおもしろいです。

動画もできました

SwiftのNewsを毎日あれこれする動画もできました。高評価&チャンネル登録&なにかしらをしてください。