[WIP]アプリケーション開発のための事前に検討しておくべき項目

Keywords

  • 設計

Contents

  • 1. 概要
  • 2. アプリケーションアーキテクチャ
  • 3. DI
  • 4. メソッドのインターフェースの設計
  • 4-1. 戻り値と引数の設計
  • 5. ファイル構成
  • 6. バリデーション
  • 6-1. 形式的なチェック
  • 6-2. 論理的なチェック
  • 7. ミュータブルかイミュータブルか
  • 8. nullの返却について
  • 9. エラーハンドリング
  • 10. レビュー方針

概要

アプリケーションの開発では、プログラマが判断するべき箇所が多く、それらを個々のプログラマが都度都度判断をしていると膨大な時間がかかってしまいます。

新規のアプリケーション開発を除いて、どういったプログラミング言語、フレームワーク、DBを使うかといった判断は少なく、また、コーディング規約によって、ほとんどのことが予め決められています。

しかし、そういった技術やコーディング規約とは異なり、プログラミング設計については、明示的に決められていることは少なく(私のチームの話だけかもしれませんが)、個々のプログラマの判断で決められることが多いです。そして、コードレビュー時に大きな手戻りが発生してしまう可能性があります。

今回、この手戻りをなくすことを目的として、事前にチーム内で議論、共有しておくべき項目をピックアップしました。

なお、本稿はSpring Bootを前提に説明しています。

アプリケーションアーキテクチャ

下記のアプリケーションアーキテクチャの選択

  • レイヤーアーキテクチャ
  • オニオンアーキテクチャ
  • ヘキサゴナルアーキテクチャ など

DI

DIとはオブジェクトを他のオブジェクトに渡すことです。DIをするとテストしやすさが向上し、また、コードの差し替えが容易になります。

Spring Bootでは@Autowiredやコンストラクタインジェクション使用して、DIを簡単に実現できます。

もちろん、Webフレームワークの機能を使わずとも、自力でDIすることは可能です。また、その際、あるインターフェースを実装したクラスをコンストラクタかメソッドに渡すことになるかと思いますが、クラスではなく、ラムダ式を渡すことも可能です。

DIコンテナを使った方法(MemberRepositoryをDI)

@Service
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public MemberModel getByMemberId(long memberId) {
        return memberRepository.findByMemberId(memberId);
    }
}

DIコンテナを使った方法(クライアント側)

@RestController
@RequestMapping("api/member")
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @RequestMapping
    public MemberModel index() {
        return memberService.getByMemberId(1L);
    }
}

ラムダ式をDIする方法

public class MemberServiceImpl {

    private final Function<Long, MemberModel> findByMemberId;

    public MemberServiceImpl(Function<Long, MemberModel> findByMemberId) {
        this.findByMemberId = findByMemberId;
    }

    public MemberModel getByMemberId(long memberId) {
        return this.findByMemberId.apply(memberId);
    }
}

ラムダ式をDIする方法(クライアント側)

@RestController
@RequestMapping("api/member")
public class MemberController {

    private final Function<Long, MemberModel> findByMemberId;

    public MemberController() {
        this.findByMemberId = l -> {
            // DBアクセス処理
        }
    }

    @RequestMapping
    public MemberModel index() {
        return memberService.getByMemberId(1L);
    }
}

両者ともテストし易さや差し替えの容易さは変わりません。 ただ、ラムダ式の場合は、どのようなリポジトリをコールするかを調べる際は、一度クライアント側のコードを見る必要があります。同様に、新たにMemberServiceImplを使った処理を書こうとする際も、既にMemberServiceImplを使っているコードのクライアントコードを見て、DBアクセス処理を書く必要が出てきてしまいます。

全ての選択肢を許容するかどれか一つにするかをチーム内で議論・共有しておいた方が、後の工程で戸惑うことはなくなるでしょう。

メソッドのインターフェースの設計

