博報堂テクノロジーズの坂井です。
個人的にPythonでアプリケーションを実装する業務があり、「機能拡張しやすい・持続的に進化可能な設計にするには具体的にどうしたら」ということを考えていて、自分なりに整理したことを共有します。
前提
次のような構造を前提とします:
- ドメイン層.py
-
domain_logic
関数 -
domain_service
関数
-
- アプリケーション層.py
-
application
関数
-
def domain_logic(input: DomainInput) -> DomainOutput:
# この中にドメイン固有のロジック
def domain_service(input: ServiceInput) -> ServiceOutput:
# このなかで domain_logic を使う
domain_output = domain_logic(domain_input)
def application():
# このなかで domain_service を使う
domain_service_output = domain_service(domain_service_input)
依存関係を図にするとこんな感じ:
ここで設計へ次のような要求があるとします:
-
domain_logic
は将来に渡ってバリエーションを増やして機能拡張できるよう対応したい。 -
ドメイン層.py
は中核的なモジュールで様々な場所から依存されているため、破壊的な変更を加えたくない。変更を加えるとしたら新しい関数やオブジェクトを書き足すだけにしたい(cf. 開放・閉鎖原則 - Wikipedia)
たとえば私たちのような広告会社の場合、ドメイン層には様々な広告メディア固有の処理を書いていく必要があります。広告メディアは日々進化しつづけていますので、今後も新しい広告メディアが生まれるたびに容易に拡張できる構造にしたいというモチベーションがあります。
1. 引数でロジックを切り替え
ではドメインロジックのバリエーションを増やして機能拡張していく必要があるとき、どういう設計にしたらよいでしょうか。
一番最初に思いつくのは次のパターンです:
def domain_logic(input: DomainInput, logic_flag: str) -> DomainOutput:
# 引数 flag でロジックを切り替え
if logic_flag == "ロジック1":
# ロジック1
elif logic_flag == "ロジック2":
# ロジック2
def domain_service(input: ServiceInput) -> ServiceOutput:
if ...:
logic_flag = "ロジック1":
elif ...:
logic_flag = "ロジック2":
domain_output = domain_logic(domain_input, logic_flag)
def application():
domain_service_output = domain_service(domain_service_input)
domain_logic
の内部に複数のロジックを直接書いて、logic_flag
引数によってどのロジックを使うか切り替えるようにしてみました。
もっとも素朴なパターンですが、
機能拡張のたびに domain_service
や domain_logic
の中に手を入れる必要があり、下手するとこれまで動いていたものを破壊してしまう可能性がある
というデメリットを持っており、長く使われる予定のソフトウェアではあまり採用したくありません。
2. 関数自体を切り替え
def domain_logic1(input: DomainInput) -> DomainOutput:
# ロジック1
def domain_logic2(input: DomainInput) -> DomainOutput:
# ロジック2
def domain_service(input: ServiceInput) -> ServiceOutput:
if ...:
output = domain_logic1()
elif ...:
output = domain_logic2()
def application():
domain_service_output = domain_service(domain_service_input)
domain_logic
をロジックごとに複数の関数に分けてみました。
これで
domain_logic
は機能拡張の際に書き足せばよく、破壊的な変更は受けない
状態になりました。しかし、
domain_service
は相変わらず機能拡張のたびに手を入れる必要がある
というデメリットは残っています。
3. 関数オブジェクト渡し
ここで、Pythonでは関数がオブジェクトであり別の関数に渡すことができるという特性を使ってみます。
def domain_logic1(input: DomainInput) -> DomainOutput:
# ロジック1
def domain_logic2(input: DomainInput) -> DomainOutput:
# ロジック2
def domain_service(input: ServiceInput, domain_logic: Callable[DomainInput, DomainOutput]) -> ServiceOutput:
domain_output = domain_logic(domain_input) # 渡された関数オブジェクトを使う
def application():
if ...:
domain_logic = domain_logic1
elif ...:
domain_logic = domain_logic2
domain_service_output = domain_service(domain_service_input, domain_logic) # 関数オブジェクトを渡す
これで
機能拡張の際に domain_service
関数に破壊的な変更が入る可能性がない
という状況を作り出すことができました。
※これは一般的に "ストラテジーパターン" とか "依存性の注入"と呼ばれるようです。
これでわりと充分なのですが、欲をいえば、
-
domain_logic1
やdomain_logic2
が似たような役割の関数であるという共通点が、コード上から明らかでない -
domain_logic
が使える情報がinput: DomainInput
しかない。将来追加の情報が必要なロジックを作る必要がでたときに、またdomain_service
に情報を受け渡させる変更をしないといけない
という新しい課題が気になってきました。
4. 関数クラスを導入
そこで、Pythonでは「関数もクラスとして定義できる」ことを利用します。
class DomainLogic:
def __init__(self, logic_flag, data):
self._logic_flag = logic_flag
self._data = data
def __call__(self, input: DomainInput) -> DomainOutput: # __call__メソッドを実装すれば関数オブジェクトになる
if self._logic_flag == "ロジック1":
# ロジック1
# self._dataが使える
elif self._logic_flag == "ロジック2":
# ロジック2
# self._dataが使える
def domain_service(input: ServiceInput, domain_logic: DomainLogic) -> ServiceOutput:
domain_output = domain_logic(domain_input) # 渡されたDomainLogicオブジェクトを関数呼び出し(__call__)
def application():
data = fetchdata()
if ...:
domain_logic = DomainLogic(logic_flag="ロジック1", data=data) # DomainLogicを構築 データも渡せる
elif ...:
domain_logic = DomainLogic(arg="ロジック2", data=data)
domain_service_output = domain_service(domain_service_input, domain_logic) # 関数オブジェクトを渡す
Pythonでは __call__
メソッドを実装したクラスのインスタンスは自動的に関数オブジェクトとなり、 domain_logic()
と関数として呼び出すことができます。これを活用して DomainLogic
というクラスを新設し、これまでの domain_logic
関数はそのインスタンスだという位置づけにしました。
-
domain_service
がDomainLogic
オブジェクトを必要としていることがコードから伝わりクリーンになる -
DomainLogic
のコンストラクタに情報data
を渡しておくことで、ロジックが追加的な情報を使った振る舞いを持てるようになる(しかもそれをdomain_service
は知らなくてよい)
というメリットを新たに享受することができました。しかし一方で、
機能拡張のたびに DomainLogic
の中に手を入れる必要がある
というデメリットは戻ってきてしまいました。
5. 抽象関数クラスから継承する
最後に、Pythonの抽象クラス機能を使って抽象と具体を分離します。
from abc import ABC
class DomainLogic(ABC):
@abstractmethod
def __call__(self, input: DomainInput) -> DomainOutput:
# 中身は空っぽ
pass
def domain_service(input: ServiceInput, domain_logic: DomainLogic) -> ServiceOutput:
domain_output = domain_logic(domain_input)
# ドメイン具体層
class DomainLogic1(DomainLogic):
def __init__(self, data1):
self._data1 = data1
def __call__(self, input: DomainInput) -> DomainOutput:
# ロジック1
# 内部で self._data1が使える
class DomainLogic2(DomainLogic):
def __init__(self, data2):
self._data2 = data2
def __call__(self, input: DomainInput) -> DomainOutput:
# ロジック2
# 内部で self._data2が使える
def application():
if ...:
data1 = fetchdata1()
domain_logic = DomainLogic1(data1=data1) # DomainLogicを構築 データも渡せる
elif ...:
data2 = fetchdata2()
domain_logic = DomainLogic2(data2=data2)
domain_service_output = domain_service(domain_service_input, domain_logic) # 関数オブジェクトを渡す
このように DomainLogic
を抽象クラスとし、具体的なロジックはこれを継承させた DomainLogic1
, DomainLogic2
に担当してもらうようにしました。
※本来はインターフェースを使いたいところですが、Pythonにはインターフェースがないので代わりに抽象クラスが使われるのが一般的です。
これによって
-
domain_service
を含むドメイン抽象層はずっしり安定し、機能拡張の際も触らなくてよくなった。ドメイン具体層に新しいDomainLogic
サブクラスを書き足すだけでOK -
DomainLogic
のコンストラクタに情報data
を渡しておくことで、ロジックが追加的な情報を使った振る舞いを持てるようになる(しかもそれをdomain_service
は知らなくてよい)
というメリットを両立させることができました。
オブジェクト間の依存関係を書くとこんな感じ:
DomainLogic
やdomain_service
が、具体クラスである DomainLogic1
DomainLogic2
の追加変更の影響を受けない様子がわかります。
これでだいぶ変更に強い構造になりましたが、その代償として
登場するモジュールの数や役割が増え、ソースコードが追いにくくなる
というデメリットがでてきてしまいます。
なんでもかんでもここまで抽象化するのはやりすぎかなと思います、構造の意図を新メンバーに理解してもらうためにドキュメント化するなど労力も大きくなっていきます。
まとめ
登場した5つのパターンのメリデメを整理します:
機能拡張に対する ドメイン層の安定性 |
ドメインロジックが 使える情報 |
コードの素朴さ | |
---|---|---|---|
1.引数で切り替え | ❌❌ | ❌ | ✅ |
2.関数を切り替え | ❌ | ❌ | ✅ |
3.関数オブジェクト渡し | ✅ | ❌ | ✅ |
4.関数クラスを導入 | ❌ | ✅ | ❌ |
5.抽象関数クラスを継承 | ✅ | ✅ | ❌❌ |
拡張性を得る代わりに理解しやすさが犠牲になるトレードオフであることがわかります。
開発チームのフェーズやドメインの性質によって、上記のパターンを使い分けていこうと思います。