ユースケース駆動開発実践_実装

設計が済んだら実装を進めたいと思います。JavaのSpringBootを使って実装したいと思います。

コントローラー

コントローラーはリクエストを受け取るインターフェースになります。リクエストを受け取った後にユースケース毎のアプリケーションサービスを呼び出すようにします。

package jp.co.studev.controller.order;

import io.swagger.annotations.ApiOperation;
import jp.co.studev.application.useCase.order.OrderApplicationService;
import jp.co.studev.domain.order.model.CreditCard;
import jp.co.studev.domain.order.model.PayType;
import jp.co.studev.domain.order.model.Payment;
import jp.co.studev.errorHandler.MyException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.io.Serializable;
import java.util.List;

@RestController
@RequestMapping("shopping")
public class ShoppingConteroller {

  @Autowired
  private OrderApplicationService orderApplicationService;

  @RequestMapping(value = "order", method = RequestMethod.POST, consumes= MediaType.APPLICATION_JSON_VALUE)
  @ApiOperation(value = "Returns new order result", notes = "response description", response = Boolean.class, produces = "text/json")
  public boolean newOrder(@RequestBody NewOrderRequest request) throws Exception{
    return orderApplicationService.newOrder(request.getAddress(), request.getPayment(), request.getCouponcode());
  }
}


class NewOrderRequest implements Serializable {
  private String address;
  int paycode;
  String creditnumber;
  List<String> couponcode;

  public Payment getPayment () throws MyException{
    PayType payType = PayType.byCode(this.paycode);
    if (payType.equals(PayType.CreditCard)){
      return new CreditCard(payType, this.creditnumber);
    } else {
      return new Payment(payType);
    }
  }
  public String getAddress() {
    return address;
  }

  public void setAddress(String address) {
    this.address = address;
  }

  public int getPaycode() {
    return paycode;
  }

  public void setPaycode(int paycode) {
    this.paycode = paycode;
  }

  public String getCreditnumber() {
    return creditnumber;
  }

  public void setCreditnumber(String creditnumber) {
    this.creditnumber = creditnumber;
  }

  public List<String> getCouponcode() {
    return couponcode;
  }

  public void setCouponcode(List<String> couponcode) {
    this.couponcode = couponcode;
  }
}

アプリケーションサービス

アプリケーションサービスではユースケース毎のメソッドを実装し、アプリケーションサービス内で各ドメインのサービスを呼び出します。ドメインのサービスとしてデータを参照やドメインオブジェクトのインスタンスを生成するサービスとデータの永続化を行うサービスで分けて実装しています。

package jp.co.studev.application.useCase.order;

import jp.co.studev.domain.coupon.model.Coupon;
import jp.co.studev.domain.coupon.service.CouponDataService;
import jp.co.studev.domain.coupon.service.CouponRefService;
import jp.co.studev.domain.order.model.AdditionalAmout;
import jp.co.studev.domain.order.model.Order;
import jp.co.studev.domain.order.model.PayType;
import jp.co.studev.domain.order.model.Payment;
import jp.co.studev.domain.order.service.OrderDataService;
import jp.co.studev.domain.order.service.OrderRefService;
import jp.co.studev.domain.product.model.Product;
import jp.co.studev.domain.product.service.ProductRefService;
import jp.co.studev.domain.share.model.Item;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class OrderApplicationService {

  @Autowired
  OrderRefService orderRefService;
  @Autowired
  OrderDataService orderDataService;
  @Autowired
  CouponRefService couponRefService;
  @Autowired
  CouponDataService couponDataService;


  @Transactional(readOnly = false, propagation = Propagation.REQUIRES_NEW)
  public boolean newOrder(String shippingAdress, Payment payment, List<String> couponCodeList) throws Exception{
    int userId = 1;

    List<Coupon> couponList = couponRefService.findCouponByCodes(couponCodeList);
    Order newOrder = orderRefService.newOrder(userId, shippingAdress, payment, couponList);

    orderDataService.newOrder(newOrder);
    couponDataService.couponUsed(couponList);
    return true;
  }
}

ドメインサービス

ドメインサービス内では集約されたドメインオブジェクトの生成やデータの永続化を行います。以下はデータ参照の方のサービスの例です。集約されたオブジェクトの生成はそれだけでも複雑なロジックが必要になるので対応するドメインサービス内で対応できるようにします。ただし集約のルートオブジェクト側で対応できるのであればコンストラクタに渡して対応できるようにします。

package jp.co.studev.domain.order.service;

