【Java】3層アーキテクチャとは?会員登録処理をプレゼンテーション層・ドメイン層・データアクセス層に分けて理解する

■概要

アプリケーションを作っていると、1つのクラスに「画面からの入力チェック」「業務のルール」「データベースへの保存」がすべて詰め込まれてしまうことがある。
こうなると、どこに何が書いてあるのか分かりにくくなり、画面やDBを少し変えただけで業務ルールまで書き換えが必要になってしまう。

これを防ぐ代表的な設計手法が3層アーキテクチャ(3-Layer Architecture)である。
処理を役割ごとに次の3つの「層」に分けて作る。

層役割例
プレゼンテーション層利用者との受付窓口。入力の受付・形式チェック・型変換・結果の表示画面、API、コンソール出力
ドメイン層アプリの中心となる業務ルール(ビジネスロジック)「18歳未満は登録できない」「同じメールアドレスは登録できない」
データアクセス層データの置き場所とのやり取り。保存・検索DBへのSQL発行、ファイル読み書き

3層に分けるときのポイントは次の3つ。

  • チェックの種類で置き場所を分ける
    • 形式チェック("abc" は年齢として数字ではない)→ プレゼンテーション層
    • 業務ルールチェック(15歳は登録できない)→ ドメイン層
    • 業務ルールをドメイン層にまとめておけば、画面がWebでもスマホでもバッチでも、同じルールが必ず適用される
  • 依存の向きを「プレゼンテーション層 → ドメイン層 ← データアクセス層」にする
    • 素朴に作ると「プレゼンテーション層 → ドメイン層 → データアクセス層」となり、DBの都合が変わるたびに業務ルールまで影響を受ける
    • そこで、保存の窓口となるインターフェース(MemberRepository)をドメイン層に置き、データアクセス層がそれを実装する
    • これを依存性逆転の原則(DIP)と呼ぶ
  • 使う部品は外から受け取る(依存性の注入:DI)
    • MemberService は実装クラス(InMemoryMemberRepository)を一度も名指しせず、コンストラクタで受け取る
    • 将来本物のDBを使う場合も、MemberRepository を実装した別クラスを作って差し替えるだけで、ドメイン層は1行も変えなくてよい

■実装するサンプル処理の概要

「会員登録」と「会員一覧の表示」を、3層に分けて実装する。

  • プレゼンテーション層(presentation パッケージ)
    • MemberController … 入力値(すべて文字列)を受け取り、氏名・メールアドレスの形式チェックと、年齢の文字列→int 変換を行う。結果やエラーメッセージを表示する
  • ドメイン層(domain パッケージ)
    • MemberService … 業務ルール(18歳未満は登録不可、メールアドレス重複禁止)をチェックし、問題なければ保存を依頼する
    • Member … 会員を表すエンティティ(record で定義)
    • MemberRepository … 保存・検索の窓口となるインターフェース
    • BusinessRuleException … 業務ルール違反時に投げる例外(非検査例外)
  • データアクセス層(dataaccess パッケージ)
    • InMemoryMemberRepository … MemberRepository の実装。学習用に LinkedHashMap を DB テーブル代わりにし、発行されるであろう SQL をログ出力する
  • ThreeLayerProcessingMain … 各層の部品を下の層から順に組み立て、次の4ケースを実行する
    1. 正常な登録(2件)
    2. 入力の形式エラー(年齢が "abc"/氏名が空)→ プレゼンテーション層で検出され、下の層は呼ばれない
    3. 業務ルール違反(15歳/メールアドレス重複)→ ドメイン層で検出され、INSERT はされない
    4. 会員一覧の表示

▼実行環境

  • Java:バージョン17(record を使用するため Java 16 以降が必要)

■クラス図

