依存性の反転と依存性の注入は、ソフトウェア開発における基本的な概念で、アプリケーションのモジュール性、保守性、テスト性を向上させるのに役立ちます。
バックエンドのプロジェクトでよく使われますが、これらの原則はフロントエンドのプロジェクトでも非常に重要です。
この記事では、React.js、Next.js、TypeScriptを例に、依存関係の反転と注入とは何か、フロントエンドのプロジェクトで効果的に適用する方法について説明します。
依存関係の逆転を理解する
依存性の反転は、アプリケーション内の制御フローを反転させることを指すソフトウェア開発の重要な原理です。下位レベルのモジュールが上位レベルのモジュールに依存する構造の代わりに、依存性の反転は下位レベルのモジュールが独立しており、依存性が外部から提供されるべきであると提唱します。
フロントエンドの文脈では、これはコンポーネントが外部サービスに直接依存するのではなく、私たちのニーズに応じて定義されたインターフェース(またはタイプ)に依存すべきであることを意味します。これにより、アプリケーションの柔軟性が向上し、依存性を実際のものからテスト時に模擬されたものに簡単に置き換えることができるため、テストが容易になります。
下記の図は、React.js
やNext.js
を使用したフロントエンド・アプリケーションで依存関係を逆転させる原則と、この記事で説明したポイントを示しています。
依存関係を逆転させる利点
- モジュール性の向上: モジュールはより独立し、異なるコンテキストで再利用可能になります
- テストのしやすさ: ビジネスロジックは外部依存性に依存しなくなるため、これらの依存性を迅速かつ容易にシミュレートできます
- メンテナンスのしやすさ: 依存関係の変更は一箇所に限定されるため、ドミノ効果のリスクが減少します
- 結合の低減: 依存関係はコードに直接組み込まれておらず、アプリケーションの異なる部分間の結合が減少します
React.js、Next.js、TypeScriptで依存関係の逆転を適用
フロントエンドの依存関係の反転と逆転の理論的な側面を見てきたところで、React.js、Next.js、TypeScriptを使ってフロントエンドのプロジェクトに適用する方法を見ていきます。
TypeScriptには強力な型付け機能があり、依存関係のための明確で正確なインターフェイスを定義するのに使うことができます。インターフェイスを使うことも、型を使うこともできます。依存関係にインタフェースを定義することで、テスト中に実際の依存関係をシミュレートされた依存関係に置き換えることが容易になります。
サンプルプロジェクトでは、ReactとNext.js-v14をベースにしたプロジェクトで接続できる機能を開発します。
依存関係の逆転がない例
依存関係の逆転がなぜフロントエンドで重要なのかを理解するために、ほとんどのフロントエンド・プロジェクトで見られるコードの例を以下に記載します。
'use client'
import { FC, useState } from 'react'
const LoginPage: FC = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleLogin = () => {
fetch('https://api.example.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
.then((response) => {
if (!response.ok) {
throw new Error('ログイン 失敗')
}
const user = response.json()
console.log('ログイン 成功! ユーザー:', user)
})
.catch((error) => {
console.error('ログイン エラー:', error)
throw error
})
}
return (
<>
<h2>ログイン ページ</h2>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button>ログイン</button>
</>
)
}
export default LoginPage
この例では、ユーザーが接続できるようにするAPIへのHTTPリクエストは、Reactコンポーネントに直接配置されています。
このやり方の問題点は、このコンポーネントを簡単にテストできないことです。
HTTPリクエストをシミュレートして、さまざまなシナリオでのコンポーネントの動作をテストすることができないのです(例えば、リクエストが失敗した場合など)。
同様に、Reactコンポーネント、つまりユーザー・インターフェースは、APIリクエストに直接リンクされています。接続用のAPIを変更したい場合は、Reactコンポーネントを修正しなければなりません。
このAPIに依存するコンポーネントが複数ある場合、これは大いに問題になります。
もう一つの問題は、接続を必要とする新しい機能を追加したい場合、各コンポーネントの接続コードを書き直さなければならないことです。接続を必要とするコンポーネントが複数ある場合、こちらもすぐに問題になります。
最後に、ユーザー・インターフェース(Reactコンポーネント)にはビジネス・ロジックが含まれています。実際、Reactコンポーネントはユーザー・インターフェースだけに焦点を当てるべきで、ビジネス・ロジックを含むべきではありません。これでは、コンポーネントの保守やテストが難しくなります。
依存関係の逆転の例
こうした問題をすべて回避するには、依存関係の反転を実装すればよいのです。
手始めに、認証サービス用のインターフェイスを作成します。
export type AuthService = {
login(email: string, password: string): Promise<unknown>
logout(): Promise<void>
}
このインターフェイスを実装することで、APIを呼び出すことができます。
import { AuthService } from '@/modules/auth/authService'
export const AuthApi: AuthService = {
login: async (email, password) => {
try {
const response = await fetch('https://api.example.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error('Login failed')
}
return await response.json()
} catch (error) {
console.error('Login error:', error)
throw error
}
},
logout: () => {
return fetch('https://api.example.com/logout', {
method: 'POST',
})
.then((response) => {
if (!response.ok) {
throw new Error('Logout failed')
}
})
.catch((error) => {
console.error('Logout error:', error)
throw error
})
},
}
そのため、APIコールを行うには、コード内でインターフェースを使用するだけでよさそうです。これにより、コンポーネントが外部のライブラリやサービスに依存するのを防ぐことができます。
以下は、上で定義したインターフェイスを使用して依存関係を逆転させたReactコンポーネントの例です。
'use client'
import { FC, useState } from 'react'
import { AuthApi } from "@/modules/auth/authApi"
const authService = AuthApi
const LoginPage: FC = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleLogin = async () => {
try {
const user = await authService.login(email, password)
console.log('ログイン 成功! ユーザー:', user)
} catch (error) {
console.error('ログイン エラー:', error)
}
}
return (
<>
<h2>ログイン ページ</h2>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={handleLogin}>ログイン</button>
</>
)
}
export default LoginPage
コードを簡単にするため、すべてのインターフェイスをグループ化した型を作ることができます。
import { AuthService } from '@/modules/auth/authService'
export type Dependencies = {
authService: AuthService
}
次に、コードのコンテキストに応じて(クライアント側にいるか、サーバー側にいるか、テスト中であるかなど)、すべてのインターフェースの実装をまとめたオブジェクトを作成することができます。
import { AuthService } from '@/modules/auth/authService'
import { AuthApi } from '@/modules/auth/authApi'
export type Dependencies = {
authService: AuthService
}
export const dependencies: Dependencies = {
authService: AuthApi,
}
必要なのは、このオブジェクトを使用して、必要なコンテキストで依存関係を呼び出すことだけです。
フロントエンドのReactプロジェクトでは、いくつかの解決策が考えられます。
たとえばReactコンポーネント内で、依存関係の実装を直接呼び出すことができます
'use client'
import { FC, useState } from 'react'
import { dependencies } from '@/dependencies'
const LoginPage: FC = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleLogin = async () => {
try {
const user = await dependencies.authService.login(email, password)
console.log('ログイン 成功! ユーザー:', user)
} catch (error) {
console.error('ログイン エラー:', error)
}
}
return (
<>
<h2>ログイン ページ</h2>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={handleLogin}>ログイン</button>
</>
)
}
export default LoginPage
依存関係にアクセスできるように、コンテキストとプロバイダーを作成するための環境を設定します
- コンテキストの作成
import { createContext, useContext } from 'react'
import { Dependencies } from '@/dependencies'
export const DependenciesContext = createContext<Dependencies | null>(null)
export const useDependencies = () => {
const context = useContext(DependenciesContext)
if (!context) {
throw new Error(
'useDependencies は DependencyContextProvider 内で使用する必要があります',
)
}
return context
}
- アプリケーションにコンテキストを実装
'use client'
import './globals.css'
import { DependenciesContext } from '@/dependenciesContext'
import { dependencies } from '@/dependencies'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ja">
<body>
<DependenciesContext.Provider value={dependencies}>
{children}
</DependenciesContext.Provider>
</body>
</html>
)
}
- コンポーネント内のコンテキストを使用して依存関係にアクセスする
'use client'
import { FC, useState } from 'react'
import { useDependencies } from '@/dependenciesContext'
const LoginPage: FC = () => {
const { authService } = useDependencies()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleLogin = async () => {
try {
const user = await authService.login(email, password)
console.log('ログイン 成功! ユーザー:', user)
} catch (error) {
console.error('ログイン エラー:', error)
}
}
return (
<>
<h2>ログイン ページ</h2>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button>ログイン</button>
</>
)
}
export default LoginPage
まとめ
依存性の反転と依存性の注入は、開発プロジェクトに多くの利点をもたらす実践です。
これらの原則をReact.js、Next.js、TypeScriptを使ってフロントエンドプロジェクトに適用することで、モジュール式で、簡単にテスト可能で、スケーラブルなアプリケーションを作成することができます。
これらのアプローチは、高品質で保守性の高いアプリケーション開発に不可欠な要素です。技術の進化は絶えず進んでいるため、常に新しい学びを求め、技術の深化を図ることが重要です。
本日の話題が皆様の開発活動に役立つことを願っています。最後までお読みいただきありがとうございました。