クリーンアーキテクチャって難しいですね。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.ts
1
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.ts
1
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.ts
1
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.ts
1
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.ts
1
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.js
1
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.ts
1
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.ts
1
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.tsx
1
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 };
}