classDiagram direction TB class Main["ThreeLayerProcessingMain"] { +main(String[] args)$ void } namespace presentation { class MemberController { -MemberService memberService +MemberController(MemberService memberService) +register(String name, String email, String ageInput) void +showAll() void -showError(String message) void } } namespace domain { class MemberService { -int MIN_AGE$ -MemberRepository memberRepository +MemberService(MemberRepository memberRepository) +register(String name, String email, int age) Member +findAll() List~Member~ } class MemberRepository { <<interface>> +save(String name, String email, int age) Member +findByEmail(String email) Optional~Member~ +findAll() List~Member~ } class Member { <<record>> +int id +String name +String email +int age } class BusinessRuleException { +BusinessRuleException(String message) } } namespace dataaccess { class InMemoryMemberRepository { -Map~Integer, Member~ table -int nextId +save(String name, String email, int age) Member +findByEmail(String email) Optional~Member~ +findAll() List~Member~ } } class RuntimeException Main ..> InMemoryMemberRepository : 生成 Main ..> MemberService : 生成(リポジトリを注入) Main ..> MemberController : 生成(サービスを注入) MemberController --> MemberService : 業務処理を依頼 MemberController ..> BusinessRuleException : catch MemberService --> MemberRepository : 保存・検索を依頼(interfaceにのみ依存) MemberService ..> BusinessRuleException : throw InMemoryMemberRepository ..|> MemberRepository : implements MemberRepository ..> Member BusinessRuleException --|> RuntimeException
  • ThreeLayerProcessingMain が「組み立て係」として、InMemoryMemberRepository → MemberService → MemberController の順に生成し、下の層の部品を上の層のコンストラクタに渡す(DI)
    • Spring Framework などを使う実務では、この組み立てをフレームワークが自動で行ってくれる
  • MemberController(プレゼンテーション層)は MemberService(ドメイン層)に業務処理を依頼し、BusinessRuleException を catch してエラーメッセージを表示する
  • MemberService は MemberRepository(interface)にだけ依存し、業務ルール違反の場合は BusinessRuleException を throw する
  • InMemoryMemberRepository(データアクセス層)は、ドメイン層の MemberRepository を implements している
    • 矢印がデータアクセス層からドメイン層に向いている点がポイント。ドメイン層はデータアクセス層のことを何も知らない

■フローチャート

全体の流れ(ThreeLayerProcessingMain.main)

flowchart TD A([開始]) --> B["【データアクセス層】InMemoryMemberRepository を生成"] B --> C["【ドメイン層】MemberService を生成<br>(リポジトリをコンストラクタで注入)"] C --> D["【プレゼンテーション層】MemberController を生成<br>(サービスをコンストラクタで注入)"] D --> E[["ケース1:正常な登録<br>register(山田太郎, yamada@example.com, 30)<br>register(佐藤花子, sato@example.com, 25)"]] E --> F[["ケース2:入力の形式エラー<br>register(鈴木一郎, suzuki@example.com, abc)<br>register(空文字, noname@example.com, 40)"]] F --> G[["ケース3:業務ルール違反<br>register(高橋次郎, takahashi@example.com, 15)<br>register(山田花子, yamada@example.com, 28)"]] G --> H[["ケース4:会員一覧の表示<br>showAll()"]] H --> I([終了])
  1. データアクセス層の InMemoryMemberRepository を生成する
  2. ドメイン層の MemberService を、リポジトリを渡して生成する
  3. プレゼンテーション層の MemberController を、サービスを渡して生成する
  4. ケース1~4を順に実行する

会員登録(MemberController.register → MemberService.register → InMemoryMemberRepository)

