LoginSignup
138
128

ドメイン駆動設計を参考にしながらJava×SpringBootで家計管理アプリを自作

Posted at

1. はじめに

1-1. 簡単な自己紹介

事務系の仕事をしておりましたが、プログラミングに興味を持ち、在職中から独学でJavaを学習していました。
現在は退職し、IT業界への就職を目指して活動中です。
退職後の期間にSpringBootを使ってアプリケーションを作成したので、アプリの概要や作成しながら考えたことなどについてまとめました。

2. アプリの概要

簿記の考え方を取り入れた家計管理アプリです。
一般的な家計簿というよりは、個人向けのちょっとした会計アプリといったほうが適切な表現かもしれません。
日々のお金のやり取りを「仕訳」という形で記録し、それを元に月の収支や資産・負債の残高を把握します。

GitHubリポジトリ:https://github.com/wtbyt298/myaccountbook

スクリーンショット 2023-07-13 165747
スクリーンショット 2023-07-13 165959
スクリーンショット 2023-07-13 165812

2-1. 作成のきっかけ

  • 私生活ではある程度自分の趣味等にお金を使いつつも計画的に貯蓄をしたいと考えており、また奨学金の返済中であることから、正確な家計管理の重要性を感じていた。
  • 「ある月にいくら貰い、いくら使ったか」という視点だけでは、正確な家計管理をするには不十分だと感じていた。収支だけでなく、資産や負債がどのくらいあるのかを把握する必要がある。
  • 以前簿記の資格試験の勉強をした際に、費用の前払いや固定資産の減価償却の考え方を個人の家計管理に応用できるのでないか?と考えていた。
  • 既存の家計簿アプリの中にも銀行口座の残高など資産を管理できるものはあるが、減価償却などの概念を扱ったものはあまりないと感じた。(例えば、車の購入は大型出費など費用としているものが多い。)

このような背景から、「簿記の考え方を取り入れた家計管理アプリ」をテーマにしました。実際に開発しながらJavaの言語仕様や標準APIの知識を固め直しつつ、設計手法やテスト手法などについても学んでいきたいと考えました。

2-2. 主な機能

機能
1 仕訳の登録
2 仕訳の削除
3 仕訳の訂正
4 仕訳の一覧表示(月単位)
5 仕訳一覧のソート(日付順・金額順)
6 勘定科目の一覧表示
7 補助科目(勘定科目の内訳)の追加
8 補助科目名の変更
9 費用・収益の一覧表示(月単位)
10 資産・負債・純資産の残高の一覧表示(月単位)
11 収入・支出のグラフ表示
12 予算設定(勘定科目ごと)
13 予算比(予算に対する実績値の比率)の計算
14 ユーザ登録
15 ログイン・ログアウト

2-3. 使用技術

  • アプリケーション作成
    • Java 17.0.2
    • Spring Boot 3.0.3
      • Spring Security 6.0.5
    • JOOQ 3.17.8(ORマッパー)
  • データベース
    • MySQL 8.0.32
  • UI
    • HTML(Thymeleaf)
    • CSS(Bootstrap 5.0.2)
    • JavaScript(Chart.js 4.3.0)
  • インフラ

3. 実装するにあたって意識したこと

実務未経験者が初めて作成するアプリなので、何も考えずに作るとコードが無秩序な状態に陥ってしまうのではないかと思い、何かしら方針を決めたいと考えました。以前、書籍『現場で役立つシステム設計の原則』を読んだ際に、保守や変更に強いソフトウェアを作るための設計思想に「ドメイン駆動設計(Domain Driven Design:DDD)」という考え方があることを知り、書籍『ドメイン駆動設計入門』『実践ドメイン駆動設計』を読んで自分なりに勉強していました。せっかく興味を持って学習していたことなので、今回の開発ではDDDのアーキテクチャパターンやベストプラクティスを参考にし、できるだけ保守性を高めるという方向性で実装を進めることにしました。

DDDについて学ぶ際は以下の書籍や記事も参考になりました。

『ドメイン駆動設計 モデリング / 実装ガイド』
『ドメイン駆動設計 サンプルコード & FAQ』

little hands' lab
https://little-hands.hatenablog.com/

ドメイン駆動設計に15年取り組んでわかったこと
「ビジネスルール・値オブジェクト・型」が3つのキーワード
https://logmi.jp/tech/articles/322952

ドメインオブジェクトの責務について
https://qiita.com/j5ik2o/items/a64007c6d7a89ec2e086

3-1. ドメイン知識をコードで表現し、疎結合・高凝集な設計を目指す

