python-ddd-framework 0.4.0__py3-none-any.whl → 0.6.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 (104) hide show
  1. python_ddd_framework/application/runtime.py +46 -19
  2. python_ddd_framework/application_services/bindings.py +0 -64
  3. python_ddd_framework/application_services/dispatcher.py +1 -1
  4. python_ddd_framework/application_services/invocation.py +7 -500
  5. python_ddd_framework/application_services/module.py +16 -5
  6. python_ddd_framework/authorization/module.py +1 -1
  7. python_ddd_framework/background_execution/local.py +20 -1
  8. python_ddd_framework/background_execution/processes.py +11 -1
  9. python_ddd_framework/background_jobs/execution.py +1 -1
  10. python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
  11. python_ddd_framework/background_workers/execution.py +1 -1
  12. python_ddd_framework/background_workers/runtime.py +11 -2
  13. python_ddd_framework/caching/unit_of_work.py +2 -4
  14. python_ddd_framework/cli/project.py +11 -10
  15. python_ddd_framework/developer_kit/generation.py +46 -36
  16. python_ddd_framework/developer_kit/publication.py +442 -0
  17. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
  18. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
  19. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
  20. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
  21. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
  22. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
  23. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
  24. python_ddd_framework/diagnostics/source.py +14 -1
  25. python_ddd_framework/domain/aggregates.py +5 -12
  26. python_ddd_framework/domain/event_values.py +124 -0
  27. python_ddd_framework/events/runtime.py +2 -7
  28. python_ddd_framework/events/unit_of_work.py +1 -4
  29. python_ddd_framework/fastapi/action.py +2 -1
  30. python_ddd_framework/fastapi/adapter.py +11 -117
  31. python_ddd_framework/fastapi/application_services.py +17 -402
  32. python_ddd_framework/fastapi/error_handlers.py +128 -0
  33. python_ddd_framework/fastapi/realtime/runtime.py +1 -1
  34. python_ddd_framework/fastapi/request_context.py +2 -5
  35. python_ddd_framework/fastapi/routing.py +4 -10
  36. python_ddd_framework/fastapi/service_endpoints.py +182 -0
  37. python_ddd_framework/fastapi/service_routes.py +237 -0
  38. python_ddd_framework/fastapi/transfer.py +1 -1
  39. python_ddd_framework/hosted_services/bridge.py +76 -21
  40. python_ddd_framework/hosted_services/contracts.py +5 -1
  41. python_ddd_framework/hosted_services/runtime.py +71 -17
  42. python_ddd_framework/identity/__init__.py +21 -13
  43. python_ddd_framework/identity/application.py +12 -66
  44. python_ddd_framework/identity/contracts.py +38 -411
  45. python_ddd_framework/identity/domain_rules.py +17 -0
  46. python_ddd_framework/identity/dtos.py +152 -0
  47. python_ddd_framework/identity/extensions.py +9 -0
  48. python_ddd_framework/identity/management.py +28 -25
  49. python_ddd_framework/identity/models.py +86 -0
  50. python_ddd_framework/identity/module.py +3 -4
  51. python_ddd_framework/identity/options.py +54 -0
  52. python_ddd_framework/identity/seeding.py +59 -0
  53. python_ddd_framework/identity/services.py +1 -1
  54. python_ddd_framework/identity/sqlalchemy/module.py +9 -6
  55. python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
  56. python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
  57. python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
  58. python_ddd_framework/identity/stores.py +120 -0
  59. python_ddd_framework/identity/tokens.py +1 -1
  60. python_ddd_framework/invocation/callables.py +1 -1
  61. python_ddd_framework/invocation/dispatcher.py +1 -1
  62. python_ddd_framework/invocation/entrypoints.py +2 -1
  63. python_ddd_framework/invocation/function_runtime.py +2 -2
  64. python_ddd_framework/invocation/interception.py +1 -1
  65. python_ddd_framework/invocation/managed_proxy.py +2 -1
  66. python_ddd_framework/invocation/managed_services.py +8 -14
  67. python_ddd_framework/invocation/methods.py +2 -7
  68. python_ddd_framework/invocation/module.py +1 -1
  69. python_ddd_framework/invocation/runtime.py +483 -0
  70. python_ddd_framework/invocation/scopes.py +91 -0
  71. python_ddd_framework/lifecycle/participants.py +4 -0
  72. python_ddd_framework/messaging/channel.py +35 -8
  73. python_ddd_framework/messaging/contracts.py +9 -0
  74. python_ddd_framework/messaging/module.py +13 -0
  75. python_ddd_framework/messaging/runtime.py +17 -2
  76. python_ddd_framework/modularity/discovery.py +2 -7
  77. python_ddd_framework/modularity/graph.py +2 -8
  78. python_ddd_framework/observability/logging.py +1 -1
  79. python_ddd_framework/observability/tracing.py +1 -1
  80. python_ddd_framework/redis/cache.py +2 -0
  81. python_ddd_framework/redis/notification_runtime.py +1 -1
  82. python_ddd_framework/services/arbitration.py +1 -20
  83. python_ddd_framework/services/composition.py +71 -2
  84. python_ddd_framework/services/provider.py +23 -1
  85. python_ddd_framework/services/relocation.py +96 -0
  86. python_ddd_framework/settings/refresh.py +1 -1
  87. python_ddd_framework/settings/sqlalchemy/store.py +1 -1
  88. python_ddd_framework/sqlalchemy/metadata.py +11 -413
  89. python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
  90. python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
  91. python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
  92. python_ddd_framework/sqlalchemy/migration.py +2 -1
  93. python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
  94. python_ddd_framework/sqlalchemy/module.py +2 -1
  95. python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
  96. python_ddd_framework/unit_of_work/contracts.py +68 -7
  97. python_ddd_framework/unit_of_work/manager.py +4 -7
  98. python_ddd_framework/unit_of_work/module.py +1 -1
  99. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
  100. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
  101. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
  102. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
  103. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
  104. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
@@ -2,7 +2,43 @@
2
2
 
3
3
  This module owns the generated order example. Adapt its business rules from confirmed requirements. The Host explicitly selects its Application, SQLAlchemy, and HttpApi Modules and infrastructure providers.
4
4
 
