かとじゅんの技術日誌

技術の話をするところ

ウェブアプリケーションの構造について

日経ソフトウエア 11月号の特集2で「最新Eclipseで良いJavaプログラムを書こう」に関連する話題として、さらに視野を広げて実用的なウェブアプリケーションでのレイヤー構造とかドメインオブジェクトの関係はどうなるのか?という点について解説してみたいと思います。(まだ日経ソフトウエア11月号を手にしていない方はぜひ買ってくださいw)

結論から先に出しますが、ドメイン駆動設計では一般論として下図のようなレイヤー構造やオブジェクトの関連が提唱されています。

ドメイン層のオブジェクトについては変わりないのですが、ドメイン以外のレイヤーに新しく2つのサービスが登場しているので、まずそこから簡単に説明します。

ドメイン層以外のサービス

実はサービスはドメイン層だけではなく、アプリケーション層とインフラストラクチャ層にも存在する場合があります。その役割を以下にまとめてみました。

レイヤー オブジェクト 役割
アプリケーション層 サービス アプリケーション固有のサービスを提供するクラス。ドメインオブジェクトやドメイン層のサービスを使って、アプリケーション固有の振る舞い(ビジネスロジック)を実装します。多くの場合はビジネスロジックをアプリケーションのサービスクラスに実装して、UI層から利用することになります。
インフラストラクチャ層 サービス インフラストラクチャ固有のサービスを提供するクラス。たとえば、データベースやネットワークに対する振る舞いを提供するサービスです。たとえば、データベースの特定のテーブルに検索や更新系のクエリを送信するサービス。*1ネットワークであればサーバとクライアントの機能を提供するサービスなどが考えられます。

ドメイン層のオブジェクトは、使いようによっては様々な要件をアプリケーションとして実装できる汎用的な部品群です。そのため、アプリケーション固有な要件はドメイン層ではなく、アプリケーション層のサービスに実装します。ドメインは汎用的に、アプリケーションはそれらを使ってより具体的に表現します。また、ドメイン層はビジネスにおける問題領域を扱うことが責務なので、データベースやネットワークについてはインフラストラクチャ層からサービスとしてドメイン層にインターフェイスを提供するようにします。このようにレイヤーの責務を逸脱しなければ、ドメイン層の見通しがよくなり、わかりやすくて理解しやすい設計に近づけることができます。

DXO(Data eXchange Object)とは

上図ではUI層のモデルと、ドメイン層のエンティティ、インフラストラクチャ層のエンティティは、いずれも似たような情報を使います。たとえば、顧客の名前などを保持した情報です。その情報を画面から受け取ってDBに保存したり、DBから検索して画面に表示したりします。(上図のDXOの矢印の部分です。矢印の配置が悪いのですが、変換対象はドメイン層のエンティティやバリューオブジェクトになります)似たような情報になるはずなのに、レイヤーがわかれて別々のオブジェクトを定義しているのはなぜでしょうか?

その理由を、典型的なユーザ登録画面の内部を例に考えてみたいと思います。

仮に以下のようなクラス郡がある前提とします。具体的なソースは下部にあります。

  • UI層は何らかのウェブフレームワーク(たとえばStrutsJSFなど)で、ユーザ登録画面に入力された情報をRegisterUserFormに、画面の処理をRegisterUserActionで担当します。
  • アプリケーション層では、ユーザ登録のビジネスロジックをUserRegisterServiceで担当します。
  • ドメイン層では、ユーザを識別するエンティティとしてUser、ユーザの名前を表すバリューオブジェクトとして、Nameが存在します。また、リポジトリとしてはUserRepositoryが存在します。
  • インフラストラクチャ層はユーザ情報は複数のテーブルに分かれていて、それぞれにデータベースアクセスのためのサービスとしてDaoクラスが用意されています。

大まかな処理内容を以下に示します。

UI層
  • ユーザ登録画面で入力された情報はRegisterUserFormに格納される
  • 次にRegisterUserAction#doRegisterメソッドが呼ばれます。引数には1のRegisterUserFormが渡される。
  • RegisterUserFormDxo#convertUserメソッドの引数にRegisterUserFormを指定し、Userに変換する。この際、RegisterUserFormの姓と名、姓かなと名かなの独立しているプロパティを、Nameに変換している。また、RegisterUserForm#getRegisterDateは画面の都合上、文字列の型ですが、ドメイン上では扱いやすくするためにjava.util.Dateに変換している。
  • UserRegisterService#registerメソッドの引数に渡し、呼び出す。
アプリケーション層
  • UserRegisterService#registerメソッドではアプリケーションとしてのビジネスロジックが実装され、その中でDBに永続化する場合にUserRepositoryInDB#storeメソッドが呼ばれます。
ドメイン層
  • UserRepositoryInDB#storeメソッドの引数にUserを指定して呼び出す。
  • UserRepositoryInDB#storeメソッド内では、UserDxo#convertUserTableメソッドでUserTable, UserDxo#convertUserProfileTableでテーブルクラスに変換する。特にconvertUserProfileTableメソッドでは、Userが持つそれぞれのNameを姓と名、姓かなと名かなに変換している。また、UserProfileTable#setRegisterDateは、Timestamp型なので変換が必要となる。
  • さらにUserDao, UserProfileDaoを使って、データベースに対して登録処理を行ないます。


