17
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

DDDをHaskellで考える EntityとIdentity、そしてモデル図

Posted at

DDD初心者が拙いHaskellを使って色々考える試みです。

はじめに

先日DDDをHaskellで考える 業務ロジックとシステムロジックという記事を投稿しました。
この試みの個人的なひとまずのゴールとしては「Haskellを軸にレイヤー設計まで考えてみて、何か作りきる」なのですが、
その際に必ず登場するであろうEntityに付いて考えてみたいと思います。

ですが正直なところ、今回はあまりHaskellであることを活かせないのではないかと考えています。

また、先日やっとエヴァンス本を購入しましたが、時間が無くて全然読み進められてません。
今後ここで述べた考えを改める可能性もありますが、初学者の学習過程と思ってご容赦ください。

Haskellについて

手前味噌ですが僕が今回の試みにあたり軸に考えている部分を先述の投稿に載せています。
参照透過性と副作用、業務ロジックとビジネスロジック、関数とアクションについてはその記事と同じ意味で用います。

Entity

1年前、配属がかわり「お前はこれからDDDをやるんだ」と言われたときに、初めてEntityと言う単語を聞きました。

当時は何やら全くわかりませんでしたが、「きっと主キーがあって永続化するやつのことだな?」と解釈しました。
この考えは今でも外れていないと思っています。

以下の様な「主キー」と「属性」で表現される様なオブジェクトを以下Entityとします。

Item.java
public class Item {
    private final ItemId id;
    private final ItemName name;
}

Entityのステータス

ところで、永続化する以上は所謂ステータスの様な概念があるはずです。
足してみましょう。

Item.java
public class Item {
    private final ItemId id;
    private final ItemName name;
    private final ItemStatus status;
}

シンプルに解決出来た様に思いますが、この方針をとったことで職場で苦しむことになりました。
この方法は以下の問題にいずれ直面することになります。

  • 全てのステータスにおいて同じ属性を持つとは限らない
  • 特定のステータスの場合のみ行いたい処理を表現しづらい
  • Itemに関する全てが集まるので巨大になる
  • モデル図(もしくはクラス図)にItemというクラスがひとつ現れるだけで得られる情報がほぼない

Identity

もしかしたらステータスごとに別のEntityにしても良かったのでは、と話す様になったころ、たまたまIdentityという単語を聞きました。
どうやら察するに「Entityを一意に特定する値(もしくは値群)」である様です。
(上記Itemの例で言うとItemIdが相当します)

なぜIdentityをあえてEntityとわけて捉えるのかを考えたところ、必ずしもIdentityEntityは1:1ではないと言う考えに至りました。

これは丁度疑問に思っていた、Entityをステータス毎にわける、と言う考えと一致すると思い、早速試し書きをしてみました。

お題と試し書き

例によって都合の良いお題を考え、それを実装します。

業者が利用者に商品を貸し出す
(具体的にどの様な商売であるかはこの記事の関心外なので、ItemUserとします)

  1. 商品は倉庫にある
  • このとき商品は新品もしくは中古品である
  • 倉庫の商品は引当により引当済みになる
  • 引き当てられた商品は発送により発送済みになる
  • 着荷予定日を持つ
  • 受け取りにより受け取り済みになる
  • 受け取り日時を持つ

また、キャンセルおよび返却により商品は倉庫に戻る

  • ユーザの全ての商品をキャンセルする
  • キャンセルは引当済みか発送済みの状態なら可能
  • 倉庫に戻る際に新品か否かを引き継ぐ
  • 返却は受け取り済みの場合のみ可能
  • 返却の際に中古品とする

実装はHaskellで行います。

型を用意

まずは型を用意します。

ただのStringをラップしただけの型やEnumimportは省略します。

StockedItem.hs
-- 倉庫の商品
data StockedItem = StockedItem {
    id            :: ItemId,
    name          :: ItemName,
    stockedStatus :: StockedStatus,
    status        :: ItemStatus
} deriving Show
ProvisionedItem.hs
-- 引当済みの商品
data ProvisionedItem = ProvisionedItem {
    id            :: ItemId,
    name          :: ItemName,
    stockedStatus :: StockedStatus,
    status        :: ItemStatus
} deriving Show
ShippedItem.hs
-- 発送済みの商品
data ShippedItem = ShippedItem {
    id            :: ItemId, 
    name          :: ItemName,
    stockedStatus :: StockedStatus,
    arrival       :: ArrivalScheduledDate, -- 着荷予定日
    status        :: ItemStatus
} deriving Show
ReceivedItem.hs
-- 受け取り済みの商品
data ReceivedItem = ReceivedItem {
    id       :: ItemId,
    name     :: ItemName,
    received :: ReceivedDate, -- 受取日
    status   :: ItemStatus
} deriving Show

