標籤彙整: domain driven design

DDD實戰:建立訂單的多種方式

需求

設計一套專案管理軟體,有三種身份的使用者能建立專案。分別是「管理員」、「已註冊顧客」、「未註冊顧客」。

原解法

在Project類別底下,實作三個靜態函式:
* createForAdmin
* createForClient
* createForGuest
分別代表三種建立專案的方法。

在建立時只要呼叫:

$project = Project::createForClient();

即可。

問題

Project類別的函式代表了「一個專案擁有的方法、屬性」,現又加入了靜態函式去代表「關於專案的一切函式」。
在Project類別還小時,這是一個快速又可接受的作法。
但當Project類別肥大起來,這增加了理解的困難(Project代表的含意太過廣泛)。

DDD觀點

負責建立domain objects的任務可以獨立成一個Factory類別。
在建立時只要呼叫:

$factory = new ProjectFactory();
$project = $factory->createForClient();

即可。

DDD實戰:一段訂單建立程序-Part 2

我在DDD實戰:一段訂單建立程序-Part 1進行了一次效果不佳的重構。

最後不盡理想的結果如下:

orderRepository = new OrderRepository();
    }

    private function getNewOrderOrCreate()
    {
        if (Auth::check()){

            $order = $this->orderRepository->getNewOrderByUser(Auth::user());
            if ( !$order ){
                $order = Order::createWithUser(Auth::user());
            }
                        
        } else {
            
            $order = $this->orderRepository->getNewOrderBySesssionId(Session::getId());            
            if ( !$order ){
                $order = Order::createWithSession(Session::getId());
            }

        }
        
        return $order;
    }

    public function getOrder()
    {              
        $order = $this->orderRepository->getNewOrderOrCreate();

        return View::make('client/order', ['order' => $order]);
    }

}

我對controller中的getNewOrderOrCreate函式非常不滿意。
controller中的其他部份,都只是初始化某些類別、把某些參數傳來傳去,謹此而已,唯獨這個函式內含複雜邏輯。真醜。

但是那些判斷logic確實是controller該負責的事情,也不能丟進model,怎麼辦呢?

DDD觀點

DDD中區分了Application Layer、Domain Layer。乍看之下getNewOrderOrCreate不能進Domain Layer,只能硬放在Application Layer變成醜controller。

我在DDD實戰:一段金流程序提到過Service的概念。

我研究了一下發現…

其實Service又可分為Application Service、Domain Service以及Infrastructure Service。

要判斷一個Service隸屬於哪種,常常非常困難(譬如說,您覺得DDD實戰:一段金流程序中的ReceivePaymentNotification算是Application service還是Domain service?)

不過眼前的getNewOrderOrCreate卻非常清楚,應該是屬於Application Layer!

(但這都沒關係。目前我沒打算針對三種Service在程式中加上namespace,也沒打算在檔案結構上分別開資料夾之類的。現階段我就隨便開個Service資料夾,然後管他哪種Service通通丟進去就好了。)

那麼,就讓我來封裝一個Service,接著在Controller使用它吧:

getOrCreate();

        return View::make('client/order', ['order' => $order]);
    }

}
orderRepository = new OrderRepository();
    }
    
    public function getOrCreate()
    {
        if (Auth::check()){

            $order = $this->orderRepository->getNewOrderByUser(Auth::user());
            if ( !$order ){
                $order = Order::createWithUser(Auth::user());
            }
                        
        } else {
            
            $order = $this->orderRepository->getNewOrderBySesssionId(Session::getId());            
            if ( !$order ){
                $order = Order::createWithSession(Session::getId());
            }

        }
        
        return $order;
    }
        
}

終於成功了!終於讓controller內的code看起來非常簡潔、expressive,卻又不影響到Domain Layer!
我還發現,getNewOrderOrCreate光看函式名稱就覺得概念模糊。這種模糊感本身就暗示了抽象化的不足、程式設計想得不夠清楚。
一模一樣的內容,封裝成類別、再重新命名,就成了FetchOrderForClient底下的getOrCreate,真是清楚多了。

DDD實戰:一段訂單建立程序-Part 1

需求

我公司為一電子商務公司,顧客可在網路上下訂單購買商品。
為提供出色顧客消費體驗,故允許顧客可不註冊不登入、直接建立訂單(付款前再要求登入即可)。

原解法

* 將已登入之顧客訂單紀錄顧客id;未登入之顧客訂單紀錄session id。

