概要
Protocol でデータソースを抽象化(Ch5)
OrderRepository(Protocol) を定義し、BigQuery・ファイル・テスト用モックなど任意の実装を DI で差し込めるようにする。CampaignAnalyzer はデータソースの実装詳細に依存せず、テスト時は FakeOrderRepo でユニットテストが書ける。
match 文でクーポン分岐を集約(Ch6)
if coupon_type == "fixed": ... elif coupon_type == "percent": ... の文字列比較を match CouponType に置き換える。mypy が case _: による網羅性を検証でき、新しいクーポン種別は _apply_coupon() の case 1行追加で対応できる(OCP)。
heapq.nlargest で Top-N 効率化(Ch7)
sorted(...)[:N] は O(N log N) だが heapq.nlargest(K, ...) は O(N log K)。SKU数が数十万になるECサイトでは差が大きい。defaultdict でカテゴリ別集計の手動 if-else も廃絶する。
Decimal × frozen dataclass で金額精度保証(Ch4)
float の浮動小数点誤差(0.1 + 0.2 != 0.3)は金額計算で致命的。Decimal + quantize(Decimal("0.01")) で円未満を制御。frozen=True, slots=True で値オブジェクトとして不変性を保証する。
問題
ECサイトの販促システムには、注文データをクーポン種別・商品カテゴリ別に集計してキャンペーン効果を算出するバッチ処理 CampaignAnalyzer がある。現在のコードには 7つの設計上の問題 がある。問題点を全て洗い出し、Protocol / match 文 / heapq.nlargest / defaultdict / frozen dataclass / Decimal を適用してリファクタリングせよ。
制約・前提条件
- Python 3.12+、型ヒント(
Protocol、TypeAlias)を使うこと @dataclass(frozen=True, slots=True)で注文・クーポン・集計結果を値オブジェクト化することmatch文でクーポン種別(FIXED / PERCENT / FREESHIP)の分岐を整理することheapq.nlargestで Top-N 集計を行い、手動ソートを使わないことProtocolでデータソースを抽象化し、DI できるようにすること- 金額計算は
Decimalで行いfloatを使わないこと - Google スタイル docstring・インラインコメント・名前付き定数を含めること
悪いコード (Before)
import json
class CampaignAnalyzer:
def __init__(self):
# 問題①: データソースをハードコード(テスト不可・変更困難)
self.data_file = "/data/orders.json"
def load_orders(self, campaign_id):
with open(self.data_file) as f:
data = json.load(f)
return [o for o in data if o["campaign_id"] == campaign_id]
def apply_coupon(self, order):
# 問題②: マジックストリング "fixed"/"percent"/"freeship"(typo 検出不可)
coupon = order.get("coupon")
if coupon is None:
return 0
if coupon["type"] == "fixed":
return min(coupon["value"], order["subtotal"])
elif coupon["type"] == "percent":
# 問題③: float 演算(0.1 + 0.2 != 0.3 問題 → 金額計算に不適切)
return order["subtotal"] * coupon["value"] / 100
elif coupon["type"] == "freeship":
return order["shipping_fee"]
else:
# 問題④: 未知種別でも 0 を返す(エラーを握りつぶす)
return 0
def summarize(self, campaign_id):
orders = self.load_orders(campaign_id)
# 問題⑤: 手動のキー存在チェック(defaultdict で不要)
result = {}
for order in orders:
cat = order["category"]
discount = self.apply_coupon(order)
if cat in result:
result[cat]["count"] += 1
result[cat]["gross"] += order["subtotal"]
result[cat]["discount"] += discount
else:
result[cat] = {
"count": 1,
"gross": order["subtotal"],
"discount": discount,
}
# 問題⑥: 生 dict を返す(frozen dataclass でなく型安全でない)
return result
def top_skus(self, campaign_id):
orders = self.load_orders(campaign_id)
sku_net = {}
for order in orders:
sku = order["sku_id"]
discount = self.apply_coupon(order)
net = order["subtotal"] - discount
if sku in sku_net:
sku_net[sku] += net
else:
sku_net[sku] = net
# 問題⑦: sorted()[:5] で全件ソート(heapq.nlargest より非効率)
top = sorted(sku_net.items(), key=lambda x: x[1], reverse=True)[:5]
return top
Protocol で抽象化して DI で差し込むこと(Ch5)"fixed"/"percent"/"freeship" — typo が実行時まで検出不可。StrEnum CouponType × match 文に変換すること(Ch6)float で金額計算 — subtotal * value / 100 で浮動小数点誤差が発生。Decimal + quantize() に変換すること(Ch4)case _: raise ValueError(...) で明示的に送出すること(Ch6)defaultdict(int) / defaultdict(lambda: ZERO) で廃絶できる(Ch7)CategorySummary(frozen=True, slots=True) に変換すること(Ch4)sorted()[:5] で全件ソート(非効率) — O(N log N)。heapq.nlargest(5, ...) で O(N log K) に改善すること(Ch7)ヒント(段階的開示)
ヒント1 — 方向性
match 文はクーポン種別(StrEnum)の条件分岐に使える。heapq.nlargest(N, iterable, key=...) は sorted(..., reverse=True)[:N] よりも大きなデータで効率的。Protocol でデータソースを抽象化することで、テスト時に FakeOrderRepo を差し込める。frozen=True dataclass で注文レコードを値オブジェクト化すると、誤変更を静的に防げる。金額は Decimal で扱うこと。
ヒント2 — アプローチ
Protocolでデータソース抽象化:from typing import Protocol class OrderRepository(Protocol): def fetch_orders(self, campaign_id: str) -> list[Order]: ...match文でクーポン分岐:match coupon.coupon_type: case CouponType.FIXED: discount = min(coupon.value, order.subtotal) case CouponType.PERCENT: discount = order.subtotal * coupon.value / PERCENT_DIVISOR case CouponType.FREESHIP: discount = order.shipping_fee case _: raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}")heapq.nlargestで Top-N:import heapq top_skus = heapq.nlargest(TOP_N, sku_net.items(), key=lambda kv: kv[1])Decimalで金額計算:from decimal import Decimal PERCENT_DIVISOR: Final[Decimal] = Decimal("100") discount = order.subtotal * coupon.value / PERCENT_DIVISOR discount = discount.quantize(Decimal("0.01")) # 小数第2位に丸め
ヒント3 — コードの骨格
from __future__ import annotations
import heapq
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
from typing import Final, Protocol, TypeAlias
TOP_N: Final[int] = 5
PERCENT_DIVISOR: Final[Decimal] = Decimal("100")
ZERO: Final[Decimal] = Decimal("0")
CampaignId: TypeAlias = str
class CouponType(StrEnum):
FIXED = "fixed"
PERCENT = "percent"
FREESHIP = "freeship"
@dataclass(frozen=True, slots=True)
class Coupon:
coupon_id: str
coupon_type: CouponType
value: Decimal
@dataclass(frozen=True, slots=True)
class Order:
order_id: str
sku_id: str
category: str
subtotal: Decimal
shipping_fee: Decimal
coupon: Coupon | None
@dataclass(frozen=True, slots=True)
class CategorySummary:
category: str
order_count: int
gross_revenue: Decimal
total_discount: Decimal
net_revenue: Decimal
class OrderRepository(Protocol):
def fetch_orders(self, campaign_id: CampaignId) -> list[Order]: ...
class CampaignAnalyzer:
def __init__(self, repo: OrderRepository) -> None:
self._repo = repo
def _apply_coupon(self, order: Order) -> Decimal:
if order.coupon is None:
return ZERO
coupon = order.coupon
match coupon.coupon_type:
case CouponType.FIXED:
return min(coupon.value, order.subtotal)
case CouponType.PERCENT:
return (order.subtotal * coupon.value / PERCENT_DIVISOR).quantize(Decimal("0.01"))
case CouponType.FREESHIP:
return order.shipping_fee
case _:
raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}")
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | データソースをファイルにハードコード(テスト不可) | Ch5 依存関係 | Protocol OrderRepository + DI で抽象化 |
| 2 | マジックストリング "fixed"/"percent"/"freeship" | Ch6 条件分岐 | StrEnum CouponType × match 文 |
| 3 | float で金額計算(浮動小数点誤差) | Ch4 不変/精度 | Decimal + quantize() |
| 4 | 未知クーポン種別で 0 を返す(握りつぶし) | Ch6 エラー処理 | case _: raise ValueError |
| 5 | 手動のキー存在チェック(if key in dict) | Ch7 コレクション | defaultdict(int) / defaultdict(lambda: ZERO) |
| 6 | 生 dict を返す(型安全でない集計結果) | Ch4 不変値 | CategorySummary(frozen=True, slots=True) |
| 7 | sorted()[:5] で全件ソート(O(N log N)) | Ch7 効率 | heapq.nlargest(TOP_N, ...) で O(N log K) |
模範解答
"""campaign_analyzer.py — クーポン適用 × カテゴリ別売上集計(Bad → Good リファクタリング)
良いコード・悪いコードで学ぶ設計入門(改訂新版)
- Ch4: frozen=True dataclass × Decimal で不変値オブジェクト × 金額精度保証
- Ch5: Protocol による依存関係の整理(テスタブル設計)
- Ch6: match 文でクーポン種別の条件分岐を1箇所に集約(DRY × OCP)
- Ch7: defaultdict + heapq.nlargest でコレクション操作を整理(効率化)
Python 3.12+ / match / Protocol / heapq / StrEnum / Decimal
"""
from __future__ import annotations
import heapq
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
from typing import Final, Protocol, TypeAlias
# ── 名前付き定数(マジックナンバー根絶)────────────────────────────────────
TOP_N: Final[int] = 5 # Top-N SKU ランキングの件数
PERCENT_DIVISOR: Final[Decimal] = Decimal("100") # % → 小数変換用除数
ZERO: Final[Decimal] = Decimal("0") # Decimal のゼロ値(型一致のため)
# PEP 695 TypeAlias(Python 3.12+)。str の意味を明示する型エイリアス
CampaignId: TypeAlias = str
# ── 問題①②: マジックストリングを StrEnum で型安全化(Ch6)────────────────
class CouponType(StrEnum):
"""クーポン種別を型安全に管理する StrEnum。
StrEnum は str のサブクラスなので json.dumps() にそのまま渡せる。
match 文の case 節でリテラル比較が静的に検証される。
"""
FIXED = "fixed" # 定額割引(円)
PERCENT = "percent" # 割合割引(%)
FREESHIP = "freeship" # 送料無料
# ── 問題③⑥: dict でなく frozen=True dataclass で値オブジェクト化(Ch4)──
@dataclass(frozen=True, slots=True)
class Coupon:
"""クーポン1件を表す不変値オブジェクト。
Attributes:
coupon_id: クーポンID。
coupon_type: クーポン種別(CouponType)。
value: 割引値(FIXED=円、PERCENT=%, FREESHIP は無視)。
"""
coupon_id: str
coupon_type: CouponType
value: Decimal # float ではなく Decimal(問題③の解消)
@dataclass(frozen=True, slots=True)
class Order:
"""注文1件を表す不変値オブジェクト。
Attributes:
order_id: 注文ID。
sku_id: 商品SKU ID。
category: 商品カテゴリ(例: "apparel", "electronics")。
subtotal: 小計(税抜き)。Decimal で精度を保証する。
shipping_fee: 送料。Decimal で精度を保証する。
coupon: 適用クーポン(なければ None)。
"""
order_id: str
sku_id: str
category: str
subtotal: Decimal # float → Decimal(問題③の解消)
shipping_fee: Decimal # float → Decimal(問題③の解消)
coupon: Coupon | None
@dataclass(frozen=True, slots=True)
class CategorySummary:
"""カテゴリ別売上集計を表す不変値オブジェクト(問題⑥の解消)。
Attributes:
category: 商品カテゴリ名。
order_count: 注文件数。
gross_revenue: クーポン適用前の総売上(小計合計)。
total_discount: クーポン割引総額。
net_revenue: クーポン適用後の純売上。
"""
category: str
order_count: int
gross_revenue: Decimal
total_discount: Decimal
net_revenue: Decimal
# ── 問題①: データソースを Protocol で抽象化(Ch5 依存関係整理)────────────
class OrderRepository(Protocol):
"""注文データを取得するデータソースのインターフェース。
実装例: BigQueryOrderRepo / FileOrderRepo / FakeOrderRepo(テスト用)。
Protocol にすることで CampaignAnalyzer はデータソースに依存しない。
"""
def fetch_orders(self, campaign_id: CampaignId) -> list[Order]:
"""指定キャンペーンIDに紐づく注文一覧を返す。"""
...
# ── メインクラス ──────────────────────────────────────────────────────────
class CampaignAnalyzer:
"""キャンペーン効果を集計・分析するクラス。
OrderRepository を DI で受け取るため、テスト時にモックを差し込める。
Attributes:
_repo: 注文データ取得リポジトリ(Protocol に準拠した任意の実装)。
"""
def __init__(self, repo: OrderRepository) -> None:
# アンダースコアでカプセル化(外部から直接アクセスさせない)
self._repo = repo
def _apply_coupon(self, order: Order) -> Decimal:
"""注文にクーポンを適用して割引額を返す(問題②④の解消)。
match 文(Python 3.10+)でクーポン種別を分岐する。
StrEnum と組み合わせることで mypy が網羅性を静的検証できる。
Args:
order: 割引額を計算する対象注文。
Returns:
割引額(Decimal)。クーポンなしは ZERO。
Raises:
ValueError: 未知の CouponType が渡された場合(問題④の解消)。
"""
if order.coupon is None:
return ZERO # クーポンなしは割引なし
coupon = order.coupon
match coupon.coupon_type:
case CouponType.FIXED:
# 定額割引: 割引額は小計を超えない(最大でも小計まで)
return min(coupon.value, order.subtotal)
case CouponType.PERCENT:
# 割合割引: subtotal × (value / 100)、小数第2位まで丸め
# float ではなく Decimal で計算(問題③の解消)
discount = order.subtotal * coupon.value / PERCENT_DIVISOR
return discount.quantize(Decimal("0.01"))
case CouponType.FREESHIP:
# 送料無料: shipping_fee 相当額を割引(Decimal で一致)
return order.shipping_fee
case _:
# StrEnum を使っていれば通常到達しないが防衛的に送出(問題④の解消)
raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}")
def summarize(self, campaign_id: CampaignId) -> list[CategorySummary]:
"""カテゴリ別の売上・割引サマリーを生成する(問題⑤⑥の解消)。
Args:
campaign_id: 集計対象のキャンペーンID。
Returns:
カテゴリ別 CategorySummary のリスト(net_revenue 降順)。
"""
orders = self._repo.fetch_orders(campaign_id)
# 問題⑤: 手動の if-else キー存在チェックを defaultdict で廃絶(Ch7)
counts: defaultdict[str, int] = defaultdict(int)
gross: defaultdict[str, Decimal] = defaultdict(lambda: ZERO)
discounts: defaultdict[str, Decimal] = defaultdict(lambda: ZERO)
for order in orders:
discount = self._apply_coupon(order) # 割引計算は1箇所に集約
counts[order.category] += 1
gross[order.category] += order.subtotal
discounts[order.category] += discount
# 問題⑥: 生 dict でなく CategorySummary(frozen=True)で返す(Ch4)
summaries = [
CategorySummary(
category=cat,
order_count=counts[cat],
gross_revenue=gross[cat],
total_discount=discounts[cat],
net_revenue=gross[cat] - discounts[cat],
)
for cat in counts
]
return sorted(summaries, key=lambda s: s.net_revenue, reverse=True)
def top_skus(self, campaign_id: CampaignId) -> list[tuple[str, Decimal]]:
"""純売上 Top-N の SKU リストを返す(問題⑦の解消)。
heapq.nlargest は O(N log K)(K=TOP_N)。
sorted()[:N] の O(N log N) より SKU 数が大きいほど効率的。
Args:
campaign_id: 集計対象のキャンペーンID。
Returns:
(sku_id, net_revenue) タプルリスト(net_revenue 降順、最大 TOP_N 件)。
"""
orders = self._repo.fetch_orders(campaign_id)
sku_net: defaultdict[str, Decimal] = defaultdict(lambda: ZERO)
for order in orders:
discount = self._apply_coupon(order)
sku_net[order.sku_id] += order.subtotal - discount
# 問題⑦: sorted()[:5] の代わりに heapq.nlargest で Top-N を効率取得(Ch7)
return heapq.nlargest(TOP_N, sku_net.items(), key=lambda kv: kv[1])
テスト用フィクスチャ(FakeOrderRepo)
from decimal import Decimal
# FakeOrderRepo: Protocol に準拠したテスト用モック(BQ 接続不要)
class FakeOrderRepo:
def __init__(self, orders: list[Order]) -> None:
self._orders = orders
def fetch_orders(self, campaign_id: str) -> list[Order]:
return [o for o in self._orders if True] # 全件返す(テスト用簡略化)
orders = [
Order("O-001", "SKU-A", "apparel", Decimal("5000"), Decimal("500"),
Coupon("C-1", CouponType.FIXED, Decimal("500"))),
Order("O-002", "SKU-B", "electronics", Decimal("20000"), Decimal("800"),
Coupon("C-2", CouponType.PERCENT, Decimal("10"))),
Order("O-003", "SKU-A", "apparel", Decimal("3000"), Decimal("0"),
Coupon("C-3", CouponType.FREESHIP, Decimal("0"))),
Order("O-004", "SKU-C", "apparel", Decimal("8000"), Decimal("500"), None),
]
analyzer = CampaignAnalyzer(repo=FakeOrderRepo(orders))
summarize() — カテゴリ別集計
summaries = analyzer.summarize("CMP-2026-06")
for s in summaries:
print(f"{s.category}: gross={s.gross_revenue}, discount={s.total_discount}, net={s.net_revenue}")
# apparel: gross=16000, discount=500, net=15500
# electronics: gross=20000, discount=2000.00, net=18000.00
# O-001 (apparel): FIXED 500円割引 → discount=500
# O-002 (electronics): PERCENT 10% → 20000 * 10 / 100 = 2000.00
# O-003 (apparel): FREESHIP → shipping_fee=0 → discount=0
# O-004 (apparel): クーポンなし → discount=0
# apparel: gross=5000+3000+8000=16000, discount=500+0+0=500, net=15500
# electronics: gross=20000, discount=2000.00, net=18000.00
top_skus() — Top-N SKU
top = analyzer.top_skus("CMP-2026-06")
for sku_id, net in top:
print(f" {sku_id}: net={net}")
# SKU-B: net=18000.00 ← electronics 20000 - 2000 = 18000
# SKU-C: net=8000 ← apparel 8000 - 0 = 8000
# SKU-A: net=7500 ← 5000-500 + 3000-0 = 7500
Decimal 精度確認
# float vs Decimal の精度差
print(0.1 + 0.2) # 0.30000000000000004 ← 誤差あり
print(Decimal("0.1") + Decimal("0.2")) # 0.3 ← 正確
# PERCENT 割引の精度
order = Order("O-X", "SKU-X", "test", Decimal("9999"), Decimal("0"),
Coupon("C-X", CouponType.PERCENT, Decimal("33")))
analyzer_x = CampaignAnalyzer(FakeOrderRepo([order]))
# 9999 * 33 / 100 = 3299.67(小数第2位に丸め)
discount = analyzer_x._apply_coupon(order)
print(discount) # 3299.67
heapq.nlargest の効率比較
# SKU 数が 100,000 の場合の計算量比較
# Bad: sorted(sku_net.items(), ...)[:5] → O(N log N) = O(100000 × 17) ≈ 1,700,000 ops
# Good: heapq.nlargest(5, sku_net.items(), ...) → O(N log K) = O(100000 × 2) ≈ 200,000 ops
# → 約 8.5 倍の効率化(K=5, log5 ≈ 2)
| ポイント | 適用した設計原則/パターン | 書籍/仕様対応 |
|---|---|---|
OrderRepository(Protocol) + DI | 依存関係の逆転・テスタブル設計 | Ch5 依存関係の整理 |
StrEnum CouponType | 型安全なステータス管理(文字列リテラル廃絶) | Ch6 条件分岐の整理 |
match coupon.coupon_type | パターンマッチングで条件分岐を1箇所に集約 | Ch6 / PEP 634 (Python 3.10+) |
case _: raise ValueError | 握りつぶし禁止・防衛的プログラミング | Ch6 / Ch10 エラー処理 |
defaultdict(int) / defaultdict(lambda: ZERO) | 手動 if-else のキー存在チェックを廃絶 | Ch7 コレクション操作の整理 |
heapq.nlargest(TOP_N, ...) | Top-N 取得の効率化 O(N log K) | Ch7 コレクション操作の整理 |
CategorySummary(frozen=True, slots=True) | 不変値オブジェクト(Value Object) | Ch4 不変の活用 |
Decimal + quantize() | 金額精度保証(浮動小数点誤差の排除) | Ch4 / Python decimal モジュール |
設計図 — Bad(ハードコード × float × sorted)vs Good(Protocol × Decimal × heapq)
ポイント解説
Protocol でデータソースを抽象化(Ch5)self.data_file = "/data/orders.json" のようにデータソースをクラス内にハードコードすると、テスト時にファイルを用意しなければならず、BigQuery 移行時にクラス全体を書き換えることになる。OrderRepository(Protocol) を定義して __init__ に DI することで、FakeOrderRepo(テスト用)/ BigQueryOrderRepo(本番)を差し替えられる。Protocol は抽象基底クラス(ABC)と異なり、継承なしで構造的サブタイピングが機能する。
match 文 × StrEnum でクーポン分岐を集約(Ch6)if coupon["type"] == "fixed": の文字列比較は "fixd" のような typo が実行時まで検出されない。StrEnum CouponType を定義し match coupon.coupon_type に変換すると、mypy が case 節の型を静的検証する。新しいクーポン種別(例: BUNDLE = "bundle")を追加するときも StrEnum に1行追加して _apply_coupon() に case を追加するだけで済む(OCP の部分的な達成)。
Decimal で金額精度を保証(Ch4)float は IEEE 754 の浮動小数点数であり、0.1 + 0.2 == 0.30000000000000004 になる。金額計算でこの誤差が積み重なると請求金額が数円ずれることがある。Decimal は10進小数を正確に表現し、quantize(Decimal("0.01")) で小数点以下2桁に丸めることができる。入力時点から Decimal を使い、内部計算で float に変換しないことが重要。
case _: raise ValueError で握りつぶし禁止(Ch6/Ch10)Bad コードの
else: return 0 は未知のクーポン種別が追加されたときにサイレントに 0 を返すため、バグが検出されない。case _: raise ValueError(f"未知のクーポン種別: {coupon.coupon_type!r}") に変更することで、新しい種別が追加されたが _apply_coupon() に対応する case を追加し忘れたときに即座にエラーが出る。
defaultdict で手動集計を廃絶(Ch7)if cat in result: result[cat]["count"] += 1 else: result[cat] = {"count": 1, ...} の手動キー存在チェックは defaultdict(int) / defaultdict(lambda: ZERO) で廃絶できる。集計ロジックの本質(何を足すか)だけがコードに残り、「存在確認してなければ初期化する」という定型句が消える。
heapq.nlargest で Top-N 取得を効率化(Ch7)sorted(sku_net.items(), key=..., reverse=True)[:5] は全件を O(N log N) でソートしてから先頭5件を取る。heapq.nlargest(5, sku_net.items(), key=...) は O(N log K)(K=5)で、N が大きいほど差が開く。SKU 数が 100,000 の場合、理論上約 8.5 倍効率的になる(log5 ≈ 2 vs log100000 ≈ 17)。
CategorySummary(frozen=True, slots=True) 値オブジェクト(Ch4)生 dict を返すと呼び出し元で
result["net_revneue"](typo)が実行時 KeyError になる。CategorySummary を frozen=True, slots=True の dataclass にすることで、フィールドへの直接代入禁止・メモリ効率化・mypy による属性アクセスの静的検証が同時に得られる。
実務への応用
- Argo Workflows キャンペーン集計ステップ:
CampaignAnalyzer.summarize()をワークフローの最終ステップに組み込み、CategorySummary(frozen=True)をdataclasses.asdict()で BigQuery のmart_campaign_summaryテーブルへ INSERT する。型付きのデータクラスは BQ スキーマ定義と1対1対応させやすい。 - BigQuery OrderRepository 実装:
BigQueryOrderRepoクラスでOrderRepository(Protocol)を実装し、BQ から注文データを取得する。Decimalは BQ のNUMERIC(精度29桁)型に対応するため、BigQuery Storage API からの読み取りも精度を落とさずに扱える。 - DataDog カスタムメトリクス:
top_skus()の結果を DataDogstatsd/ API で送信し「SKU別キャンペーン貢献売上」のダッシュボードを構築する。Decimal→floatの変換はメトリクス送信時のみ行い、内部計算は常にDecimalを維持すること。 - テスト戦略:
FakeOrderRepoをOrderRepositoryとして実装し、pytestでキャンペーン集計ロジックを BQ 接続なしにテストできる。Protocol採用によりunittest.mock.MagicMockでのモックも容易になる。dbt Unit Tests(dbt 1.8+)と組み合わせて Python バッチと SQL モデルの集計結果をクロスチェックする。
今日のまとめ
Protocol でデータソースを抽象化し match 文 × StrEnum でクーポン分岐を1箇所に集約することで、新しいクーポン種別の追加が _apply_coupon() の case 1行追加で完結する。Decimal × frozen=True dataclass × defaultdict × heapq.nlargest の組み合わせは、ECサイトの集計バッチにおける「精度・不変性・効率・型安全」の4条件を同時に満たす実務標準パターン。