DDDのアプローチにおいては、アプリケーションの対象領域に存在する知識やルールを「モデル」として分析し、その結果を継続的にコードに反映させることで、アプリケーションの問題解決力を高めようという狙いがあるとのことでした。
簿記・家計管理においても重要なルールは存在します。(借方・貸方といった概念、貸借の合計の一致など)そういったルール・制約をドメインモデルのコードとして表現し、データと関連するロジックをひとまとめにして整理することで、コードの重複やデータの不整合を起こさないように気を付けました。

一例ですが、以下は仕訳クラス(JournalEntry)のコードの抜粋です。
3つのドメインオブジェクトを受け取り、仕訳インスタンスを新規生成して返します。
仕訳とは簿記に登場する概念で、お金の取引の記録のようなイメージです。
「昼食に900円のラーメンを注文し、現金で支払った」とすると、
「借方:食費 900円 / 貸方:現金 900円」のように記録します。

この時、借方(左)と貸方(右)の金額は一致していなければなりません。
下記のコードでは、インスタンス生成前に借方の合計と貸方の合計が一致しているかをチェックし、一致していなければ例外を投げるようにしています。(具体的なチェック処理はEntryDetailsというオブジェクトに委譲しています。)そのため、貸借の金額が一致していない仕訳インスタンスは存在できません。

JournalEntry.java
    //省略

	/**
	 * 新規作成用のファクトリメソッド
	 */
	public static JournalEntry create(DealDate dealDate, Description description, EntryDetails entryDetails) {
		if (! entryDetails.isSameTotal()) {
			throw new CannotCreateJournalEntryException("明細の貸借合計が一致していません。");
		}
		return new JournalEntry(EntryId.newInstance(), dealDate, description, entryDetails);
	}
EntryDetails.java
    //省略
    
    /**
	 * @return 借方合計と貸方合計が一致している場合true
	 */
	boolean isSameTotal() {
		return debitSum().equals(creditSum());
	}
	
	/**
	 * @return 借方合計金額
	 */
    private Amount debitSum() {
    	final int total = elements.stream()
    		.filter(each -> each.isDebit())
    		.mapToInt(each -> each.amount().value())
    		.sum();
    	
		return Amount.valueOf(total);
	}
	
	/**
	 * @return 貸方合計金額
	 */
	private Amount creditSum() {
    	final int total = elements.stream()
    		.filter(each -> each.isCredit())
    		.mapToInt(each -> each.amount().value())
    		.sum();
    	
		return Amount.valueOf(total);
	}

3-2. パッケージ間・クラス間の依存関係をコントロールする

4. 設計の方針」の項目で詳しく解説しますが、アプリケーション全体を4つの階層に分割し、それぞれの依存関係のルールを順守するようにしました。また、クラス間の呼び出し関係が複雑になり、変更の影響範囲が多方面に及ぶことを避けるようにしました。そのために、publicにする必要のないクラスはパッケージプライベートにしたり、クラス内でしか使われないメソッドはprivateにするなど、アクセス修飾子を有効に使うようにしました。

3-3. コードの可読性を意識する

書籍『リーダブルコード』などを参考にし、

  • メソッド名や変数名には情報を詰め込み、意味のある名前にする。
  • 条件分岐やループによるネストを解消し、ロジックを単純化する。
  • 一度に1つのことを行うようにし、多くの処理をこなしているメソッドがあれば別のメソッドや別のクラスに分離する。

といったことを意識しました。

例えば、いわゆるサービスクラスですが、「サービス」という言葉の意味が曖昧で、「UserService」というクラス名だとユーザに関する処理であれば何でも書けるように思えます。ユーザ作成処理を責務とするクラスであれば、「CreateUserUseCase」のような具体的な名前を付け、1つのクラスに責務を詰め込みすぎないように心がけました。
こういった命名の方針は、書籍『ドメイン駆動設計 モデリング / 実装ガイド』も参考にしています。

4. 設計の方針

アーキテクチャとしてオニオンアーキテクチャを採用しました。
オニオンアーキテクチャの概要については以下の記事が参考になりました。

ドメイン駆動 + オニオンアーキテクチャ概略[DDD]
https://little-hands.hatenablog.com/entry/2017/10/11/075634

4-1. おおまかなディレクトリ構成

- application
    - query
    - shared
    - usecase
- domain
    - model
    - service
    - shared
- infrastructure
    - mysqlquery
    - mysqlrepository
- presentation
    - controller
    - forms
    - shared
    - viewmodels