5
- [Project setup](../../../../README.md) · [Working rules](../../../../AGENTS.md) · [Architecture](../../../../docs/architecture.md) · [Directory and coding rules](../../../../docs/development.md#module-layout-and-coding-rules)
5
+ [Project setup](../../../../README.md) · [Working rules](../../../../AGENTS.md) · [Architecture](../../../../docs/architecture.md) · [Business planning](../../../../docs/development.md#plan-a-business-change) · [Framework choices](../../../../docs/development.md#choose-a-framework-capability) · [Directory rules](../../../../docs/development.md#module-layout-and-coding-rules)
6
+
7
+ ## Business model record
8
+
9
+ The following facts describe the generated example. When adapting it, replace them with confirmed business language, boundaries, and acceptance examples. Keep reasons for aggregate/consistency choices and unresolved questions here, with links to their code and checks. A proposed rule remains a proposal until the business requirement is established.
10
+
11
+ | Concern | Current example |
12
+ | --- | --- |
13
+ | Owner and language | This capability owns an Order's title and approval state. The six layer Modules implement this capability under the Host. Decide actual business boundaries from the application's language and rules. |
14
+ | Identity and values | `Order` has a UUID identity and a version; `OrderTitle` owns title validation and `OrderStatus` defines states. `Money` demonstrates a shared immutable value without becoming an Order field. |
15
+ | Commands and invariants | Creation produces a pending order. Direct approval requires an existing order and the supplied expected version; the approval setting must allow it, and an approved order cannot be approved again. See the walkthrough below for each rule's owner. |
16
+ | Consistency and collaboration | The repository stages one aggregate change in the caller's UoW, preserving optimistic concurrency. Commit precedes cache invalidation and optional online notification. Collaborating modules use public contracts with declared dependencies. |
17
+ | Acceptance and open decisions | The generated domain test covers repeated approval. Real business rejection rules, aggregate relationships, and integration guarantees must come from the application's requirements and receive their own verification. See [verification](#persistence-and-verification) for the current coverage boundary. |
18
+
19
+ ### Invariants and rejection examples
20
+
21
+ Maintain this record for each changed business invariant under the project's [mandatory DDD rules](../../../../docs/architecture.md#mandatory-ddd-rules). Add its source owner, operation, and concrete rejection case; distinguish implemented behavior from executed verification. The following rows describe the generated example, not additional requirements for another domain.
22
+
23
+ | Rule | Owner and operation | Rejection example and evidence |
24
+ | --- | --- | --- |
25
+ | An approved order cannot be approved again. | [Order](domain/entities/order.py), `approve()`. | Calling `approve()` twice raises the domain error. The generated domain test covers this path; report its actual run result. |
26
+ | Approval must be enabled. | [Domain approval policy](domain/services/order_approval_service.py), `approve()`. | A disabled approval setting rejects the operation before the aggregate changes. This rule needs its own evidence when adapting the domain; the repeated-approval test does not prove it. |
27
+ | An interactive approval requires a current expected version. | [Application approval service](application/services/order_approval_service.py), `approve()`, plus repository/database concurrency enforcement. | A stale expected version is rejected; a competing database write must also be rejected at persistence. Domain-only tests do not prove the database race. |
28
+
29
+ For child entities, record the root operation that protects their invariant. For cross-aggregate writes, extend the consistency row above with the participating owners, atomic writes, later effects, and failure/idempotency decisions before implementation. Keep unresolved decisions explicit; do not infer distributed delivery or retry guarantees from the sample's local events.
30
+
31
+ ## Approval walkthrough
32
+
33
+ Follow one use case across its owners before changing a similar operation:
34
+
35
+ 1. The caller uses the [approval contract](application_contracts/services/order_approval_service.py) with [ApproveOrder](application_contracts/inputs/approve_order.py). The [application implementation](application/services/order_approval_service.py) applies the write permission, loads the order, and rejects a missing order or stale expected version.
36
+ 2. The [domain policy](domain/services/order_approval_service.py) reads the approval setting, then asks the [aggregate](domain/entities/order.py) to approve. The aggregate enforces the state transition and records a stable `OrderChanged` value; it does not save itself.
37
+ 3. The application calls the [repository](sqlalchemy/repositories/order_repository.py). It maps the aggregate, checks its persistence version, stages the next version, and lets `operation(..., aggregate=order)` collect events once. The managed UoW owns commit.
38
+ 4. After commit, the [event handler](application/event_handlers/order_changed_handler.py) removes the cached order and sends an online notification when a user ID is present. A failure here leaves the database commit intact; it does not justify blindly repeating the business write.
39
+ 5. For deferred approval, `queue_approval` returns a Job ID after transactional enqueue. [ApprovalPayload](application/background_jobs/order_approval/payload.py) carries the order ID; it does not carry the interactive expected version or user identity. The [Job](application/background_jobs/order_approval/handler.py) reads current state, skips missing/already-approved orders or disabled approval, and reuses the domain policy and repository. Queue acceptance and completed approval are separate outcomes.
40
+
41
+ Use this trace to record the required behavior before adding an operation: which rule changes, who owns it, which framework capability executes it, and which check proves it. Keep the table above aligned with the resulting code.
6
42
 
7
43
  ## Owners and entry points
8
44
 
@@ -22,8 +58,9 @@ This module owns the generated order example. Adapt its business rules from conf
22
58
  ## Behavior and enablement
23
59
 
24
60
  - Query, management, and approval use separate `{{ cookiecutter.class_prefix }}QueryApplicationService`, `{{ cookiecutter.class_prefix }}ManagementApplicationService`, and `{{ cookiecutter.class_prefix }}ApprovalApplicationService` contracts. The exposure decorator contributes the ContractsModule dependency; overrides retain the sample's deliberate paths and operation IDs. Other conventional methods are automatic, with plain GET Pydantic DTOs bound as query parameters unless explicitly declared otherwise. Reporting uses `OrderReportingService` internally without end-user permissions or HTTP exposure.
25
- - The aggregate owns pending-to-approved transitions. The domain service reads the approval setting; the caller owns version checks, transaction, and save. Repository writes collect events; AFTER_COMMIT removes the cache before online notification. Notification failure cannot undo a committed write.
26
- - The approval Job participates in transactional enqueue and ignores missing/already-approved orders. The statistics schedule refers to its Job type and typed payload; the Job definition owns persisted name/version/schema. Generated Workers are code-disabled; HostedService and periodic schedule require explicit Module registration as described in the project development guide. The hosted owner waits for its thread to exit. Settings refresh and timing interception reuse framework extensions.
61
+ - The [approval walkthrough](#approval-walkthrough) traces aggregate rules, domain policy, concurrency, transaction completion, and post-commit effects through their owners.
62
+ - The approval Job participates in transactional enqueue and ignores missing/already-approved orders. The statistics schedule refers to its Job type and typed payload; the Job definition owns persisted name/version/schema. Generated Workers are code-disabled; HostedService and periodic schedule require explicit Module registration as described in the project development guide. Settings refresh and timing interception reuse framework extensions.
63
+ - The hosted example uses `stopping` for an independent business call and an SDK thread callback with the temporary context, then `stop` joins the actual thread after accepted work drains. Startup contexts stay closed during shutdown. To keep message input during this phase, declare this service in the channel's `stopping_owners`, depend on its Module owner, and pass `stopping=context`; no channel opts in by default.
27
64
  - File upload/download/stream and `/ws/{{ cookiecutter.module_name }}` call public contracts. Typed messages in [order_messages](domain_shared/messages/order_messages.py) own their versioned names and payload schemas; handlers and the socket explicitly map business values. Sending reports local queue acceptance, with no offline replay, cross-process backplane or client acknowledgment.
28
65
 
29
66
  Startup [Options](application/options/order_options.py) bind to the `{{ cookiecutter.module_name }}` YAML section; `allow_background_approval` defaults to true. Runtime approval settings and seed contributors belong to Domain. Its Module declares `SettingsModule` and `DataSeedingModule`; contributors are independent DI services, not ApplicationServices. The Domain [OrderApprovalService](domain/services/order_approval_service.py) opts into `ValidationEnabled` through the transitive InvocationModule dependency, retains its transient lifetime, and leaves saving and transaction completion to its caller. Capability dependencies and typed declarations are owned by each Module, while Host selects providers. Change declarations at their owners rather than copying names into another registry.
@@ -57,6 +94,8 @@ Keep applied migrations immutable. The domain test covers repeat-approval reject
57
94
 
58
95
  `OrderRepository.find` returns an optional aggregate; `get` requires one. List/count/page methods keep explicit ORM mapping and stable ordering. Business `save` participates in the caller's UoW and records entity and aggregate events once. Nontransactional UoWs reject event-producing writes before staging. The framework repository's `auto_save` flushes without committing an outer UoW.
59
96
 
97
+ `OrderChanged` captures UUIDs and an enum in a frozen dataclass. `raise_local_event` checks nested values before enqueueing: use native stable scalars, tuples/frozensets, or frozen records; avoid mutable containers, mapping views, entity references, cycles, and undeclared state. Pydantic event models must be frozen without extra/private/cached state. Dataclass subclasses must themselves declare `frozen=True`. Rejection raises TypeError and preserves already queued events; the framework keeps accepted event objects without copying. Repository entity-change notifications continue to carry entity references.
98
+
60
99
  Inject `DistributedCache[OrderCacheItem]` (or the statistics item), using keys directly. Item classes in `application/caching/` own stable names. Global defaults and explicit entry options are complete alternatives. Use `consider_uow=True` for writes/deletions that should apply after a successful commit. `await events.publish(message)` dispatches immediately without a UoW and follows DOMAIN/AFTER_COMMIT stages inside a transaction; post-commit errors do not undo saved data.
61
100
 
62
101
  This is the `ddd` template (the default), with all six layers and supported examples. Use `--template basic` for an ordinary in-process service. This module lives under `backend/src/modules/`; native uv/test/build commands run from `backend/`, while installed `pddd` project commands locate the owning backend from any application directory.
@@ -4,10 +4,6 @@ import asyncio
4
4
  import logging
5
5
  from threading import Event, Thread
6
6
 
7
- from modules.{{ cookiecutter.module_name }}.application_contracts.integration_services.order_reporting_service import (
8
- OrderReportingService,
9
- )
10
-
11
7
  from python_ddd_framework import (
12
8
  HostedService,
13
9
  HostedServiceContext,
@@ -15,6 +11,10 @@ from python_ddd_framework import (
15
11
  ShutdownReason,
16
12
  )
17
13
 
14
+ from modules.{{ cookiecutter.module_name }}.application_contracts.integration_services.order_reporting_service import (
15
+ OrderReportingService,
16
+ )
17
+
18
18
 
19
19
  async def observe_integration(reporting: OrderReportingService) -> None:
20
20
  count = await reporting.get_count()
@@ -24,6 +24,10 @@ async def observe_integration(reporting: OrderReportingService) -> None:
24
24
  class OrderIntegrationService(HostedService):
25
25
  def __init__(self) -> None:
26
26
  self._stop = Event()
27
+ self._prepare = Event()
28
+ self._callback_done = Event()
29
+ self._stopping_context: HostedServiceContext | None = None
30
+ self._callback_error: BaseException | None = None
27
31
  self._thread: Thread | None = None
28
32
 
29
33
  async def start(self, context: HostedServiceContext) -> None:
@@ -34,13 +38,33 @@ class OrderIntegrationService(HostedService):
34
38
  def _receive(self, context: HostedServiceContext) -> None:
35
39
  # submit_call 只能来自外部线程;Future.result 等待同一正式 invocation 完成。
36
40
  try:
37
- context.submit_call(observe_integration).result()
38
- except HostedServiceInvocationRejectedError:
39
- return # Application 关闭已撤销调用许可。
41
+ try:
42
+ context.submit_call(observe_integration).result()
43
+ except HostedServiceInvocationRejectedError:
44
+ pass # 普通准入可能已经关闭;收尾只使用 stopping 新提供的 context。
45
+ self._prepare.wait()
46
+ stopping = self._stopping_context
47
+ if stopping is not None:
48
+ stopping.submit_call(observe_integration).result()
49
+ except BaseException as error: # noqa: BLE001 -- 线程故障转交 stopping 重新抛出。
50
+ self._callback_error = error
51
+ finally:
52
+ self._callback_done.set()
40
53
  self._stop.wait()
41
54
 
55
+ async def stopping(self, context: HostedServiceContext, reason: ShutdownReason) -> None:
56
+ # 独立业务调用完成后再等 SDK 回调,不跨线程传 scope,也不跨等待持有事务。
57
+ await context.call(observe_integration)
58
+ self._stopping_context = context
59
+ self._prepare.set()
60
+ await asyncio.to_thread(self._callback_done.wait)
61
+ if self._callback_error is not None:
62
+ raise RuntimeError("External integration callback failed") from self._callback_error
63
+
42
64
  async def stop(self, reason: ShutdownReason) -> None:
65
+ # start 部分失败时 stopping 不会执行;仍须唤醒并等待实际线程退出。
43
66
  self._stop.set()
67
+ self._prepare.set()
44
68
  if self._thread is not None:
45
69
  await asyncio.to_thread(self._thread.join)
46
70
  self._thread = None
@@ -2,12 +2,12 @@
2
2
 
3
3
  import logging
4
4
 
5
+ from python_ddd_framework import MessageHandler, unit_of_work
6
+
5
7
  from modules.{{ cookiecutter.module_name }}.domain_shared.messages.order_observation import (
6
8
  OrderObservation,
7
9
  )
8
10
 
9
- from python_ddd_framework import MessageHandler, unit_of_work
10
-
11
11
 
12
12
  class OrderObservationHandler(MessageHandler[OrderObservation]):
13
13
  @unit_of_work(disabled=True)
@@ -74,6 +74,8 @@ class SqlAlchemyOrderRepository(OrderRepository):
74
74
 
75
75
  def _order(row: OrderRow) -> Order:
76
76
  # 映射扩展:在这里把数据库字段恢复成领域实体/值对象,不调用会再次产生创建事件的工厂。
77
+ # 驱动的 UUID 子类在边界恢复为原生 UUID,聚合事件只持有稳定领域值。
77
78
  return Order(
78
- row.id, OrderTitle(value=row.title), status=OrderStatus(row.status), version=row.version
79
+ UUID(bytes=row.id.bytes), OrderTitle(value=row.title),
80
+ status=OrderStatus(row.status), version=row.version
79
81
  )
@@ -6,11 +6,18 @@ These instructions apply to this application and its business modules. Follow th
6
6
 
7
7
  1. Check the branch, worktree, requested outcome, and existing changes before editing. Analysis and review requests are read-only unless implementation is requested.
8
8
  2. Use [README](README.md) for setup and commands, [architecture](docs/architecture.md) for ownership and dependencies, and the relevant section of [development](docs/development.md) for framework usage.
9
- 3. First identify the layer and responsibility directory in the [module layout rules](docs/development.md#module-layout-and-coding-rules). Before changing a business module, read its `backend/src/modules/<name>/README.md`, then inspect the affected source and direct callers. Expand the investigation only when those facts reveal another affected boundary.
10
- 4. Read versions and dependencies from `backend/pyproject.toml`, exact resolutions from `backend/uv.lock`, and commands from `pddd --help`. Verify behavior against the installed framework version when the application documentation is insufficient.
9
+ 3. Before changing a business module, read its `backend/src/modules/<name>/README.md`, then inspect the affected source and direct callers. Use the workflow below to choose the layer and responsibility directory. Expand the investigation only when those facts reveal another affected boundary.
10
+ 4. Read versions and dependencies from `backend/pyproject.toml` and exact resolutions from `backend/uv.lock`. Follow [installed framework lookup](docs/development.md#match-the-installed-framework) for the actual package, bundled examples, and CLI. Generated guidance records the template version; upgrades do not update it automatically.
11
11
 
12
12
  Python code, metadata, configuration, environment, and deployment files belong under `backend/`. Root `scripts/` contains consumer-owned scripts. Run installed `pddd` commands from any directory in the application; run native uv/build/test and Docker commands from `backend/`.
13
13
 
14
+ ## Work from business rules to framework code
15
+
16
+ 1. Describe the requested use case in business terms: caller, command or query, expected outcome, allowed state changes, and failure conditions. Reuse the module's recorded language and rules. The generated order example supplies implementation examples; actual business requirements come from the application.
17
+ 2. For a domain change, identify the rule owner, aggregate boundary, consistency needs, and collaboration with other modules using [business-change planning](docs/development.md#plan-a-business-change). Keep confirmed decisions in the owning module README. Ask about missing rules that affect correctness; label proposals and open questions instead of presenting assumptions as approved rules.
18
+ 3. Map each needed technical capability through the [framework selection table](docs/development.md#choose-a-framework-capability). Check the installed API, the owning Module's declarations, and an existing usage before adding infrastructure. Choose the existing `basic` or `ddd` layout for the capability; place each change using the [directory rules](docs/development.md#module-layout-and-coding-rules).
19
+ 4. Implement the authorized use case through its public contract, domain behavior, and existing persistence/invocation boundaries. Reuse existing validation and [verification commands](docs/development.md#verification); state which domain rules, composition paths, and infrastructure behavior the checks actually cover. Scale this process to the change: a local correction can reuse the existing model directly.
20
+
14
21
  ## Preserve ownership and contracts
15
22
 
16
23
  - The Host composes modules, selects providers, and supplies configuration. Keep business state and rules in their owning modules.
@@ -22,6 +29,10 @@ Python code, metadata, configuration, environment, and deployment files belong u
22
29
 
23
30
  Use the documented responsibility directories for every new capability. Propose a standards change before adding a responsibility category or changing dependency direction. The `ddd` template intentionally generates all supported example capabilities, even before use; this exception applies to DDD templates and generated projects, not speculative framework runtime layers. The `basic` template generates only package markers, `module.py`, `contracts/conversion_service.py`, `services/default_conversion_service.py`, and its README. It uses ordinary transient DI, without Domain, persistence, HTTP, interception, or empty layers. Keep `module.py` limited to dependencies, registration, and lifecycle hooks, and keep package `__init__.py` free of business code and compatibility exports.
24
31
 
32
+ ## Enforce DDD rules
33
+
34
+ For a modeled domain capability, apply the [mandatory DDD rules](docs/architecture.md#mandatory-ddd-rules) during implementation and review. Reject application code that assigns aggregate business state directly, writes aggregate children outside the root's business operations, or leaks ORM/Session types through domain repository contracts. Before a cross-aggregate write, record its consistency, failure, and idempotency decisions. Keep each changed invariant's owner, operation, and rejection example in the module README and verify them with the [business-change checks](docs/development.md#check-a-domain-change). These rules do not require a `basic` service to acquire unused DDD layers.
35
+
25
36
  ## Use the framework safely
26
37
 
27
38
  - Extend only explicitly opened targets. Keep typed properties in application contracts, optional SQL mappings and local extension revisions in persistence, and reuse the same declaration for validation, OpenAPI, and storage. Never mutate shared DTO/ORM classes or duplicate the base table/Schema owner; extension migrations declare Alembic prerequisites explicitly.
@@ -29,7 +40,7 @@ Use the documented responsibility directories for every new capability. Propose
29
40
  - Invoke application services through their injected public contracts or the framework functions `invoke(application, ...)` / `call(application, ...)`. Manual construction and self-calls do not create a new managed invocation.
30
41
  - Ordinary DI services opt into interception with `@unit_of_work`, `@authorize`, or `ValidationEnabled`; declare the corresponding capability dependency on their owning Module. Keep marked methods async instance methods and preserve native scope/cache. These declarations do not add HTTP exposure or require ApplicationService inheritance.
31
42
  - Keep transactions short. Never carry a Session, unit of work, or request scope into an ordinary child task, external thread, network wait, or streamed response.
32
- - Keep aggregate rules and optimistic concurrency checks intact. Use repository operations to stage changes and collect events; do not publish the same aggregate events twice.
43
+ - Keep aggregate rules and optimistic concurrency checks intact. Construct aggregate events from frozen records and stable nested values as described in [architecture](docs/architecture.md#calls-scopes-and-transactions). Use repository operations to stage changes and collect events; do not publish the same aggregate events twice.
33
44
  - Review generated migrations. Do not edit applied revisions, bypass migration guards, or treat Host startup as migration or seeding.
34
45
  - Keep background side effects idempotent. Local events and WebSocket messages do not provide durable distributed delivery; distributed locks do not replace database concurrency or idempotency.
35
46
  - Validate external inputs, propagate meaningful failures, and preserve cancellation and cleanup. Add defaults, retries, or fallbacks only for an identified requirement.
@@ -41,6 +52,13 @@ Business modules own message values and coalescing identities. Only replaceable
41
52
  coalesce. Acceptance, processing, commit and external acknowledgement are separate facts.
42
53
  Use the framework channel and hosted bridge; preserve capacity and actual cleanup guarantees.
43
54
 
55
+ For SDK shutdown, initiate business cleanup in `HostedService.stopping(context, reason)` and
56
+ release resources in `stop(reason)`. Pass the separate stopping context only to this phase's
57
+ callbacks; startup contexts never gain shutdown permissions. Channels opt in with declared
58
+ `stopping_owners` and `send/submit(..., stopping=context)`. Keep the channel Module dependent on
59
+ the service owner, and drain accepted work before releasing resources. See the hosted-service
60
+ and message examples in the project development guide.
61
+
44
62
  ## Verify and maintain
45
63
 
46
64
  - Preserve unrelated changes. Do not edit the installed framework, generated dependency files by hand, or another repository to make an application change pass.
@@ -55,6 +55,18 @@ The [development guide](development.md#module-layout-and-coding-rules) owns the
55
55
 
56
56
  Business Modules explicitly depend on the framework capabilities they use. Domain seed contributors depend on `DataSeedingModule`, permission definitions on `AuthorizationModule`, application services on `ApplicationServicesModule`, and HTTP declarations on `FastApiModule`. Host provider selection does not repair a missing business-module dependency. Capability definitions use typed module declarations; their catalogs and runtime state belong to each Application. Every Host and six-layer Module explicitly declares the seven framework hooks. Synchronous configuration phases only compose the application; asynchronous initialization phases follow dependency-first barriers, and shutdown closes resources in reverse dependency order. Empty hooks use `pass`. Generating a complete example does not enable its workers, hosted services, or schedules.
57
57
 
58
+ ## Mandatory DDD rules
59
+
60
+ These constraints apply when a capability models aggregates. The module README owns its actual business language and invariants; generated order rules are examples to replace from confirmed requirements.
61
+
62
+ | Boundary | Required behavior and review criterion |
63
+ | --- | --- |
64
+ | Application and aggregate | Application services authorize, load, coordinate, and save through existing contracts. State transitions and entity/value invariants must run through domain operations. Do not assign aggregate business fields or reproduce their transition rules in Application. Caller permissions and expected-version checks remain Application responsibilities. |
65
+ | Aggregate root and children | Change an aggregate's internal entities and collections through the root's business operations so it can enforce the aggregate's invariants. Do not return mutable internals that let callers bypass those operations. Persistence may reconstitute stored state through the domain construction boundary; reconstitution must not manufacture new creation events or become a business-write shortcut. |
66
+ | Domain repository and persistence | Design domain repository operations around the aggregate's identity and lifecycle, not one repository per table. Domain contracts use domain values and aggregates; keep ORM rows, Session, SQL expressions, and driver-specific values in persistence. Map database scalar subclasses to the native domain value at that boundary. Keep query projections in their declared read responsibility rather than weakening the aggregate contract. |
67
+ | Cross-aggregate consistency | Before implementing a write spanning aggregates, identify the participating owners, what commits atomically, and any later effects. Record concurrency, failure-after-commit, duplicate/retry, and idempotency behavior. A local UoW may coordinate multiple aggregates when that is the confirmed requirement; do not impose one aggregate per transaction mechanically. Cross-module collaboration still uses public contracts, and local events do not promise durable distributed delivery. |
68
+ | Business evidence | For each changed invariant, the module README must identify its rule owner, business operation, and a concrete rejection example, linking the relevant source and verification. Record unverified cases and open decisions explicitly. Directory placement, type checks, and passing unrelated tests do not by themselves prove a business rule. |
69
+
58
70
  ## Calls, scopes, and transactions
59
71
 
60
72
  A managed service call passes through authorization, validation, ACTION scope, unit-of-work handling, and the final implementation. HTTP and direct service invocation share this pipeline. Inject the public service contract; do not construct implementations to bypass it. A self-call is an ordinary Python call and does not re-enter the pipeline.
@@ -67,8 +79,12 @@ Repositories map aggregates and ORM models explicitly. Their `SqlAlchemySessionP
67
79
 
68
80
  Local transactional events run in this order: DOMAIN handlers, flush/version checks, database commit, then AFTER_COMMIT handlers. Outside a UoW, `await events.publish(message)` dispatches immediately and does not claim a transaction was committed. Nontransactional UoWs reject explicit and automatic events before repository writes. A post-commit failure does not roll back committed data. Local events provide neither distributed delivery nor crash recovery.
69
81
 
82
+ `AggregateRoot.raise_local_event` validates actual field values before enqueueing. The root and nested records must be frozen dataclasses or frozen Pydantic models. Fields may contain native scalar values (`None`, bool, int, float, str, bytes, Decimal, UUID, date, datetime, time, timedelta), stable Enum values, tuples, frozensets, and NamedTuples without extra state. Datetime/time zones must be native timezone/ZoneInfo instances or None. Mutable containers and their subclasses, mapping views, entity references, unsupported custom objects, scalar subclasses, cycles, missing fields, and undeclared instance state are rejected. A mutable-container subclass remains rejected as a root or nested value even when decorated with `frozen=True`, because inherited container operations can still change its contents. Dataclass subclasses must themselves be decorated with `frozen=True`; records cannot declare cached properties or extra slots. Pydantic models cannot use `extra="allow"` or PrivateAttr. Shared immutable children are allowed. Invalid events raise TypeError without changing the queue; valid events are retained without copying. The publisher must respect normal frozen-value semantics. Direct event-bus publication and repository entity-change notifications keep their existing contracts, including entity references.
83
+
70
84
  Typed caches inject `DistributedCache[Item]` and take keys/values directly. The value type owns its cache name; the Host owns the Redis prefix and global defaults. Entry options replace the whole default object. `consider_uow=True` stages changes in the concrete UoW and applies them after successful completion; rollback discards them. Redis and PostgreSQL do not share an atomic commit.
71
85
 
86
+ Batch cache I/O remains serial. The first unhidden Redis error, invalid payload, or cancellation can stop later commands; reads may refresh expiry or delete expired entries. After a database commit, ordinary cache errors allow other pending writes to run, while cancellation stops later commands. `hide_errors` only hides Redis errors.
87
+
72
88
  ## Extension ownership
73
89
 
74
90
  Identity owns core user/role fields, edit versions, and their base migrations. `concurrency_version` protects edits and relationship replacements; `permission_version` only tracks permission state. Password changes, deactivation, and deletion revoke sessions in the same transaction. Login and refresh coordinate through the user's transaction lock; refresh families retain their own lock-before-row mutation order. Consumer modules own only their declared extension columns/indexes and project-local revisions.
@@ -102,8 +118,9 @@ Module initialization precedes selected hosted services and background execution
102
118
  - Durable jobs use typed, versioned payloads and the PostgreSQL provider. Transactional enqueue must use the same configured connection as the surrounding unit of work. External side effects remain the job's idempotency responsibility.
103
119
  - Periodic workers are explicitly registered. Generated example workers are disabled in their class declarations; configuration cannot enable a code-disabled worker.
104
120
  - Execution modes are `host`, `shared`, and `exclusive`. Spawned processes compose their own Application; they do not inherit a live Session or container.
105
- - A `HostedService` owns long-lived SDKs or threads, their failures, recovery, and actual shutdown. It is opt-in; background-process instances need explicit selection. External threads use its supported submission methods.
121
+ - A `HostedService` owns long-lived SDKs or threads, their failures, recovery, and actual shutdown. It is opt-in; background-process instances need explicit selection. Shutdown has global barriers: close ordinary admission, await `stopping`, close remaining admission, drain accepted work, then release resources in `stop` before Module/container cleanup. Only successfully started services prepare; all touched services clean up. External callbacks use the separate, temporary stopping context, never an upgraded startup context.
106
122
  - Redis business locks are leases. Preserve cancellation and cleanup when ownership is lost; a lease is not a transaction, fencing guarantee, or exactly-once guarantee.
123
+ - In-process channels preserve SDK input during stopping only for declared `stopping_owners` and a current hook context passed to `send/submit`. The channel Module depends on the service owner. Admission permissions are Application-local and temporary; accepted work retains its reservation until execution or a reported non-execution outcome, with unchanged capacity and per-message scopes.
107
124
  - WebSocket delivery targets currently connected clients. The generated sample has no cross-process backplane, offline replay, or delivery acknowledgment. HTTP streaming must release business transactions before network transmission and clean up producers on disconnect.
108
125
  - Liveness and readiness report lifecycle state, not continuous infrastructure availability. Trace export is disabled in the generated development configuration until explicitly configured.
109
126
 
@@ -2,7 +2,68 @@
2
2
 
3
3
  Use this guide with the project's [architecture](architecture.md) and [working rules](../AGENTS.md). It describes the generated application's use of Python DDD Framework {{ cookiecutter.framework_version }}. Follow the installed version when adapting examples after an upgrade.
4
4
 
5
- [Add a module](#add-a-module) · [Services and permissions](#services-and-permissions) · [Options and settings](#options-and-settings) · [Persistence](#persistence-and-migrations) · [Events and background work](#events-and-background-work) · [Verification](#verification)
5
+ [Plan a business change](#plan-a-business-change) · [Choose a capability](#choose-a-framework-capability) · [Installed version](#match-the-installed-framework) · [Add a module](#add-a-module) · [Services and permissions](#services-and-permissions) · [Options and settings](#options-and-settings) · [Persistence](#persistence-and-migrations) · [Events and background work](#events-and-background-work) · [Verification](#verification)
6
+
7
+ ## Plan a business change
8
+
9
+ Start with the affected module README and confirmed requirements. For an existing behavior, reuse its model and record only the decisions that change. For a new domain use case, answer these questions before choosing files:
10
+
11
+ | Decision | Questions to resolve |
12
+ | --- | --- |
13
+ | Language and use case | What do the business terms mean here? Who sends the command or query, and what outcome do they expect? Which facts distinguish success, rejection, and a pending operation? |
14
+ | Business boundary | Which module owns each rule and fact? Where do terms or policies change meaning across contexts? Which public contract allows collaboration with another owner? |
15
+ | Aggregate and values | Which entity controls the state change? Which related values must remain consistent together? Which rules belong on that aggregate, and which need a domain service because they depend on another input or policy? |
16
+ | Consistency and failure | What must commit atomically? What happens with a stale version, duplicate request, cancellation, or a failure after commit? Which work may finish later, and does it need durable execution? |
17
+ | Acceptance | Which concrete examples prove the rule and its failure paths? Which existing checks cover them, and which require approved new tests or real infrastructure? |
18
+
19
+ Choose boundaries from business language, invariants, and consistency needs. A database table or a generated layer Module does not establish a bounded context or aggregate boundary. Keep entity transitions and value constraints in Domain, orchestration and caller policy in Application, and storage/transport in their existing owners. Use a domain service for a rule that needs collaborators; retain rules that an aggregate can enforce itself on the aggregate.
20
+
21
+ An ordinary in-process service can use the [basic template](#add-a-module). A capability that needs the domain, persistence, and HTTP examples can use `ddd`; generated examples are enabled according to their Module declarations. Record the chosen model, reasons, and unresolved business questions in the module README. The generated DDD README contains an order model and an approval walkthrough to adapt; its sample rules are not requirements for a new business domain.
22
+
23
+ ### Check a domain change
24
+
25
+ Use the [mandatory DDD rules](architecture.md#mandatory-ddd-rules) as the review boundary. Before considering the change complete:
26
+
27
+ 1. Trace the public command through Application to the aggregate's business operation. For example, approval calls `order.approve()` through the domain policy; an application assignment to `order._status` fails review. Retain permission and expected-version checks at their existing owners.
28
+ 2. If the aggregate has children, trace additions, removals, and edits through the root. A returned list that lets a caller append an invalid child fails the same check. Inspect repository reconstitution separately from business creation and mutation.
29
+ 3. Check repository signatures and imports: domain-facing inputs/results express the aggregate contract, while ORM and driver conversion stay in persistence. Do not infer a new repository boundary from a new table.
30
+ 4. For writes touching multiple aggregates or later side effects, compare the implementation with the module's recorded atomicity, concurrency, failure, and duplicate-handling decisions. A transaction, local event, or job enqueue alone does not prove all of these guarantees.
31
+ 5. Update the module's rule record with the owner, operation, rejection example, and the smallest relevant verification. Reuse existing checks where applicable; request approval for additional tests when the task requires it. Report missing evidence rather than marking the rule verified.
32
+
33
+ These are review criteria, not a generated automated architecture-test suite. Use the actual checks listed under [verification](#verification).
34
+
35
+ ## Choose a framework capability
36
+
37
+ Use this table to reach an existing implementation. Business Modules declare capability dependencies and typed contributions; the Host selects providers and supplies configuration. Read activation rules at those owners and use `pddd inspect` to check composition.
38
+
39
+ | Task | Existing capability and boundary | Start here |
40
+ | --- | --- | --- |
41
+ | Implement a user operation or expose HTTP | Public ApplicationService contract, permission policy, and explicit HTTP exposure in the owning Application/HttpApi Modules | [Services and permissions](#services-and-permissions); module README's approval walkthrough |
42
+ | Add login or user/role administration | Existing Identity authentication and management contracts, with the Host's provider and authorization configuration | [Identity management](#identity-management) |
43
+ | Call an internal service | Inject its public contract through a declared Module dependency; ordinary DI services opt into interception only when required | [Services and permissions](#services-and-permissions); [basic module](#add-a-module) |
44
+ | Save an aggregate in one business operation | Repository contract, SQLAlchemy implementation, and the managed UoW; keep commit at the invocation boundary | [Persistence](#persistence-and-migrations); module repository and SqlAlchemy Module |
45
+ | Configure startup policy or change a runtime setting | `Options[T]` for bound startup configuration; `SettingProvider`/`SettingManager` for defined runtime values and conditional updates | [Options and settings](#options-and-settings); existing Options type and Domain setting provider |
46
+ | React to a domain change | Aggregate events and `LocalEventPhase` handlers; choose transactional or AFTER_COMMIT semantics explicitly | [Events](#events-and-background-work); aggregate and event handler in the module README |
47
+ | Run work durably or on a schedule | Typed Job and payload, `BackgroundJobEnqueuer`, and a registered schedule when needed; Host selects the queue provider | [Background work](#events-and-background-work); existing approval Job and Application Module |
48
+ | Own a periodic loop or SDK lifetime | Background Worker or HostedService with explicit enablement and awaited cleanup | [Background work](#events-and-background-work); module HostedService example |
49
+ | Cache a projection or notify connected clients | `DistributedCache[T]` and realtime APIs; preserve commit order and distinguish online notification from durable delivery | [Caching](#events-and-background-work); [HTTP and realtime](#http-files-and-real-time-communication) |
50
+ | Extend a reusable module | Existing service override or explicitly declared DTO/model extension point | [Module extensions](#extend-reusable-modules) |
51
+
52
+ Follow the linked minimal example, inspect its owner and direct callers, and verify composition before adding a parallel mechanism. If an existing API cannot meet a requirement, identify the exact missing behavior and the installed source checked; propose the smallest change within the task's authorization.
53
+
54
+ ## Match the installed framework
55
+
56
+ After the project's normal dependency setup, run these read-only lookups from `backend/`. `--no-sync` inspects the current environment without updating it:
57
+
58
+ ```sh
59
+ uv run --no-sync python -c "from importlib.metadata import version; from importlib.resources import files; p = files('python_ddd_framework'); print('Version:', version('python-ddd-framework')); print('Package:', p); print('Templates:', p.joinpath('developer_kit', 'templates'))"
60
+ uv run --no-sync pddd --help
61
+ uv run --no-sync pddd inspect --environment development
62
+ ```
63
+
64
+ Compare the installed distribution with `backend/uv.lock` and the requested dependency/source in `backend/pyproject.toml`. A mismatch needs the project's documented environment setup before API conclusions are reliable. `inspect` composes a new Application and closes it without starting resources; it does not show a running process or verify database connectivity.
65
+
66
+ The printed package path locates the implementation; its `developer_kit/templates/project` and `developer_kit/templates/module` directories contain this version's guidance and examples. Read the affected API and example together, and confirm that the application declares the needed Module/provider. Treat installed files as read-only references. After an upgrade, use the target release's migration notes and update affected application guidance deliberately; copying whole templates over business code or editing the installed package is not an upgrade procedure.
6
67
 
7
68
  ## Add a module
8
69
 
@@ -181,7 +242,7 @@ For shared databases, configure installation-specific Alembic version table loca
181
242
 
182
243
  ## Events and background work
183
244
 
184
- Raise domain events on the aggregate. Put typed handlers in a scanned package and select the required `LocalEventPhase` with `@local_event_handler`. Use DOMAIN for transactional rules and AFTER_COMMIT for work such as the sample's cache invalidation and online notifications. Post-commit failures cannot undo a committed order; local events are not a durable message bus.
245
+ Raise domain events on the aggregate using frozen records and stable nested values. Capture the needed IDs and values at publication; use tuples/frozensets or frozen records for collections and follow the [event value rules](architecture.md#calls-scopes-and-transactions). Put typed handlers in a scanned package and select the required `LocalEventPhase` with `@local_event_handler`. Use DOMAIN for transactional rules and AFTER_COMMIT for work such as the sample's cache invalidation and online notifications. Post-commit failures cannot undo a committed order; local events are not a durable message bus.
185
246
 
186
247
  Inject `DistributedCache[OrderCacheItem]` and call `get`, `set`, `remove`, or their batch variants with keys directly. Cache item classes in `application/caching/` own stable `@cache_name` metadata. Omitted entry options use global defaults (20-minute sliding expiration); an explicit `DistributedCacheEntryOptions` replaces the entire object. `get_or_add` uses an async factory; cache access error hiding does not hide factory/type errors or cancellation. Development configuration explicitly disables `caching.hide_errors`. Use `consider_uow=True` when a cache mutation must wait for successful UoW completion; handle post-commit failure as committed database work.
187
248
 
@@ -207,7 +268,9 @@ Background execution Options use catalog names to select `host`, `shared`, or `e
207
268
 
208
269
  Use the existing `DistributedLock` provider for explicit business leases. `locks.acquire(key, wait_timeout=timedelta(0))` tries without waiting; omit the argument or pass `None` for configured waiting, or pass a positive timedelta for this acquisition only. If acquisition returns false, skip or report the operation as appropriate. Preserve cancellation and cleanup on lease loss. Use database concurrency and idempotency for their separate guarantees.
209
270
 
210
- A `HostedService` manages a long-lived SDK or thread through async `start`/`stop`, including actual thread termination. Register it with `hosted_services = HostedServices((ServiceType,))`; the generated integration example is not registered by default. External threads submit through the hosted-service context, and the component owns runtime failures and recovery.
271
+ A `HostedService` starts its SDK/thread in `start(context)`, initiates business cleanup and waits for callbacks in optional `stopping(context, reason)`, then releases resources and joins threads in `stop(reason)`. Register it with `hosted_services = HostedServices((ServiceType,))`; the generated integration example is not registered by default. It demonstrates an independent `context.call` and a thread callback using `context.submit_call(...).result()`. Each call creates its own scope; never pass a Session or scope to the thread or hold a transaction while waiting for the SDK.
272
+
273
+ The stopping context is separate from the startup context, which stays closed to new work. Its permission ends when the hook returns, fails, or receives a timeout cancellation request; accepted work still drains before resource stop. Only successfully started services receive `stopping`, while every touched service must tolerate `stop` after partial startup. Each hook uses `hosted_service.shutdown_timeout`, requests cancellation on expiry, and waits for actual cleanup; it is not a total shutdown deadline. HTTP/WebSocket use native transport shutdown.
211
274
 
212
275
  ## HTTP, files, and real-time communication
213
276
 
@@ -270,6 +333,14 @@ UoW, scope or concurrently mutable payloads between tasks/threads. Host configur
270
333
  resource exit and reports timeout. Restart requires a new Application. Commands, results and
271
334
  events requiring individual processing must not declare a coalescing key.
272
335
 
336
+ To accept SDK messages during a hosted service's stopping hook, declare
337
+ `MessageChannelDefinition(T, Handler, stopping_owners=(ServiceType,))` and make the channel
338
+ Module depend on the service's owner Module. Send with `await channel.send(value, stopping=context)`
339
+ or `channel.submit(value, stopping=context)` using that hook's current context. The default empty
340
+ tuple keeps input closed. Ordinary, expired, undeclared-owner and foreign-Application contexts
341
+ are rejected. Already accepted messages still drain before resource stop; the permission changes
342
+ neither capacity nor handler validation, authorization, scope/UoW or receipt semantics.
343
+
273
344
  ## Verification
274
345
 
275
346
  Run native uv/test/build commands from `backend/`. Installed `pddd` commands locate the backend from the application root or any descendant, including nested foreign Python projects. Read fixtures first: Host tests use disposable PostgreSQL and Redis containers, so Docker must be available.
@@ -1,12 +1,25 @@
1
- """贡献诊断的调用位置;depth 由公开贡献入口指定。"""
1
+ """诊断源码定位;区分贡献入口的调用栈与被检查定义的位置。"""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import inspect
6
+ from typing import Any
6
7
 
7
8
  from .model import SourceLocation
8
9
 
9
10
 
11
+ def _source_file_line(target: Any) -> tuple[str | None, int | None]:
12
+ """动态对象边界交给 inspect;读不到行号时仍保留已经获得的文件名。"""
13
+ file_name: str | None = None
14
+ line: int | None = None
15
+ try:
16
+ file_name = inspect.getsourcefile(target)
17
+ _, line = inspect.getsourcelines(target)
18
+ except (OSError, TypeError):
19
+ pass
20
+ return file_name, line
21
+
22
+
10
23
  def caller_source(owner: str, *, depth: int) -> SourceLocation:
11
24
  frame = inspect.currentframe()
12
25
  try:
@@ -2,13 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import is_dataclass
6
5
  from typing import Generic, TypeVar
7
6
 
8
- from pydantic import BaseModel
9
-
10
7
  from .entities import Entity
11
8
  from .errors import OptimisticConcurrencyError
9
+ from .event_values import _is_immutable_event
12
10
 
13
11
  _TId = TypeVar("_TId")
14
12
 
@@ -32,7 +30,10 @@ class AggregateRoot(Entity[_TId], Generic[_TId]):
32
30
 
33
31
  def raise_local_event(self, event: object) -> None:
34
32
  if not _is_immutable_event(event):
35
- raise TypeError("aggregate local events must be immutable dataclass or Pydantic values")
33
+ raise TypeError(
34
+ "aggregate local events must be immutable dataclass or Pydantic values "
35
+ "with supported stable fields"
36
+ )
36
37
  self._local_events.append(event)
37
38
 
38
39
  def release_local_events(self) -> tuple[object, ...]:
@@ -54,11 +55,3 @@ class AggregateRoot(Entity[_TId], Generic[_TId]):
54
55
  self._version += 1
55
56
  self._version_staged = True
56
57
  return self._version
57
-
58
-
59
- def _is_immutable_event(event: object) -> bool:
60
- event_type = type(event)
61
- dataclass_parameters = getattr(event_type, "__dataclass_params__", None)
62
- if is_dataclass(event) and bool(getattr(dataclass_parameters, "frozen", False)):
63
- return True
64
- return isinstance(event, BaseModel) and event.model_config.get("frozen") is True
@@ -0,0 +1,124 @@
1
+ """聚合业务事件的值边界;只检查发布方的值,不复制或转换事件。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import MutableMapping, MutableSequence, MutableSet
6
+ from dataclasses import fields, is_dataclass
7
+ from datetime import date, datetime, time, timedelta, timezone
8
+ from decimal import Decimal
9
+ from enum import Enum
10
+ from functools import cached_property
11
+ from types import MemberDescriptorType
12
+ from uuid import UUID
13
+ from zoneinfo import ZoneInfo
14
+
15
+ from pydantic import BaseModel
16
+
17
+ _SCALAR_TYPES = frozenset(
18
+ {type(None), bool, int, float, str, bytes, Decimal, UUID, date, timedelta}
19
+ )
20
+ _ENUM_STATE = frozenset({"_name_", "_value_", "_sort_order_", "__objclass__"})
21
+ _MISSING = object()
22
+
23
+
24
+ def _is_immutable_event(event: object) -> bool:
25
+ if isinstance(event, type) or not (is_dataclass(event) or isinstance(event, BaseModel)):
26
+ return False
27
+ # 路径上的重复对象是循环;已完成的共享子值可复用检查结果。显式栈不受递归深度限制。
28
+ pending: list[tuple[object, bool]] = [(event, False)]
29
+ active: set[int] = set()
30
+ checked: dict[int, object] = {}
31
+ while pending:
32
+ value, leaving = pending.pop()
33
+ identity = id(value)
34
+ if leaving:
35
+ active.remove(identity)
36
+ checked[identity] = value
37
+ continue
38
+ if identity in active:
39
+ return False
40
+ if identity in checked:
41
+ continue
42
+ children = _stable_children(value)
43
+ if children is None:
44
+ return False
45
+ active.add(identity)
46
+ pending.append((value, True))
47
+ pending.extend((child, False) for child in reversed(children))
48
+ return True
49
+
50
+
51
+ def _stable_children(value: object) -> tuple[object, ...] | None:
52
+ value_type = type(value)
53
+ # 只接受原生标量;其子类可能带有可变实例状态。枚举在下面单独检查。
54
+ if value_type in _SCALAR_TYPES:
55
+ return ()
56
+ if value_type in (datetime, time) and isinstance(value, (datetime, time)):
57
+ return () if type(value.tzinfo) in (type(None), timezone, ZoneInfo) else None
58
+ # frozen 只拦截属性赋值;容器子类的内部存储不在 dataclass 字段中,必须先拒绝。
59
+ if isinstance(value, (MutableMapping, MutableSequence, MutableSet)):
60
+ return None
61
+ if isinstance(value, Enum):
62
+ return None if _has_extra_state(value, _ENUM_STATE) else (value.value,)
63
+ if type(value) in (tuple, frozenset) and isinstance(value, (tuple, frozenset)):
64
+ return tuple(value)
65
+ if isinstance(value, tuple):
66
+ names = getattr(value_type, "_fields", None)
67
+ if (
68
+ isinstance(names, tuple)
69
+ and len(names) == len(value)
70
+ and all(isinstance(name, str) for name in names)
71
+ and not hasattr(value, "__dict__")
72
+ and not _has_extra_state(value, frozenset(names))
73
+ ):
74
+ return tuple(value)
75
+ return None
76
+ if isinstance(value, BaseModel):
77
+ return _model_values(value)
78
+ return _dataclass_values(value)
79
+
80
+
81
+ def _dataclass_values(value: object) -> tuple[object, ...] | None:
82
+ if isinstance(value, type) or not is_dataclass(value):
83
+ return None
84
+ # 未装饰的派生类会继承 dataclass 标记,却可通过普通 setattr 增加新状态。
85
+ parameters = vars(type(value)).get("__dataclass_params__")
86
+ if getattr(parameters, "frozen", False) is not True:
87
+ return None
88
+ names = tuple(field.name for field in fields(value))
89
+ if _has_extra_state(value, frozenset(names)):
90
+ return None
91
+ return tuple(getattr(value, name, _MISSING) for name in names)
92
+
93
+
94
+ def _model_values(value: BaseModel) -> tuple[object, ...] | None:
95
+ model_type = type(value)
96
+ # frozen 不保护 PrivateAttr/extra/cached_property;字段检查不能替代这些形态约束。
97
+ if (
98
+ model_type.model_config.get("frozen") is not True
99
+ or model_type.model_config.get("extra") == "allow"
100
+ or model_type.__private_attributes__
101
+ or value.__pydantic_extra__
102
+ or value.__pydantic_private__
103
+ ):
104
+ return None
105
+ names = frozenset(model_type.model_fields)
106
+ if _has_extra_state(value, names, slots=names | frozenset(BaseModel.__slots__)):
107
+ return None
108
+ return tuple(getattr(value, name, _MISSING) for name in model_type.model_fields)
109
+
110
+
111
+ def _has_extra_state(
112
+ value: object, names: frozenset[str], *, slots: frozenset[str] | None = None
113
+ ) -> bool:
114
+ state = getattr(value, "__dict__", {})
115
+ if not isinstance(state, dict) or state.keys() - names:
116
+ return True
117
+ allowed_slots = names if slots is None else slots
118
+ # 同时检查继承的 slot 和缓存描述符,避免漏掉不在字段元数据中的可变状态。
119
+ return any(
120
+ isinstance(member, cached_property)
121
+ or (isinstance(member, MemberDescriptorType) and name not in allowed_slots)
122
+ for base in type(value).__mro__
123
+ for name, member in vars(base).items()
124
+ )