戻り値と引数の設計

  1. ObjectやMap
    JavaでいうとObjectやMap(Javaを使用していて、この選択肢をとることはなさそうですが)、PHPでいうと連想配列を引数に指定したり、メソッドの戻り値とする方法です。 ドキュメントとしての型の効用がなく、どういうデータを渡すか、または、どういうデータが返却されるかはメソッドの中身を見ないと分からない状態になります。最悪の場合、HTTP通信の値やSQLを読む必要が出てきます。また、クラス型やプリミティブ型を使用する場合にも同じことが言えます。

  2. ユーザ定義のクラス
    例えば、Userクラスといったものを作成し、それを返却する方法です。更新の場合も参照の場合もこういったユーザ定義のクラスを用います。

    public User getUser(userId);
    
    void create(User user);
    

    なお、Pair、Tuple、Tripleなどの使用も決めておいたほうが良いです。Pairを使っているときに、実はもう一つ別のデータを返したいといたときに、修正範囲が大きくなってしまいます。(逆に言えば、メソッドのインターフェースではなく、ローカル変数の型として使う場合は、このようなデメリットは無くなります。)

  3. ユーザ定義のクラス(コマンドとクエリ)

ユーザ定義クラスの使用からさらにもう一歩踏み込み、更新と参照とで使用するクラスを分ける方法(CQRS)です。

public UserQuery getUser(userId);
void create(UserCommand userCommand);

現状私のチームでは、CRUDな機能を作成する場合は、2を使用し、より複雑な機能については、3を使用することが多いです。

ファイル構成

アプリケーションアーキテクチャと戻り値の設計によって、ファイル構成を考えることができます。

ヘキサゴナルアーキテクチャとCQRSを採用すれば、下記のようになりますでしょうか。

- アダプター(IN)
  - HTTP
    - Form
  - MQ(Subscriber)
  - バッチ
- Aコンテキスト
  - コマンド
    - アプリケーションサービス(interface)※1
    - リポジトリ(interface)※2
    - ドメインサービス
    - ドメインモデル
  - クエリ
    - アプリケーションサービス(interface)※1
    - リポジトリ(interface)※2
    - ドメインサービス
    - ドメインモデル
- アダプター(OUT)
  - DB
  - API
  - MQ(Publisher)

※1: アダプター(IN)が**使う**インターフェース
※2: アダプター(OUT)が**実装する**インターフェース

バリデーション

形式的なチェック

上記のファイル構成であれば、エンドユーザが入力することの多いアダプター(IN)のHTTPで行うことが多いです。

Formクラスというものを作成し、そこで、形式チェックをすることが多いです。

論理的なチェック

バリデーションについて、議論が別れるのはここの部分だと思います。

ユーザの与信(credit)を100万以上に高めることができないようにする論理的なチェックを考えてみます。

  1. 更新する前か後か

更新する前にチェックする方法

ユーザの与信額を変更する際には、絶対にチェックされるので、モデルの不変条件が保たれる。

class User {
    private String name;
    private String address;
    private int credit;

    void updateCredit(int credit){
        if(credit > 1000000) {
            throw RuntimeException("100万より高い与信は設定できません。");
        }
    }
}

更新した後にチェックする方法

validate()を呼び忘れる可能性があり、不変条件が保たれない場合がある。

class User {
    private String name;
    private String address;
    private int credit;

    void updateCredit(int credit){
        this.credit = credit
    }

    void validate(){
            if(this.credit > 1000000) {
                throw RuntimeException("100万より高い与信は設定できません。");
            }
    }
}
  1. メソッドか関数か

メソッドの例は先ほど挙げたので、関数の方法だけ挙げます。

デメリットは[更新した後にチェックする方法]よりも、別ファイルであるため、このvalidate(User user)を呼び忘れる可能性が高いです。

class UserValidation {

void validate(User user) {
    if(user.credit > 1000000) {
        throw RuntimeException("100万より高い与信は設定できません。");
    }
}

}

ミュータブルかイミュータブルか

先ほどのUserクラスはミュータブルにしていました。不変条件をクラス内に持たせたい場合、ミュータブルで、そうでない方法をとる場合は、イミュータブルにすると良いです。

ミュータブルの方が、楽といえば楽。

nullの返却について

  • Optional
  • リストの場合は空のリスト
  • プロパティがnullの場合 ViewModelの場合はok coreもnullの可能性がある。(DBがnullだったら) ただ、coreでのnullはできるだけなくしたい。

エラーハンドリング

例外を発生させるか、Eitherを使用するか。 Eitherの場合、途中でエラーハンドリングを追加すると、メソッドのインターフェースが変わるため、影響範囲が大きくなってしまう。

レビュー方針

ヘキサゴナルアーキテクチャを採用する場合、PORTのみのレビューをまずすると良いです。