flowchart TD A(["【プレゼンテーション層】register(name, email, ageInput)"]) --> B["リクエスト受付ログを出力"] B --> C{"氏名が null または空白?"} C -- Yes --> E1["エラー表示:氏名を入力してください。"] C -- No --> D{"メールアドレスが null<br>または @ を含まない?"} D -- Yes --> E2["エラー表示:メールアドレスの形式が正しくありません。"] D -- No --> F{"Integer.parseInt(ageInput)<br>で数値に変換できる?"} F -- No --> E3["エラー表示:年齢は数字で入力してください。"] F -- Yes --> G["【ドメイン層】MemberService.register(name, email, age)"] G --> H{"age が 18 未満?"} H -- Yes --> X1["BusinessRuleException を throw<br>(18歳未満の方は会員登録できません)"] H -- No --> I["【データアクセス層】findByEmail(email)<br>SELECT ログを出力"] I --> J{"同じメールアドレスの<br>会員が存在する?"} J -- Yes --> X2["BusinessRuleException を throw<br>(既に登録されています)"] J -- No --> K["【データアクセス層】save(name, email, age)<br>ID採番・Mapに保存・INSERT ログを出力"] K --> L["【プレゼンテーション層】登録完了と会員IDを表示"] X1 --> M["【プレゼンテーション層】catch してエラーメッセージを表示"] X2 --> M E1 --> Z([終了]) E2 --> Z E3 --> Z L --> Z M --> Z
  1. プレゼンテーション層で入力の形式をチェックする
    • 氏名が空、メールアドレスに @ が無い、年齢が数値に変換できない場合は、その場でエラーを表示して終了する(ドメイン層は呼ばない)
  2. ドメイン層で業務ルールをチェックする
    • 18歳未満なら BusinessRuleException を投げる(データアクセス層は呼ばない)
    • データアクセス層の findByEmail で重複を確認し、既に登録済みなら BusinessRuleException を投げる
  3. すべてのルールをクリアしたら、データアクセス層の save で ID を採番して保存する
  4. プレゼンテーション層に戻り、登録完了(会員ID)またはエラーメッセージを表示する

会員一覧表示(MemberController.showAll)

flowchart TD A(["【プレゼンテーション層】showAll()"]) --> B["リクエスト受付ログを出力"] B --> C["【ドメイン層】MemberService.findAll()"] C --> D["【データアクセス層】findAll()<br>SELECT ログを出力し、Map の値を新しい List にコピーして返す"] D --> E{"未表示の会員が残っている?"} E -- Yes --> F["String.format で<br>会員ID・氏名・メール・年齢を整形して表示"] F --> E E -- No --> G["全件数を表示"] G --> H([終了])
  1. プレゼンテーション層がリクエストを受け付け、ドメイン層の findAll を呼ぶ
  2. ドメイン層はリポジトリの findAll をそのまま呼ぶ(将来「退会済みは除く」などのルールが増えたらここに書き足す)
  3. データアクセス層は内部の Map を直接渡さず、新しい List にコピーして返す(防御的コピー)
  4. プレゼンテーション層が String.format で見やすく整形して表示し、最後に件数を表示する

■サンプルコード

以下に今回実装するサンプルコードを示す。

・src/main/java/ThreeLayerProcessingMain.java

import dataaccess.InMemoryMemberRepository;
import domain.MemberRepository;
import domain.MemberService;
import presentation.MemberController;

/**
 * 3層アーキテクチャ(3-Layer Architecture)の学習用サンプル。
 *
 * <p>3層アーキテクチャとは、アプリケーションの処理を役割ごとに3つの「層」に分けて作る設計手法です。
 * 役割ごとに分けることで「どこに何が書いてあるか」が分かりやすくなり、
 * 1つの層を変更しても他の層への影響を小さく抑えられます。
 *
 * <h2>3つの層と、このサンプルでの担当クラス</h2>
 * <pre>
 *   [プレゼンテーション層] presentation パッケージ
 *       MemberController         … 入力の受付・形式チェック・結果表示
 *            |
 *            | 呼び出す
 *            v
 *   [ドメイン層] domain パッケージ
 *       MemberService            … 業務ルール(年齢制限・重複禁止)
 *       Member                   … 会員エンティティ
 *       MemberRepository         … 保存の窓口(インターフェース)
 *            ^
 *            | 実装する(implements)
 *            |
 *   [データアクセス層] dataaccess パッケージ
 *       InMemoryMemberRepository … データの保存・検索
 * </pre>
 *
 * <p>処理の流れ(会員登録の場合):
 * <ol>
 *   <li>プレゼンテーション層が入力を受け取り、形式をチェックして int などに変換する</li>
 *   <li>ドメイン層が業務ルールをチェックする</li>
 *   <li>データアクセス層がデータを保存する</li>
 *   <li>結果が逆順に戻ってきて、プレゼンテーション層が画面に表示する</li>
 * </ol>
 *
 * <p>このクラス(main)は、各層の部品を組み立てて動かす「組み立て係」です。
 * Spring Framework などを使う実務では、この組み立てをフレームワークが自動で行ってくれます。
 */