import jp.co.studev.domain.coupon.model.Coupon;
import jp.co.studev.domain.coupon.service.CouponRefService;
import jp.co.studev.domain.order.model.AdditionalAmout;
import jp.co.studev.domain.order.model.CouponPay;
import jp.co.studev.domain.order.model.Order;
import jp.co.studev.domain.order.model.OrderStatus;
import jp.co.studev.domain.order.model.PayType;
import jp.co.studev.domain.order.model.Payment;
import jp.co.studev.domain.product.model.Product;
import jp.co.studev.domain.product.service.ProductRefService;
import jp.co.studev.domain.share.model.Item;
import jp.co.studev.errorHandler.MyException;
import jp.co.studev.infla.dao.ItemDao;
import jp.co.studev.infla.dao.OrderDao;
import jp.co.studev.infla.dao.PayoffDao;
import jp.co.studev.infla.dao.ProductDao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collector;
import java.util.stream.Collectors;

/**
 * データ参照用のサービス
 * インスタンス生成用サービス
 */
@Service
public class OrderRefService {

  @Autowired
  OrderDao orderDao;
  @Autowired
  PayoffDao payoffDao;
  @Autowired
  ItemDao itemDao;
  @Autowired
  ProductDao productDao;

  private final static Logger logger = LoggerFactory.getLogger(OrderRefService.class);
  @Autowired
  ProductRefService productRefService;
  @Autowired
  CouponRefService couponRefService;

  public Order newOrder(int userId, String shippingAdress, Payment payment, List<Coupon> couponList) throws Exception{
    Order currentCart = shopingCart(userId);
    List<Item> items = itemDao.findItemsByOrderId(currentCart.getOrderId());

    // クーポン
    if(couponList.stream().filter(coupon -> coupon.getIsUsed() == 1).collect((Collectors.toList())).size() > 0){
      throw new MyException("使用済みクーポンを検出");
    }
    List<AdditionalAmout> additionalAmouts = couponList.stream().map((coupon) -> {
      return new CouponPay(coupon.getAmount(), coupon.getCouponId()); }).collect((Collectors.toList()));

    Map<Item, Product> changeAmountItems = changeAmountItems(items, productRefService.findProductByIds(items.stream()
            .map((i) -> { return i.getProductId();}).collect(Collectors.toList())));
    if(changeAmountItems.size() > 0 ){
      throw new MyException("金額の変更を検出");
    }

    Order newOrder = currentCart.newOrder(items, additionalAmouts, shippingAdress, payment);
    return newOrder;
  }

  // ショッピングカート時を取得
  private Order shopingCart(int userId) throws Exception{
    List<Order> cart = orderDao.findOrderByUserIdStatus(userId, OrderStatus.ShopingCart);
    if(cart.size() > 1){
      throw new MyException("");
    } else if(cart.size() == 0){
      return new Order(userId, OrderStatus.ShopingCart, new ArrayList<Item>());
    } else {
      return cart.get(0);
    }
  }

  // 最新の金額を受け取って金額に変更のあったアイテムリストを返す
  private Map<Item, Product> changeAmountItems(List<Item> items, List<Product> latestInfos) throws Exception{
    Map<Item,Product> ret = new HashMap<Item, Product>();
    for ( Item pastInfo : items) {
      boolean findFlg = false;
      for ( Product latestInfo : latestInfos) {
        if ( pastInfo.getProductId() == latestInfo.getProductId()) {
          findFlg = true;
          if (pastInfo.getAmount() != latestInfo.getAmount() ) {
            ret.put(pastInfo, latestInfo);
          }
          if ( !findFlg){
            throw new Exception();
          }
        }
      }
    }
    return ret;
  }
}

続いてデータの永続化を行うサービスになります。

package jp.co.studev.domain.order.service;

import jp.co.studev.domain.order.model.AdditionalAmout;
import jp.co.studev.domain.order.model.Order;
import jp.co.studev.domain.order.model.OrderStatus;
import jp.co.studev.domain.order.model.PayType;
import jp.co.studev.domain.share.model.Item;
import jp.co.studev.errorHandler.MyException;
import jp.co.studev.infla.dao.OrderDao;
import jp.co.studev.infla.dao.PayoffDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

/**
 * 登録用サービス
 */
@Service
public class OrderDataService {

  @Autowired
  OrderDao orderDao;
  @Autowired
  PayoffDao payoffDao;

