ドメインモデル
今回の記事では、フロントエンドのドメインロジックをどこに置くべきかについて話してみたい。
税金計算や申告ステータスの判定といったビジネスルールがコンポーネントやフック、utilsに散らばっていて、ルールが一つ変わるたびに複数のファイルを探して直しているフロントエンド開発者に向けた記事である。最後まで読めば、そのルールをReactに依存しない純粋関数へ移す方法と、その分離をどこで止めるかを判断する基準が得られる。
例はすべて総合所得税の計算で説明する。筆者が関心を持っているドメインである。
フロントエンドのドメインロジックはどこに置くべきか?
答える前に、用語を一つそろえておく。この記事でいうドメインモデルは、ビジネスの概念とその上で働くルールを一緒に持つものである。APIレスポンスの型のように、データがどんな形でやり取りされるかだけを定義するデータモデルとは異なる。コンポーネントがレスポンスの構造に直接依存すると、バックエンドのスキーマが変わるたびにフロントエンド全体が揺らぐのも、この違いのためである。ドメイン、ドメインモデル、ドメインオブジェクト、ドメインオブジェクトモデルという4つの用語の違いは、ドメイン用語に別途まとめておいた。
ソフトウェア設計に深い関心を持つKhalil Stemmlerは、当初「ビジネスロジックはフロントエンドに属さない」と主張していたが、後に「バックエンドでアーキテクチャ上行っているほぼすべてのことを、フロントエンドでも実践できるし、実践すべきだ」と立場を改めた。
筆者もこの立場に同意する。もちろん、フロントエンドがビジネスロジックの 信頼できる唯一の情報源(Single Source of Truth) になってはならない。それはバックエンドの役割である。しかし、フロントエンドにも フロントエンド独自のドメインロジック が確かに存在する。
「ユーザーが入力した情報に応じて、予想還付額をリアルタイムに表示しなければならない」ケースを考えてみよう。この計算ロジックがバックエンドにしかなければ、ユーザーが所得金額を一文字直すたびにAPIを呼び出す必要がある。ネットワークの往復時間だけUIが止まり、入力の速いユーザーなら不要なリクエストが爆発的に増えてしまう。debounceを適用しても、数百ミリ秒の遅延は「リアルタイムプレビュー」という体験を損なうのに十分である。結局、即時フィードバックが必要な計算はフロントエンド自身が実行せざるを得ず、フロントエンドでしか実行できないロジックが存在することになる。
ドメインロジックがコンポーネントに混在している場合
総合所得税のプレビュー画面を例にしよう。ユーザーが所得情報を入力すると、予想税額をリアルタイムに表示する機能である。次は、ドメインロジックとUIロジックが入り交じった、よく見かけるコードだ。
function TaxPreviewPage() {
const [총수입, set총수입] = useState(0);
const [경비율, set경비율] = useState(0.641);
const [인적공제대상인원, set인적공제대상인원] = useState(1);
const 종합소득금액 = 총수입 - 총수입 * 경비율;
const 소득공제합계 = 인적공제대상인원 * 1_500_000;
const 과세표준 = Math.max(0, 종합소득금액 - 소득공제합계);
let calculatedTax = 0;
if (과세표준 <= 14_000_000) {
calculatedTax = 과세표준 * 0.06;
} else if (과세표준 <= 50_000_000) {
calculatedTax = 과세표준 * 0.15 - 1_260_000;
} else if (과세표준 <= 88_000_000) {
calculatedTax = 과세표준 * 0.24 - 5_760_000;
} else if (과세표준 <= 150_000_000) {
calculatedTax = 과세표준 * 0.35 - 15_440_000;
} else {
calculatedTax = 과세표준 * 0.38 - 19_940_000;
}
const 기납부세액 = 총수입 * 0.033;
const refundOrPayment = 기납부세액 - calculatedTax;
return <div>...</div>;
}このコードの問題が見えるだろうか?「人的控除は1人当たり150万ウォン」「8段階の累進税率」「3.3%の源泉徴収」という 税法で定められたビジネスルール が、Reactコンポーネント内へ直接埋め込まれている。税法は毎年改正されるため、このようなルールがコンポーネントに散らばっていれば、改正時に修正箇所を探し回ることになる。開発者だけでなく、QAチームが管理するE2Eシナリオがあるなら、テストコストも無視できないだろう。
やがて、これがビューロジックなのかビジネスロジックなのか区別しづらくなり、大量の条件分岐とカスタムフックが複雑に絡み合ってしまう。
ドメインロジックを分離してみよう
Alex BespoyasovのClean Architectureのアプローチから、中心的な原則を借りよう。ドメインロジックを、フレームワークに依存しない純粋関数として分離するのである。
ドメインは、あるアプリケーションを別のアプリケーションと区別する中核である。ReactからAngularへ移行しても変わらないもの、と考えることができる。
The domain is the core that distinguishes one application from another. You can think of the domain as something that won't change if we move from React to Angular.
先ほどの税額計算の例をリファクタリングしてみよう。
まず、ドメインの型とルールを定義し、関連する情報をまとめる。
export interface Income {
grossAmount: number;
expenseRate: number;
}
export interface Deductions {
personalCount: number;
pensionPaid: number;
additionalDeductions: number;
}
const PERSONAL_DEDUCTION_PER_PERSON = 1_500_000;
const WITHHOLDING_RATE = 0.033;
const TAX_BRACKETS = [
{ limit: 14_000_000, rate: 0.06, progressiveDeduction: 0 },
/** ...구간들... **/
] as const;次に、ドメインロジックを純粋関数として分離する。
先ほどの所得計算、控除、課税標準、税額、還付額などの計算ロジックをcomputeFullTax関数へ分離する。各段階は、さらに小さな純粋関数へ分割する。結果の型をReturnType<typeof computeFullTax>で推論させれば、別途インターフェースを宣言する必要はない。
その後、コンポーネントはドメインロジックを「使う」だけにする。
import { computeFullTax } from "../domain/tax";
function TaxPreviewPage() {
const [income, setIncome] = useState<Income>({
grossAmount: 0,
expenseRate: 0.641,
});
const [deductions, setDeductions] = useState<Deductions>({
personalCount: 1,
pensionPaid: 0,
additionalDeductions: 0,
});
const result = computeFullTax(income, deductions);
return (
<div>
<IncomeForm value={income} onChange={setIncome} />
<DeductionForm value={deductions} onChange={setDeductions} />
<TaxResultSummary result={result} />
</div>
);
}何が変わったのだろうか?
- 8段階の累進税率表(
TAX_BRACKETS)が一か所にまとまり、税法改正時はdomain/tax.tsだけを修正すればよい - 計算パイプラインが
computeFullTaxという一つの関数へ集約され、全体の流れをひと目で把握できる。(例を単純にするため一つにまとめたが、実際のプロジェクトでは所得計算・控除計算・税額算出など、目的別にさらに細分化するのが適切である。) - コンポーネントは「どう見せるか」だけに集中する。税率が変わってもコンポーネントを修正する必要はない
- Reactから別のフレームワークへ移行しても、
domain/tax.tsは 変わらない
ドメインロジックを分離すると、テストは驚くほど単純になる。税金ドメインでは、計算の正確さがそのままユーザーのお金に直結するため、この点は特に重要である。
税金に関する計算ロジックを持つ純粋関数には、React Testing Libraryも、renderも、screen.getByTextも必要ない。入力を与えて出力を確認するだけでよい。「1,400万ウォン以下は税率6%」「課税標準が0ウォンなら税額も0ウォン」「フリーランサーの所得3,000万ウォンに対する還付額」といったケースを、一行のitで表現できる。ドメイン単体テストはコンポーネントを分離する基準を自然に示し、テストコードはドキュメントとしての役割まで果たす。
貧血ドメインモデル(Anemic Domain Model)
前節では、計算ロジックを分離した。しかし、ドメインロジックには計算以外にも、状態遷移ルールと 権限の判定 がある。「この申告書は今編集できるか?」「提出できるか?」「請求方式を切り替えられるか?」といった問いがそれに当たる。このようなルールを分離する際、陥りやすい罠が一つある。Martin Fowlerが名付けた 貧血ドメインモデル(Anemic Domain Model) である。
貧血ドメインモデルとは、型はドメインの言葉で適切に定義されているのに、その上で動作するルールがドメインの外へ散らばっている状態を指す。税務申告(Filing)ドメインを例にしよう。型はすっきりしている。
// types/filing.ts
export interface TaxFiling {
id: string;
status: "draft" | "submitted" | "reviewing" | "completed" | "amended";
taxYear: number;
filingType: "regular" | "late" | "amendment";
determinedTax: number;
}ところが、この型に関する判定・遷移ルールは、別の場所に埋め込まれている。
// utils/filingHelpers.ts
export function canAmendFiling(filing: TaxFiling) {
return filing.status === "completed" && filing.filingType !== "amendment";
}
// components/FilingDetail.tsx
function FilingDetail({ filing }: { filing: TaxFiling }) {
// 같은 도메인 규칙을 컴포넌트 안에 다시 작성한다
const canEdit = filing.status === "draft" || filing.status === "reviewing";
// ...
}
// hooks/useSubmitFiling.ts
export function handleSubmitFiling(filing: TaxFiling) {
if (filing.status !== "draft") return;
// ...
}同じドメインルールが、utils、コンポーネント、フックの三か所に、それぞれ異なる形で存在している。この状態で「請求可能条件を変更します」という要件が入れば、修正すべき場所を探し回ることになる。そして一か所でも見落とせば、サイトのどこかで誤った判定が行われる。Fowlerはこのようなコードを、「オブジェクト指向の皮をかぶった手続き的コードと変わらない」 と批判した。
解決策は、前節で計算ロジックに適用したものと同じである。ルールを型の隣に置く。
export interface TaxFiling {
id: string;
status: FilingStatus;
taxYear: number;
filingType: FilingType;
determinedTax: number;
}
export type FilingStatus =
| "draft"
| "submitted"
| "reviewing"
| "completed"
| "amended";
export type FilingType = "regular" | "late" | "amendment";
// 도메인 규칙은 도메인 옆에 둔다
export function canEdit(filing: TaxFiling): boolean {
return filing.status === "draft";
}
export function canSubmit(filing: TaxFiling): boolean {
return filing.status === "draft" && filing.determinedTax >= 0;
}
export function canAmend(filing: TaxFiling): boolean {
return filing.status === "completed" && filing.filingType !== "amendment";
}これで、申告に関するルールはdomain/filing.tsの一か所で管理される。どのコンポーネントでもcanAmend(filing)を呼び出せばよく、ルールが変わればこのファイルだけを修正すればよい。重要なのは、型と、その上で働くルールを一つのまとまりとして捉えることだ。 型だけをドメインフォルダに置き、ルールをutilsへ切り出す部分的な分離は、見た目がすっきりしていても、依然として貧血状態である。
APIレスポンスとドメインモデルの間の変換層
実務では、もう一つ考慮すべきことがある。バックエンドのAPIレスポンス構造とフロントエンドのドメインモデルが、常に一致するとは限らない点だ。国の機関と連携する税金サービスなら、なおさらである。韓国国税庁のHometaxから連携されるデータは略語やコード値にあふれており、フロントエンドのドメインモデルと同じ形で返ってくる可能性は低い。
そこで必要になるのが 変換層(Mapper) である。APIレスポンスの型をそのままコンポーネントまで流すのではなく、一度ドメインの型へ整えてから使う。純粋関数が一つあれば十分だ。
import type { Income } from "../domain/tax";
interface HometaxIncomeResponse {
총수입금액: number;
경비율: number;
소득유형코드: string;
// ... 나머지 약어 필드들
}
export function toIncome(response: HometaxIncomeResponse): Income {
return {
총수입_금액: response.총수입금액,
경비_비율: response.경비율,
};
}こうすれば、APIレスポンスの총수입금액、경비율のような略語やコード値による分類を、フロントエンドのドメインに合わせて 一か所で 変換できる。所得区分コードのようにenumへ展開する必要がある値には、mapper内に小さなlookup tableを置けばよい。Hometax APIのフィールド名が変わっても、mapper一つを修正するだけで済む。
ユーティリティ関数とドメインロジック
ドメインロジックを分離していると、必ずぶつかる問いがある。「これはユーティリティ関数ではないのか?」
たとえば、次の二つの関数を見てみよう。
function formatCurrency(amount: number): string {
return `${amount.toLocaleString()}원`;
}
function calculateTax(taxableBase: number): number {
const bracket = TAX_BRACKETS.find((bracket) => taxableBase <= bracket.limit);
return Math.floor(taxableBase * bracket.rate - bracket.progressiveDeduction);
}formatCurrencyは、数値を文字列へ変換する純粋な プレゼンテーション(Presentation)ロジック である。「ウォン」という単位を付け、3桁ごとにカンマを入れることはビジネスルールではなく、ユーザーにどう見せるかに関するものだ。一方、calculateTaxには「8段階の累進税率を適用する」という、税法に基づくビジネスルール が含まれている。これはUIがなくても同じように適用されるべき、ドメインのルールである。
筆者が実務で用いる判断基準は、次のとおりだ。
このロジックがなくなったとき、ビジネスが壊れるのか、それとも画面だけが壊れるのか?
ビジネスが壊れるならドメインロジックであり、画面だけが壊れるならプレゼンテーションロジックである。この問い一つで、ほとんどの境界を区別できる。
| 判断基準 | ドメインロジック | ユーティリティ/プレゼンテーションロジック |
|---|---|---|
| ないと何が壊れるか? | 税額計算を誤る | 画面(UI)の表示がおかしくなる |
| フレームワークが変わると? | そのまま維持される | 変わる可能性がある |
| 企画書に明記されているか? | 「課税標準 × 税率 - 累進控除」 | 「金額はカンマ区切り」 |
| バックエンドにも同じロジックがあるか? | ある、またはあるべき | ない(フロントエンドだけの関心事) |
しかし、現実はそれほど明快ではない。最も厄介なのは、ドメインロジックのように見えて、実際にはプレゼンテーションロジックである場合だ。
次のコードを見てみよう。FilingStatusというドメインの概念を引数として扱うため、ドメインロジックに分類している。しかし、本当にこれはドメインロジックなのだろうか?
// domain/filing.ts
function getStatusBadgeColor(status: FilingStatus): string {
const colors: Record<FilingStatus, string> = {
draft: "gray",
submitted: "blue",
reviewing: "yellow",
completed: "green",
amended: "purple",
};
return colors[status];
}
function getStatusDisplayText(status: FilingStatus): string {
const labels: Record<FilingStatus, string> = {
draft: "작성 중",
submitted: "제출 완료",
reviewing: "검토 중",
completed: "신고 완료",
amended: "경정청구",
};
return labels[status];
}getStatusBadgeColorとgetStatusDisplayTextは、FilingStatusというドメインの概念を使ってはいるが、行っているのは 画面上の表現 である。バッジの色が変わっても、ビジネスには何の影響もない。このような関数をdomain/filing.tsへ入れると、ドメインモジュールが次第に肥大化し、本当のドメインロジックとプレゼンテーションロジックが混在することになる。
ドメインモデルとViewModelの分離
この問題を解決する実用的な方法がある。同じドメインフォルダ内で、ViewModelを別ファイルに分離することだ。.ui.tsではなく.viewModel.tsという名前を使えば、MVVMパターンのViewModelという概念へ自然につながる。「ドメインデータを画面に合わせて変換する層」という役割が、名前からすぐに伝わるためだ。
domains/
└── filing/
├── filing.ts # 순수 도메인 모델 + 도메인 로직
├── filing.viewModel.ts # ViewModel (표현 변환 계층)
├── filing.test.ts # 도메인 로직 테스트
└── filingMapper.ts # API ↔ 도메인 변환
先ほど見たgetStatusBadgeColor、getStatusDisplayTextを、そのままfiling.viewModel.tsへ移す。さらに、getFilingTypeLabel(type: FilingType): stringのように、申告の種類を韓国語のラベルへ展開する変換もここへ集める。filing.tsはビジネスルールだけ、filing.viewModel.tsは画面上の表現だけを担う。
重要なのは 依存関係の向き である。filing.viewModel.tsはfiling.tsをimportするが、filing.tsはfiling.viewModel.tsを決してimportしない。ドメインはプレゼンテーションを知らず、プレゼンテーションがドメインを知る構造だ。これはRobert C. Martinが述べた依存性のルール(Dependency Rule)の縮図といえる。
筆者は、一緒に変更されるファイルは同じディレクトリに置くべきだと考え、同じフォルダへ配置した。FilingStatus型に新しい値(たとえば'rejected')が加われば、filing.tsとfiling.viewModel.tsの両方を修正しなければならない。同じフォルダにあれば、修正範囲をひと目で把握できる。
境界と凝集
ドメインロジックを分離することと同じくらい重要なのが、どこに境界を引くか である。筆者が実務でよく直面する境界判断の問題を、いくつか整理してみよう。
フロントエンドで扱うデータは、おおむね四つの出所に分けられる。
- サーバーデータ:APIレスポンスとして受け取ったもの
- 派生データ:サーバーデータから計算されたもの
- UI状態:画面制御のためのもの、ユーザーの操作
- ユーザー入力:フォームへの入力途中のもの
この四つを一つの型に混ぜると、ドメインモデルが汚染される。
// 안티패턴: 모든 것이 섞인 타입
interface TaxFiling {
// 서버 데이터 (도메인)
id: string;
status: FilingStatus;
determinedTax: number;
// 파생 데이터 (도메인)
refundAmount: number;
canAmend: boolean;
// UI 상태 (표현)
isExpanded: boolean;
activeStep: number;
// 임시 상태
editingDeductions: Deduction[];
}この型には、ドメインの概念、UI状態、一時データが一つのかごに入っている。activeStepが変わるたびに、申告ドメインが更新されることになる。(フォームのステップが変わることは、ビジネスイベントではない。)
改善するには、境界に従って型を分離する。ドメインモデルにはid、status、determinedTaxのようなビジネスの概念だけを、UI状態(FilingFormViewState)にはisExpanded、activeStepのような画面制御だけを、フォーム状態(DeductionEditForm)には入力途中の一時データだけを含める。
こうすれば、それぞれの型が 一つの変更理由 だけを持つ。ドメインの型は税法が変わるときだけ、UI状態は画面設計が変わるときだけ、フォーム状態は入力UXが変わるときだけ修正される。
一緒に変わるものは一緒に置こう
Eric EvansのDDDには、Aggregate(集約) という概念がある。「関連するオブジェクトのまとまりを一つの単位として扱うこと」である。フロントエンドでこの概念をそのまま適用する必要はないが、一緒に変わるデータとルールは一緒に置く。 という中心的な原則は参考にできる。
税金サービスを例にすると、Income(所得)とExpenseRate(必要経費率)は常に一緒に変わる。所得区分が変われば適用される必要経費率も変わり、総合所得金額の計算にも影響する。したがって、これらは一つのファイルdomain/tax.tsへまとめる。
一方、TaxFiling(申告書)は税額計算とは別に変わり得る。申告書の状態遷移ルールが変わっても、税率の計算ロジックには影響しない。そのため、domain/filing.tsへ分離するのが適切である。
이렇게 묻자: "A가 변할 때 B도 반드시 변해야 하는가?"
→ Yes: 같은 모듈에 둔다 (Income + ExpenseRate + TaxBracket)
→ No: 분리한다 (Tax 계산 ↔ Filing 상태관리)
Classと関数型
ここまで読むと、一つの根本的な疑問が浮かぶかもしれない。これまでの例はすべてinterfaceと純粋関数の組み合わせだったが、Classでドメインを表現すれば、凝集がより自然になるのではないだろうか?
そのとおりである。Classベースでドメインを表現すると、データと振る舞いが一つのオブジェクトにまとまるため、凝集がコード構造にそのまま現れる。
class TaxFilingModel {
constructor(
public readonly id: string,
public readonly status: FilingStatus,
public readonly taxYear: number,
public readonly filingType: FilingType,
public readonly determinedTax: number,
) {}
canEdit(): boolean {
return this.status === "draft";
}
canAmend(): boolean {
return this.status === "completed" && this.filingType !== "amendment";
}
canSubmit(): boolean {
return this.status === "draft" && this.determinedTax >= 0;
}
}
const filing = new TaxFilingModel(
"F-001",
"completed",
2025,
"regular",
547200,
);
filing.canAmend();Classベースで書けば、振る舞いがデータへ帰属する。そして、利用側で主語が明確になる。filing.canAmend()は、まるで自然言語を読むように直感的だ。主語(filing)と動詞(canAmend)がはっきり結び付いている。jihoon.eat('감자탕')と書けば、「ジフンがカムジャタンを食べる」とすぐに読み取れるのと同じだ。
一方、関数型スタイルでは次のようになる。
canAmend(filing);
eat("jihoon", "감자탕");関数型では、データが外部に存在する。上の二つのコードは、filingというデータを引数として受け取り、何らかの動作を実行する。eat関数は、jihoonと감자탕というデータを引数として受け取って実行する。
結果として、主語と動詞の結び付きが弱くなる。canAmendという関数がTaxFilingに関係することは、ファイルを開くか型シグネチャを確認しなければ分からない。さらに、同じファイルにcanAmend(filing)、canEdit(filing)、calculateTax(taxableBase)のような関数が混在すれば、どの関数がどのドメインに属するのかをひと目で把握しにくくなることがある。
では、Classを使うべきか?
正直に言えば、答えは 「状況による」。しかし、筆者の経験上、React + TypeScript環境でClassが万能ではない現実的な理由がある。
Reactの状態管理との摩擦
Reactの状態管理は、基本的に Plain Object と最も自然に組み合わせられる。useStateやuseReducerは技術的にはどんな値でも保持でき、Redux DevTools自体がClassインスタンスのプロトタイプを取り除くわけでもない。ただし、Redux/Zustandの永続化ミドルウェアが状態をJSONとして保存・復元すると、ClassインスタンスはJSON.stringify → JSON.parseのサイクルでメソッドとプロトタイプを失い、plain objectになる。一方、React Server ComponentからClient Componentへpropsを渡す境界は、対応するシリアライズ可能な(serializable)値だけを受け付けるため、任意のClassインスタンスはそもそも渡せない。
次のコードを見てみよう。
const [filing, setFiling] = useState(
new TaxFilingModel("F-001", "draft", 2025, "regular", 0),
);Reactの状態を更新するだけなら、filingがTaxFilingModelのインスタンスでなくなることはない。ただし、Redux/Zustandの永続化でJSONとして保存・復元されると、値がメソッドのないplain objectになる可能性があり、何気なく呼び出したfiling.canAmend()がランタイムエラーを起こし得る。React Server ComponentからClient Componentへ渡す場合は、Classインスタンスが対応するシリアライズ形式ではないため、受け渡しの時点で失敗する。
イミュータビリティを保証する難しさ
Reactは、状態の変更を 参照同一性(referential equality) に基づいて検出する。Classインスタンスのメソッドがthis.items.push(...)のように内部を変更しても参照は同じままなので、Reactは再レンダリングをトリガーしない。そのため、結局はaddDeduction(item)がreturn new DeductionList([...this.items, item])のように毎回新しいインスタンスを返すよう実装しなければならない。そうなると、Classの利点である「カプセル化された状態変更」の意味が薄れ、関数型の更新とさほど変わらないコードになる。
関数型で凝集を確保する方法
では、関数型スタイルでeat('jihoon', '감자탕')のような、凝集の弱さに関する問題をどう改善できるだろうか?筆者が効果的だと感じた三つの方法を紹介する。
モジュールの名前空間で凝集させる
最も直感的な方法である。ファイル(モジュール)自体をドメイン単位にし、import時に名前空間を利用する。先ほど定義したdomain/filing.tsをそのまま使えばよい。
import * as FilingModel from "../domain/filing";
FilingModel.canEdit(filing);
FilingModel.canAmend(filing);
FilingModel.canSubmit(filing);FilingModel.canAmend(filing)はfiling.canAmend()ほどではないが、少なくともこの関数がFilingドメインに属することがコードからすぐに分かる。関数が複数のドメインにまたがって混在するリスクもなくなる。
最初の引数はドメインの主体に
関数型で凝集を表す、もう一つの規約がある。最初の引数を常に「振る舞いの主体」にする。 canAmend(filing)、calculateTotalIncome(income)のようにシグネチャを統一すると、canAmend(filing)は「filingについてcanAmendかどうかを問う」と読める。Unixのパイプラインという考え方(data |> transform)にも通じる。実際、Goのメソッドレシーバーはまさにこのパターンであり、Rustのimplブロックでselfを最初の引数として受け取るのも同じ発想である。
Factory 関数で振る舞いをまとめる
Classの凝集性が欲しいときに使えるパターンである。ファクトリ関数がドメインオブジェクトとその振る舞いをまとめて返す。
export function createFilingModel(data: TaxFiling) {
return {
...data,
canEdit: () => data.status === "draft",
canSubmit: () => data.status === "draft" && data.determinedTax >= 0,
canAmend: () =>
data.status === "completed" && data.filingType !== "amendment",
} as const;
}
const filing = createFilingModel(rawFiling);
filing.canAmend();
filing.canEdit();このパターンなら、Classの表現力(filing.canAmend())と、オブジェクトリテラルで振る舞いを構成する関数型の実用性を同時に得られる。ただし、返されるオブジェクトは関数プロパティを持つため、それ自体はJSONシリアライズ可能なデータではない。毎回関数オブジェクトを新しく作るコストもあるが、フロントエンドで扱うデータ量なら、パフォーマンス上の問題になることはほとんどない。
どこまで分離するべきか?
Clean Architectureを読むと、3〜4層に分けてPort/Adapterを定義する理想的な構造が示されている。しかし、実際にすべてのプロジェクトへこの構造を適用するのは、過剰設計(over-engineering)になり得る。
筆者が考える実用的な基準は、次のとおりだ。
- ドメインの型をAPIレスポンスの型から分離する。
interfaceでもtypeでも、フロントエンドで使用するドメインの概念を別ファイルに定義する。 - ビジネスルールを含むロジックは、コンポーネントの外へ出す。
domain/フォルダでなくてもよい。重要なのは、Reactに依存しない純粋関数にすることである。 - APIレスポンス → ドメインモデルへの変換を一か所で行う。 Mapper関数でもZodスキーマでも、その一か所だけを修正すれば変更が波及しない構造にする。
プロジェクトが複雑になったら、次のような対応も検討できる。
- Bounded Context単位でフォルダを分ける。 Tossフロントエンドチャプターでも、「一緒に変更されるファイルを同じディレクトリに配置する」という原則を強調している。ドメイン単位でフォルダを分ければ、importパスがドメインの境界を自然に表す。
- Use Case層を導入する。 ドメインロジックの組み合わせが複雑になったら、「所得情報の取得 → 必要経費率の適用 → 控除項目の計算 → 税額の算出 → 還付額の確定」というシナリオを一つの関数にまとめるApplication層が必要になる。
src/
├── domains/
│ ├── tax/
│ │ ├── tax.ts # 세액 계산 도메인 (세율, 공제, 계산 파이프라인)
│ │ ├── tax.viewModel.ts # 세액 표현 (금액 포맷, 구간 라벨)
│ │ ├── tax.test.ts # 세액 계산 테스트
│ │ └── incomeMapper.ts # 홈택스 API ↔ 도메인 변환
│ ├── filing/
│ │ ├── filing.ts # 신고 상태 도메인 (상태 전이, 권한)
│ │ ├── filing.viewModel.ts # 신고 표현 (상태 배지, 라벨)
│ │ ├── filing.test.ts
│ │ └── filingMapper.ts
│ └── deduction/
│ ├── deduction.ts # 공제 항목 도메인 (자격 조건, 한도)
│ └── deduction.viewModel.ts
├── hooks/ # React 의존 로직
├── components/ # UI 컴포넌트
└── api/ # API 호출
「税金」という一つのドメインの中でも、税額計算(tax)、申告管理(filing)、控除項目(deduction) は、それぞれ独立したサブドメインに分けられる。税率が変わっても申告状態の遷移ロジックには影響せず、控除項目が追加されても申告書の提出フローはそのままである。これがBounded Contextの実践的な適用である。
まとめ
まとめると、フロントエンドにもフロントエンドならではのドメインロジックがあり、そのルールはコンポーネントではなく型のそばの純粋関数に置く。APIレスポンスは1つのmapperでドメインの型に変換し、画面表現はViewModelに切り離し、一緒に変わるものは一緒に置く。
これをフロントエンドで実践するとは、単にフォルダを分けることではなく、何層もの境界を意識的に判断することである。「これはビジネスルールか、プレゼンテーションロジックか?」「このデータはドメインの状態か、UIの状態か?」「この関数は十分に凝集しているか?」。こうした問いを習慣的に投げかけるだけでも、コードの構造は自然と改善される。
もちろん、すべてのプロジェクトでClean Architectureの層をすべてそろえる必要はない。単純なCRUDアプリを4層に分け、すべてのドメインにFactoryパターンを適用するのは、本末転倒である。Classの優れた凝集性と関数型の実用的な柔軟性のどちらを選ぶかは、プロジェクトの複雑さとチームのコンテキストによって決まる。
唯一の正解はない。しかし少なくとも、「ドメインが何かを知らずにコードを書くこと」 と 「ドメインを認識し、境界を判断し、意識的に分離すること」 の間には明確な違いがある。この記事を読んだ方にも、自分のプロジェクトで「ここでのドメインは何だろう。そして、このコードはどこに置くべきだろう?」と一度問いかけてみてほしい。