public class ThreeLayerProcessingMain {

  public static void main(String[] args) {

    // ===== 部品の組み立て =====
    // 下の層から順に作り、上の層へ渡していく。
    // ドメイン層は MemberRepository(インターフェース)型で受け取るので、
    // 将来DB版の実装に変えたくなったら、この1行を差し替えるだけで済む。
    MemberRepository repository = new InMemoryMemberRepository(); // データアクセス層
    MemberService service = new MemberService(repository);        // ドメイン層
    MemberController controller = new MemberController(service);  // プレゼンテーション層

    // ===== ケース1:正常に登録できる =====
    System.out.println("===== ケース1:正常な登録 =====");
    controller.register("山田太郎", "yamada@example.com", "30");
    System.out.println();
    controller.register("佐藤花子", "sato@example.com", "25");
    System.out.println();

    // ===== ケース2:プレゼンテーション層で弾かれる(入力の形式エラー) =====
    // ドメイン層・データアクセス層まで処理が届かないことをログで確認してみよう。
    System.out.println("===== ケース2:入力の形式エラー(プレゼンテーション層で検出) =====");
    controller.register("鈴木一郎", "suzuki@example.com", "abc");
    System.out.println();
    controller.register("", "noname@example.com", "40");
    System.out.println();

    // ===== ケース3:ドメイン層で弾かれる(業務ルール違反) =====
    System.out.println("===== ケース3:業務ルール違反(ドメイン層で検出) =====");
    // 年齢制限違反:データアクセス層は呼ばれない
    controller.register("高橋次郎", "takahashi@example.com", "15");
    System.out.println();
    // メールアドレス重複:重複確認のためにデータアクセス層の SELECT は呼ばれるが、INSERT はされない
    controller.register("山田花子", "yamada@example.com", "28");
    System.out.println();

    // ===== ケース4:一覧表示 =====
    System.out.println("===== ケース4:会員一覧の表示 =====");
    controller.showAll();
  }
}

・src/main/java/presentation/MemberController.java

package presentation;

import domain.BusinessRuleException;
import domain.Member;
import domain.MemberService;
import java.util.List;

/**
 * 【プレゼンテーション層】利用者からの入力を受け取り、結果を表示するクラス。
 *
 * <p>プレゼンテーション層の役割:
 * <ul>
 *   <li>利用者との「受付窓口」です。Webアプリなら画面やAPI、今回はコンソール出力が該当します。</li>
 *   <li>入力値の<b>形式</b>チェック(空でないか、数字かどうか)や、型の変換(文字列→int)を行います。</li>
 *   <li>結果やエラーを、利用者に分かりやすい形に整えて表示します。</li>
 *   <li>業務ルール(年齢制限など)は書きません。それはドメイン層の仕事です。</li>
 * </ul>
 *
 * <p>「形式チェック」と「業務ルールチェック」の違い:
 * <pre>
 *   形式チェック(ここ)    : "abc" は年齢として数字じゃない → 画面の入力ミス
 *   業務ルール(ドメイン層): 15歳は登録できない           → 業務上の決まり
 * </pre>
 * 業務ルールをドメイン層にまとめておけば、画面がWebでもスマホでもバッチでも、同じルールが必ず適用されます。
 */
public class MemberController {

  /** 業務処理を依頼する相手(ドメイン層)。 */
  private final MemberService memberService;

  /**
   * @param memberService 会員サービス
   */
  public MemberController(MemberService memberService) {
    this.memberService = memberService;
  }