レイヤーが異なるとそれぞれのオブジェクトの持つプロパティの形式や型が変わってくることがわかったと思います。このようなデータの変換処理を行うオブジェクトのことをDXOと呼びます。

レイヤが異なる毎に、RegisterUserForm -> User -> UserTable, UserProfileTableの3つのクラスが登場しました。なぜならば、同じ情報でもレイヤーの目的に応じて形を変えて存在するからです。レイヤーを無視して、RegisterUserFormをドメインに持ち込んだり、UserProfileTableをドメインに持ち込んだりしていては、ドメイン層は混乱します。この説明では登録処理ですが、編集処理の場合だと UserProfileTable -> User -> RegisterUserForm のDXOを行って、画面に情報を表示する必要があります。つまり逆の流れの変換処理もあります。*2

それぞれのレイヤーの責務に応じたモデルの型を扱うことで、レイヤーとモデルの混同を防止できます。たとえ、同じプロパティやメソッドを持っていても、レイヤーが異なると概念が別なので、型としては異なるべきだと考えます。そうすることでレイヤーの独立性を維持できると考えています。

ドメイン駆動設計も設計手法の一つに過ぎないので、お作法を守りながらもより良い設計を求めていく姿勢が大事だと思う今日このごろです。何事も「守破離」ですね。

あわせて読みたい
http://d.hatena.ne.jp/daisuke-m/20091110/1257838467

UI層

// HTML上のユーザ登録フォームを表すクラス
public class RegisterUserForm {

    public String getFirstName(){/*省略*/};

    public String getFirstKanaName(){/*省略*/};

    public String getLastName(){/*省略*/};

    public String getLastKanaName(){/*省略*/};

    public String getRegisterDate(){/*省略*/};

}

// ユーザ登録画面のコントローラクラス。
public class RegisterUserAction {

    private UserDxo userDxo = new UserDxo();

    // 登録ボタンを押された時の処理
    public String doRegister(RegisterUserForm form){
        // DXOしてサービスを呼び出す
        userRegisterService.register( userDxo.convertUser( form ) );
    }

}

// RegisterUserFormをUserに変換するためのDXO
public class RegisterUserFormDxo {

    public User convertUser( RegisterUserForm form ){
        // DXO
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
        Date registerDate = sdf.parse( form.getRegisterDate() );
        User user = new User( User.newIdentity(),
                        new Name( form.getFirstName(), form.getFirstKanaName() ),
                        new Name( form.getLastName(), form.getLastKanaName() ), registerDate );

        return user;
    }

    // ドメインオブジェクトをフォームに変換する場合、編集画面などで利用できる。
    public RegisterForm convertForm( User user );

    // 既存のインスタンスに変換する場合はこのようなインターフェイスになる。
    public void convert( RegisterForm form, User user );
    public void convert( User user, RegisterForm form );

}

アプリケーション層

// アプリケーション固有の処理(ビジネスロジック)を扱うサービス。
public class UserRegisterService {

    private UserRepositoryInDB userRepository = new UserRepositoryInDB();

    public void register(User user){
       // ビジネスロジック 前
       userRepository.store( user );
       // ビジネスロジック 後
    }

}

ドメイン層

// 名前とかな名を扱う”名前”というバリューオブジェクト
public class Name implements ValueObject<Name> {

    public Name(String name, String kanaName){/*省略*/};

    public String getName(){/*省略*/};
    
    public String getKanaName(){/*省略*/};

    public static String newIdentity(){/*省略*/};

}

// ユーザを識別するエンティティ
public class User implements Entity<User, String> {

    public User(String identity, Name firstName, Name lastName, Date regsiterDate){/*省略*/};

    public String getIdentity(){/*省略*/};

    public Name getFirstName(){/*省略*/};

    public Name getLastName(){/*省略*/};

    public Date getRegisterDate(){/*省略*/};

}

// UserをUserTableやUserProfileTableに変換するためのDXO
public class UserDxo {

    public UserTable convertUserTable( User user ){
        UserTable ut = new UserTable(user.getIdentity());
        return ut;
    }

    public UserProfileTable convertUserProfileTable( User user ){
        UserProfileTable upt = new UserProfileTable(user.getIdentity());
        upt.setFirstName(user.getFirstName().getName());
        upt.setFirstKanaName(user.getFirstName().getKanaName());
        upt.setLastName(user.getLastName().getName());
        upt.setLastKanaName(user.getLastName().getKanaName());
        upt.setRegisterDate(user.getRegisterDate().getTime());
        return upt;
    }

}

// ドメインにおいて汎用的な永続化を扱うリポジトリ。
public class UserRepositoryInDB implements Respository<User, String> {

    private UserDxo userDxo = new UserDxo();

    public void store( User user ){
         if ( isExist( user ) == false ){
             userDao.insert( userDxo.convertUserTable( user ) );
             userProfileTableDao.insert( userDxo.convertUserProfileTable( user ) );
         } else {
             // 略
         }
    }

}

*1:DAO=データアクセスオブジェクトもサービスの一つと考えられます

*2:この例では手動でオブジェクト間のデータの詰め替え作業を行っていますが、commons-beanutilやSeasarS2DxoやS2BeanUtilsを使えば手間がかなり軽減できます。ご参考までに。