今回は状態毎に違う型としてみました。
また所謂interface Itemの様なものは無く、これらXxxItemは完全に独立した別の型として存在します。

受け取り済みまでの振る舞いを用意

次に振る舞いを用意します。

今回はLifeCycleというXxxItemの遷移についてのみ責任を持つモジュールを実装する形にしてみました。

LifeCycle.hs
provision :: StockedItem -> ProvisionedItem
provision stocked = ProvisionedItem (StockedItem.id stocked) (StockedItem.name stocked) (StockedItem.stockedStatus stocked) Provisioned

ship :: ProvisionedItem -> ArrivalScheduledDate -> ShippedItem
ship provisioned date = ShippedItem (ProvisionedItem.id provisioned) (ProvisionedItem.name provisioned) (ProvisionedItem.stockedStatus provisioned) date Shipped

receive :: ShippedItem -> ReceivedDate -> ReceivedItem
receive shipped date = ReceivedItem (ShippedItem.id shipped) (ShippedItem.name shipped) date Received

-- XxxItem (StockedItem.id stocked) (StockedItem.name stocked) の様な記述は
-- Javaで言うところの new XxxItem(stocked.getId(), stocked.getName()) に相当します

当然ですが全て関数で実現します。

状態遷移がこの3つの関数で表せている感じがします。
また、shipには着荷予定日が、receiveには受取日が必要なことがわかります。

キャンセルを実現する

キャンセルは引当済み、もしくは発送済みに対して実施できます。
それを実現する方法は軽く考えてもいくつかの方法がある様に思えます。

ここではUserIdでDBから商品を複数件を参照してきて、それら全てをキャンセルする処理を考えます。

新しい型を作り、それを複数件得た後にキャンセルを実施する

findCancelable :: UserId -> IO [CancelableItem]

cancel :: CancelableItem -> StockedItem
  • :thumbsup:
  • CancelableItemが登場する処理はキャンセルに関連する処理だと察しやすい
  • キャンセルに関する仕様変更をCancelableItem関連に局所化出来そう
  • :thumbsdown:
  • 型もリポジトリもものすごい数になりそう
  • キャンセル可能とは何か、がリポジトリに隠れてしまう(後述)

引当済みリストの参照と発送済みリストの参照を別に行い、両方の全てをキャンセルする

findProvisionedItems :: UserId -> IO [ProvisionedItem]

findShippedItems :: UserId -> IO [ShippedItem]

cancelProvisionedItem :: ShippedItem -> ProvisionedItem

cancelShippedItem :: ShippedItem -> StockedItem
  • :thumbsup:
  • キャンセルを実現する際に増える型やアクションがない
  • 更に別の要求を実現する際も、特に新たに何かを用意する必要が無い
  • :thumbsdown:
  • リポジトリにキャンセルという単語が現れないので影響範囲を限定できない感じがする
  • 他にもキャンセル可能なステータスが出来た場合に書き足すことが多そう
  • provisionedshippedをたらい回すいたる箇所で「その2つがキャンセル可能である」と脳内補完しなければならない

上記の方法をタプルを使い実現する

findCancelable :: UserId -> IO ([ProvisionedItem], [ShippedItem])

cancelProvisionedItem :: ShippedItem -> ProvisionedItem

cancelShippedItem :: ShippedItem -> StockedItem
  • :thumbsup:
  • タプルにすればprovisionedshippedをある程度はまとめておける
  • タプルであれば色々な複数を扱う概念を全てタプルで済ませられるので、新たな型を用意するよりは楽そう
  • :thumbsdown:
  • キャンセル可能という概念が存在するのにただタプルで表現するのはDDD的ではないのではないか
  • Haskellは3要素以上のタプルを扱うのが少し面倒な気がする

ではどうするか

上記いずれにも長短があるが、感覚としては最初に述べたCancelableItemを採用するべきだと感じている。
もともとが複数ステータスを1つの型で表現したら苦しんだという課題のはずなので、今回は極端なまでに型を分けてみたいと思う。
(結局のところこの試みは初学者の探求なので、迷ったら極端に振り切ってみようと思う)

しかしfindCancelable :: UserId -> IO [CancelableItem]の様な型定義だと、内部の実装は
where status == Provisioned or status == Shippedみたいにハードコードになってしまうだろう。

リポジトリについては後々じっくり考えたいが、仕様を極力型で表現しようと思い、今回はあまり深く考えずにCancelableCondと言う型を用意してみることにする。

CancelableCond.hs
data CancelableCond = CancelableCond { status :: [ItemStatus] } deriving Show

cancelableCond = CancelableCond [Provisioned, Shipped]

-- コンストラクタを非公開関数にしておけば cancelableCond が唯一の CancelableCond の値に出来ると思う