  /**
   * 会員登録リクエストを処理する。
   *
   * <p>画面のフォームから送られてくる値は、基本的にすべて「文字列」です。
   * そのため引数もあえて全部 String にして、Webフォームからの入力を模しています。
   *
   * @param name     氏名(入力値)
   * @param email    メールアドレス(入力値)
   * @param ageInput 年齢(入力値。文字列のまま受け取る)
   */
  public void register(String name, String email, String ageInput) {
    System.out.println("[Presentation] 会員登録リクエスト受付: name=" + name
        + ", email=" + email + ", age=" + ageInput);

    // ---------- (1) 入力の形式チェック ----------
    // isBlank() は「空文字、またはスペースだけ」の場合に true を返す(Java 11 以降)。
    if (name == null || name.isBlank()) {
      showError("氏名を入力してください。");
      return; // エラーなのでここで処理を終える(ドメイン層は呼ばない)
    }
    if (email == null || !email.contains("@")) {
      showError("メールアドレスの形式が正しくありません。");
      return;
    }

    // ---------- (2) 型の変換(文字列 → 数値) ----------
    int age;
    try {
      // Integer.parseInt は数字以外の文字列を渡すと NumberFormatException を投げる。
      age = Integer.parseInt(ageInput);
    } catch (NumberFormatException e) {
      showError("年齢は数字で入力してください。(入力:" + ageInput + ")");
      return;
    }

    // ---------- (3) ドメイン層に業務処理を依頼 ----------
    try {
      Member member = memberService.register(name, email, age);
      // ---------- (4) 結果を利用者向けに整形して表示 ----------
      System.out.println("[Presentation] 登録が完了しました。会員ID: " + member.id());
    } catch (BusinessRuleException e) {
      // ドメイン層が「業務ルール違反」と判断した場合は、そのメッセージを画面に出す。
      showError(e.getMessage());
    }
  }

  /**
   * 会員一覧を表示する。
   */
  public void showAll() {
    System.out.println("[Presentation] 会員一覧リクエスト受付");
    List<Member> members = memberService.findAll();

    // 表示の形を整えるのはプレゼンテーション層の仕事。
    // String.format は「%d(整数)」「%s(文字列)」の部分に、後ろの引数を順番に埋め込んでくれる。
    // 例えば年齢を「30歳」と表示する、といった見せ方の工夫もここで行う。
    for (Member member : members) {
      System.out.println(String.format("  会員ID:%d  氏名:%s  メール:%s  年齢:%d歳",
          member.id(), member.name(), member.email(), member.age()));
    }
    System.out.println("  (全 " + members.size() + " 件)");
  }

  /**
   * エラーメッセージを表示する共通処理。
   *
   * @param message エラーメッセージ
   */
  private void showError(String message) {
    System.out.println("[Presentation] エラー: " + message);
  }
}

・src/main/java/domain/MemberService.java

package domain;

import java.util.List;

/**
 * 【ドメイン層】会員に関する「業務ルール(ビジネスロジック)」をまとめたクラス。
 *
 * <p>ドメイン層の役割:
 * <ul>
 *   <li>アプリの中心となる「業務のルール」を書く場所です。</li>
 *   <li>例:「18歳未満は会員登録できない」「同じメールアドレスで二重登録できない」</li>
 *   <li>画面の見た目(文字の表示方法)やデータの保存方法は知りません。</li>
 * </ul>
 *
 * <p>このクラスはデータの保存に {@link MemberRepository}(インターフェース)だけを使い、
 * 具体的な実装クラス(dataaccess パッケージのクラス)を一度も名指ししていません。
 * 実物はコンストラクタで外から受け取ります(これを「依存性の注入(DI)」と呼びます)。
 */
public class MemberService {

  /** 会員登録できる最低年齢。マジックナンバー(意味の分からない数値の直書き)を避けるため定数にする。 */
  private static final int MIN_AGE = 18;

  /** データの保存・取得を任せる相手。final にすることで、後から別のものに差し替えられないようにする。 */
  private final MemberRepository memberRepository;

  /**
   * コンストラクタ。使うリポジトリを外から受け取る。
   *
   * @param memberRepository 会員リポジトリ(実装は何でもよい)
   */
  public MemberService(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
  }