  public Order newOrder(Order order) {
    orderDao.updateOrder(order);
    // TODO 精算情報周りのインサート処理(item情報は事前に変更がないことを担保して呼び出す)
    return order;
  }
}

集約のルートオブジェクト

集約のルートオブジェクトでコンストラクタのロジックによりオブジェクトを正しく生成できるようにします。例えば前回の注文のドメインオブジェクトであれば注文の状態によって支払い方法や配送先の情報があったり、なかったりするのでコンストラクタにより整合性が取れるように対応します。以下は注文のルートオプジェクトのクラスになります。

package jp.co.studev.domain.order.model;

import jp.co.studev.domain.product.model.Product;
import jp.co.studev.domain.share.model.Item;

import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class Order {
  int orderId;
  int userId;
  String shippingAddress;
  OrderStatus orderStatus;
  LocalDateTime createDate;
  LocalDateTime updateDate;

  Optional<Payoff> payoff = Optional.empty();
  List<Item> items;
  List<AdditionalAmout> additionalAmouts;


  // 永続化後のコンストラクタ
  public Order(int orderId, int userId, String shippingAddress, int orderStatusCode, Timestamp createDate, Timestamp updateDate) {
    this.orderId = orderId;
    this.userId = userId;
    this.shippingAddress = shippingAddress;
    this.orderStatus = OrderStatus.byCode(orderStatusCode);
    this.createDate = createDate.toLocalDateTime();
    this.updateDate = updateDate.toLocalDateTime();
  }

  // 新規カート作成コンストラクター
  public Order(int userId, OrderStatus orderStatus, List<Item> items) throws Exception{
    if(orderStatus != OrderStatus.ShopingCart){
      throw new Exception("");
    }
    this.userId = userId;
    this.orderStatus = orderStatus;
    this.items = items;
  }

  // 注文時のコンストラクター
  Order(int orderId, int userId, String shippingAddress, OrderStatus orderStatus, List<Item> items,
               List<AdditionalAmout> additionalAmouts, Payment payment, LocalDateTime createDate) throws Exception{
    if(!orderStatus.equals(OrderStatus.Decieded)){
      throw new Exception("");
    }
    this.orderId = orderId;
    this.userId = userId;
    this.orderStatus = orderStatus;
    this.shippingAddress = shippingAddress;
    this.items = items;
    this.additionalAmouts = additionalAmouts;
    int tax = 5;
    LocalDateTime orderDate = LocalDateTime.now();
    this.createDate = createDate;
    this.updateDate = orderDate;

    int sumAmount = cartAmountSum() + additionalAmoutSum();

    // 3000円以下なら配送料追加
    if (sumAmount < 3000 && isNotContainShipingCost()) {
      AdditionalAmout shipingCost = new ShipingCost(500);
      this.additionalAmouts.add(shipingCost);
      sumAmount += shipingCost.amount;
    }

    // 決済情報の初期化
    this.payoff = Optional.of(new Payoff(payment, sumAmount, tax, orderDate));
  }

  public Order newOrder(List<Item> items, List<AdditionalAmout> additionalAmouts, String shippingAddressm, Payment payment) throws Exception {
    return new Order(this.orderId, this.userId, shippingAddressm, OrderStatus.Decieded, items, additionalAmouts, payment, this.createDate);
  }


  public void changeItem(List<Item> newItems) throws Exception{
    if ( this.orderStatus != OrderStatus.ShopingCart){
      throw new Exception("");
    }
    for ( Item newItem : newItems) {
      boolean existing = false;
      for (Item item : this.items) {
        if (item.getProductId() == newItem.getProductId()) {
          existing = true;
          this.items.remove(item);
          this.items.add(newItem);
        }
      }
      if (!existing) {
        this.items.add(newItem);
      }
    }
  }


  private int cartAmountSum(){
    int sumAmount = 0;
    for (Item item : this.items) {
      sumAmount += item.getAmount() * item.getNum();
    }
    return sumAmount;
  }

  private int additionalAmoutSum(){
    int sumAmount = 0;
    for ( AdditionalAmout adamount: this.additionalAmouts){
      sumAmount += adamount.amount;
    }
    return sumAmount;
  }

  private boolean isNotContainShipingCost(){
    for ( AdditionalAmout adamount: this.additionalAmouts){
      if ( adamount instanceof ShipingCost){
        return false;
      }
    }
    return true;
  }


  public int getOrderId() {
    return orderId;
  }

  public int getUserId() {
    return userId;
  }

  public String getShippingAddress() {
    return shippingAddress;
  }

  public OrderStatus getOrderStatus() {
    return orderStatus;
  }

  public LocalDateTime getCreateDate() {
    return createDate;
  }

  public LocalDateTime getUpdateDate() {
    return updateDate;
  }

  public Optional<Payoff> getPayoff() {
    return payoff;
  }

  public List<Item> getItems() {
    return items;
  }

  public List<AdditionalAmout> getAdditionalAmouts() {
    return additionalAmouts;
  }

  public void setOrderId(int orderId) {
    this.orderId = orderId;
  }

  public void setUserId(int userId) {
    this.userId = userId;
  }

  public void setShippingAddress(String shippingAddress) {
    this.shippingAddress = shippingAddress;
  }

  public void setOrderStatus(OrderStatus orderStatus) {
    this.orderStatus = orderStatus;
  }

  public void setCreateDate(LocalDateTime createDate) {
    this.createDate = createDate;
  }

  public void setUpdateDate(LocalDateTime updateDate) {
    this.updateDate = updateDate;
  }

  public void setPayoff(Optional<Payoff> payoff) {
    this.payoff = payoff;
  }

  public void setItems(List<Item> items) {
    this.items = items;
  }

  public void setAdditionalAmouts(List<AdditionalAmout> additionalAmouts) {
    this.additionalAmouts = additionalAmouts;
  }
}

