リポジトリパターンの導入によるReactプロジェクトのリファクタリング
はじめに
東京ファクトリーではSaaSプロダクトProceed Cloud
のMVP開発開始から2年の間に蓄積してきてしまった「技術的負債」の返済に向けたリファクタリングに取り組んでいます。
本記事では、Reactにリポジトリパターンを導入してフロントエンドのリファクタリングを実施した例をご紹介します。
リポジトリパターンそのものの詳細はここでは述べませんが、参考にさせていただいた記事を記載しておきます。
リファクタリング前の課題
もともと、カスタムフックを積極的に活用しており、コードの見通しは改善しつつありました。
リファクタリング前のコード
.
├── api
│ └── userApi.ts
├── components
│ └── userComponent.tsx
└── hooks
└── useUser.ts
↓カスタムフックを使ってViewからロジックを隔離しています。
import { useUser } from '~/hooks/useUser';
const User: React.FC<{ userId: number, idToken: string }> = ({ userId, idToken }) => {
const { data: user } = useUser(userId, idToken);
if (!user) return null;
return (
<>
<div>{user.name}</div>
<div>{user.email}</div>
</>
)
}
const UserUpdateButton: React.FC<{
userId: number,
idToken: string,
name: string,
email: string
}> = ({ userId, idToken, name, email }) => {
const { data: user, overwrite } = useUser(userId, idToken);
const updateUser = (body: { email: string, name: string }) => overwrite(body);
if (!user) return null;
return (
<button onClick={()=>{updateUser({name, email})}>
update
</button>
)
}
const UserDeleteButton: React.FC<{ userId: number, idToken: string }> = ({ userId, idToken }) => {
const { data: user, deactivateUser } = useUser(userId, idToken);
if (!user) return null;
return (
<button onClick={()=>{deactivateUser()} >
delete
</button>
)
}
↓カスタムフックの中でAPIを呼びます。呼び出すAPIはエンドポイントそのままでなく、一応は抽象化されています。
import useSWR from 'swr';
import { userApi } from '~/api/userApi';
const SWR_KEY_PREFIX = '/user';
export const useUser = (userId: string, idToken: string) => {
const { data, error, mutate } = useSWR(`${SWR_KEY_PREFIX}/${userId}`, () =>
userApi.findById(userId, idToken)
);
const updateUser = useCallback(
async (body: { email: string, name: string }) => {
await userApi.update(userId, body);
mutate();
},
[userApi, userId, mutate]
);
const deactivateUser = useCallback(async () => {
await userApi.delete(userId);
mutate();
}, [userApi, userId, mutate]);
return {
data,
error,
overwrite: updateUser,
deactivate: deactivateUser
};
};
↓APIのエンドポイント詳細が記述されています。
export const userApi = {
findById: async (
userId: string,
idToken: string
): Promise<User> => {
return await fetch(`${process.env.api}/user/${userId}`, {
method: 'GET',
headers: {
Authorization: idToken
}
});
},
update: async (
idToken: string,
userId: number,
body: { email: string, name: string }
): Promise<{}> => {
return await fetch(
`${process.env.api}/user/${userId}/update`,
{
method: 'PUT',
headers: {
Authorization: idToken
},
body: JSON.stringify(body)
}
);
},
delete: async (idToken: string, organizationId: number): Promise<{}> => {
return await fetch(
`${process.env.api}/user/${userId}/delete`,
{
method: 'DELETE',
headers: {
Authorization: idToken
}
}
);
}
};
しかし、変更容易性やテスタビリティという観点では下記のような課題が残っていました。
-
コンポーネントからビジネスロジックが切り離されていない
カスタムフックがAPIの詳細実装に関心を持ってしまっており、Reactコンポーネントとバックエンド側の実装が密結合となってしまっている。
上記のためMockAPIへの代替がしづらく、フロントエンドを実装する前にAPIの開発が整うまで待つ必要があり、ユニットテストを実施することが難しくなっている。 -
認証トークンを各コンポーネントが持っている
認証トークン(idToken
)はAPIへの問合せに使用しているが、Reactコンポーネントは問合せ結果にのみ関心があり、認証トークンそのものには関心を持つべきではない。
認証トークンは各コンポーネントではなく、どこかで一元管理したい。
リファクタリング後のアーキテクチャ
上記の課題を解決するため、リポジトリパターンを導入しました。
リファクタリング後のコード
.
├── api
│ └── ApiEndpoints.ts
│ └── userApi.ts
│ └── mock
│ └── mockedUserApi.ts
├── components
│ └── userComponent.tsx
├── entities
│ └── User
│ └── index.ts
├── globalStates
│ └── repositories.ts
├── hooks
│ └── useUser.ts
├── pages
│ └── _app.tsx
└── repositories
└── userRepository.ts
import { useRepositories } from '~/globalStates/repositories';
import { UserUpdateBody, UserId } from '~/entities/User';
import { useUser } from '~/hooks/useUser';
const User: React.FC<{ userId: number }> = ({ userId }) => {
const { repositories } = useRepositories();
const { data: user } = useUser(userId, repositories);
if (!user) return null;
return (
<>
<div>{user.name}<div>
<div>{user.email}<div>
</>
)
}
const UserUpdateButton: React.FC<{ userId: number, name: string, email: string }> = ({ userId, name, email }) => {
const { data: user, overwrite } = useUser(userId, repositories);
const updateUser = (body: UserUpdateBody) => overwrite(body);
if (!user) return null;
return (
<button onClick={()=>{updateUser({name, email})} >
update
</button>
)
}
const UserDeleteButton: React.FC<{ userId: number }> = ({ userId }) => {
const { data: user, deactivateUser } = useUser(userId, repositories);
if (!user) return null;
return (
<button onClick={()=>{deactivateUser()} >
delete
</button>
)
}
import { atom, useRecoilState } from 'recoil';
import { userApi } from '~/api/userApi';
export enum RecoilAtomKeys {
REPOSITORIES = 'applicationRepositories',
...
}
const getRepositories = (baseUrl: string, token: string) => ({
userRepository: new UserApi(baseUrl, token),
anotherRepositiry: new AnotherApi(baseUrl, token),
...
});
const repositoriesGlobalState = atom<
Partial<ReturnType<typeof getRepositories>>
>({
key: RecoilAtomKeys.REPOSITORIES,
default: {}
});
export const useRepositories = () => {
const [repositories, setRepositories] = useRecoilState(
repositoriesGlobalState
);
const setRepositoriesOptions = useCallback(
(baseUrl: string, token: string) =>
baseUrl && token && setRepositories(getRepositories(baseUrl, token)),
[setRepositories]
);
return {
repositories: { ...repositories },
setRepositoriesOptions
};
};
import { useRepositories } from '~/globalStates/repositories';
export default App: React.FC<AppProps> = ({ Component, pageProps }) => {
const { setRepositoriesOptions } = useRepositories();
const idToken = ...
useEffect(() => {
if (!idToken) return;
setRepositoriesOptions(`${process.env.api}`, idToken);
}, [
idToken,
setRepositoriesOptions,
]);
return (
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
);
};
import { z } from 'zod';
const userId = z.number();
export type UserId = z.infer<typeof userId>;
const user = z.object({
name: z.string(),
email: z.string(),
id: userId,
});
export type User = z.infer<typeof user>;
const UserUpdateBody = user.pick({
email: true,
name: true,
});
export type UserUpdateBody = z.infer<typeof UserUpdateBody>;
import { User, UserCreateBody, UserId, UserUpdateBody } from '~/entities/User';
export interface UserRepository {
findById(userId: UserId): Promise<User>;
update(userId: UserId, body: UserUpdateBody): Promise<void>;
delete(userId: UserId): Promise<void>;
}
import { UserRepository } from '~/repository/userRepository'
import { UserId, UserUpdateBody } from '~/entities/User'
import useSWR from 'swr';
interface RepositoryDependencies {
userRepository?: UserRepository;
}
const SWR_KEY_PREFIX = '/user';
export const useUser = (
userId: UserId,
{ userRepository }: RepositoryDependencies
) => {
const { data, error, mutate } = useSWR(
() =>
userRepository
? [`${SWR_KEY_PREFIX}/${userId}`, userId, userRepository]
: null,
(_, id, repository) => repository.findById(id)
);
const updateUser = useCallback(
async (body: UserUpdateBody) => {
await userRepository?.update(userId, body);
mutate();
},
[userRepository, userId, mutate]
);
const deactivateUser = useCallback(async () => {
await userRepository?.delete(userId);
mutate();
}, [userRepository, userId, mutate]);
return {
data,
error,
overwrite: updateUser,
deactivate: deactivateUser
};
};
export const ApiEndpoints = {
User: (userId: string) => ({
get: () => `/user/${userId}`,
update: () => `/user/${userId}/update`,
delete: () => `/user/${userId}/delete`
}),
OtherEntity: ...
}
import { ApiEndpoints } from './ApiEndpoints';
import { UserRepository } from '~/repositories/userRepository'
import { UserId, User, UserUpdateBody } from '~/entities/User'
import axios, { Axios } from 'axios';
export class UserApi implements UserRepository {
apiAxios: Axios;
constructor(baseUrl: string, idToken: IdToken) {
this.apiAxios = axios.create({
headers: {
Authorization: idToken
},
baseURL: baseUrl
});
}
async findById(userId: UserId): Promise<User> {
const res = await this.apiAxios.get<User>(
ApiEndpoints.User(`${userId}`).get()
);
return res.data;
}
async update(userId: UserId, body: UserUpdateBody): Promise<void> {
await this.apiAxios.put<User>(
ApiEndpoints.User(`${userId}`).update(),
body
);
}
async delete(userId: UserId): Promise<void> {
await this.apiAxios.delete<>(
ApiEndpoints.User(`${userId}`).delete()
);
}
}
参考資料から借用した下図のレイヤードアーキテクチャで説明すると、カスタムフックの中にユースケースをもつ格好になります。
これは下記ポイントを考慮しています。
- アーキテクチャの中心にEntityを定義する。
- コンポーネントにとってAPIの詳細実装は関心事ではない。
- APIの詳細実装はコンポーネントにDIされる。
- バックエンドのエンドポイントを抽象化する。
- 簡単にMock APIを用意することができる。
順にポイントを見ていきます。
アーキテクチャの中心にEntityを定義する
ロジックの核となるオブジェクトをEntityとして定義します。
クリーンアークテクチャの教科書通り、Entityはどこにも依存させずアーキテクチャの中心に配置します。今回はZod
をつかってEntityを定義します。Zod
を使えばバリデーションも容易です。
該当箇所
Entity
import { z } from 'zod';
const userId = z.number();
export type UserId = z.infer<typeof userId>;
const user = z.object({
name: z.string(),
email: z.string(),
id: userId,
});
export type User = z.infer<typeof user>;
const UserUpdateBody = user.pick({
email: true,
name: true,
});
export type UserUpdateBody = z.infer<typeof UserUpdateBody>;
コンポーネントにとってAPIの詳細実装は関心事ではない
コンポーネント側のカスタムフックでは、Repositoryのインターフェースにのみ従うようにし、APIの詳細実装への関心から解放します。
該当箇所
Repositoryインターフェース
import { User, UserCreateBody, UserId, UserUpdateBody } from '~/entities/User';
export interface UserRepository {
findById(userId: UserId): Promise<User>;
update(userId: UserId, body: UserUpdateBody): Promise<void>;
delete(userId: UserId): Promise<void>;
}
カスタムフック
import { UserRepository } from '~/repository/userRepository'
import { UserId, UserUpdateBody } from '~/entities/User'
import useSWR from 'swr';
interface RepositoryDependencies {
userRepository?: UserRepository;
}
const SWR_KEY_PREFIX = '/user';
export const useUser = (
userId: UserId,
{ userRepository }: RepositoryDependencies
) => {
const { data, error, mutate } = useSWR(
() =>
userRepository
? [`${SWR_KEY_PREFIX}/${userId}`, userId, userRepository]
: null,
(_, id, repository) => repository.findById(id)
);
const updateUser = useCallback(
async (body: UserUpdateBody) => {
await userRepository?.update(userId, body);
mutate();
},
[userRepository, userId, mutate]
);
const deactivateUser = useCallback(async () => {
await userRepository?.delete(userId);
mutate();
}, [userRepository, userId, mutate]);
return {
data,
error,
overwrite: updateUser,
deactivate: deactivateUser
};
};
APIの詳細実装はコンポーネントにDIされる
APIの詳細実装はコンポーネントにDIされます。
このとき、Recoilを通してDIすることで、認証トークンをグローバルステートとして一元管理でき、それぞれのコンポーネントで認証トークンを意識する必要はありません。
該当箇所
Repository詳細実装
import { ApiEndpoints } from './ApiEndpoints';
import { UserRepository } from '~/repositories/userRepository'
import { UserId, User, UserUpdateBody } from '~/entities/User'
import axios, { Axios } from 'axios';
export class UserApi implements UserRepository {
apiAxios: Axios;
constructor(baseUrl: string, idToken: IdToken) {
this.apiAxios = axios.create({
headers: {
Authorization: idToken
},
baseURL: baseUrl
});
}
async findById(userId: UserId): Promise<User> {
const res = await this.apiAxios.get<User>(
ApiEndpoints.User(`${userId}`).get()
);
return res.data;
}
async update(userId: UserId, body: UserUpdateBody): Promise<void> {
await this.apiAxios.put<User>(
ApiEndpoints.User(`${userId}`).update(),
body
);
}
async delete(userId: UserId): Promise<void> {
await this.apiAxios.delete<>(
ApiEndpoints.User(`${userId}`).delete()
);
}
}
Recoilを通してRepository詳細実装をコンポーネントにDI
import { atom, useRecoilState } from 'recoil';
import { userApi } from '~/api/userApi';
export enum RecoilAtomKeys {
REPOSITORIES = 'applicationRepositories',
...
}
const getRepositories = (baseUrl: string, token: string) => ({
userRepository: new UserApi(baseUrl, token),
anotherRepositiry: new AnotherApi(baseUrl, token),
...
});
const repositoriesGlobalState = atom<
Partial<ReturnType<typeof getRepositories>>
>({
key: RecoilAtomKeys.REPOSITORIES,
default: {}
});
export const useRepositories = () => {
const [repositories, setRepositories] = useRecoilState(
repositoriesGlobalState
);
const setRepositoriesOptions = useCallback(
(baseUrl: string, token: string) =>
baseUrl && token && setRepositories(getRepositories(baseUrl, token)),
[setRepositories]
);
return {
repositories: { ...repositories },
setRepositoriesOptions
};
};
Recoilをグローバルステートに適用
import { useRepositories } from '~/globalStates/repositories';
export default App: React.FC<AppProps> = ({ Component, pageProps }) => {
const { setRepositoriesOptions } = useRepositories();
const idToken = ...
useEffect(() => {
if (!idToken) return;
setRepositoriesOptions(`${process.env.api}`, idToken);
}, [
idToken,
setRepositoriesOptions,
]);
return (
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
);
};
カスタムフックを通してコンポーネントでAPIを使用する
import { useRepositories } from '~/globalStates/repositories';
import { UserUpdateBody, UserId } from '~/entities/User';
import { useUser } from '~/hooks/useUser';
const User: React.FC<{ userId: number }> = ({ userId }) => {
const { repositories } = useRepositories();
const { data: user } = useUser(userId, repositories);
if (!user) return null;
return (
<>
<div>{user.name}<div>
<div>{user.email}<div>
</>
)
}
const UserUpdateButton: React.FC<{ userId: number, name: string, email: string }> = ({ userId, name, email }) => {
const { data: user, overwrite } = useUser(userId, repositories);
const updateUser = (body: UserUpdateBody) => overwrite(body);
if (!user) return null;
return (
<button onClick={()=>{updateUser({name, email})} >
update
</button>
)
}
const UserDeleteButton: React.FC<{ userId: number }> = ({ userId }) => {
const { data: user, deactivateUser } = useUser(userId, repositories);
if (!user) return null;
return (
<button onClick={()=>{deactivateUser()} >
delete
</button>
)
}
import { UserRepository } from '~/repository/userRepository'
import { UserId, UserUpdateBody } from '~/entities/User'
import useSWR from 'swr';
interface RepositoryDependencies {
userRepository?: UserRepository;
}
const SWR_KEY_PREFIX = '/user';
export const useUser = (
userId: UserId,
{ userRepository }: RepositoryDependencies
) => {
const { data, error, mutate } = useSWR(
() =>
userRepository
? [`${SWR_KEY_PREFIX}/${userId}`, userId, userRepository]
: null,
(_, id, repository) => repository.findById(id)
);
const updateUser = useCallback(
async (body: UserUpdateBody) => {
await userRepository?.update(userId, body);
mutate();
},
[userRepository, userId, mutate]
);
const deactivateUser = useCallback(async () => {
await userRepository?.delete(userId);
mutate();
}, [userRepository, userId, mutate]);
return {
data,
error,
overwrite: updateUser,
deactivate: deactivateUser
};
};
バックエンドのエンドポイントを抽象化する
Repositoryの詳細実装とバックエンドRouterの間にクッションとしてAPI Endpoint
を設けることでバックエンドのエンドポイントを抽象化します。
これによりバックエンド側の変更に対応しやすくなります。
また、コンポーネント側のカスタムフックと同様、Repositoryの詳細実装もRepositoryのインターフェースにのみ従うようにし、APIの詳細実装への関心から解放します。
該当箇所
API Endpoint
export const ApiEndpoints = {
User: (userId: string) => ({
get: () => `/user/${userId}`,
update: () => `/user/${userId}/update`,
delete: () => `/user/${userId}/delete`
}),
OtherEntity: ...
}
export const ApiEndpointsV2 = {
User: (userId: string) => ({
get: () => `/user/v2/${userId}`,
update: () => `/user/v2/${userId}/update`,
delete: () => `/user/v2/${userId}/delete`
}),
OtherEntity: ...
}
Repositoryインターフェース
import { User, UserCreateBody, UserId, UserUpdateBody } from '~/entities/User';
export interface UserRepository {
findById(userId: UserId): Promise<User>;
update(userId: UserId, body: UserUpdateBody): Promise<void>;
delete(userId: UserId): Promise<void>;
}
Repository詳細実装
import { ApiEndpointsV2 as ApiEndpoints } from './ApiEndpoints';
import { UserRepository } from '~/repositories/userRepository'
import { UserId, User, UserUpdateBody } from '~/entities/User'
import axios, { Axios } from 'axios';
export class UserApi implements UserRepository {
apiAxios: Axios;
constructor(baseUrl: string, idToken: IdToken) {
this.apiAxios = axios.create({
headers: {
Authorization: idToken
},
baseURL: baseUrl
});
}
async findById(userId: UserId): Promise<User> {
const res = await this.apiAxios.get<User>(
ApiEndpoints.User(`${userId}`).get()
);
return res.data;
}
async update(userId: UserId, body: UserUpdateBody): Promise<void> {
await this.apiAxios.put<User>(
ApiEndpoints.User(`${userId}`).update(),
body
);
}
async delete(userId: UserId): Promise<void> {
await this.apiAxios.delete<>(
ApiEndpoints.User(`${userId}`).delete()
);
}
}
簡単にMockAPIを用意することができる
上記ではRepositoryの詳細実装をコンポーネントにDIしていましたが、MockAPIもRepositoryインターフェースを継承させればコンポーネントにDIすることができます。
ここで、MockAPIは認証トークン(idToken
)を扱う必要がないので、Recoilを通さず直接コンポーネントにDIしています。
該当箇所
MockAPIの準備
import { User, UserCreateBody, UserId, UserUpdateBody } from '~/entities/User';
export interface UserRepository {
findById(userId: UserId): Promise<User>;
update(userId: UserId, body: UserUpdateBody): Promise<void>;
delete(userId: UserId): Promise<void>;
}
import { UserRepository } from '~/repositories/userRepository'
import { User, UserId, UserUpdateBody } from '~/entities/User';
const sleep = (msec: number) =>
new Promise((resolve) => setTimeout(resolve, msec));
class MockedUserApi implements UserRepository {
users: Record<number, User> = {};
findById = async (userId: UserId) => {
await sleep(500);
return this.users[userId] ?? '404 No user data';
};
update = async (userId: UserId, body: UserUpdateBody) => {
await sleep(500);
this.users[userId] = { userId, ...UserUpdateBody };
};
delete = async (userId: UserId) => {
await sleep(500);
delete this.users[userId]
};
}
export const userRepository = new MockedUserApi();
MockAPIをコンポーネントにDI
import { atom, useRecoilState } from 'recoil';
import { userRepository } from '~/api/mock/mockedUserApi';
export enum RecoilAtomKeys {
REPOSITORIES = 'applicationRepositories',
...
}
const getRepositories = (baseUrl: string, token: string) => ({
anotherRepositiry: new AnotherApi(baseUrl, token),
...
});
const repositoriesGlobalState = atom<
Partial<ReturnType<typeof getRepositories>>
>({
key: RecoilAtomKeys.REPOSITORIES,
default: {}
});
export const useRepositories = () => {
const [repositories, setRepositories] = useRecoilState(
repositoriesGlobalState
);
const setRepositoriesOptions = useCallback(
(baseUrl: string, token: string) =>
baseUrl && token && setRepositories(getRepositories(baseUrl, token)),
[setRepositories]
);
return {
repositories: { ...repositories, userRepository },
setRepositoriesOptions
};
};
さいごに
リポジトリパターンの導入によって変更容易性が高まりました。
一方で全体のコード量が増えてコードの見通し自体は悪化してしまったかもしれません。
これらのメリデメを整理しつつ、より良いリファクタリング方法を探っていく必要がありそうです。
参考資料
Discussion