Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

チケットモデリングをしてレビュー会してみた話

adventCalendar2021-day11

この記事はテックタッチアドベントカレンダー11日目を担当する misu です。最近、Slack の reminder で slash command が起動できないことを知らなくて悲しい思いをしました。

この記事について

こちらのリポジトリの問題を解いてチームへ持ち寄ってレビューしてもらった結果について書いています。 結論、自分では考えきれなかったことやアイデアを得られ、かつ時間は少なかったものの盛り上がったので、次回もやろうという運びになりました。

前提条件

モデリングの制約抜粋(リポジトリ に記載)

対象のチケット一覧(こちらより引用、一部考慮しなくていいものはわかりやすいようにマーク)

モデリング / コーディング

ユースケースとしては以下を考えました。

  • 消費者として、チケット料金の一覧を見れる(前提条件に載せたもの)

チケット一覧で料金が返されるようにしたいと思います。そのため、チケットごとに条件に応じて料金を計算する必要があります。 チケットの料金の計算は下記条件の組み合わせによって決まると読み取りました。

  • 日付
    • ex.)映画の日(毎月1日)
  • 曜日
    • ex.)平日, 土日
  • 祝日
  • 時間

これらによって料金が計算できるので、この組み合わせを PricePattern としました。 PricePattern にチケットの料金を渡すと、計算された結果を持ったチケットが返ってくるとします。これを PricedTicket としました。

コードを下記のように書いてみました。(Goです)

type PricedTicket struct {
    Name  string // PricePattern の Name が入る
    Price int
}

type PricePattern struct {
    Name string // ex.) 平日20時まで
    *DayDiscount
    *DayOfWeekDiscount
    *HolidayDiscount
    *TimeDiscount
}

func (a *PricePattern) Caliculate(base int) PricedTicket {
    re := base
    if a.DayDiscount != nil {
        re += a.DayDiscount.DiscountValue
    }
    if a.DayOfWeekDiscount != nil {
        re += a.DayOfWeekDiscount.DiscountValue
    }
    if a.HolidayDiscount != nil {
        re += a.HolidayDiscount.DiscountValue
    }
    if a.TimeDiscount != nil {
        re += a.TimeDiscount.DiscountValue
    }
    return PricedTicket{
        Name:  a.Name,
        Price: re,
    }
}

表のような形に対応するため、チケット(Ticket)が PricePattern を複数持つようにしました。Ticket は PricePattern から PricedTicket のリストを返却できるようにしておきます。(PricedTicketList() メソッド)

このメソッドを使った後に、表に合わせたレスポンス用のオブジェクトに詰めればいいと考えました。表のヘッダがネストしていて複雑であり、それをモデル側で吸収するのは難しそうだと考えたからです。モデルは画面に引っ張られずにシンプルに保っておきたいです。

type Ticket struct {
    // ex.) 一般
    Name          string
    Price         int
    PricePatterns []PricePattern
}

func (a *Ticket) PricedTicketList() []PricedTicket {
    re := make([]PricedTicket, 0, len(a.PricePatterns))
    for _, item := range a.PricePatterns {
        re = append(re, item.Caliculate(a.Price))
    }
    return re
}

なお、今回は考慮しませんでしたが、チケット購入の経験から席によっては値段が増減されるかも知れません。 チケットを買うときは、見たい映画の時間を選ぶ → 席を選ぶというシーケンスが経験としてあったので。 コードの全容はページ最下部に載せておきました。

レビュー

レビューの前に要件やアウトプットのイメージのすりあわせのため、5 ~ 10 分ほど自分だったらこうモデリングするということを考えてもらいました。

指摘や QA 一部

Q: 表を出すには問題なさそう。ただ、購入明細を考えるとそれで運用できる?明細からチケットをたどるのが大変では?
A: PricePattern に ID をもたせる必要がある。しかし、PricePattern から Ticket 辿る必要があるので、明細には PricePattern の ID と Ticket の ID をもたせればいいかも知れない。


Q: PricePattern で実装したら、テストパターンなど運用が大変そう。考えていたがうまくまとまらなかった。
A: ですよね。先の質問であったとおり PricePattern は他のユースケース考えたときには破綻しそうな気がするので考え直したほうがいいと思う。


Q: 購入時の検索を考えるとそれぞれ表のマス目に応じたマスタデータでもつのが現実的?(購入の日時やユーザ属性でチケット割り出す)
A: 計算モデルの実装だったのでその考えはなかった。結局実運用考えるとそういうモデリングになってしまうのかな。。


  • チケットを買うのは User であり、User の属性(会員など)によって Ticket の値段が変わる。そのため、Ticket と User を扱う Service クラスで計算を実行する形でもよさそう。

