ddd4py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ddd4py/__init__.py +44 -0
- ddd4py/application/__init__.py +9 -0
- ddd4py/application/application_service_life_cycle.py +121 -0
- ddd4py/application/unit_of_work.py +53 -0
- ddd4py/domain/__init__.py +0 -0
- ddd4py/domain/model/__init__.py +11 -0
- ddd4py/domain/model/domain_event.py +90 -0
- ddd4py/domain/model/domain_registry.py +20 -0
- ddd4py/domain/model/event_context.py +27 -0
- ddd4py/event/__init__.py +5 -0
- ddd4py/event/event_context_provider.py +56 -0
- ddd4py/event/event_store.py +24 -0
- ddd4py/event/stored_event.py +74 -0
- ddd4py/exception/__init__.py +4 -0
- ddd4py/exception/error_code.py +50 -0
- ddd4py/exception/system_exception.py +16 -0
- ddd4py/module.py +81 -0
- ddd4py/notification/__init__.py +20 -0
- ddd4py/notification/consumed_notification.py +31 -0
- ddd4py/notification/consumed_notification_store.py +36 -0
- ddd4py/notification/notification.py +72 -0
- ddd4py/notification/notification_publisher.py +9 -0
- ddd4py/notification/notification_reader.py +33 -0
- ddd4py/notification/notification_serializer.py +17 -0
- ddd4py/notification/published_notification_tracker.py +60 -0
- ddd4py/notification/published_notification_tracker_store.py +17 -0
- ddd4py/port/__init__.py +0 -0
- ddd4py/port/adapter/__init__.py +0 -0
- ddd4py/port/adapter/messaging/__init__.py +5 -0
- ddd4py/port/adapter/messaging/exchange_listener.py +19 -0
- ddd4py/port/adapter/messaging/message_publisher.py +13 -0
- ddd4py/port/adapter/messaging/message_subscriber.py +112 -0
- ddd4py/port/adapter/messaging/stub/__init__.py +3 -0
- ddd4py/port/adapter/messaging/stub/message_publisher_stub.py +19 -0
- ddd4py/port/adapter/persistence/__init__.py +0 -0
- ddd4py/port/adapter/persistence/inmem/__init__.py +11 -0
- ddd4py/port/adapter/persistence/inmem/in_mem_consumed_notification_store.py +27 -0
- ddd4py/port/adapter/persistence/inmem/in_mem_event_store.py +31 -0
- ddd4py/port/adapter/persistence/inmem/in_mem_published_notification_tracker_store.py +20 -0
- ddd4py/port/adapter/persistence/inmem/in_mem_unit_of_work.py +37 -0
- ddd4py/port/adapter/persistence/sqlalchemy/__init__.py +4 -0
- ddd4py/port/adapter/persistence/sqlalchemy/session_preparer.py +28 -0
- ddd4py/port/adapter/persistence/sqlalchemy/sqlalchemy_unit_of_work.py +110 -0
- ddd4py/settings.py +40 -0
- ddd4py/testing/__init__.py +15 -0
- ddd4py/testing/contracts.py +111 -0
- ddd4py/testing/di.py +34 -0
- ddd4py-0.1.0.dist-info/METADATA +123 -0
- ddd4py-0.1.0.dist-info/RECORD +51 -0
- ddd4py-0.1.0.dist-info/WHEEL +4 -0
- ddd4py-0.1.0.dist-info/licenses/LICENSE +21 -0
ddd4py/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""モジュラモノリス + DDD のカーネル。"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from ddd4py.application import ApplicationServiceLifeCycle, UnitOfWork, transactional
|
|
6
|
+
from ddd4py.domain.model import (
|
|
7
|
+
DomainEvent,
|
|
8
|
+
DomainEventPublisher,
|
|
9
|
+
DomainEventSubscriber,
|
|
10
|
+
DomainRegistry,
|
|
11
|
+
EventContext,
|
|
12
|
+
)
|
|
13
|
+
from ddd4py.event import EventContextProvider, EventStore, NullEventContextProvider, StoredEvent
|
|
14
|
+
from ddd4py.exception import CoreCode, ErrorCode, ErrorLevel, SystemException
|
|
15
|
+
from ddd4py.module import AppModule, CompositeModule
|
|
16
|
+
from ddd4py.settings import BaseAppSettings, CoreSettings
|
|
17
|
+
|
|
18
|
+
# バージョンの真実源は pyproject.toml の [project].version 一箇所。
|
|
19
|
+
# 引数は import 名ではなく配布名 (どちらも ddd4py)。
|
|
20
|
+
__version__ = version("ddd4py")
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AppModule",
|
|
24
|
+
"ApplicationServiceLifeCycle",
|
|
25
|
+
"BaseAppSettings",
|
|
26
|
+
"CompositeModule",
|
|
27
|
+
"CoreCode",
|
|
28
|
+
"CoreSettings",
|
|
29
|
+
"DomainEvent",
|
|
30
|
+
"DomainEventPublisher",
|
|
31
|
+
"DomainEventSubscriber",
|
|
32
|
+
"DomainRegistry",
|
|
33
|
+
"ErrorCode",
|
|
34
|
+
"ErrorLevel",
|
|
35
|
+
"EventContext",
|
|
36
|
+
"EventContextProvider",
|
|
37
|
+
"EventStore",
|
|
38
|
+
"NullEventContextProvider",
|
|
39
|
+
"StoredEvent",
|
|
40
|
+
"SystemException",
|
|
41
|
+
"UnitOfWork",
|
|
42
|
+
"__version__",
|
|
43
|
+
"transactional",
|
|
44
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .unit_of_work import UnitOfWork
|
|
2
|
+
from .application_service_life_cycle import ApplicationServiceLifeCycle, transactional
|
|
3
|
+
|
|
4
|
+
# 入力アダプタ (port.adapter.resource / messaging) はヘキサゴナル契約上 domain を直接 import
|
|
5
|
+
# できないため、アダプタが扱う必要のあるドメインの値オブジェクトは application 経由で公開する。
|
|
6
|
+
# SoT は ddd4py.domain.model.event_context のままで、ここは再輸出のみ。
|
|
7
|
+
from ddd4py.domain.model import EventContext
|
|
8
|
+
|
|
9
|
+
__all__ = ["ApplicationServiceLifeCycle", "EventContext", "UnitOfWork", "transactional"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from typing import TYPE_CHECKING, Any, override
|
|
6
|
+
|
|
7
|
+
from di import DIContainer
|
|
8
|
+
from injector import inject, singleton
|
|
9
|
+
|
|
10
|
+
from ddd4py.application.unit_of_work import UnitOfWork
|
|
11
|
+
from ddd4py.domain.model import DomainEvent, DomainEventPublisher, DomainEventSubscriber
|
|
12
|
+
from ddd4py.event import EventStore
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
# トランザクション境界のネスト深さ。subscriber の dispatch トランザクション内から listener が
|
|
18
|
+
# `@transactional` な ApplicationService を呼ぶと境界が入れ子になるため、最外の境界だけが
|
|
19
|
+
# commit / rollback するよう深さを追跡する (UoW の start 自体は in_transaction() で join 済み)。
|
|
20
|
+
# ApplicationServiceLifeCycle は singleton のため、実行コンテキストごとの深さは ContextVar に持つ。
|
|
21
|
+
_transaction_depth: ContextVar[int] = ContextVar("ddd4py_transaction_depth", default=0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _EventStoreSubscriber(DomainEventSubscriber[DomainEvent]):
|
|
25
|
+
"""publish された全ドメインイベントを outbox に追記する購読者。"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, event_store: EventStore):
|
|
28
|
+
self.__event_store = event_store
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
def subscribed_to_event_type(self) -> type[DomainEvent]:
|
|
32
|
+
return DomainEvent
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
def handle_event(self, domain_event: DomainEvent) -> None:
|
|
36
|
+
self.__event_store.append(domain_event)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@singleton
|
|
40
|
+
class ApplicationServiceLifeCycle:
|
|
41
|
+
"""`@transactional` の実体。トランザクション境界とドメインイベント購読の生存期間を握る。"""
|
|
42
|
+
|
|
43
|
+
@inject
|
|
44
|
+
def __init__(self, unit_of_work: UnitOfWork, event_store: EventStore):
|
|
45
|
+
self.__unit_of_work = unit_of_work
|
|
46
|
+
self.__event_store = event_store
|
|
47
|
+
|
|
48
|
+
def begin(self, is_listening: bool = True) -> None:
|
|
49
|
+
if is_listening:
|
|
50
|
+
self.listen()
|
|
51
|
+
self.__unit_of_work.start()
|
|
52
|
+
_transaction_depth.set(_transaction_depth.get() + 1)
|
|
53
|
+
|
|
54
|
+
def fail(self, exception: Exception | None = None) -> None:
|
|
55
|
+
"""ネスト中の失敗も最外までトランザクション全体を巻き戻す (部分 commit を許さない)。
|
|
56
|
+
|
|
57
|
+
内側の fail で深さを 0 に戻すため、伝播後の外側 fail の rollback は新規セッションへの
|
|
58
|
+
空 rollback となり無害。
|
|
59
|
+
|
|
60
|
+
前提: ネスト内の失敗は必ず最外境界まで伝播させること (握り潰し禁止)。内側の例外を catch
|
|
61
|
+
して処理を続行すると、最外の success() は depth=0 の空 commit となり、副作用も consumed
|
|
62
|
+
marker も確定しないまま成功扱い (ack) になって配送が静かに失われる。
|
|
63
|
+
"""
|
|
64
|
+
_transaction_depth.set(0)
|
|
65
|
+
self.__unit_of_work.rollback()
|
|
66
|
+
# トランザクション境界を抜ける際に購読者を破棄する。残したまま @transactional の外で
|
|
67
|
+
# DomainEventPublisher.publish が呼ばれると、EventStore subscriber が UnitOfWork の
|
|
68
|
+
# セッションに書き込んでしまい、後続処理へトランザクション状態が漏れる。
|
|
69
|
+
DomainEventPublisher.instance().reset()
|
|
70
|
+
if exception is not None:
|
|
71
|
+
raise exception
|
|
72
|
+
|
|
73
|
+
def success(self) -> None:
|
|
74
|
+
depth = _transaction_depth.get()
|
|
75
|
+
if depth > 1:
|
|
76
|
+
# ネストした内側の境界は commit せず、最外の境界に委ねる。
|
|
77
|
+
_transaction_depth.set(depth - 1)
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
self.__unit_of_work.commit()
|
|
81
|
+
finally:
|
|
82
|
+
_transaction_depth.set(0)
|
|
83
|
+
DomainEventPublisher.instance().reset()
|
|
84
|
+
|
|
85
|
+
def listen(self) -> None:
|
|
86
|
+
DomainEventPublisher.instance().reset()
|
|
87
|
+
DomainEventPublisher.instance().subscribe(_EventStoreSubscriber(self.__event_store))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _dynamic_args(deco_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
91
|
+
# デコレータは任意のシグネチャの関数を包むため、引数型は本質的に Any になる。
|
|
92
|
+
def wrapper(*args: Any, **kwargs: Any) -> Callable[..., Any]: # noqa: ANN401
|
|
93
|
+
if len(args) != 0 and callable(args[0]):
|
|
94
|
+
# 第一引数に関数が渡された場合: 引数なしのデコレータとして扱う
|
|
95
|
+
return functools.wraps(args[0])(deco_func(args[0]))
|
|
96
|
+
|
|
97
|
+
def _wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
98
|
+
return functools.wraps(func)(deco_func(func, *args, **kwargs))
|
|
99
|
+
|
|
100
|
+
return _wrapper
|
|
101
|
+
|
|
102
|
+
return wrapper
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@_dynamic_args
|
|
106
|
+
def transactional[T](method: Callable[..., T], is_listening: bool = True) -> Callable[..., T]:
|
|
107
|
+
"""AOP によるトランザクション管理を行うためのデコレータ"""
|
|
108
|
+
|
|
109
|
+
@functools.wraps(method)
|
|
110
|
+
def handle_transaction(*args: Any, **kwargs: Any) -> T: # type: ignore[return] # noqa: ANN401
|
|
111
|
+
life_cycle: ApplicationServiceLifeCycle = DIContainer.instance().resolve(ApplicationServiceLifeCycle)
|
|
112
|
+
life_cycle.begin(is_listening)
|
|
113
|
+
try:
|
|
114
|
+
_return = method(*args, **kwargs)
|
|
115
|
+
life_cycle.success()
|
|
116
|
+
except Exception as e: # noqa: BLE001
|
|
117
|
+
life_cycle.fail(e)
|
|
118
|
+
else:
|
|
119
|
+
return _return
|
|
120
|
+
|
|
121
|
+
return handle_transaction
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UnitOfWork[T](abc.ABC):
|
|
7
|
+
"""UnitOfWork の抽象クラス。
|
|
8
|
+
|
|
9
|
+
UnitOfWork の詳細は以下を参照。
|
|
10
|
+
https://bliki-ja.github.io/pofeaa/UnitofWork
|
|
11
|
+
https://learn.microsoft.com/ja-jp/archive/msdn-magazine/2009/june/the-unit-of-work-pattern-and-persistence-ignorance
|
|
12
|
+
|
|
13
|
+
UnitOfWork はいくつかの課題を解決する。
|
|
14
|
+
* 最小の DB トランザクション実行 / SQL クエリ発行により、パフォーマンス問題を解決する
|
|
15
|
+
* DDD において、ドメインオブジェクトの制御と永続化処理を分離させられる
|
|
16
|
+
* 異なる DB コネクション、異なるストアへの操作を単一の論理的なトランザクションにまとめられる
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abc.abstractmethod
|
|
20
|
+
def mark(self, instance: T) -> None:
|
|
21
|
+
"""UnitOfWork の追跡対象に追加する。
|
|
22
|
+
|
|
23
|
+
self.mark() に指定されたインスタンスは self.persist() にて、更新するインスタンスか
|
|
24
|
+
新規作成するインスタンスかどうかの判定に用いる。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abc.abstractmethod
|
|
28
|
+
def persist(self, instance: T) -> None:
|
|
29
|
+
"""永続化対象としてインスタンスを追跡する"""
|
|
30
|
+
|
|
31
|
+
@abc.abstractmethod
|
|
32
|
+
def delete(self, *instances: T) -> None:
|
|
33
|
+
"""削除対象としてインスタンスを追跡する"""
|
|
34
|
+
|
|
35
|
+
@abc.abstractmethod
|
|
36
|
+
def start(self) -> None:
|
|
37
|
+
"""トランザクションを開始する"""
|
|
38
|
+
|
|
39
|
+
@abc.abstractmethod
|
|
40
|
+
def flush(self) -> None:
|
|
41
|
+
"""永続化処理を途中実行する。
|
|
42
|
+
|
|
43
|
+
self.commit() とは異なりトランザクションの完了までは行わない。
|
|
44
|
+
DB から ID が採番される関係上、一度 DB に反映して ID を取得したい場合などに使用する。
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abc.abstractmethod
|
|
48
|
+
def rollback(self) -> None:
|
|
49
|
+
"""ロールバックする"""
|
|
50
|
+
|
|
51
|
+
@abc.abstractmethod
|
|
52
|
+
def commit(self) -> None:
|
|
53
|
+
"""トランザクションをコミットする"""
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .domain_registry import DomainRegistry
|
|
2
|
+
from .event_context import EventContext
|
|
3
|
+
from .domain_event import DomainEvent, DomainEventPublisher, DomainEventSubscriber
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"DomainEvent",
|
|
7
|
+
"DomainEventPublisher",
|
|
8
|
+
"DomainEventSubscriber",
|
|
9
|
+
"DomainRegistry",
|
|
10
|
+
"EventContext",
|
|
11
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Self
|
|
8
|
+
|
|
9
|
+
import pytz
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ddd4py.domain.model.event_context import EventContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(init=True, unsafe_hash=True, frozen=True)
|
|
16
|
+
class DomainEvent(abc.ABC):
|
|
17
|
+
"""ドメインイベント"""
|
|
18
|
+
|
|
19
|
+
event_version: int
|
|
20
|
+
occurred_on: datetime = field(default_factory=lambda: datetime.now(pytz.timezone("Asia/Tokyo")))
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
if self.event_version is None or self.event_version < 0:
|
|
24
|
+
raise ValueError("event_version must be >= 0")
|
|
25
|
+
if self.occurred_on is None or not isinstance(self.occurred_on, datetime):
|
|
26
|
+
raise ValueError("occurred_on must be set")
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def type(self) -> str:
|
|
30
|
+
return f"{self.__class__.__name__}.{self.event_version}"
|
|
31
|
+
|
|
32
|
+
@abc.abstractmethod
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
"""MQ のペイロードとして送信する JSON 形式の値を指定する"""
|
|
35
|
+
|
|
36
|
+
def routing_context(self) -> EventContext | None:
|
|
37
|
+
"""イベントを処理すべき文脈の自己申告。既定 (None) は発生元 (append 時点の文脈)。
|
|
38
|
+
|
|
39
|
+
境界をまたぐ副作用を起こすイベントだけが override する。申告すると全購読者が申告先の
|
|
40
|
+
文脈で処理される点に注意 (発生元でも副作用が要るなら、別イベントに分けること)。
|
|
41
|
+
"""
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DomainEventPublisher(threading.local):
|
|
46
|
+
"""スレッドローカルなドメインイベントパブリッシャー
|
|
47
|
+
|
|
48
|
+
シングルトンのインスタンス自体は共有されるが、内部データは threading.local により
|
|
49
|
+
スレッドごとに分離される。
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_instance: DomainEventPublisher | None = None
|
|
53
|
+
_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self.__subscribers: set[DomainEventSubscriber] = set()
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def instance(cls) -> DomainEventPublisher:
|
|
61
|
+
"""スレッドセーフなシングルトンインスタンス取得"""
|
|
62
|
+
if cls._instance is None:
|
|
63
|
+
with cls._lock:
|
|
64
|
+
if cls._instance is None:
|
|
65
|
+
cls._instance = DomainEventPublisher()
|
|
66
|
+
return cls._instance
|
|
67
|
+
|
|
68
|
+
def reset(self) -> Self:
|
|
69
|
+
self.__subscribers = set()
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def publish(self, domain_event: DomainEvent) -> None:
|
|
73
|
+
for subscriber in self.__subscribers:
|
|
74
|
+
if isinstance(domain_event, subscriber.subscribed_to_event_type()):
|
|
75
|
+
subscriber.handle_event(domain_event)
|
|
76
|
+
|
|
77
|
+
def subscribe(self, subscriber: DomainEventSubscriber) -> None:
|
|
78
|
+
self.__subscribers.add(subscriber)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DomainEventSubscriber[T](abc.ABC):
|
|
82
|
+
"""サブスクライバー"""
|
|
83
|
+
|
|
84
|
+
@abc.abstractmethod
|
|
85
|
+
def handle_event(self, domain_event: T) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@abc.abstractmethod
|
|
89
|
+
def subscribed_to_event_type(self) -> type[T]:
|
|
90
|
+
pass
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from di import DIContainer
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from injector import T
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DomainRegistry:
|
|
12
|
+
"""集約 / ドメインサービスから技術実装を解決するための窓口。
|
|
13
|
+
|
|
14
|
+
集約がドメインサービスを必要とするとき、application 層に IF を渡させず
|
|
15
|
+
`DomainRegistry.resolve(EncryptionService)` のように自分で解決する。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def resolve(interface: type[T]) -> T:
|
|
20
|
+
return DIContainer.instance().resolve(interface)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class EventContext:
|
|
8
|
+
"""StoredEvent / Notification に刻印される実行文脈。
|
|
9
|
+
|
|
10
|
+
カーネルは「文脈が何であるか」を知らない。知っているのは 2 つだけ:
|
|
11
|
+
|
|
12
|
+
- `partition_key`: subscriber がイベントを処理する境界を解決するためのキー。
|
|
13
|
+
マルチテナントなら App / テナントの識別子、単一テナントなら既定値のまま。
|
|
14
|
+
- `payload`: 受信側が文脈を復元するのに必要な全情報 (JSON 化可能なこと)。
|
|
15
|
+
|
|
16
|
+
利用側は自分の語彙 (App / Pool / Organization など) をこの 2 値に翻訳して渡す。
|
|
17
|
+
カーネルに業務語彙を持ち込まないための境界がここ。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
DEFAULT_PARTITION_KEY = "default"
|
|
21
|
+
|
|
22
|
+
partition_key: str = DEFAULT_PARTITION_KEY
|
|
23
|
+
payload: dict = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
if not self.partition_key:
|
|
27
|
+
raise ValueError("partition_key must not be empty")
|
ddd4py/event/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import TYPE_CHECKING, override
|
|
6
|
+
|
|
7
|
+
from ddd4py.domain.model import EventContext
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import AbstractContextManager
|
|
12
|
+
|
|
13
|
+
from ddd4py.domain.model import DomainEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventContextProvider(abc.ABC):
|
|
17
|
+
"""アンビエントな実行文脈の読み書きを担う唯一のポート。
|
|
18
|
+
|
|
19
|
+
- `current()`: いま処理中の文脈を読む (outbox への刻印時に使う)
|
|
20
|
+
- `bind()`: 受信した文脈を確立する (inbox の dispatch 時に使う)
|
|
21
|
+
|
|
22
|
+
マルチテナントなアプリは「いま処理中のテナント / プレーン」を返す実装を DI 登録する。
|
|
23
|
+
単一テナントなら `NullEventContextProvider` のままでよい。
|
|
24
|
+
|
|
25
|
+
カーネルが実行文脈そのもの (リクエストスコープの ContextVar 等) を持たないのは、
|
|
26
|
+
文脈の語彙が利用側ごとに違うため。カーネルは「刻印する / 確立する」ことだけを知っている。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@abc.abstractmethod
|
|
30
|
+
def current(self) -> EventContext:
|
|
31
|
+
"""いま処理中の実行文脈を返す"""
|
|
32
|
+
|
|
33
|
+
@abc.abstractmethod
|
|
34
|
+
def bind(self, context: EventContext) -> AbstractContextManager[None]:
|
|
35
|
+
"""受信した文脈を、ブロックの間だけ確立する"""
|
|
36
|
+
|
|
37
|
+
def context_of(self, domain_event: DomainEvent) -> EventContext:
|
|
38
|
+
"""イベントに刻印する文脈を決める。
|
|
39
|
+
|
|
40
|
+
既定は発生元 (append 時点の文脈)。境界をまたぐイベントだけが
|
|
41
|
+
`DomainEvent.routing_context()` で処理先を自己申告し、それが優先される。
|
|
42
|
+
"""
|
|
43
|
+
return domain_event.routing_context() or self.current()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NullEventContextProvider(EventContextProvider):
|
|
47
|
+
"""単一テナント / 単一プレーン向けの既定実装。文脈を持たず、常に既定値を返す。"""
|
|
48
|
+
|
|
49
|
+
@override
|
|
50
|
+
def current(self) -> EventContext:
|
|
51
|
+
return EventContext()
|
|
52
|
+
|
|
53
|
+
@override
|
|
54
|
+
@contextmanager
|
|
55
|
+
def bind(self, context: EventContext) -> Iterator[None]:
|
|
56
|
+
yield
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from typing import TYPE_CHECKING, Self
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ddd4py.domain.model import DomainEvent
|
|
8
|
+
from ddd4py.event.stored_event import StoredEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventStore(abc.ABC):
|
|
12
|
+
"""outbox。`@transactional` の内側で DomainEvent を StoredEvent として追記する。"""
|
|
13
|
+
|
|
14
|
+
@abc.abstractmethod
|
|
15
|
+
def all_stored_events_between(self, from_stored_event_id: int, to_stored_event_id: int) -> list[StoredEvent]:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abc.abstractmethod
|
|
19
|
+
def all_stored_events_since(self, stored_event_id: int) -> list[StoredEvent]:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abc.abstractmethod
|
|
23
|
+
def append(self, domain_event: DomainEvent) -> Self:
|
|
24
|
+
pass
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import datetime
|
|
9
|
+
|
|
10
|
+
from ddd4py.domain.model import DomainEvent, EventContext
|
|
11
|
+
|
|
12
|
+
# `<発行元モジュール>.<ドメインイベントのクラス名>.<イベントバージョン>`
|
|
13
|
+
# 各部は Python 識別子 (unicode 可)。"." で 3 分割できることが不変条件なので、区切り文字と
|
|
14
|
+
# 空白の混入だけを弾く。ユビキタス言語をそのままクラス名にする言語 (日本語等) を排除しない。
|
|
15
|
+
_TYPE_PATTERN = re.compile(r"[^\W\d]\w*\.[^\W\d]\w*\.\d+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(init=True, frozen=True)
|
|
19
|
+
class StoredEvent:
|
|
20
|
+
"""トランザクショナル outbox の 1 行。集約の更新と同一トランザクションで永続化される。"""
|
|
21
|
+
|
|
22
|
+
event_id: int | None
|
|
23
|
+
type: str
|
|
24
|
+
event_body: dict
|
|
25
|
+
occurred_on: datetime.datetime
|
|
26
|
+
# イベントを処理すべき境界のキー (既定は発生元)。subscriber がこのキーで処理文脈を解決するため、
|
|
27
|
+
# イベントは処理先を自己記述する。境界をまたぐイベントは routing_context() の申告値で上書きされる。
|
|
28
|
+
partition_key: str
|
|
29
|
+
context: dict
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
# fullmatch で全体一致を要求し、末尾改行・余剰文字の混入を弾く。
|
|
33
|
+
if _TYPE_PATTERN.fullmatch(self.type) is None:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Invalid event type: '{self.type}'. "
|
|
36
|
+
"Expected format: '<publisher>.<DomainEventClassName>.<version>'.",
|
|
37
|
+
)
|
|
38
|
+
if not self.partition_key:
|
|
39
|
+
raise ValueError("partition_key must not be empty")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def publisher(self) -> str:
|
|
43
|
+
return self.type.split(".")[0]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def event_type(self) -> str:
|
|
47
|
+
"""`{ドメインイベントのクラス名}.{イベントバージョン番号}` を返す"""
|
|
48
|
+
return ".".join(self.type.split(".")[1:])
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def version(self) -> int:
|
|
52
|
+
return int(self.type.split(".")[2])
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def new(event_id: int | None, domain_event: DomainEvent, context: EventContext) -> StoredEvent:
|
|
56
|
+
return StoredEvent(
|
|
57
|
+
event_id,
|
|
58
|
+
f"{domain_event.__module__.split('.')[0]}.{domain_event.type}",
|
|
59
|
+
domain_event.to_dict(),
|
|
60
|
+
domain_event.occurred_on,
|
|
61
|
+
context.partition_key,
|
|
62
|
+
context.payload,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __eq__(self, other: object) -> bool:
|
|
66
|
+
if not isinstance(other, StoredEvent):
|
|
67
|
+
return False
|
|
68
|
+
if self.event_id is None or other.event_id is None:
|
|
69
|
+
return False
|
|
70
|
+
return self.event_id == other.event_id
|
|
71
|
+
|
|
72
|
+
def __hash__(self) -> int:
|
|
73
|
+
# 採番前 (event_id is None) でも set / dict に入れられること。
|
|
74
|
+
return hash(("StoredEvent", self.event_id))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorLevel(Enum):
|
|
15
|
+
WARN = ("WARN", logger.warning)
|
|
16
|
+
ERROR = ("ERROR", logger.error)
|
|
17
|
+
CRITICAL = ("CRITICAL", logger.critical)
|
|
18
|
+
|
|
19
|
+
def __init__(self, level: str, logging_: Callable[[str], None]):
|
|
20
|
+
self.level = level
|
|
21
|
+
self.__logging = logging_
|
|
22
|
+
|
|
23
|
+
def to_logger(self, error_code: ErrorCode, detail: str) -> None:
|
|
24
|
+
self.__logging(f"[Code] {error_code.name} [Message] {error_code.message} [Detail] {detail}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ErrorCode(Enum):
|
|
28
|
+
"""業務エラーの分類。利用側プロジェクトはこれを継承して自分のコード体系を定義する。
|
|
29
|
+
|
|
30
|
+
継承先の例:
|
|
31
|
+
class AuthorityCode(ErrorCode):
|
|
32
|
+
USER_NOT_FOUND = ("ユーザーが見つかりません", ErrorLevel.WARN, HTTPStatus.NOT_FOUND)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, error_level: ErrorLevel, http_status: HTTPStatus):
|
|
36
|
+
self.message = message
|
|
37
|
+
self.error_level = error_level
|
|
38
|
+
self.http_status = http_status
|
|
39
|
+
|
|
40
|
+
def log(self, detail: str) -> None:
|
|
41
|
+
self.error_level.to_logger(self, detail)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CoreCode(ErrorCode):
|
|
45
|
+
"""カーネル自身が送出するエラー。業務エラーは利用側が ErrorCode を継承して定義する。"""
|
|
46
|
+
|
|
47
|
+
CORE_1000 = ("想定外の原因エラーが発生しました", ErrorLevel.ERROR, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
48
|
+
# 実行文脈 (テナント境界など) が未解決のままデータアクセスしようとした防衛発火 (fail-closed)。
|
|
49
|
+
# 汎用の 1000 と分けることで、監視で境界違反の発火だけを切り分けてカウントできる。
|
|
50
|
+
CORE_1001 = ("実行文脈が未解決のため処理を拒否しました", ErrorLevel.ERROR, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ddd4py.exception.error_code import ErrorCode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SystemException(RuntimeError):
|
|
10
|
+
def __init__(self, error_code: ErrorCode, detail: str):
|
|
11
|
+
super().__init__(f"{error_code.name}: {detail}")
|
|
12
|
+
self.error_code = error_code
|
|
13
|
+
self.detail = detail
|
|
14
|
+
|
|
15
|
+
def logging(self) -> None:
|
|
16
|
+
self.error_code.log(self.detail)
|