これを用いてリポジトリの参照アクションを以下の様に実装してみるとする。

findCancelable :: UserId -> CancelableCond -> IO [CancelableItem]

これで型とアクション名からキャンセルに関するアクションであることも、具体的にキャンセル可能とは何かも知ることが出来そう。

そしてこの方針をとるのであれば、今後型がとても増えることに覚悟を決めてCancelableItemを用意しなければならない。

data CancelableItem = CancelableItem {
    id            :: ItemId,
    name          :: ItemName,
    stockedStatus :: StockedStatus,
    status        :: ItemStatus
} deriving Show

この2つを用意すれば、あとは実装あるのみだ。
LifeCycleにキャンセルと、勢いで返却も実装してしまおう。

LifeCycle.hs
cancel :: CancelableItem -> StockedItem
cancel cancelable = StockedItem (CancelableItem.id cancelable) (CancelableItem.name cancelable) (CancelableItem.stockedStatus cancelable) Stocked

returnBack :: ReceivedItem -> StockedItem
returnBack received = StockedItem (ReceivedItem.id received) (ReceivedItem.name received) Used Stocked

目立たないけど、返却する際はUsedが固定で埋め込まれているのがポイントです。

やってみて

結局今回はIdentityItemを特定するが、特定した結果どのステータスのEntityを得るかは文脈に応じてリポジトリを使い分けることで決める、と言う方針を取った。

他のアクションの定義イメージ

findProvisioned :: UserId -> ProvisionedCond -> IO [ProvisionedItem]

findCancelable :: UserId -> CancelableCond -> IO [CancelableItem]

findReceived :: UserId -> ReceivedCond -> IO [ReceivedItem]

キャンセルと言う仕様がCancelと言う単語で現れる事で、仕様の表現度と改修範囲の局所化が出来そうだ。
型やアクションが多いくなる事の大変さはもう少し規模を大きくしないとわからないと思うので、今回はここまで。

モデル図

Entityを分けた場合のもう一つの利点として、モデル図が自然と多くの情報を表現出来る様になると言う点があると感じた。

クラス図ではなくて、状態遷移図を書いてみた。
型を四角で、振る舞いを矢印で表現出来ているし、必要なパラメータが必要なタイミングで渡される様が見て取れる。
LifeCycle.png

巨大Entityの表現をクラス図で済ませてしまうと、得られる情報が少なくなってしまう。
例えばOptionalの項目が埋まるタイミングや、各振る舞いが実行出来るのはどの状態のEntityなのか、と言った肝心な部分がわからない。
Item.png

ステータス毎にクラスをわけなかった場合の実装

せっかくなので、ステータスを分けなかった場合の実装をJavaで考えてみることにする。

Item.java
public class Item {
    private final ItemId itemId;
    private final ItemName itemName;
    private final ItemStatus itemStatus;
    private final Optional<StockStatus> stockStatus;
    private final Optional<ArrivalScheduledDate> arrivalScheduledDate;
    private final Optional<ReceivedDate> receivedDate;

    public StockStatus getStockStatus() {
        return stockStatus.orElseThrow(
                () -> new RuntimeException("no StockStatus present")
        );
    }

    public ArrivalScheduledDate getArrivalScheduledDate() {
        return arrivalScheduledDate.orElseThrow(
                () -> new RuntimeException("no ArrivalScheduledDate present")
        );
    }

    public ReceivedDate getReceivedDate() {
        return receivedDate.orElseThrow(
                () -> new RuntimeException("no ReceivedDate present")
        );
    }

    public Item returnBack() {
        assert itemStatus == ItemStatus.Received;

        return new Item(
                itemId,
                itemName,
                itemStatus,
                Optional.of(StockStatus.Used),
                Optional.<ArrivalScheduledDate>empty(),
                Optional.<ReceivedDate>empty()
        );
    }
}

一部しか書いてませんが、こんな感じになるのではないかと思います。

shipreturnBackを命じる前に自身のステータスをチェックしたり、Optionalの中身を無理矢理取り出すgetterが必要になります。
これの一番の問題は、それらが実行例外になることです。

せっかく静的言語でやっているのに、肝心のロジック部分が実行例外ではなんか心許ないというかもったいないというか、そんな感じがします。

Entityを分けた場合にステータスを持つべきか

ステータスごとにEntityを分けた場合、ItemStatusをデータ構造的に保持するべきか、少し迷いました。

特に強い理由はありませんが、今回は持たせています。
もう少し大きなコードを書いてみてまた考えたいと思います。

おわりに

今回は以上です。
あまりHaskellである事を活かせた感じはしませんが、手書きしてみたコードと出来たモデル図から経験値は得られたのではないかと思います。

もっと軽く書き上がる感じにしたいので、次は失敗の表現に付いて短くまとめてみる予定です。

17
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?