新しい feature が古い system に手を伸ばすとき

Vibe-coded な React Native app で繰り返し起きる failure mode のひとつは、新しい feature が既存 service を直接 import し、自分の ownership ではない state に触れて、無関係な flow を壊してしまうことです。auth がよく巻き込まれるのは、誰にとっても shared dependency になりがちだからです。

Cursor と Claude Code で短期間に組み上げた app では、各 feature はその場の local context で生成されます。code は compile する。happy path も通るかもしれない。でも service boundary がないと、独立して見える file 同士が一緒に壊れます。

これが scale した Vibe coding の根本問題です。AI coding assistant は context-blind な architect です。生成するのは、あなたが頼んだもの。codebase が必要としているものではありません。明示的な service boundary がない限り、新しい feature は常に breaking change の候補になります。

Interface Rule

ルールは単純です。app 内のすべての service は interface を公開する。 interface は自分の codebase に置く。実装は Supabase、Firebase、RevenueCat、mock など何であっても、その interface の後ろに隠す。app の残りの部分は、それが何者かを知らなくていい。

// src/services/auth/auth.interface.ts
// Your app owns this. Not Supabase. Not Firebase. You.

export interface User {
  id: string
  email: string
  displayName: string | null
  avatarUrl: string | null
}

export interface Session {
  accessToken: string
  refreshToken: string
  expiresAt: number
  user: User
}

export interface AuthService {
  signIn(credentials: EmailCredentials): Promise<Session>
  signOut(): Promise<void>
  getSession(): Promise<Session | null>
  onAuthStateChange(
    callback: (session: Session | null) => void
  ): () => void
}

ここが seam です。app 側のすべては AuthService とだけ会話する。SDK 側はそれを実装するだけ。Supabase を Firebase に差し替えたいなら、新しい implementation class を 1 つ書けばいい。login screen は変わらない。route guard も変わらない。hook も変わらない。Claude Code は swap が起きたことを知る必要すらない。

Composition Root

依存グラフを構築する file は 1 つだけにする。それ以外では consume するだけにする。

// src/services/create-services.ts
// ONE file. Cursor never touches this. You own it.

import { config } from '@/constants/config'
import { SupabaseAuthService } from './auth/supabase'
import { RevenueCatService } from './payments/revenuecat'

export type Services = ReturnType<typeof createServices>

export function createServices() {
  const storage = createStorageServices(config.storageMode)

  return {
    storage,
    auth: new SupabaseAuthService(storage.secureStorage),
    payments: new RevenueCatService(),
    analytics: new SegmentAnalyticsService(),
    crashReporting: new SentryService(),
    network: createNetworkService(),
  }
}

Supabase を Firebase に替えたい? 1 行変えるだけ。 Segment を PostHog に替えたい? やはり 1 行。 これが、AI assistance と一緒に Vibe coding しながらも scale できる React Native architecture の姿です。

なぜ AI-coded app ではこれがもっと重要なのか

人間がコードを書くときは、architecture を頭の中に持っています。supabase を screen component に直接 import してはいけないことも分かっている。でも AI coding assistant が持っているのは architecture ではなく context window です。そして context window は忘れます。

// src/context/auth-provider.tsx
// This provider doesn't know what auth SDK you use.
// Claude Code generates against the interface, never the implementation.

export function AuthProvider({ children }: PropsWithChildren) {
  const { auth } = useServices()
  const [session, setSession] = useState<Session | null>(null)

  useEffect(() => {
    auth.getSession().then(setSession)
    return auth.onAuthStateChange(setSession)
  }, [auth])

  const signIn = useCallback(
    async (creds: AuthCredentials) => {
      const s = await auth.signIn(creds)
      setSession(s)
    },
    [auth]
  )

  return (
    <AuthContext.Provider
      value={{ session, user: session?.user ?? null, signIn }}>
      {children}
    </AuthContext.Provider>
  )
}

この file は 3 回の backend migration を経ても変わっていません。supabase.auth.signInWithPassword がどういう API かを知らない。知っているのは auth.signIn() だけです。それが本質です。

Autotomy Expo Starter Pack には、この architecture が最初から組み込まれています。service interface、composition root、barrel export まで全部配線済みなので、Cursor と Claude Code が初日から境界を尊重したコードを生成できます。