フロントエンド(SPA)でオニオンアーキテクチャっぽくデータフェッチとドメインを分離する例
#React #設計 #Server_State #宣言的UIの設計レシピ #データフェッチ
SPAでオニオンアーキテクチャっぽくRepositoryやPresentation Modelを実装したくなったときの例
以下のほぼ書きなぐりのスクラップが元koushisa.icon
https://zenn.dev/link/comments/4e07ef68df291f
https://zenn.dev/koushisa/scraps/2ebedc2d009e1c
前提
基盤はAtomic State Management
コードはRecoil
TanStack Queryでも似たようなことはできる
2023/10/22時点だとjotai-tanstack-queryの方がオススメ
このページを読みながら参考にしておきたいもの
https://recoiljs.org/docs/recoil-relay/graphql-mutations/#write-through-cache
GraphQLだが公式に似たような実装サンプルがおいてある
複雑GUIにおけるRepositoryパターンの勘所
SPAでのレイヤードアーキテクチャの考察と不確実性へのマインドセット
適用対象の想定
データフェッチしてきた予約メニューを扱う
この予約メニューは集約(Aggregate)
フロントエンドには見えないが、裏側では複数のエンティティによって成り立っている
フロントエンドから見た時
予約メニューを取得するエンドポイントに1つ対し、ユースケースが複数存在する
単純なリスト表示だったり
セレクトボックスの項目として表示したりだったり
問題
レスポンスの構造とビューの求める構造が一致しない
Global State, Form Stateなどのステートとビジネスロジックに絡まりがある
やりたいこと
コンポーネントとロジックを切り離す
フェッチしてきたデータは当然キャッシュする
→I/Oとドメインと実装詳細(private)とビューを分解する
code:typescript.ts
// 永続化層
// APIレスポンス<->エンティティの状態をマッピングする
type MenusEntity = {
value: Menu[]
hasMenu: boolean
hasMultiple: boolean
hasContents: boolean
head: Menu | undefined
defaultMenu: Menu | undefined
}
// APIレスポンスを基にエンティティを作成する純粋関数。これはテストする。
const createMenusEntity = (menus: Menu[]): MenusEntity => {
/*~*/
}
// データフェッチを行ってエンティティを返す
// Repositoryパターンと同義
export const menusEntity = selector<MenusEntity>({
key: 'menusEntity',
get:
async ({ get }) =>
const menus = await fetchMenus()
return createMenusEntity(menus)
},
})
---
// ドメイン層
// サービス固有のドメイン層
// エンティティの状態<->ドメインの状態をマッピングする
// ユビキタス言語が使えたら◎だが、言語補完的な意味合いも強い
// エンティティの状態を特定の課題解決のために抽象化したもので、これがアプリの状態制御の核となる
// 例ではタグで区別できるようにした
type MenusDomainModel =
| {
type: 'blank'
}
| {
type: 'single'
menu: Menu
}
| {
type: 'multiple'
menus: Menu[]
}
| {
type: 'skippable'
defautMenu: Menu
}
// エンティティを受け取り、ドメインモデルを返す純粋関数。これはテストする。
const createMenusDomainModel = (menusEntity: MenusEntity): MenusDomainModel => {
const {
value: menus,
head,
hasMenu,
hasMultiple,
hasContents,
defaultMenu,
} = menusEntity
if (!hasMenu && !hasContents) {
return {
type: 'blank',
}
}
if (defaultMenu !== undefined) {
return {
type: 'skippable',
defaultMenu,
}
}
if (!hasMultiple) {
return {
type: 'single',
menu: head,
}
}
return {
type: 'multiple',
menus,
}
}
// 永続化層のエンティティがSelectorで抽象化されていることでキャッシュが効く
// エンティティのユースケースごとに複数のドメイン層を派生させることも簡単
// 規模によってはmenusEntity, createMenusEntityと統合してしまっても良い
export const menusDomainModel = selector<MenusDomainModel>({
key: 'menusDomainModel',
get({ get }) {
return createMenusDomainModel(get(menusEntity))
},
})
コンポーネントは以下のように利用する
code:ts
// ドメインモデルを受け取り、特定のユースケースを達成するビジネスロジック
// ユースケースに依存するのでViewの関心も含んでよい(むしろViewと同じファイルに配置するのがオススメ)
// I/Oが分離されてるのでビジネスロジックを純粋関数化しやすい、テスタビリティが高い
const someBusinessLogic = (menus: MenusDomainModel) => {
switch(menus.type) {
/*~*/
}
return /*~*/
}
// Menusコンポーネント専用のPresentationModel
const presentationModel = selector({
key: "menusPresentationModel",
get: ({get}) => {
// ドメインモデル
const menus = get(menusDomainModel)
return someBusinessLogic(menus)
}
})
const Menus = () => {
// 対象のデータ取得とビジネスロジックをコンポーネントから分離できた
const menus = useRecoilValue(presentationModel)
return <>/*~*/</>
}
解説
副作用とドメインを切り離す
menusEntity: RepositoryパターンでEntityを返す
menusDomainModel: Entityを受け取ってドメインモデルを構築する
someBusinessLogic: ドメインモデルを受け取ってビジネスロジックのユースケースを実装する
テスト対象
ドメイン知識となる以下の純粋関数
createMenusEntity
createMenusDomainModel
someBusinessLogic(必要に応じて)
あくまでもユニットテストの話
結合テスト増やしたほうがいいんじゃないとかは模索中
2023/04/25 フロントエンドのテスト設計とアーキテクチャ
考察
Recoil層はI/Oを切り離すためのアプリケーションサービスだと捉えると素直かも
コンポーネントのデータアクセスをカスタムフックでラップするメリット
Async Selector: APIは「叩く」のではなく「叩かれる」もの
技術的な知識が含まれるので、テストしてもコスパ悪そう
アプリの制御構造と依存関係が分離される
RESTでもコロケーションと同じ設計思想を共有できる
オーバーフェッチを気にすることなく派生データを作ることができる
Suspenseにより、データフェッチが終わっていないことを気にする必要もない
EntityやドメインモデルはRecoilのSelectorとして取り回せる
Render As You Fetchパターン
ユースケースの多様性に対しコンポーネントと1:1のPresentation Modelを作ることができる
ドメインモデル(menusDomainModel)が独立している
ReadModel、WriteModelでも同じドメイン知識を使い回すことができる
CQSっぽくも作れる
感想
実装例書いたけど、このような構造が必要となる前に考えるべきことがある
フロントエンドにロジック持ち込みすぎていないか?
本来その複雑性はデザインで解決すべきではないか?
SPAでのレイヤードアーキテクチャの考察と不確実性へのマインドセット
基本的には、より上位レイヤで問題解決しておくほうが合理的であると思う
UI/UXを見直す
コンポーネントツリーを見直す
Web APIのインタフェースを見直す
RESTに限界が来ているか、Resource-based APIとUsecase-based APIの勘所をミスってることの現れかもしれない
GraphQLとRESTの比較
なのでコレは設計パターンとして知っておくといいかもぐらいな感じ
koushisa.iconの結論
宣言的UIのデータアーキテクチャはコンポーネントを中心に考える
GUIでスケーラブルなアプリを書く秘訣は、良いコンポーネントを適切なタイミングで抽出することで、それ以上のことはない
atomWithAspida
jotai-tanstack-query