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.
Files changed (51) hide show
  1. ddd4py/__init__.py +44 -0
  2. ddd4py/application/__init__.py +9 -0
  3. ddd4py/application/application_service_life_cycle.py +121 -0
  4. ddd4py/application/unit_of_work.py +53 -0
  5. ddd4py/domain/__init__.py +0 -0
  6. ddd4py/domain/model/__init__.py +11 -0
  7. ddd4py/domain/model/domain_event.py +90 -0
  8. ddd4py/domain/model/domain_registry.py +20 -0
  9. ddd4py/domain/model/event_context.py +27 -0
  10. ddd4py/event/__init__.py +5 -0
  11. ddd4py/event/event_context_provider.py +56 -0
  12. ddd4py/event/event_store.py +24 -0
  13. ddd4py/event/stored_event.py +74 -0
  14. ddd4py/exception/__init__.py +4 -0
  15. ddd4py/exception/error_code.py +50 -0
  16. ddd4py/exception/system_exception.py +16 -0
  17. ddd4py/module.py +81 -0
  18. ddd4py/notification/__init__.py +20 -0
  19. ddd4py/notification/consumed_notification.py +31 -0
  20. ddd4py/notification/consumed_notification_store.py +36 -0
  21. ddd4py/notification/notification.py +72 -0
  22. ddd4py/notification/notification_publisher.py +9 -0
  23. ddd4py/notification/notification_reader.py +33 -0
  24. ddd4py/notification/notification_serializer.py +17 -0
  25. ddd4py/notification/published_notification_tracker.py +60 -0
  26. ddd4py/notification/published_notification_tracker_store.py +17 -0
  27. ddd4py/port/__init__.py +0 -0
  28. ddd4py/port/adapter/__init__.py +0 -0
  29. ddd4py/port/adapter/messaging/__init__.py +5 -0
  30. ddd4py/port/adapter/messaging/exchange_listener.py +19 -0
  31. ddd4py/port/adapter/messaging/message_publisher.py +13 -0
  32. ddd4py/port/adapter/messaging/message_subscriber.py +112 -0
  33. ddd4py/port/adapter/messaging/stub/__init__.py +3 -0
  34. ddd4py/port/adapter/messaging/stub/message_publisher_stub.py +19 -0
  35. ddd4py/port/adapter/persistence/__init__.py +0 -0
  36. ddd4py/port/adapter/persistence/inmem/__init__.py +11 -0
  37. ddd4py/port/adapter/persistence/inmem/in_mem_consumed_notification_store.py +27 -0
  38. ddd4py/port/adapter/persistence/inmem/in_mem_event_store.py +31 -0
  39. ddd4py/port/adapter/persistence/inmem/in_mem_published_notification_tracker_store.py +20 -0
  40. ddd4py/port/adapter/persistence/inmem/in_mem_unit_of_work.py +37 -0
  41. ddd4py/port/adapter/persistence/sqlalchemy/__init__.py +4 -0
  42. ddd4py/port/adapter/persistence/sqlalchemy/session_preparer.py +28 -0
  43. ddd4py/port/adapter/persistence/sqlalchemy/sqlalchemy_unit_of_work.py +110 -0
  44. ddd4py/settings.py +40 -0
  45. ddd4py/testing/__init__.py +15 -0
  46. ddd4py/testing/contracts.py +111 -0
  47. ddd4py/testing/di.py +34 -0
  48. ddd4py-0.1.0.dist-info/METADATA +123 -0
  49. ddd4py-0.1.0.dist-info/RECORD +51 -0
  50. ddd4py-0.1.0.dist-info/WHEEL +4 -0
  51. 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")
@@ -0,0 +1,5 @@
1
+ from .stored_event import StoredEvent
2
+ from .event_store import EventStore
3
+ from .event_context_provider import EventContextProvider, NullEventContextProvider
4
+
5
+ __all__ = ["EventContextProvider", "EventStore", "NullEventContextProvider", "StoredEvent"]
@@ -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,4 @@
1
+ from .error_code import CoreCode, ErrorCode, ErrorLevel
2
+ from .system_exception import SystemException
3
+
4
+ __all__ = ["CoreCode", "ErrorCode", "ErrorLevel", "SystemException"]
@@ -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)