このページに書いてあること
業務用のシステムって、一体どのようにクラス設計するのがいいのか、自分なりの経験を踏まえて至った現時点での答えを整理します。
システムを開発するにあたって、結構重要な内容としてクラス設計があると思います。しかし、「オブジェクト指向」という言葉が一人歩きしているように思う場面も多いです。
前提
- Javaアプリ(C#でもいい。オブジェクト指向の言語なら)
- BtoC、BtoBに関わらず、業務系のシステムを対象
- WebAPIなども含みます。 要はJavaバッチなどは対象外。
- プロジェクトチームのプログラミングスキルはそこまで高くない。
考慮すべきポイント
- ビジネスの変化に(要件の変更・追加・削除)に柔軟に対応する構成にしたい。
- 今後も継続的に改修されていくが、メンバーが入れ替わる可能性が高いため、可読性を大事にしたい。
- コーディングの難易度が上がってしまうため、あまり難しい理論とかは入れたくない。(DDDとかは嫌だ)
こんなところでしょうか。要は、「簡単に拡張できるコードを簡単なコードで書きたい」ということですね。
結論
簡単に結論を書きます。
クラスの分割を役割に応じて縦(手続き的)と横(オブジェクト指向的)に分割して構成します。
大した話ではなくて、SpringBootでいうController/Service/Respositoryをどう分けましょうかというお話です。
考え方1:業務は固有・仕組みは共通という前提
単純な話でして、システム開発は大抵の場合は顧客の業務をどうにかするためのものです。効率化であったり、ビジネス創出であったりですね。
で、この時に重要なのは、いろんなシステムをみた時に、共通する部分と共通しない部分はどこかということです。
それは見出しの通りで、顧客の業務そのものは共通することはありません。しかし、それを実現する仕組みはかなりの確率で共通しています。
業務は固有
例えば、銀行の国際送金業務とコンビニの発注業務のシステムを考えた時、その業務の手順に共通はどれだけあるでしょうか?0じゃないでしょうか?このように、業態が異なればその業務は全く別のものになります。また、同じ国際送金業務でも、銀行がやる場合と証券会社がやる場合では違うでしょう。銀行同士でも違う(業務が同じであれば、同じシステムを入れれば低コストで実現できる)でしょう。
つまり、基本的に業務部分は使い回すことはできないのです。
仕組みは共通
全く異なる業務システムでも、その中で使う仕組みは大抵同じです。どういうことかというと、データを格納したければLDAPやSQLを使うし、電文を送信したければHTTPやSOAPなどを使用します。
つまり、DBに格納するであったり、リクエストをどこかに送信するなんていう仕組みの部分は共通(というか使い回し)が効くということがわかると思います。ある業界に絞っていえば、規格に則っているなんて場合もあるでしょう。例えば、国際送金にはスウィフト電文を使うなどが決まっていますので、国際送金業務という範囲では、やはり仕組みは共通と言えそうです。
考え方2:業務に起因する重複コードは受け入れる
たまに、重複コードは一切許さず2回以上登場するコードはメソッド化して呼び出せなんていう過激派がいますが、(ここまでいかずともですが・・・)こんな考え方でクラスやメソッドを作っていくと、役割に応じた切り分けが不可能となり、ネーミング的にも機能分割的にも混沌としたコードが出来上がります。
また、最初は美しくExtendsなどで分割できていたとしても、改修を続けるうちに(最悪の例では次の改修時に)継承したクラスのごく一部だけ変えたいなんて言うケースはザラにあります。
こんなクラスを最初に作って美しく分割していたとしても、次の改修で一部異なるだけの処理のために、AbstractClass2を作って2重継承させます。地獄の始まりですね。
私は4重継承までみたことがあります。
従来では重複のないコードが美しいとされていましたが、メンバーの入れ替わりが多い案件だと複雑なコードは却ってお荷物になります。そのコードを理解できない、メンテナンスできないという状態に陥るからです。
ではどうするか。業務と仕組みに分けて考えて、業務部分はそれぞれのクラスで実装し、仕組み部分は共通化します。
例え、多少の重複コードが発生しようが業務部分は個別に実装するのです。多少どころか大部分が共通でも改修時に楽になります。
こんな感じでmethod1は業務クラスで実装します。method1の内容は重複しているコードが書かれるかもしれませんが、業務が異なるものとして重複を受け入れましょう。そうすると、最初は同じだった業務を後から片方だけ変更したいと言うような要件が出ても最低限の変更をすることで修正ができます。
method2については、共通の仕組みとしてAbstractClassに配置しています。
考え方3:SOLID原則のうち、インタフェース分離の原則と依存性逆転の原則を大事にする
SOLID原則というオブジェクト指向における経典のような5ヶ条があるのですが、いくつかの原則の頭文字をとってそのように表現されます。
- Single Responsibility Principle:単一責任の原則
- Open/closed principle:オープン/クロースドの原則
- Liskov substitution principle:リスコフの置換原則
- Interface segregation principle:インターフェース分離の原則
- Dependency inversion principle:依存性逆転の原則
今回の趣旨からいくと、IとDを重視します。S,O,LはIとDを意識していくとある程度担保することができるようになりそうです。
Interface segregation principle:インターフェース分離の原則
これは非常にわかりやすいです。
インタフェースに余計なメソッドを入れてはいけない。
という話です。
以下の様なクラスを見たことありませんか?
何が問題かわかりませんね。Serviceインタフェースは4つのメソッドを持っています。この時、ServiceはgetUserとcreateUser / Service2はdeleteUserとupdateUserのみが必要だったとした場合問題となります。それぞれのクラスには不要なメソッドが実装されてしまうことになるわけです。
なぜこの様なことになるかというと、インタフェースの分け方を「ユーザー情報操作をする」という役割だけでわけてしまったためです。
例えば、Serviceインタフェースに新しいユーザー情報操作を追加したい場合、全実装クラスに不要なメソッドの実装を追加するという地獄を見ることになります。既存クラスは使用しないメソッドが追加されるため、実装に修正が入ってしまいますね。
どうするのが良いかというと、お互いに関心のない処理は分離して実装します。
それは以下の様になります。ユーザー操作というインタフェースがあって、Select/Create/Delete/Updateを各クラスにて実現しています。
クラス数は増えてしまいますが、クラス間の依存は完全に排除され、Selectの修正はCreateには影響を及ぼしません。
ここで、共通処理がある様な場合(例えばデータベースへの接続手続きの実行など)は、以下の様にAbstractにて実現します。
どうでしょうか?インタフェースと抽象クラスの使い方が掴めてきましたね。
Dependency inversion principle:依存性逆転の原則
教科書的に書くと以下の通りです。
上位のモジュールは下位のモジュールに依存してはならない。
どちらのモジュールも「抽象」に依存すべきである 。
「抽象」は実装の詳細に依存してはならない。実装の詳細が「抽象」に依存すべきである
意味がわからないですね。
何も考えずにクラス分けすると、こんな感じになります。この構成の問題は、ControllerがServiceの実装を直接呼び出しているので、Serviceクラスを修正すると、Controllerはその影響をモロにうけるということです。
モロにうけるというのは、Controllerも修正・再試験をしないといけなくなるという意味で捉えていただいて大丈夫です。
で、大抵の人は上記構成はまずいのは何となくわかるが、どうしたらいいかまではわからないのでとりあえずインタフェースを使います。その時、以下のようになると思います。パッケージ構造にも注目してください。
どうでしょう。インタフェースを使うとこんな感じになりそうですね。各パッケージの中に、それぞれのインタフェースを配置して、必要に応じて実装したクラスがあります。(厳密にいうと、DIを使用していない場合はこの構成も実装に依存してしまいますので、DIを使っている前提で理解をお願いします。Springでいう@Resourceアノテーションを使っている前提で。)
ここでルールを思い出して欲しいのですが、4つありました。
上位のモジュールは下位のモジュールに依存してはならない。
どちらのモジュールも「抽象」に依存すべきである 。・・・・・IFに依存しているためOK
「抽象」は実装の詳細に依存してはならない。・・・・・IFに依存しているためOK
実装の詳細が「抽象」に依存すべきである・・・・・IFに依存しているためOK
1個目のルールだけ達成できていません。
そこで、こうします。パッケージ構造に注目してください。
インタフェースが親パッケージにきました。これが依存性逆転の原則です。
こうすることで
- Service2#getUserの中身をどれだけ弄ろうが、Controllerは自パッケージのインタフェースしか見ていないため影響を受けない。
- 同様に、各Serviceクラスがどんな処理をしているのか知る必要がない。(依存度を下げられる)
などの恩恵をうけることが出来ます。
ここで重要なのは、どのインタフェースを親パッケージに入れるのか?
ということでしょう。Serviceパッケージの全インタフェースを親パッケージに入れればいいのでしょうか?
経験上、NOです。
上位に公開する必要のあるクラスのインタフェースのみを親パッケージに配置します。
Serviceパッケージ内でしか呼ばれないクラスのインタフェースは、外部に公開する必要がありませんから、Serviceパッケージ内に入れておくのです。
つまり、以下の様なクラス配置にします。
これにより、外部に公開する機能と内部でしか使わない機能を明確に分離することができるため、修正などでの影響確認が非常に容易になります。
業務ロジック(Service)の修正はControllerに影響を及ぼさないですし、Serviceパッケージ内のインタフェースの修正は、構成上Controllerには関係ありません。
Repositoryも同様ですね。
インタフェースを経由して実装を呼び分けますから、オープンクローズドの原則や、リスコフ置換の原則もある程度カバーできそうですね。