value-objects-money-and-units について
Dart のドメインロジック向けに、金額、物理量、時刻の値オブジェクトと正規化された保存形式を定めるスキルです。整数による配分で、分割額の合計を元の金額と一致させます。
主な機能
- 01金額を整数の最小通貨単位で保存
- 02ISO-4217 の通貨指数を使用
- 03allocate() による金額配分
- 04SI 基本単位で数量を保存
- 05Clock 注入によるテスト可能な時刻処理
活用シーン
- 01割り勘や金額配分を実装
- 02通貨ごとの精度エラーを防止
- 03数量と時刻の正規保存モデルを設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill value-objects-money-and-unitsインストール後、Claude Code で/value-objects-money-and-unitsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/allocate.dartmoney.dartreferences/allocate-and-splitting.mdcanonical-storage.mddomain-model.mdscripts/check-money-violations.shverify-core.sh詳細
値オブジェクト:金額と単位
すべての数量は一度だけ、単一の正規形式で保存し、表示またはエクスポートするときだけ変換します。金額は整数の最小通貨単位と通貨で表し、物理量はSI基本単位の整数で表し、時刻はUTCで扱います。これにより、ユーザーが表示設定(通貨記号、単位系、ロケール)を切り替えても、保存済みの行を破損させることはありません。また、「部分の合計=全体」を期待に頼らず、構造として保証できます。
このコアは純粋なDartです。flutter/*、intl、dart:io、プラグインは使用しません。書式設定と数字の正規化は、ここではなく上流または下流で行います。これらの値オブジェクトは、認定された純粋な基盤レイヤーであるlib/core/に配置します(project-structure-and-packagesを参照)。utils//common//shared/の寄せ集めには決して配置しないでください。
作業内容に応じて、以下のリファレンスを確認してください。
references/allocate-and-splitting.md— 最大剰余法によるallocate()プリミティブ、その不変条件、2か所で丸めてしまう落とし穴、境界条件のポリシー、検証済みテストベクター、小計→税→チップの分割パイプライン。references/canonical-storage.md— ISO-4217の指数ルール、SI単位の表、decimalベースの解析、テンキー入力用のセント累積器、丸めの規律、Clockを注入する日付付きレート/陳腐化エンジン。references/domain-model.md— 値型のモデリング:関係はIDリンクで表し、保存せずに導出し、集約ごとに通貨を1つに統一し、値の等価性を持つ不変状態にする方法。
PRを作成する前に、scripts/check-money-violations.shとscripts/verify-core.shを実行してください。
譲れないルール
- 金額は
intの最小通貨単位とCurrencyの組み合わせです。double/num/REALは絶対に使わないでください。 2 進浮動小数点数では0.01を正確に表現できません。誤差が気づかないうちに蓄積し、ユーザーが二度と再計算できない合計額の破損につながります。金額 API でdoubleを受け取ったり返したりしてはいけません。 - 通貨の実際の ISO-4217 指数から最小通貨単位/基準通貨単位を導出してください。
* 100、/ 100、「小数点以下 2 桁」をハードコードしてはいけません。 指数は JPY/VND では 0、USD/EUR では 2、KWD/BHD/OMR では 3 です。100をハードコードすると、指数 0 の通貨では 100 倍の誤差になり、指数 3 の通貨では 10 倍の誤差になります。currency.minorPerMajorを経由してください。 - 未知の通貨コードは型付きの失敗として扱い、暗黙に指数を 2 に設定してはいけません。 指数テーブルには同梱されている通貨のみを列挙します。
Currency.tryParseは null を返し、呼び出し元がFailureを返します。 - 集約単位で通貨は 1 つとし、異なる通貨間の算術演算は禁止します。 通貨が異なる 2 つの
Moneyの加算は型の誤りです。例外をスローする(プログラマーエラー)か、先に FX レイヤーを通じて換算してください。Moneyの算術演算で通貨を毎回チェックせずに済むよう、通貨はそれを包含する集約の属性として保持します。 - 金額の分割はすべて、単一の
allocate(amount, weights)プリミティブを経由させてください。 共有アイテム、税額の按分、チップの按分、割引など、すべての丸め処理を 1 つの経路に統一します。これにより、「各部分の合計が最小通貨単位まで正確に全体と一致する」ことを一度だけ証明し、一度だけテストできます。 - 丸め処理が必要な箇所は 1 か所ではなく 2 か所あります。
allocate()と、パーセントから最小通貨単位への変換です。allocate()に渡す前に、パーセントを整数の最小通貨単位へ 一度だけ 丸めてください。素朴にdoubleのパーセントを使うと、allocateのカバレッジでは検出できない、典型的な 1 セントのずれが生じます。 - 個別に丸めた各部分を合計して全体の金額を求めてはいけません。 既知の整数の合計額に対して常に
allocate()を使い、残差を各部分に吸収させてください。 - 合計額は導出し、保存してはいけません。 非正規化して合計額を保存すると、典型的な誤差蓄積のバグにつながります。合計額は読み取り時に、アイテムと重みから計算します。
- 関係は埋め込みコピーではなく、安定した
idのリンクとしてモデル化してください。 すべてのエンティティに明示的なfinal id(UUID など)を付与してください。価格を編集しても割り当ては維持され、参加者を削除するとリンクセットからその参加者が取り除かれるだけになります。 - 正規形で保存し、境界で換算してください。 物理量は SI 基本単位の整数(
intのメートル/ミリリットル/分)とし、時刻は UTC のDateTimeにします。.from<DisplayUnit>ファクトリーは正規形へ丸めて変換し、to<DisplayUnit>()ゲッターは表示境界でのみ使うdoubleを返します。 - 入力がこのコアに渡される前に、数字や区切り文字を ASCII に正規化してください。 ローカライズされた入力に対して
int.parse/double.parse/Decimal.parseを直接呼び出してはいけません。東アラビア数字では例外が発生します。上流で正規化してください(i18n-rtl-l10nを参照)。 decimalで解析し、境界で明示した丸めモードを使って一度だけ丸めてください。 正確な除算や解析にはpackage:decimalを使用し、解析時または最終合計の境界で明示的なRoundingMode(既定はハーフイーブン/銀行家の丸め)を適用してください。中間合計では丸めないでください(偏りが蓄積します)。package:clockのClockを注入してください。DateTime.now()は絶対に呼び出さず、独自のClockServiceも作成してはいけません。 この純粋なコアで時刻を参照するすべてのクラスは、コンストラクター引数としてClockを受け取ります。Riverpod/機能コードではclockProviderを通じて同じClockを注入し(service-boundary-and-nativeを参照)、両方の語彙を連携させます。これにより、固定クロックやfake_asyncを使って、テスト内の時刻依存ロジックを決定論的にできます。- コアには Flutter と IO を含めないでください。 依存関係は
decimalとclockのみです。書式設定はプレゼンテーションレイヤーに、ストレージはデータレイヤーに配置します。
正規ストレージ
ウィジェットとリポジトリ間では、生の int や文字列ではなく、値オブジェクトをやり取りします。
| 数量 | 正規ストレージ | 型 | 保存しないもの |
|---|---|---|---|
| 金額 | 最小通貨単位の整数 + Currency | int | double、書式設定済みの文字列 |
| 距離 | メートルの整数 | int | km、マイル |
| 体積 | ミリリットルの整数 | int | リットル、ガロン |
| 期間 | 分の整数(または Duration) | int | double 型の時間 |
| タイムスタンプ | UTC の ISO-8601 時点 | DateTime(UTC) | 現地時間 |
/// ISO-4217 minor-unit exponents. Explicit table — NEVER default to 2.
enum Currency {
jpy('JPY', 0), vnd('VND', 0),
usd('USD', 2), eur('EUR', 2), gbp('GBP', 2),
kwd('KWD', 3), bhd('BHD', 3), omr('OMR', 3);
const Currency(this.code, this.exponent);
final String code;
final int exponent;
/// 10^exponent — minor units per major unit. The ONLY scaling source.
int get minorPerMajor => switch (exponent) {
0 => 1,
2 => 100,
3 => 1000,
_ => throw StateError('unsupported exponent $exponent for $code'),
};
static Currency? tryParse(String code) {
// Plain loop, not `firstOrNull` (a package:collection extension) — keeps the
// core dependency-free beyond `decimal`.
for (final c in Currency.values) {
if (c.code == code) return c;
}
return null;
}
}
/// Money is (integer minor units) + (currency). No floats, ever.
final class Money implements Comparable<Money> {
const Money(this.minorUnits, this.currency);
final int minorUnits; // e.g. 12345 with KWD == 12.345 KWD
final Currency currency;
Money operator +(Money o) => currency == o.currency
? Money(minorUnits + o.minorUnits, currency)
: throw ArgumentError('currency mismatch: $currency vs ${o.currency}');
@override
int compareTo(Money o) {
assert(currency == o.currency, 'compare across currencies is a bug');
return minorUnits.compareTo(o.minorUnits);
}
@override
bool operator ==(Object o) =>
o is Money && o.minorUnits == minorUnits && o.currency == currency;
@override
int get hashCode => Object.hash(minorUnits, currency);
}
物理量の値オブジェクトも同じ形に従います。つまり、正規フィールドは整数で、丸めを行う .from<Unit> ファクトリーと、境界でのみ使う to<Unit>() ゲッターを備えます。references/canonical-storage.md と examples/money.dart を参照してください。
唯一の除算経路:allocate()
金額を分割するときは必ず、この整数の最大剰余法(ハミルトン方式)のプリミティブを使います。ほかの方法で金額を分割してはいけません。
/// Splits [amount] minor units across [weights], guaranteeing the parts sum
/// EXACTLY to [amount]. Deterministic ascending-index tie-break; residual < n.
/// Negative amount mirrors and negates (discounts/refunds); zero weight-sum
/// falls back to equal weights; empty weights returns [] — money math must
/// never throw into the UI.
List<int> allocate(int amount, List<int> weights) {
final n = weights.length;
if (n == 0) return const [];
if (amount < 0) return allocate(-amount, weights).map((s) => -s).toList();
final sanitized = [for (final w in weights) w < 0 ? 0 : w];
final weightSum = sanitized.fold(0, (a, b) => a + b);
final w = weightSum == 0 ? List.filled(n, 1) : sanitized;
final total = weightSum == 0 ? n : weightSum;
final shares = List<int>.filled(n, 0);
final remainders = <({int remainder, int index})>[];
var distributed = 0;
for (var i = 0; i < n; i++) {
final product = amount * w[i]; // multiply FIRST, then ~/ and % — no float
final floorShare = product ~/ total;
shares[i] = floorShare;
distributed += floorShare;
remainders.add((remainder: product % total, index: i));
}
var leftover = amount - distributed; // always in 0 ..< n
remainders.sort((a, b) => a.remainder != b.remainder
? b.remainder.compareTo(a.remainder)
: a.index.compareTo(b.index));
for (var k = 0; leftover > 0; k++, leftover--) {
shares[remainders[k].index] += 1;
}
assert(shares.fold(0, (a, b) => a + b) == amount, 'allocate must be exact');
return shares;
}
検証済みのベクトル(テストでアサートしてください):
allocate(1001,[1,1,1]) == [334,334,333] ·
allocate(660,[1584,4033,1933]) == [138,353,169] ·
allocate(1510,[1584,4033,1933]) == [317,807,386]。
分割パイプラインでは、複数の段階で allocate() を適用します。まず項目の小計、次に allocate(taxMinor, subtotals)、続いて allocate(tipMinor, subtotals) の順です。これにより、請求総額全体の金額がすべて分配され、参加者ごとの最終額の合計が請求総額と正確に一致します。パイプライン全体、境界条件のポリシー、パーセントから最小通貨単位への丸め箇所については、references/allocate-and-splitting.md と examples/allocate.dart を参照してください。
入力を正確な最小通貨単位にパースする
入力は上流ですでに ASCII に正規化されている必要があります。decimal でパースし(2 進数の誤差がありません)、通貨の指数でスケールし、丸めは一度だけ行います。
/// Caller MUST have normalized digits + separators to ASCII first.
Money moneyFromMajorString(String ascii, Currency c) {
final scaled = (Decimal.parse(ascii) * Decimal.fromInt(c.minorPerMajor))
.round(); // exact; Decimal has no binary-float error
return Money(scaled.toBigInt().toInt(), c);
}
小数点キーが固定されていない数値キーパッドでは、数字を最小通貨単位に直接加算し、ロケールの小数点区切り文字には触れないでください。references/canonical-storage.md を参照してください。
保存せずに導出し、Clock を注入する
合計は項目と重みから読み取り時に計算します。合計を保存すると、値のずれが発生する不具合になります。日付付きレート、鮮度区分、期限など、時間に依存する値オブジェクトには、テストを決定的にするため Clock を注入します。
final class RateSnapshot {
const RateSnapshot(this._clock);
final Clock _clock;
int ageDays(DateTime asOfUtc) => _clock.now().difference(asOfUtc).inDays;
}
// prod: RateSnapshot(const Clock()); test: RateSnapshot(Clock.fixed(fixedUtc));
アンチパターン
double amount/num priceフィールド。0.01を表現できず、合計が取り返しのつかない形で破損します。最小通貨単位をintで使ってください。amount * 100/cents / 100。 指数が 2 以外の通貨ではすべて誤りです。currency.minorPerMajorを使ってください。- 不明な通貨を小数点以下 2 桁として扱う。 誤ったスケーリングが気付かれないまま発生します。型付きの失敗を返してください。
- 異なる通貨の
Moneyを加算する、または集約全体が単一通貨なのに明細ごとに通貨を保存する。 為替換算の境界で変換し、通貨は集約レベルで保持してください。 - 個別に丸めた分配額を合計して総額を求める。 丸めが二度行われ、ずれが生じます。代わりに、既知の整数総額を
allocate()で分配してください。 - 配分ループ内でパーセントから税金やチップを再計算する。 パーセントを最小通貨単位の整数に一度だけ丸めてから、
allocate()を使ってください。 - エンティティに非正規化した
totalを保存する。 導出してください。 - 純粋なコア内で
DateTime.now()や、自作のClockService/SystemClock/FakeClockを使う。 時刻をテストできず、合成もできません。package:clockのClockを注入してください。 Decimal(someDouble)を使う、またはローカライズされた生入力にint.parseを使う。 2 進数の誤差が発生する、または東洋数字で例外になります。StringからDecimalをパースし、先に正規化してください。- 中間合計を丸める。 偏りが蓄積します。境界で一度だけ丸めてください。
完了の定義
- すべての金額フィールドは、
intの最小通貨単位とCurrencyを組み合わせたものです。double/numは使用しません。 - スケーリングはすべて
currency.minorPerMajorを通じて行います。リテラルの100は使用しません。 - 未知の通貨では型付きの失敗を返し、デフォルト値で解析することはありません。
- 異なる通貨間の算術演算は行いません。通貨は集約レベルで保持します。
- 金額の分割にはすべて
allocate()を使用します。検証済みの値ベクトルを使って、合計が保存されることを表明し、テストします。 - パーセンテージは、
allocate()の前に一度だけ整数の最小通貨単位へ丸めます。 - 合計は保存せず、読み取り時に算出します。
- エンティティには明示的で安定した
idを持たせます。関連付けには ID リンクを使用します。 - 物理量は SI 単位の
intとして保存し、変換は境界でのみ行います。 - 時刻を読み取るクラスには注入された
Clockを渡します。コアではDateTime.now()を使用しません。 - コアがインポートするのは
decimalとclockのみです。Flutter/intl/dart:io は使用しません。 -
scripts/check-money-violations.shとscripts/verify-core.shが成功します。
関連スキル
project-structure-and-packagesでは、機能優先のアプリレイアウトにおいて、この純粋なコア(承認された基盤レイヤー)がどこに配置されるか(lib/core/)を説明しています。service-boundary-and-nativeでは、clockProviderを介して Riverpod コードにこのClockを注入する方法(同じpackage:clockの境界をプロバイダー経由で接続)を説明しています。error-handling-typed-resultsでは、解析と FX が例外の代わりに返す、Result<T, F extends Failure>というシールド型の基本構造を説明しています。i18n-rtl-l10nでは、ASCII の数字/区切り文字の正規化と、プレゼンテーション境界での通貨フォーマット(この純粋なコアには含めない)を説明しています。dart3-idioms-and-coding-standardsでは、不変の値型、シールド型、例外をスローしない全域的なドメイン関数について説明しています。persistence-driftでは、最小通貨単位と ISO コードを保存する方法(REAL は使用しない)と、行をこれらの値オブジェクトにマッピングする方法を説明しています。testing-strategyでは、これらの純粋関数で必要となる、クロックを注入したテーブル駆動のユニットテストについて説明しています。
参考資料
- ISO 4217 通貨の指数 — https://en.wikipedia.org/wiki/ISO_4217
package:decimal— https://pub.dev/packages/decimalpackage:clock— https://pub.dev/packages/clockfake_async— https://pub.dev/packages/fake_async- 最大剰余法(Hamilton 法) — https://en.wikipedia.org/wiki/Largest_remainder_method
- Dart のレコードとパターン — https://dart.dev/language/records