ドメインモデリング
意味の異なる値を同じ String や i64 のまま置くと、コンパイラは区別できず、境界をすり抜けた値がドメイン深部まで届く。Kamae では newtype・enum・明示的なコンストラクタで意図を型に刻む。
ライフサイクル上の変化は 状態遷移、外部データの取り込みは 境界防御、保存単位は 永続化、集約、イベント と揃える。
ドメイン概念を明示的に表現する
Section titled “ドメイン概念を明示的に表現する”プリミティブのままだと、単位の混同や ID の取り違えはコンパイル時に検出できない。newtype のコストはボイラープレートより、誤った組み合わせを早く落とす効果の方が大きい。
次の例は、空文字を拒否する RequestId による典型的な newtype である。
#[derive(Clone, Debug, PartialEq, Eq, Hash)]pub struct RequestId(String);
impl RequestId { pub fn new(value: String) -> Result<Self, RequestIdError> { if value.trim().is_empty() { return Err(RequestIdError::Empty); } Ok(Self(value)) }
pub fn as_str(&self) -> &str { &self.0 }}値が意図的に透明で不変条件がない場合を除き、newtype のフィールドは private とする。
時刻・金額・単位は明示的な概念としてモデル化する。単位・タイムゾーン・精度・丸めが暗黙の裸のプリミティブより、OccurredAt、ServiceDate、Money、CurrencyCode、DistanceMeters、DurationSeconds を優先する。金額には f32 / f64 を使わない。
状態のバリアントには enum を優先する
Section titled “状態のバリアントには enum を優先する”閉じた状態集合やドメイン上の代替には Rust の enum を使う。各状態が異なるデータを持つなら、構造体風のバリアントとする。
pub enum TaxiRequest { Waiting(WaitingRequest), EnRoute(EnRouteRequest), InTrip(InTripRequest), Completed(CompletedRequest), Cancelled(CancelledRequest),}特定のソース状態だけが遷移を受け付けるときは、別の状態構造体とする。
集約境界を定義する
Section titled “集約境界を定義する”集約は、まとめて原子的に変わる必要のある不変条件を所有する。ルールを所有する状態または集約に遷移メソッドを置き、他集約は ID で参照する。判断用に安定したスナップショットをロードするユースケースは除く。
トランザクションスコープ・バージョニング・集約横断の調整は 永続化、集約、イベント を参照する。
アクセス都合だけで無関係なエンティティを集めた「神」集約は避ける。2 つの集約ルートをメモリ上で変更し、呼び出し側の両方の save に頼る遷移も避け、ユースケースと明示的なドメインイベントで集約をまたぐ変更を行う。
構築を正直に保つ
Section titled “構築を正直に保つ”new、try_new、TryFrom、FromStr で構築時に不変条件を強制する。public フィールドを公開すると、呼び出し元が検証を迂回して無効な組み合わせを作れてしまう。
不変条件のない単純データ、または #[cfg(test)] 内の builder だけが struct リテラルを許容する。本番経路と同じコンストラクタをテストでも使う方針は テストデータ を参照する。
trait の derive は意図的に選ぶ
Section titled “trait の derive は意図的に選ぶ”不変条件付きドメイン型に、本当のドメイン default がない限り Default derive しない。空 ID・ゼロ金額・最初の enum バリアントは、通常 invalid または misleading な default になる。
Clone は狭く derive する。小さな不変 value object や DTO では問題が少ないが、集約・エンティティへの広い Clone は所有関係の誤りや、古いコピーをそのまま永続化する経路を隠す。
private 不変条件があるドメイン型に無制限の Serialize / Deserialize derive しない。DTO・行構造体・リーフ value object の serde try_from でデシリアライズも検証を通す。
ドメインモデルを分離する
Section titled “ドメインモデルを分離する”API JSON・DB 行・ドメインエンティティに同一 struct を使わない。外部形状は optional や非正規化フィールドを含み、ドメインに漏れうる。
フローは次のとおり。
API/DB/env raw data -> DTO/row struct -> TryFrom -> domain type境界防御 を参照する。
概念ごとに整理する
Section titled “概念ごとに整理する”1 ドメイン概念につき 1 ファイル(または小さなモジュール)とし、型・コンストラクタ・メソッド・テストを同じ場所に置く。types.rs と models.rs に型だけ、別モジュールに振る舞いだけ、という分離は、変更のたびにファイルを行き来させ、不変条件のレビューも難しくする。
Phantom 型による typestate パターン
Section titled “Phantom 型による typestate パターン”非法な状態をコンパイル時に不可能にするとき、ライフサイクルフェーズをゼロサイズの phantom marker 型パラメータで符号化する。
use std::marker::PhantomData;
pub struct Draft;pub struct Submitted;pub struct Approved;
pub struct ExpenseReport<State> { report_id: ReportId, amount: Money, _state: PhantomData<State>,}
impl ExpenseReport<Draft> { pub fn submit(self) -> Result<ExpenseReport<Submitted>, SubmitError> { if self.amount.is_zero() { return Err(SubmitError::EmptyAmount); } Ok(ExpenseReport { report_id: self.report_id, amount: self.amount, _state: PhantomData, }) }}
impl ExpenseReport<Submitted> { pub fn approve(self, approver: ApproverId) -> ExpenseReport<Approved> { ExpenseReport { report_id: self.report_id, amount: self.amount, _state: PhantomData, } }}typestate を使うときは次を満たす。
- フェーズごとに利用可能な操作が大きく変わる
- 1 つの struct にまとめると多数の
Optionやランタイムチェックが必要になる
各状態が異なるフィールドを持ち遷移が主 API なら、別の状態構造体を優先する(状態遷移)。typestate と状態構造体は併用できる。例: ExpenseReport<Submitted> が SubmittedReport を包む。
ドメイン enum の #[non_exhaustive]
Section titled “ドメイン enum の #[non_exhaustive]”#[non_exhaustive] は、下流 crate が enum の match に wildcard arm を含める必要があることを示す。次の用途に向く。
- ドメイン enum を拡張点として公開する public library crate
- 他リポジトリの matcher に breaking を出さずバリアントを追加する integration 向け event / status enum
次の場合は避ける。
- enum が 1 サービス crate 内部にあり、
matchサイトが同一 workspace にある - 網羅的
matchが安全属性である(例: 課金で全TaxiRequestバリアントが必須)
単一ドメイン crate 内では non_exhaustive なしの網羅 match を優先し、バリアント追加時にコンパイラ更新を強制する。
金額に f32 / f64 を使わない。rust_decimal::Decimal または minor unit の整数表現を newtype で包む。
use rust_decimal::Decimal;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]pub struct Money { amount_minor: i64, currency: CurrencyCode,}
impl Money { pub fn from_minor(amount_minor: i64, currency: CurrencyCode) -> Result<Self, MoneyError> { if amount_minor < 0 { return Err(MoneyError::Negative); } Ok(Self { amount_minor, currency }) }
pub fn add(self, other: Money) -> Result<Money, MoneyError> { if self.currency != other.currency { return Err(MoneyError::CurrencyMismatch); } Ok(Money { amount_minor: self .amount_minor .checked_add(other.amount_minor) .ok_or(MoneyError::Overflow)?, currency: self.currency, }) }}非金額の数量(距離・重量)は単位付き newtype(DistanceMeters、WeightGrams)とし、異なる単位の加算をコンパイルエラーにする。
newtype 間の From と TryFrom
Section titled “newtype 間の From と TryFrom”依存方向に沿って変換を設計する。wire / DB 型からドメイン型へ向ける。ドメインモジュールで逆方向にしない。
| 変換 | 推奨 |
|---|---|
| 同意味で検証済みの target | TryFrom<Source> for Target |
| ロスレスで常に valid | From<Source> for Target |
| レスポンス向け domain → wire | adapter または DTO 隣の From<Domain> for ResponseDto |
| DB 行向け domain → row | persistence adapter の From<&Domain> for Row |
impl TryFrom<String> for PassengerId { type Error = PassengerIdError; fn try_from(value: String) -> Result<Self, Self::Error> { PassengerId::new(value) }}
impl From<RequestId> for String { fn from(id: RequestId) -> Self { id.into_inner() }}transport 型依存を作る TryFrom<Domain> for Dto をドメイン crate に impl しない。outbound マッピングは adapter 層に置く。
手動 Eq、Hash、Ord
Section titled “手動 Eq、Hash、Ord”全フィールドが対応 trait を持ち意味が derive と一致するとき derive。
手動 impl する場合:
f64を含むが丸め/離散 view で等価が必要- 順序が domain 固有(
Priorityがフィールド辞書順でない) - 等価から derived/cache field を無視
#[derive(Clone, Debug)]pub struct FareEstimate { distance_m: DistanceMeters, duration_s: DurationSeconds, confidence: f64, // derived score, not part of identity}
impl PartialEq for FareEstimate { fn eq(&self, other: &Self) -> bool { self.distance_m == other.distance_m && self.duration_s == other.duration_s }}
impl Eq for FareEstimate {}
impl Hash for FareEstimate { fn hash<H: std::hash::Hasher>(&self, state: &mut H) { self.distance_m.hash(state); self.duration_s.hash(state); }}secret や PII を含み、ログで map key に誤用しうる型に Hash / Eq derive しない。PII 保護 を参照する。
テスト builder
Section titled “テスト builder”本番コンストラクタは strict とする。#[cfg(test)] または tests/support で sensible default と fluent override の builder を置く。
#[cfg(test)]mod support { use super::*;
#[derive(Default)] pub struct WaitingRequestBuilder { request_id: Option<RequestId>, passenger_id: Option<PassengerId>, }
impl WaitingRequestBuilder { pub fn with_passenger(mut self, passenger_id: PassengerId) -> Self { self.passenger_id = Some(passenger_id); self }
pub fn build(self) -> WaitingRequest { WaitingRequest::new( self.request_id .unwrap_or_else(|| RequestId::new("req-test-1".into()).unwrap()), self.passenger_id .unwrap_or_else(|| PassengerId::new("pax-test-1".into()).unwrap()), ) .unwrap() } }}
#[test]fn assign_driver_moves_to_en_route() { let waiting = WaitingRequestBuilder::default() .with_passenger(PassengerId::new("pax-42".into()).unwrap()) .build(); let en_route = waiting.assign_driver(DriverId::new("drv-9".into()).unwrap()); assert_eq!(en_route.driver_id().as_str(), "drv-9");}builder はテストとフィクスチャ専用とする。テスト簡略化のためドメインエンティティに Default を公開しない。
よくある crate 組み合わせ
Section titled “よくある crate 組み合わせ”| スタック | モデリングパターン |
|---|---|
nutype + thiserror | 生成 guard 付き検証 newtype(クレートガイド(nutype)) |
rust_decimal + newtypes | checked 算術の Money、TaxRate |
serde(try_from) + newtypes | JSON 境界のリーフ value object(境界防御) |
proptest + builders | primitive に Arbitrary のあと try_new(プロパティベーステスト) |
レビューでは、ビジネス意味を持つ素の String や i64、invalid sentinel を生む Default、金額・課金での f64、文書化された例外なしの Deserialize / FromRow derive、public フィールドリテラルによる不変条件の迂回を指摘する。
レビュー観点
Section titled “レビュー観点”呼び出し元が不変条件を迂回できないか — High
Section titled “呼び出し元が不変条件を迂回できないか — High”不変条件を持つドメイン型で public フィールドまたは public タプルフィールドがある場合は指摘する。コンストラクタを正規の経路とすること。
複数フィールドの不変条件の一部だけを更新するミューテータ、再検証のスキップ、無効な中間状態の流出を許すミューテータを指摘する。
正規コンストラクタ内の直接構築、非公開テストヘルパ、使用前に検証付きドメインコンストラクタへ変換される DTO / 行構造体には指摘しない。
意味のあるプリミティブは newtype で表現されているか — High
Section titled “意味のあるプリミティブは newtype で表現されているか — High”ユーザー ID、注文 ID、メールアドレス、金額、数量、外部参照など、異なるドメイン概念にそのまま String、&str、整数、decimal、UUID 型を使っている箇所を指摘する。
プライベートフィールドの newtype と検証付きコンストラクタを提案する。
ローカル一時変数、非公開アダプタフィールド、テストリテラル、シリアライズ専用 DTO フィールド、Rust 型以上のドメイン不変条件を持たない値には指摘しない。
DTO、DB 行、ドメインエンティティは分離されているか — Medium
Section titled “DTO、DB 行、ドメインエンティティは分離されているか — Medium”Deserialize、FromRow、ORM derive により外部データが検証を迂回したり、ドメイン不変条件がストレージ形状に結合するドメインエンティティを指摘する。
意図的なリードモデル、プロジェクション、API レスポンス DTO、ドメイン状態へデシリアライズできない監査用エクスポート型の Serialize には指摘しない。
状態は明示的にモデル化されているか — Medium
Section titled “状態は明示的にモデル化されているか — Medium”status: String / enum と多数のオプションフィールドを持つ単一構造体で、状態ごとの構造体や列挙バリアントの方が必須フィールドを明確にできる場合は指摘する。
金額、時間、単位は明示的か — Medium
Section titled “金額、時間、単位は明示的か — Medium”型や名前付きコンストラクタなしに単位、通貨、タイムゾーン、包含 / 排他範囲を混在させる金額、数量、期間、レート、タイムスタンプを指摘する。
ドメインコードは概念ごとに整理されているか — Low
Section titled “ドメインコードは概念ごとに整理されているか — Low”無関係な概念を集め、振る舞いとデータを分離する catch-all の types.rs、models.rs、domain.rs モジュールを指摘する。
狭い境界づけられたコンテキスト目的のまとまったモジュール、生成スキーマモジュール、意図的に薄く保たれた互換シムには指摘しない。