  /**
   * 会員を登録する。
   *
   * <p>業務ルールを順番にチェックし、すべて満たした場合だけ保存を依頼します。
   * ルール違反があれば {@link BusinessRuleException} を投げて処理を中断します。
   *
   * @param name  氏名
   * @param email メールアドレス
   * @param age   年齢
   * @return 登録された会員
   * @throws BusinessRuleException 業務ルールに違反した場合
   */
  public Member register(String name, String email, int age) {

    // ルール1:年齢制限。18歳未満は登録できない。
    if (age < MIN_AGE) {
      throw new BusinessRuleException(MIN_AGE + "歳未満の方は会員登録できません。(入力:" + age + "歳)");
    }

    // ルール2:メールアドレスの重複禁止。
    // findByEmail は Optional を返すので、isPresent() で「見つかったかどうか」を判定できる。
    if (memberRepository.findByEmail(email).isPresent()) {
      throw new BusinessRuleException("メールアドレス「" + email + "」は既に登録されています。");
    }

    // すべてのルールをクリアしたので、保存はデータアクセス層にお任せする。
    // ドメイン層は「保存して」とお願いするだけで、どこにどう保存されるかは気にしない。
    return memberRepository.save(name, email, age);
  }

  /**
   * 全会員を取得する。
   *
   * <p>今回は特別な業務ルールがないので、リポジトリの結果をそのまま返しています。
   * 将来「退会済みの会員は除く」などのルールが増えたら、ここに書き足します。
   *
   * @return 会員一覧
   */
  public List<Member> findAll() {
    return memberRepository.findAll();
  }
}

・src/main/java/domain/MemberRepository.java

package domain;

import java.util.List;
import java.util.Optional;

/**
 * 【ドメイン層】会員データの「保存・取得」の窓口を表すインターフェース。
 *
 * <p>「リポジトリ(Repository)」は、データの保管庫という意味です。
 * ドメイン層は「会員を保存したい」「メールアドレスで探したい」という<b>やりたいこと</b>だけを
 * ここに宣言し、<b>どうやって</b>保存するか(DB?ファイル?メモリ?)は決めません。
 *
 * <p>なぜインターフェースをドメイン層に置くのか?
 * <pre>
 *   素朴に作ると依存の向きはこうなります:
 *     プレゼンテーション層 → ドメイン層 → データアクセス層
 *   これだと、DBの都合が変わるたびにドメイン層(業務ルール)まで影響を受けてしまいます。
 *
 *   そこで「インターフェースはドメイン層が持ち、データアクセス層がそれを実装する」形にすると:
 *     プレゼンテーション層 → ドメイン層 ← データアクセス層
 *   となり、一番大事な業務ルールが、DBなどの技術的な詳細に振り回されなくなります。
 *   (これを「依存性逆転の原則(DIP)」と呼びます)
 * </pre>
 */
public interface MemberRepository {

  /**
   * 会員を新しく保存し、IDが採番された会員を返す。
   *
   * @param name  氏名
   * @param email メールアドレス
   * @param age   年齢
   * @return IDが付与された会員
   */
  Member save(String name, String email, int age);

  /**
   * メールアドレスで会員を探す。
   *
   * <p>戻り値の {@link Optional} は「値があるかもしれないし、無いかもしれない」を表す箱です。
   * null を返すよりも「見つからない場合がある」ことが型で明確になり、
   * null チェック漏れによるエラー(NullPointerException)を防ぎやすくなります。
   *
   * @param email メールアドレス
   * @return 見つかった会員(見つからなければ空の Optional)
   */
  Optional<Member> findByEmail(String email);

  /**
   * 登録済みの全会員を返す。
   *
   * @return 会員の一覧
   */
  List<Member> findAll();
}

・src/main/java/domain/Member.java

package domain;