アプリケーション層、ドメイン層、インフラストラクチャ層、プレゼンテーション層の4層構成です。
各層の責務は以下のとおりです。

  • アプリケーション層・・・ドメインモデルを使ってユースケースを組み立てる。トランザクション管理を行い、アプリケーション全体の調整役のような役割を持つ。
  • ドメイン層・・・ドメイン知識(ルール・制約)を表現する。
  • インフラストラクチャ層・・・ドメインモデルの永続化、再構築といった技術的な機能を提供する。
  • プレゼンテーション層・・・クライアントとのデータの入出力を担当する。

例えば、ユーザ登録処理であれば、
①プレゼンテーション層のコントローラは入力されたデータを受け取り、アプリケーション層に渡す。(必要があれば書式変換等を行う。)
②アプリケーション層のサービスクラスは渡されたデータからユーザのエンティティを生成する。ユーザの重複チェックを行い、問題なければインフラ層に渡す。
③インフラ層のリポジトリはユーザのエンティティを受け取り、DBのテーブルにデータを保存する。
のような流れになります。

4-2. パッケージ図

パッケージ図

ドメイン層が基盤となり、その他の層がドメイン層に依存する形になっています。依存の方向は上の階層から下の階層で統一し、ドメイン層がDBへの永続化の詳細や画面表示の都合に振り回されないようにしています。

実際に画面の開発を進めた際に、「やっぱりこういう項目も表示したい」「この項目の表示形式を別の形式に変更したい」といったことが後から出てきました。画面、ドメインモデル、DBのテーブルを密接に結びつけるのではなく、それぞれの関心事に集中させるような設計になっているので、修正の影響をプレゼンテーション層に閉じ込めることができてやりやすいと感じました。

5. 工夫したこと

5-1. 仕訳の登録時に複数行の明細を作成できるようにした

まずは機能面です。
仕訳を登録する時に、1件1件登録するだけでなく、複数の項目を同一の仕訳としてまとめて登録できるようにしました。
例えば、給与を貰った際の仕訳登録は下の画像のようになります。入力フォームはボタンクリックで動的に追加・削除が可能で、借方(左)、貸方(右)それぞれに最大10件の明細を追加することができます。

スクリーンショット 2023-07-12 201427
スクリーンショット 2023-07-12 202125

入力フォームの追加・削除など画面に関する処理や、入力漏れのチェックなど一般的なバリデーションはプレゼンテーション層のクラスが担当します。貸借の金額が一致しているか、貸借の勘定科目の組み合わせが正しいかといった整合性チェックはドメイン層のクラスが担当します。
次のコードは資産・負債等の区分を表すクラスと、貸借の組み合わせルールを表現するクラスのコードです。重要なルール・制約はドメイン層に定義し、UI側にドメインのルールを記述しないようにしました。

AccountingType.java
/**
 * 会計区分
 */
public enum AccountingType {

	ASSETS(new Assets()),           //資産
	LIABILITIES(new Liabilities()), //負債
	EQUITY(new Equity()),			//純資産
	EXPENSES(new Expenses()),       //費用
	REVENUE(new Revenue());         //収益
	
	private final AccountingElement element;
	
	private AccountingType(AccountingElement element) {
		this.element = element;
	}
	
	/**
	 * 会計区分名
	 */
	public String lavel() {
		return element.lavel();
	}
	
	/**
	 * 貸借区分
	 */
	public LoanType loanType() {
		return element.loanType();
	}
	
	/**
	 * 集計区分
	 */
	public SummaryType summaryType() {
		return element.summaryType();
	}
	
	/**
	 * 自身を借方、引数を貸方として組み合わせ可能かどうかを判断する
	 * @param other 貸方の会計区分
	 */
	public boolean canCombineWith(AccountingType other) {
		return AllowedCombinationRule.ok(this, other);
	}
	
}
AllowedCombinationRule.java
/**
 * 会計区分の貸借の可能組み合わせのルールを表現するクラス
 */
public class AllowedCombinationRule {

	//key:借方会計区分
	//value:keyに対して組み合わせ可能な貸方会計区分
	private static Map<AccountingType, Set<AccountingType>> allowed; 
	
	static {
		allowed = new HashMap<>();
		allowed.put(ASSETS, EnumSet.of(ASSETS, LIABILITIES, EQUITY, REVENUE));
		allowed.put(LIABILITIES, EnumSet.of(ASSETS, LIABILITIES, REVENUE));
		allowed.put(EQUITY, EnumSet.of(ASSETS, LIABILITIES, EQUITY, EXPENSES));
		allowed.put(EXPENSES, EnumSet.of(ASSETS, LIABILITIES, EQUITY, REVENUE));
		allowed.put(REVENUE, EnumSet.noneOf(AccountingType.class)); //収益は貸方にのみ指定可能
	}
	