在MVC架構之下,我個人嚴禁在M內直接操作session,所以我將已登入之顧客訂單建立程序封裝於createWithUser函式,並傳入目前使用者;將未登入之顧客訂單建立程序封裝於createWithSession並傳入當前session id。
以上兩函式屬於Model範疇。
我接著將判斷使用者登入與否、以及判斷顧客是否有「未完成訂單」的code寫在controller,並封裝成getNewOrderOrCreate(拿出顧客之前未完成訂單、讓顧客繼續買東西。不然就建立一筆新訂單。)

inStatus(Order::CREATED_STATUS)->first();
            if ( !$order ){
                $order = Order::createWithUser(Auth::user());
            }        

        } else { // use is not logined

            $order = Order::where('session_id', Session::getId())->inStatus(Order::CREATED_STATUS)->first();
            if ( !$order ){
                $order = Order::createWithSession(Session::getId());
            }

        }
        return $order;        
    }

    public function getOrder()
    {              
        $order = $this->getNewOrderOrCreate();

        return View::make('client/order', ['order' => $order]);
    }

}

問題:
* getNewOrderOrCreate函式負責太多事情,code讀起來十分困難

已經將建立程序封裝在Model了,controller的code還是這麼雜,怎麼辦呢?

DDD觀點

應該定義一個專門負責以各種不同方式、參數取出domain objects的Repository類別。

我決定撰寫一個OrderRepository類別專職處理getNewOrderOrCreate負責的任務,並在controller中使用這個類別:

orderRepository = new OrderRepository();
    }

    public function getOrder()
    {              
        $order = $this->orderRepository->getNewOrderOrCreate();

        return View::make('client/order', ['order' => $order]);
    }

}
inStatus(Order::CREATED_STATUS)->first();
            if ( !$order ){
                $order = Order::createWithUser(Auth::user());
            }        

        } else { // use is not logined

            $order = Order::where('session_id', Session::getId())->inStatus(Order::CREATED_STATUS)->first();
            if ( !$order ){
                $order = Order::createWithSession(Session::getId());
            }

        }
        return $order;        
    }
    
}

controller內的code終於簡潔多了!
如果說DDD的repository對應到MVC,是M中最靠近C的一層的話…
那麼MVC常要求的’skinny controller, fat model’我終於做到了吧!

問題:
* 只是將code由C移出,getNewOrderOrCreate難以理解的問題依然存在
* 現在repository內操作session了…有點像在M內操作session的感覺,這不對吧?不是說了嚴禁嗎?

反省

getNewOrderOrCreate太過含糊不清,應該讓repository內的函式更expressive,光看函式名就得知會取出什麼樣的domain objects。否則,不但不夠reusable、也失去多寫一層repository的意義。

兩點想法:

* 關於session的操作,還是讓它留在controller內吧。

* controller對應到DDD觀點下,要負責application layer大部分的任務。
application layer本來就要負責知道當前提供的應用走到哪一階段(state)。
因此到底要取出未完成訂單、還是建立一筆新訂單,是controller要負責的事。

orderRepository = new OrderRepository();
    }

    private function getNewOrderOrCreate()
    {
        if (Auth::check()){

            $order = $this->orderRepository->getNewOrderByUser(Auth::user());
            if ( !$order ){
                $order = Order::createWithUser(Auth::user());
            }
                        
        } else {
            
            $order = $this->orderRepository->getNewOrderBySesssionId(Session::getId());            
            if ( !$order ){
                $order = Order::createWithSession(Session::getId());
            }

        }
        
        return $order;
    }

    public function getOrder()
    {              
        $order = $this->getNewOrderOrCreate();

        return View::make('client/order', ['order' => $order]);
    }

}
inStatus(Order::CREATED_STATUS)->first();
    }
    
    public function getNewOrderBySesssionId($sessionId)
    {
        return Order::where('session_id', $sessionId)->inStatus(Order::CREATED_STATUS)->first();        
    }
    
}

問題:
* controller又有點變回一開始那個鳥樣了
* 雖然repository讓controller內的code更可讀一點點了…,但反省半天到最後只有這麼一點benefit,而且repository內的函式在其他地方未必真的會用到,這reusability也許不必要
* 綜合以上兩點,我可不可以說這根本是over design、over engineering?

結論

如您所見,在我的範例之中,增加一層repository,當下得到的benefit並不大。有些人會認為跟最一開始的code根本沒差多少。

根據Martin Fowler的介紹,當query logic龐大起來時,多加上Repository這層專門負責處理這些query logic,不但可以減少duplicate code,還可以讓呼叫方呼叫時更清楚自己在呼叫什麼。

Repository在DDD中被認為是Domain Layer最重要的組成之一。
何時引進這層,就看當下需求了。

2015-01-04 更新