状態を表すeval

注文状態などをコードの値として管理するのは大変そうなのでまずはevalで対応できるようにしてみます。注文状態として以下のevalを作成します。

package jp.co.studev.domain.order.model;

public enum OrderStatus {
  None(-1, "該当なし"),
  ShopingCart(1, "ショッピング中"),
  Decieded(2, "確定"),
  Cancel(100, "キャンセル");

  private int code;
  private String name;

  OrderStatus(int code, String name) {
    this.code = code;
    this.name = name;
  }
  public int getCode() {
    return this.code;
  }
  public String getName() {
    return this.name;
  }

  public static OrderStatus byCode(int code) {
    if (ShopingCart.code == code) {
      return ShopingCart;
    } else if (Decieded.code == code) {
      return Decieded;
    } else if (Cancel.code == code) {
      return Cancel;
    } else {
      return None;
    }
  }
}

DBアクセス周りについてはDBから取得した値をevalにマッピングできるか調査が必要になる場合があるかもしれないと思います。今回使っているmybatisの場合はリソースファイルに以下のように mybatis.type-handlers-package の設定を入れておけば、指定したパッケージにevalに変換するためのtype hanlderを追加することでDBに入っているintの値をevalに変換などすることができます。

mybatis.type-handlers-package=jp.co.studev.infla.mybatis.typehandler

この場合、OrderStatusに変換するtype handlerは以下のようになります。

package jp.co.studev.infla.mybatis.typehandler;

import jp.co.studev.domain.order.model.OrderStatus;
import jp.co.studev.domain.order.model.PayStatus;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class OrderStatusHandler extends BaseTypeHandler<OrderStatus> {

  @Override
  public void setNonNullParameter(PreparedStatement ps, int i, OrderStatus parameter, JdbcType jdbcType)
          throws SQLException {
    ps.setString(i, parameter.getName());
  }

  @Override
  public OrderStatus getNullableResult(ResultSet rs, String columnName)
          throws SQLException {
    return OrderStatus.byCode(rs.getInt(columnName));
  }

  @Override
  public OrderStatus getNullableResult(ResultSet rs, int columnIndex)
          throws SQLException {
    return OrderStatus.byCode(rs.getInt(columnIndex));
  }

  @Override
  public OrderStatus getNullableResult(CallableStatement cs, int columnIndex)
          throws SQLException {
    return OrderStatus.byCode(cs.getInt(columnIndex));
  }

}

また、そもそもマッピング先の型にコンストラクタを作成しておけばそちらが優先して呼ばれるようなので、専用のtype handlerを作成するよりも楽かもしれないです。例えば以下のようなコンストラクターを作成しておくことでコンストラクタ側でevalに変換することができます。

// 永続化後のコンストラクタ
public Order(int orderId, int userId, String shippingAddress, int orderStatusCode, Timestamp createDate, Timestamp updateDate) {
  this.orderId = orderId;
  this.userId = userId;
  this.shippingAddress = shippingAddress;
  this.orderStatus = OrderStatus.byCode(orderStatusCode);
  this.createDate = createDate.toLocalDateTime();
  this.updateDate = updateDate.toLocalDateTime();
}

作成中ですが実装中のコードはこちらになります。