	/**
	 * @param debit 借方会計区分
	 * @param credit 貸方会計区分
	 * @return 組み合わせ可能である場合true
	 */
	public static boolean ok(AccountingType debit, AccountingType credit) {
		Set<AccountingType> allowedTypes = allowed.get(debit);
		return allowedTypes.contains(credit);
	}
	
}

※ここに記述したルールは今回作成したアプリの都合に合わせており、実際の簿記とは異なる部分があります。

5-2. 参照系処理のパフォーマンスを重視する設計にした

集計処理の実装を考えた際、はじめは登録済みの仕訳データを元に残高を計算すればよいと考えていました。DBのレコードから現金、食費などの科目ごとに増加額と減少額を計算し、その差額をとれば残高を求めることができます。しかし、収支一覧や資産状況一覧を表示したり、グラフを表示したりと参照系のユースケースが多く、そのたびに毎回DBのレコードを元に計算を行うのはパフォーマンス上どうなのだろう?という疑問が生じました。

そこで、「勘定」というオブジェクトを用意し、仕訳の登録や削除と同時に勘定の残高を更新することにしました。つまり、「食費:900円 / 現金:900円」の仕訳を登録する時に、食費の勘定を900円増やし、現金の勘定を900円減らす、といった具合です。DBには仕訳テーブルとは別に月次残高テーブルというテーブルを用意し、残高を記録します。グラフ表示などの際には複雑な計算はせず、記録されたデータを利用するようにします。

また、更新系処理ではドメインモデルを使いますが、参照系処理では複数のドメインモデルにまたがったデータが必要になるケースが多く、処理が複雑化することが考えられました。(例えば、リポジトリから複数集約のエンティティのListを取得し、アプリケーション層でループを回しながらIDが一致するか判定し、DTOに詰め替えてコントローラに渡す・・・など)

そこで、コマンド・クエリ責務分離(Command Query Responsibility Segregation:CQRS)という実装パターンを参考にし、参照系処理では取得したいデータに合わせてクエリモデルという別のモデルを定義することで、処理をシンプルにすることができました。ただ、厳密なCQRSというわけではなく、必要に応じて取り入れるというスタンスです。参照系処理であっても、ドメインモデルをそのまま返したほうがシンプルにできそうだと思った部分では、ドメインモデルを使うようにしています。

CQRS実践入門 [ドメイン駆動設計]
https://little-hands.hatenablog.com/entry/2019/12/02/cqrs

6. 苦労したこととそこから得られたこと

6-1. 未知のエラーが次から次へと発生した

今回初めてフレームワークを使ったのですが、数多くのエラーに遭遇しました。アプリを起動する途中で落ちてしまったり、起動できても想定通りに動かなかったり・・・。結局、依存ライブラリの設定が誤っていたり、DBへの接続情報の設定が誤っていたり、Thymeleafのテンプレートの書き方が誤っていたりと、ほとんどは自分の知識不足や勘違いが原因でした。

はじめはコンソールに長いエラーログが表示されると焦っていたのですが、しっかり読んでみるときちんと原因が書いてあったり、直接の原因は分からなくても解決の糸口になりそうな情報が見つかったりして、それをヒントに公式ドキュメントをもう一度じっくり読んだり、検索をかけて解決策を見つけることができました。そうは言ってもなかなか一筋縄ではいかないものですが、エラーログを読んで原因を推測し、解決できるまで粘り強く取り組む力はある程度身についたのではないかと思います。

6-2. アプリを公開する段階でかなり苦戦した

アプリの公開にあたっては、Cloud Native Buildpacksを使ってビルドしたコンテナイメージを、fly.ioというサービスにデプロイするという方法をとりました。DBについてはPlanetScaleというサービスを使ってMySQLデータベースを作成し、fly.ioに公開したアプリから接続しています。
というように、やっていることは単純なのですが、実際にやってみるとビルドがうまくいかなかったり、無事にビルドできてもデプロイに失敗したり、ついにデプロイに成功した!と思ったらアプリが動いていない・・・というように全く順調にはいきませんでした。アプリが動かないのはfly.ioのVMのメモリに対してアプリの容量が大きすぎたのが原因だったようで、非ヒープ領域を小さく抑えるように設定したところ、動作するようになりました。