/**
 * 【ドメイン層】会員を表す「エンティティ」。
 *
 * <p>エンティティとは、業務の世界に登場する「モノ」や「人」をプログラムで表現したものです。
 * ここでは「会員」を表しています。
 *
 * <p>ポイント:
 * <ul>
 *   <li>このクラスは画面(プレゼンテーション層)やデータベース(データアクセス層)のことを一切知りません。</li>
 *   <li>そのため、画面がWebからスマホアプリに変わっても、DBが変わっても、このクラスは書き換え不要です。</li>
 * </ul>
 *
 * <p>Java 16 以降で使える {@code record} を使っています。
 * record は「値を入れて取り出すだけのクラス」を短く書くための構文で、
 * コンストラクタ・getter({@code id()} や {@code name()})・equals・toString を自動で作ってくれます。
 *
 * @param id    会員ID(データアクセス層で採番される)
 * @param name  氏名
 * @param email メールアドレス
 * @param age   年齢
 */
public record Member(int id, String name, String email, int age) {
}

・src/main/java/domain/BusinessRuleException.java

package domain;

/**
 * 【ドメイン層】業務ルールに違反したときに投げる例外。
 *
 * <p>例:「18歳未満は登録できない」「同じメールアドレスは登録できない」など。
 *
 * <p>{@link RuntimeException} を継承しているので「非検査例外」になります。
 * 非検査例外は、呼び出し側で {@code throws} や {@code try-catch} を書くことが強制されません。
 * 今回はプレゼンテーション層でまとめて catch し、エラーメッセージを画面に表示します。
 */
public class BusinessRuleException extends RuntimeException {

  /**
   * @param message 利用者に伝えるエラーメッセージ
   */
  public BusinessRuleException(String message) {
    // 親クラス(RuntimeException)のコンストラクタにメッセージを渡す。
    // あとで getMessage() で取り出せるようになる。
    super(message);
  }
}

・src/main/java/dataaccess/InMemoryMemberRepository.java

package dataaccess;

import domain.Member;
import domain.MemberRepository;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * 【データアクセス層】会員データをメモリ上に保存するリポジトリの実装。
 *
 * <p>データアクセス層の役割:
 * <ul>
 *   <li>データベースやファイルなど「データの置き場所」とのやり取りを担当します。</li>
 *   <li>業務ルール(年齢制限など)は一切書きません。言われた通りに保存・検索するだけです。</li>
 * </ul>
 *
 * <p>本来はここで SQL を発行して DB に保存しますが、学習用に環境構築なしで動くよう、
 * {@link Map} を「なんちゃってDBテーブル」として使っています。
 *
 * <p>ドメイン層の {@link MemberRepository} を {@code implements}(実装)しているのがポイントです。
 * 将来本物のDBを使うときは、例えば {@code JdbcMemberRepository} という別クラスを作り、
 * 同じく MemberRepository を実装すれば、ドメイン層を1行も変えずに差し替えられます。
 */
public class InMemoryMemberRepository implements MemberRepository {

  /**
   * 会員データの保存先(DBのテーブル代わり)。キーが会員ID、値が会員。
   *
   * <p>{@link LinkedHashMap} は「入れた順番」を覚えてくれる Map です。
   * 普通の HashMap だと順番がバラバラになるため、一覧表示の順序を安定させる目的で使っています。
   */
  private final Map<Integer, Member> table = new LinkedHashMap<>();

  /** 次に割り当てる会員ID。DBの「自動採番(AUTO_INCREMENT)」の代わり。 */
  private int nextId = 1;

  @Override
  public Member save(String name, String email, int age) {
    // IDを採番してから会員オブジェクトを作る。nextId++ は「今の値を使ってから1増やす」という意味。
    Member member = new Member(nextId++, name, email, age);
    table.put(member.id(), member);

    // 実際のDBアクセスのイメージが湧くように、発行されるであろうSQLをログとして表示する。
    System.out.println("  [DataAccess] INSERT INTO member VALUES ("
        + member.id() + ", '" + name + "', '" + email + "', " + age + ")");
    return member;
  }