我越看那個controller越覺得很不爽。
研究了一下,發現DDD當然有指引這類問題的方向。
我缺少的是Application Service。
參見:DDD實戰:一段訂單建立程序-Part 2

DDD實戰:一段金流程序

最近開始研究DDD(Domain Driven Design),發現它確實是組織大型軟體架構出色又實際的方法。
可惜網路上只找得到一堆理論與說明,實際的範例code很少,我只好自己摸索了。
我會陸續將成果分享出來,希望對需要的人有所幫助。


需求

我公司串接由A公司提供之金流服務(第三方支付),A公司在確認收到顧客付款(信用卡、ATM轉帳)之後,會以HTTP POST通知我公司某設定好之URL。
由於可能有粗心大意之顧客,針對單筆訂單送出多筆交易、重複付款,因此需要在發現重複付款之時通知A公司放棄之前相關授權交易。

原解法

我公司有代表交易之trades資料表與代表訂單之orders資料表。
商業邏輯如下:
* 使用A公司SDK計算與驗證參數
* 從trades找到對應之交易並設為「已授權」
* 從orders找到對應之訂單並設為「已付款」

在MVC架構之下,我將其撰寫於某專責於金流交易之controller:

fetchParams();

        // 驗證失敗,參數並非A公司加密傳來
        if (!$params) {
            return '0|Fail';        
        }
         
        // 更新交易狀態
        $t = Trade::where('trade_no', $params['trade_no'])->first();
        $t->recieved_datetime = date('c');            
        $t->status = 1;
        $t->save();
        
        // 重複付款了,發送API通知A公司取消前幾筆交易
        if ($t->order->status == ORDER::PAID_STATUS){
            // blah blah
            // ...
            return '1|OK';            
        }
        
        // 更新訂單狀態
        $t->order->status = ORDER::PAID_STATUS;
        $t->order->save();
        
        return '1|OK';            
    }

}

問題:
* code可讀性不夠高
* 此controller action一次處理多件事情、分工不佳

反省

也許可將這串程序封裝成Trade類別底下的一個動作?


class SomeController extends BaseController {

    public function postPaymentReceive(){
        // A公司提供之SDK
        $sdk = new SDK();
        $params = $sdk->fetchParams();

        // 驗證失敗,參數並非A公司加密傳來
        if (!$params) {
            return '0|Fail';        
        }
        
        // 授權此筆交易
        // 內含交易狀態更新、訂單狀態更新、重複扣款通知
        $t = Trade::where('trade_no', $params['trade_no'])->first();
        $t->authenticate($params);
                
        return '1|OK';            
    }

}

問題:
* 只是將code由controller移進model,所有問題都依舊存在

在MVC架構之下,只追求’thin controller, fat model’會很容易將軟體架構只改善到這個階段。

DDD觀點

一個「交易」實體本身不會「授權」自己,應該由「某個程序」去進行「授權」一個「交易」實體的動作。
也就是說這行code不合理:

$t->authenticate();

它將原本Trade類別的意義由「一筆交易擁有的屬性、行為」擴大去包含了「會對一筆交易施加的行為」。
就算改寫為靜態函式依然擴大了原Trade類別的意義。
Trade類別的意義過廣會導致可讀性降低、不好理解、難維護。

DDD中關於一個service的判斷如下:
* 涉及到domain中的其他object
* stateless
* 涉及一個domain,通常不屬於一個entity或value object

我決定撰寫一個service類別專職處理這段接收通知的金流程序,並在controller中使用這個類別:


class SomeController extends BaseController {

    public function postPaymentReceive(){
        // A公司提供之SDK
        $sdk = new SDK();
        $params = $sdk->fetchParams();

        $service = new ReceivePaymentNotification();

        return $service->handle($params);        
    }

}
processParameters($params);

        return '1|OK';
    }
    
    public function processParameters($params)
    {        
        $t = Trade::where('trade_no', $params['trade_no'])->first();

        $this->processTransaction($t, $params);        

        $this->processOrder($t->order);        
    }

    public function processTransaction($t, $params)
    {
        $t->recieved_datetime = date('c');            
        $t->status = 1;
        $t->save();
    }
    
    public function processOrder($o)
    {

        // 重複付款了,發送API通知A公司取消前幾筆交易
        if ($o->status == ORDER::PAID_STATUS){
            // blah blah
            // ...
            return '1|OK';            
        }
        
        // 更新訂單狀態
        $o->status = ORDER::PAID_STATUS;
        $o->save();
    }
    
}

幾乎只是將原本的code剪下、貼上而已,卻花了我整個下午。

您覺得可讀性、可維護性有改善嗎?

期待您的寶貴意見,歡迎在下方給我feedback。