インフラの知識があまりなかったため苦戦してしまいましたが、やりたいことの実現のためによく知らない分野について自分で調べたり勉強したりというという経験ができたのはよかったです。少しだけでもやったことがあれば、今後はその経験を取っ掛かりにしてさらに勉強し、知識を高めていくことができると思います。今回のアプリの公開にはfly.ioを使いましたが、今後はAWS上での環境構築についても学んでみたいと考えています。

7. 反省点

7-1. 設計にとらわれすぎてなかなか進まない

コードを書いている時に設計上良いか悪いかということを考えすぎて、思った以上に時間がかかってしまいました。DDDのパターンの適用にとらわれすぎていた部分があったと思います。良い設計かどうかという判断力は長い経験の中で身につけていくものだと思いますし、作成するシステムによっても最適なものは異なります。「完璧な設計など存在せず、初学者がはじめから良い設計を手に入れられるはずはない」とある意味開き直って、途中からはとにかく手を動かしつつ少しずつ改善してより良い設計を目指すようにしました。また、アーキテクチャを適用することは目的ではなく手段であり、重要なのはどういう理由でその設計にしたのか、ということだと思ったので、何かしらの設計変更をした際にはその理由を記録として残すようにしました。

実際の開発では、予算や納期、パフォーマンスの要件など様々な制約があったり、プロジェクトによって開発の方針も異なるものだと思います。読みやすいコード、変更に強いコードを目指しつつも、求められるものに対して最適な実装をできる人間になれるよう勉強していかなければならないと感じました。

7-2. プレゼンテーション層のコードが複雑になりがち

プレゼンテーション層は複雑な画面表示の都合を扱うので、関心事が入り混じって他の層に比べてコードが複雑になってしまいました。コードが複雑になるとテストが書きにくく、実際にテストが不十分な部分もあり今後の課題だと思っています。低レベルの処理をうまく別クラスに切り出したりしてコードを単純化し、変更や機能追加に対応できるようにしていきたいです。

7-3. 簿記の知識がないと使いづらい

今回は自分自身が使う目的で開発したので、当然といえば当然なのですが、ユーザ目線で考えるのであれば「仕訳」「借方」「貸方」などの用語は使わず、単なる出金、入金、振替として抽象化し、簿記を知らなくても直感的に使えるよう工夫していく必要があると思います。

8. 今後の目標

8-1. 追加の機能を実装する

今回、最低限実装したい機能として「2-2. 主な機能」に載せたものを作成したのですが、以下のような追加の機能も考えています。

  • 勘定科目ごとに仕訳の履歴を表示する
  • 固定費を自動で登録する
  • 年単位での収支の把握
  • PDF形式でレポート出力

DDDが説く保守性・変更容易性の恩恵は、実際に仕様変更をしてみたり、長く運用してみないと実感できないものだと思います。今回自分が書いたコードについては、「DDDを参考にしたからといって本当に保守性が高いとは限らない」と思っていて、まだまだ勉強不足で改善点は多いのではないかと考えています。アプリを作って終わりではなく、今後経験を積んでいく中で得たことを反映させ、機能を拡張しながら継続的に改善していきたいです。

8-2. モデリングの知識・実践力を高める

DDDを学ぶ中でコードレベルでの設計はある程度理解していたつもりでしたが、システム全体の具体的な設計の知識が不足していました。ある程度ドメインモデリングを行い、そこからボトムアップでコードを書いていったので、ユースケースのコードを書く段階になって細かな要件の見落としに気づいたりすることがありました。

最近は書籍『ユースケース駆動開発実践ガイド』を読んでユースケース分析の手法について学んだり、(順番が前後してしまいましたが)今回開発したアプリを元にシーケンス図などの図表を作成してみたりといったことをしています。

9. おわりに

今回初めてアプリを開発する中で、エラーをなかなか解決できなかったり、実現したい機能に対して良い実装方法が思い浮かばなかったりと苦労も多かったですが、全体を通して「楽しい」「面白い」という感覚を持ちながら取り組むことができました。地道に作成してきたアプリを公開し、実際に動くのを確認した時には大きな達成感がありました。

実務では楽しいことよりも苦労やつらいことのほうが多いのではないかと思います。しかし、壁にぶつかっても諦めず、周囲の人たちと協力しながら根気よく仕事を進めていけるようにしたいです。そのためにもまずは仕事に就けるように頑張ります。

大変長くなってしまいましたが、最後まで読んでいただきありがとうございました。

138
128
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
138
128