  @Override
  public Optional<Member> findByEmail(String email) {
    System.out.println("  [DataAccess] SELECT * FROM member WHERE email = '" + email + "'");

    // Stream API を使った検索。
    //   values()      : Map に入っている全会員を取り出す
    //   stream()      : 1件ずつ流れ作業で処理できる形にする
    //   filter(...)   : 条件(メールアドレスが一致)に合うものだけ残す
    //   findFirst()   : 最初の1件を Optional に入れて返す(無ければ空の Optional)
    return table.values().stream()
        .filter(member -> member.email().equals(email))
        .findFirst();
  }

  @Override
  public List<Member> findAll() {
    System.out.println("  [DataAccess] SELECT * FROM member");

    // 内部の Map をそのまま渡すと、呼び出し側に中身を書き換えられてしまう恐れがある。
    // そこで新しい List にコピーして返す(防御的コピー)。
    return new ArrayList<>(table.values());
  }
}

■実行結果

===== ケース1:正常な登録 =====
[Presentation] 会員登録リクエスト受付: name=山田太郎, email=yamada@example.com, age=30
  [DataAccess] SELECT * FROM member WHERE email = 'yamada@example.com'
  [DataAccess] INSERT INTO member VALUES (1, '山田太郎', 'yamada@example.com', 30)
[Presentation] 登録が完了しました。会員ID: 1

[Presentation] 会員登録リクエスト受付: name=佐藤花子, email=sato@example.com, age=25
  [DataAccess] SELECT * FROM member WHERE email = 'sato@example.com'
  [DataAccess] INSERT INTO member VALUES (2, '佐藤花子', 'sato@example.com', 25)
[Presentation] 登録が完了しました。会員ID: 2

===== ケース2:入力の形式エラー(プレゼンテーション層で検出) =====
[Presentation] 会員登録リクエスト受付: name=鈴木一郎, email=suzuki@example.com, age=abc
[Presentation] エラー: 年齢は数字で入力してください。(入力:abc)

[Presentation] 会員登録リクエスト受付: name=, email=noname@example.com, age=40
[Presentation] エラー: 氏名を入力してください。

===== ケース3:業務ルール違反(ドメイン層で検出) =====
[Presentation] 会員登録リクエスト受付: name=高橋次郎, email=takahashi@example.com, age=15
[Presentation] エラー: 18歳未満の方は会員登録できません。(入力:15歳)

[Presentation] 会員登録リクエスト受付: name=山田花子, email=yamada@example.com, age=28
  [DataAccess] SELECT * FROM member WHERE email = 'yamada@example.com'
[Presentation] エラー: メールアドレス「yamada@example.com」は既に登録されています。

===== ケース4:会員一覧の表示 =====
[Presentation] 会員一覧リクエスト受付
  [DataAccess] SELECT * FROM member
  会員ID:1  氏名:山田太郎  メール:yamada@example.com  年齢:30歳
  会員ID:2  氏名:佐藤花子  メール:sato@example.com  年齢:25歳
  (全 2 件)

ケース1では、ドメイン層の重複チェックで SELECT が発行され、問題がないので INSERT されて会員IDが採番された。

ケース2では、ログに [DataAccess] が1行も出ていない。
年齢が数字でない・氏名が空といった形式エラーはプレゼンテーション層で弾かれ、ドメイン層・データアクセス層まで処理が届いていないことが分かる。

ケース3では、15歳の登録はドメイン層の年齢チェックで弾かれたため、データアクセス層は呼ばれていない。
メールアドレス重複の場合は、重複確認のための SELECT は発行されているが、INSERT はされていない。
どちらも業務ルール違反としてドメイン層が BusinessRuleException を投げ、プレゼンテーション層がそのメッセージを表示している。

ケース4では、正常に登録できた2件だけが一覧表示された。

このように、各層が自分の役割だけを担当しているため、「入力チェックを直したい」ならプレゼンテーション層、「年齢制限を20歳に変えたい」ならドメイン層、「本物のDBに保存したい」ならデータアクセス層と、修正箇所がすぐに分かる。

コメント

タイトルとURLをコピーしました