クリーンアーキテクチャって難しいですね。DDDとの違いもまだ良くわかってませんが、
その構成や思想に触れ、ええやん!と思ったので何とかフロントでも採用できないかなぁと思い、
色々調べてまとめましたので、晒してみようと思います。
参考サイト
▼ 実装クリーンアーキテクチャ
https://qiita.com/nrslib/items/a5f902c4defc83bd46b8
▼ フロントエンドでClean Architectureを適用してみる(+サンプルコード)
https://qiita.com/ttiger55/items/50d88e9dbf3039d7ab66
▼ Facebook製の新しいステート管理ライブラリ「Recoil」を最速で理解する
https://blog.uhy.ooo/entry/2020-05-16/recoil-first-impression/
ファイル構成
SWR を使ってデータをfetchする場合は、こんなまどろっこしいことしなくても、
APIサーバーにClean Architectureを採用すれば問題ないと思いますが、
フロントが直接AWSやFirebaseなどからデータを読み書きするサーバーレスのような構成の場合は、
各ドメイン層は独立したinterfaceを持ち、疎結合になっていると便利かなと思いました。
また、Presentation層はReact Custom HookでUIに反映させてます。
呼び出すときに UseCase を引数に渡すことで、関係のない UseCase を実行しないようにしてます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| domain/
├── driver
│ └── userDriver.ts
├── entity
│ └── user.ts
├── interface
│ ├── driver
│ │ └── UserDriver.ts
│ ├── repository
│ │ └── UserRepository.ts
│ └── usecase
│ └── UserUseCase.ts
├── repository
│ └── userRepository.ts
├── usecase
│ ├── index.ts
│ └── userUseCase.ts
hooks/
└── useUser.tsx
|
Entity
ドメインモデルで不変なオブジェクト。
user.ts1 2 3 4 5 6 7 8 9 10 11
| // domain/entity/user.ts
export class User {
readonly uid: string;
readonly email: string
constructor(uid: string, email: string) {
this.uid = uid;
this.email = email
}
}
|
UseCase
ドメインモデルを使ってビジネス手順のみを記載。
userUseCase.ts1 2 3 4 5 6 7
| // domain/interface/usecase/userUseCase.ts
import { User } from "../../entity/user";
export interface UserUseCase {
find(uid: string): Promise<User | null>;
update(uid: string, email: string): Promise<void>;
}
|
userUseCase.ts1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| // domain/usecase/userUseCase.ts
import { User } from "../entity/user";
import { UserRepository } from "../interface/repository/UserRepository";
import { UserUseCase } from "../interface/usecase/UserUseCase";
export class UserUseCaseImpl implements UserUseCase {
readonly userRepository: UserRepository;
constructor(repository: UserRepository) {
this.userRepository = repository;
}
async find(uid: string): Promise<User | null> {
return this.userRepository.find(uid);
}
async update(uid: string, email: string): Promise<void> {
await this.userRepository.update(uid, email);
}
}
|
Repository
外部と内部をつなぐRepository層でアプリケーション固有のドメインモデルに変換する。
userRepository.ts1 2 3 4 5 6 7
| // domain/interface/repository/userRepository.ts
import {User} from "../../entity/user";
export interface UserRepository {
find(uid: string): Promise<User | null>;
update(uid: string, email: string): Promise<void>;
}
|
userRepository.ts1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| // domain/repository/userRepository.ts
import { User } from "../entity/user";
import { UserRepository } from "../interface/repository/UserRepository";
import { UserDriver } from "../interface/driver/UserDriver";
export class UserRepositoryImpl implements UserRepository {
private readonly userDriver: UserDriver;
constructor(driver: UserDriver) {
this.userDriver = driver;
}
async find(uid: string): Promise<User | null> {
const res = await this.userDriver.find(uid);
return res ? new User(res.uid, res.email) : null;
}
async update(uid: string, email: string): Promise<void> {
await this.userDriver.update(uid, email);
}
}
|
Driver
外部データを取得し、内容は変えずにレスポンスそのままを返する。
index.js1 2 3 4 5 6 7 8 9 10
| // domain/interface/driver/UserDriver.ts
export interface UserDriver {
find(uid: string): Promise<UserModel | null>;
update(uid: string, email: string): Promise<void>;
}
export type UserModel = {
uid: string;
email: string;
};
|
userDriver.ts1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| // domain/driver/userDriver.ts
import { UserDriver, UserModel } from "../interface/driver/UserDriver";
export class UserDriverImpl implements UserDriver {
private readonly db;
constructor(database: any = null) {
this.db = database;
}
async find(uid: string): Promise<UserModel | null> {
const doc = await this.db.collection('user').doc(uid).get();
if (!doc.exists) {
return null;
}
return doc.data();
}
async update(uid: string, email: string): Promise<void> {
await this.db.collection('user').doc(uid).update({ email });
}
}
|
Presenter (React Custom Hook + Recoil State)
Global な state を使う場合は、Recoil を React Custom Hook 内にカプセル化することによって、
Hookを経由しないと状態を更新できなくし、state の安全性を担保しています。
index.ts1 2 3 4 5 6 7 8
| // domain/usecase/index.ts
import { UserRepositoryImpl } from "../repository/userRepository";
import { UserDriverImpl } from "../driver/userDriver";
import { UserUseCaseImpl } from "./userUseCase";
const userRepository = new UserRepositoryImpl(new UserDriverImpl());
export const userUseCase = new UserUseCaseImpl(userRepository);
|
useUser.tsx1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| // hooks/useUser.tsx
import {
atom,
useRecoilValue,
} from "recoil";
import { User } from "../domain/entity/user";
import { UserUseCase } from "../domain/interface/usecase/UserUseCase";
const UserAtom = atom<User | null>({
key: "USER_ATOM",
default: null,
});
export function useUser(useCase: UserUseCase) {
const user = useRecoilValue(UserAtom);
const findUser = async (uid: string): Promise<User | null> => {
return useCase.find(uid);
};
const updateUser = async (uid: string, email: string): Promise<void> => {
await useCase.update(uid, email);
}
return { user, findUser, updateUser };
}
|