モデル・コードの変更がどう表現されるかハンズオン体験談

# はじめに

以下イベントにて、 BIGLOBEさんの「B:モデル・コードの変更がどう表現されるかハンズオン」に参加してきました。

[レガシーをぶっつぶせ。現場でDDD！2nd 「インプット＜アウトプット！」](https://genbade-ddd.connpass.com/event/156060/)

その時の体験を元に、実施した内容、学習ステップの共有になります。

- ここでの理解は、受け止め方の一例になります。
- 元のハンズオンは Java で実施しましたが、自分自身の理解を深める目的で Python で書き直しています。
  - 書き換えの過程で、BIGLOBEさんが作った元のコードと異なるやり方になってる箇所もあるかもしれません。
  - 受け止め方の一例として解釈いただければと思います。

以下に、本記事で修正したコードを保存しています。
https://github.com/98lerr/workshopmobile

本家は、以下にあります。
ブランチで、ハンズオン中にグループごとに修正した内容も保管していただいています。
https://github.com/biglobe-isp/workshopmobile

## ハンズオンの概要

このハンズオンは、要件追加時、モデルとコードの修正がどのように行われていくかを体験するハンズオンです。
詳細は、README.md をご参照ください。

今回の教材は、 格安SIM の料金プランです。
初期状態では 1GB プランのみが選択可能なコードが用意されています。
ハンズオンを通し、大容量プランや、オプションプランを追加していけるようにします。

## ハンズオンを通じて大事と思ったこと

- モデルを無闇に複雑にしないことを心がける。
  - 要求事項を一度に反映せず、一つずつ反映する。
  - 「あとで必要になりそうだから」という実装はしない。今必要なものにフォーカスする。
- 悩んでも決まらない名前は、一旦決めてしまうのも一つの手。
  - テストがあれば、リファクタできる。

# ハンズオンでやったこと、考えたこと

## 初期段階

以下のコードになります。
https://github.com/98lerr/workshopmobile/tree/FirstState

モデルとして、以下が提示された状態です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286147/05a9e8be-9a42-a47a-8550-700ab0766ae3.png" height=200px>
*図: 最初のモデル*

ドメイン層には、プランと月額のみが用意されています。

- プラン: src/main/python/mobile/domain/plan/Plan.py
- 月額: src/main/python/mobile/domain/fee/MonthlyFee.py

呼び出し側(ここではインターフェース層、アプリケーション層を一つにまとめている)では、まだプランが1つしかないので、プランを固定して呼び出しています。
(`request_obj` は本当は `request` にしたい変数です。interfaceに用いたパッケージの flask で予約された変数なので、こうしてます。)

```python:src/main/python/mobile/api/MonthlyFeeApi.py
@app.route("/monthly-fee", methods=['GET'])
def monthly_fee():
    request_obj = Request(request.args.get("plan"), request.args.get("entame_free"))
    response = {"monthly_fee": Plan.plan_1ギガ.value.get_value()} |

    return jsonify(response) 
```

テストは、最終的な要件に合わせて書かれてます。
現時点では、「1G、エンタメフリーオプションなし」のケースしか通りません。

## 最初のステップ

今回追加される要件は、以下二点です。
- 回線のプランが3種類に増える。
- 「エンタメフリーオプション」が増える。(ただし1G契約では利用不可)

まずは、現状実装の横展開になる「回線のプランが3種類に増える」のみを考えます。

### モデルの修正

モデルに変更なく、実施できそうです。
吹き出しで、選択肢が増えたことだけを表現します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286147/92883c2f-56f4-28b9-3204-d0d4c927c551.png" height=200px>
*図: 月額のプランを増やす*

### コードの修正

変更した結果が、以下になります。
https://github.com/98lerr/workshopmobile/tree/SecondState

まずは、 `Plan` の候補を追加します。

```diff:src/main/python/mobile/domain/plan/Plan.py
class Plan(Enum):
     plan_1ギガ = MonthlyFee(1000)
+    plan_3ギガ = MonthlyFee(2000)
+    plan_30ギガ = MonthlyFee(6000)
```

ドメイン部分は、これで実装完了です。
モデルが変わってないのでシンプルです。

次に、呼び出し側を修正します。
1ギガの固定をやめました。
その分、input別の呼び出しメソッドを `Request` で用意し、 `Request` のインスタンス経由で呼び出すようにしています。

```diff:src/main/python/mobile/api/MonthlyFeeApi.py
def monthly_fee():
    request_obj = Request(request.args.get("plan"), 
    request.args.get("entame_free")) 
-   response = {"monthly_fee": Plan.plan_1ギガ.value.get_value()} 
+   plan = request_obj.get_plan() 
+   response = {"monthly_fee": plan.value.get_value()} 

    return jsonify(response) 
```

```diff:src/main/python/mobile/api/Request.py
+   def get_plan(self):
+       if self.plan == "g1":
+           return Plan.plan_1ギガ
+       if self.plan == "g3":
+           return Plan.plan_3ギガ
+       if self.plan == "g30":
+           return Plan.plan_30ギガ
+       raise RuntimeError("failed")
```

これで、エンタメオプションなしのテストケースは通るようになりました。

## エンタメオプションを追加する(残りの実装)

### モデルの修正

エンタメオプション、という新しい言葉が出て来るのでモデル更新が発生します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286147/c9f94454-b5c9-4935-dd2c-9a5bbd42aaea.png" height=200px>
*図: エンタメオプションを追加する*

ここで、3つ疑問が出てきました。

- エンタメオプションがあるからには、いろんなオプションがある？
- エンタメオプションは月額だけ？年額もある？
- 金額をまとめるのは誰の仕事？ 

それぞれについて、考えてみます。

#### [エンタメオプションがあるからには、いろんなオプションがある？]

エンタメオプションと言う名前からして、今後色々な「〜オプション」と言うのが出てくることが容易に想像できます。
すると、ここではいきなり「エンタメオプション」を作るのではなく、「オプション」を挟んだ形にした方がいいのではないかと言う考えになってきます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286147/28c61eb6-3656-fb5c-1e93-c8fdd24d9e38.png" height=200px>
*図: エンタメオプションは一旦オプションでまとめる？*

ハンズオン内で出した結論は、「考慮しない(今回はエンタメオプションだけを考える)」です。

今必要なのは、「エンタメオプションを追加できる」ことです。
他のオプションが追加された時のことは、そのときに考えれば良い。
そのとき、オプション、というまとめ方が適切でないかもしれません。
将来をあれこれ想像するとキリがなくなります。
今必要な更新、に修正を絞ります。(ここが、このハンズオン一番の肝だと思っています)

#### [エンタメオプションは月額だけ？年額もある？]

月額使用量に対する金額を表す プラン と 月額料金 に対し、エンタメオプションは今後違う料金体系があるかもしれません。
プラン と同じ 月額料金 で金額を表して良いのか？という疑問が出てきました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286147/22ef3094-26de-904c-1295-718d7f4048d6.png" height=200px>
*図: エンタメオプションの料金はプランと別に表現した方が良い？*

ハンズオン内で出した結論は、こちらも「考慮しない(月額料金で表す)」です。

理由は先ほどと同じです。
要件にない将来をあれこれ想像するとキリがないです。今必要なことを実装します。

#### [金額をまとめるのは誰の仕事？]

残るは、プラン、エンタメオプションの合計額を出すのは誰か？です。

プランがまとめるのか、エンタメオプションがまとめるのか、ユースケースがまとめるのか。
候補は色々出てきます。

ここで、もう一つの制約も考慮しなくてはなりません。
> エンタメフリーオプション
> - プランが3ギガ/30ギガの場合購入可能。(1ギガは利用不可)。

このことを、プランが知っているのも、エンタメオプションが知っているのも不自然ですね。
ましてやユースケースに知識を持たせるわけにもいきません。

何かしらの、上位概念が必要ということがわかってきました。
「言葉として大きすぎる」という懸念もありましたが、これを `契約` と呼ぶことにしました。

最終的なモデルは、以下になります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286147/35ad05a2-d540-4e7a-e3b1-2b7b39fb8522.png" height=270px>
*図: エンタメオプションを追加したモデル*

### コードの修正

変更した結果が、以下になります。
https://github.com/98lerr/workshopmobile/tree/ThirdState

まずは、エンタメフリーオプションを新規作成します。

```python:src/main/python/mobile/domain/option/エンタメフリーオプション.py
class エンタメフリーオプション(Enum):
    なし = MonthlyFee(0)
    あり = MonthlyFee(1200)
```

続いて、契約を新規作成します。

```python:src/main/python/mobile/domain/contract/契約.py
class 契約:
    def __init__(self, プラン:Plan, エンタメフリーオプション: エンタメフリーオプション):
        if プラン == Plan.plan_1ギガ and エンタメフリーオプション == エンタメフリーオプション.あり:
            raise RuntimeError
        self.plan = プラン
        self.entame_free = エンタメフリーオプション

    def 月額を取得(self):
        return self.plan.value.get_value() + self.entame_free.value.get_value()
```

これに合わせて、Request を修正します。
これまでは `MonthlyFeeApi.py` から　Plan に直接金額を訊ねていましたが、契約からとるようにしました。

```diff:
+   def get_entame_free(self) -> エンタメフリーオプション:
+       if self.entame_free == "true":
+           return エンタメフリーオプション.あり
+       if self.entame_free == "false":
+           return エンタメフリーオプション.なし
+       raise RuntimeError("failed")
+
+   def get_contract(self) -> 契約:
+       return 契約(self.get_plan(), self.get_entame_free())
```

これで、全てのテストケースが通るようになりました。
モデル、コード、双方の修正完了です。

# 最後に

ハンズオンの時間制約で、理解不十分な箇所があったことから、
コードを別言語で書き直した上で同じことをやり直すアプローチをとってみました。

余談ですが、今回のクラス名、変数名はかなり日本語を使っています。
ハンズオン内で「Javaは日本語いける」と伺ったので「もしかしてPythonも？？」と試してみましたが、ちゃんと動くんですね。