反省と設計振り返り

レビューを 30 分で企画しましたが、考えてもらう時間を含めると時間が足りず不完全燃焼になってしまいました。30 分は弊社の技術共有会のタイムスケジュールに合わせて設定したという背景があります。短かったためにモデルの考察が深く進まず、マスタ採用な形になってしまったのかなと思いました。ここは完全に私のスケジュールミスです。もう少し考えたいという声がほとんどだったので、次回は1時間枠でやりたいと思います。設計考えることや議論自体は好評だったのでまた近々開催することになりました。

設計と実装については、正直、柔軟さや実運用の仕様を考えると再考しないといけないと思っています。課題リポジトリ の考察に近い実装にはなっていそうだと思いましたが(Ticket自体に計算を実装しておらず、別の概念として切り出したため) PricePattern が要件によって膨れてしまうことは、容易に想像がつきます。

今の時点でさえ、多くの Object を持ってしまっています。そのためチケットの値段決定に関わる Object を複数もつものが計算を行うのではなく、仕様(時間・日付・会員などの組み合わせ)をドメイン側に定義して計算を行うほうが、コードを読んで仕様がわかりやすいし、それぞれの計算ルールをシンプルにしていけると思いました。

今回のようなチケットを買うときの条件(請求)に応じてチケットの値段が決まる、条件に応じたチケットの値段一覧が必要なときにはその仕様をコードに落とし込んでしまうのが有効だと学びました。仕様を定義しておくことで請求に適したものが選択できるし、一覧では仕様を返しておけばよいかと思います。

自分が最初に書いたコードを変えていくのであれば、PricePattern を Interface 的な位置づけにし、チケット一覧に適合する計算仕様をそれぞれ Object としてまとめていってしまうのがよさそうだと思いました。

参考

コード

package domain

import (
    "errors"
    "fmt"
    "time"
)

type Ticket struct {
    // ex.) 一般
    Name          string
    Price         int
    PricePatterns []PricePattern
}

type PricedTicket struct {
    Name  string
    Price int
}

func (a *Ticket) PricedTicketList() []PricedTicket {
    re := make([]PricedTicket, 0, len(a.PricePatterns))
    for _, item := range a.PricePatterns {
        re = append(re, item.Caliculate(a.Price))
    }
    return re
}

type PricePattern struct {
    Name string // ex.) 平日20時まで
    *DayDiscount
    *DayOfWeekDiscount
    *HolidayDiscount
    *TimeDiscount
}

func (a *PricePattern) Caliculate(base int) PricedTicket {
    re := base
    if a.DayDiscount != nil {
        re += a.DayDiscount.DiscountValue
    }
    if a.DayOfWeekDiscount != nil {
        re += a.DayOfWeekDiscount.DiscountValue
    }
    if a.HolidayDiscount != nil {
        re += a.HolidayDiscount.DiscountValue
    }
    if a.TimeDiscount != nil {
        re += a.TimeDiscount.DiscountValue
    }
    return PricedTicket{
        Name:  a.Name,
        Price: re,
    }
}

type Day int

func NewDay(v int) (Day, error) {
    d := Day(v)
    if err := d.validate(); err != nil {
        return d, err
    }
    return d, nil
}

func (a Day) validate() error {
    if a <= 0 || 31 < a {
        return errors.New("day range is 1 - 31")
    }
    return nil
}

// 日付割引
type DayDiscount struct {
    Day           Day
    DiscountValue int
}

type Weekday string

const (
    sunday    = "sunday"
    monday    = "monday"
    tuesday   = "tuesday"
    wednesday = "wednesday"
    thursday  = "thursday"
    friday    = "friday"
    saturday  = "saturday"
)

func NewWeekday(v string) (Weekday, error) {
    d := Weekday(v)
    if err := d.validate(); err != nil {
        return d, err
    }
    return d, nil
}

func (a Weekday) validate() error {
    switch a {
    case sunday, monday, tuesday, wednesday, thursday, friday, saturday:
    default:
        return fmt.Errorf("invalid weekday: %v", a)
    }
    return nil
}

// 曜日割引
type DayOfWeekDiscount struct {
    DayOfWeek     Weekday
    DiscountValue int
}

// 時間割引
type TimeDiscount struct {
    StartHour     int
    StartMinute   int
    EndHour       int
    EndMinute     int
    DiscountValue int
}

// 祝日割引
type HolidayDiscount struct {
    Date          time.Time
    DiscountValue int
}