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.
- python_ddd_framework/application/runtime.py +46 -19
- python_ddd_framework/application_services/bindings.py +0 -64
- python_ddd_framework/application_services/dispatcher.py +1 -1
- python_ddd_framework/application_services/invocation.py +7 -500
- python_ddd_framework/application_services/module.py +16 -5
- python_ddd_framework/authorization/module.py +1 -1
- python_ddd_framework/background_execution/local.py +20 -1
- python_ddd_framework/background_execution/processes.py +11 -1
- python_ddd_framework/background_jobs/execution.py +1 -1
- python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
- python_ddd_framework/background_workers/execution.py +1 -1
- python_ddd_framework/background_workers/runtime.py +11 -2
- python_ddd_framework/caching/unit_of_work.py +2 -4
- python_ddd_framework/cli/project.py +11 -10
- python_ddd_framework/developer_kit/generation.py +46 -36
- python_ddd_framework/developer_kit/publication.py +442 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
- python_ddd_framework/diagnostics/source.py +14 -1
- python_ddd_framework/domain/aggregates.py +5 -12
- python_ddd_framework/domain/event_values.py +124 -0
- python_ddd_framework/events/runtime.py +2 -7
- python_ddd_framework/events/unit_of_work.py +1 -4
- python_ddd_framework/fastapi/action.py +2 -1
- python_ddd_framework/fastapi/adapter.py +11 -117
- python_ddd_framework/fastapi/application_services.py +17 -402
- python_ddd_framework/fastapi/error_handlers.py +128 -0
- python_ddd_framework/fastapi/realtime/runtime.py +1 -1
- python_ddd_framework/fastapi/request_context.py +2 -5
- python_ddd_framework/fastapi/routing.py +4 -10
- python_ddd_framework/fastapi/service_endpoints.py +182 -0
- python_ddd_framework/fastapi/service_routes.py +237 -0
- python_ddd_framework/fastapi/transfer.py +1 -1
- python_ddd_framework/hosted_services/bridge.py +76 -21
- python_ddd_framework/hosted_services/contracts.py +5 -1
- python_ddd_framework/hosted_services/runtime.py +71 -17
- python_ddd_framework/identity/__init__.py +21 -13
- python_ddd_framework/identity/application.py +12 -66
- python_ddd_framework/identity/contracts.py +38 -411
- python_ddd_framework/identity/domain_rules.py +17 -0
- python_ddd_framework/identity/dtos.py +152 -0
- python_ddd_framework/identity/extensions.py +9 -0
- python_ddd_framework/identity/management.py +28 -25
- python_ddd_framework/identity/models.py +86 -0
- python_ddd_framework/identity/module.py +3 -4
- python_ddd_framework/identity/options.py +54 -0
- python_ddd_framework/identity/seeding.py +59 -0
- python_ddd_framework/identity/services.py +1 -1
- python_ddd_framework/identity/sqlalchemy/module.py +9 -6
- python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
- python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
- python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
- python_ddd_framework/identity/stores.py +120 -0
- python_ddd_framework/identity/tokens.py +1 -1
- python_ddd_framework/invocation/callables.py +1 -1
- python_ddd_framework/invocation/dispatcher.py +1 -1
- python_ddd_framework/invocation/entrypoints.py +2 -1
- python_ddd_framework/invocation/function_runtime.py +2 -2
- python_ddd_framework/invocation/interception.py +1 -1
- python_ddd_framework/invocation/managed_proxy.py +2 -1
- python_ddd_framework/invocation/managed_services.py +8 -14
- python_ddd_framework/invocation/methods.py +2 -7
- python_ddd_framework/invocation/module.py +1 -1
- python_ddd_framework/invocation/runtime.py +483 -0
- python_ddd_framework/invocation/scopes.py +91 -0
- python_ddd_framework/lifecycle/participants.py +4 -0
- python_ddd_framework/messaging/channel.py +35 -8
- python_ddd_framework/messaging/contracts.py +9 -0
- python_ddd_framework/messaging/module.py +13 -0
- python_ddd_framework/messaging/runtime.py +17 -2
- python_ddd_framework/modularity/discovery.py +2 -7
- python_ddd_framework/modularity/graph.py +2 -8
- python_ddd_framework/observability/logging.py +1 -1
- python_ddd_framework/observability/tracing.py +1 -1
- python_ddd_framework/redis/cache.py +2 -0
- python_ddd_framework/redis/notification_runtime.py +1 -1
- python_ddd_framework/services/arbitration.py +1 -20
- python_ddd_framework/services/composition.py +71 -2
- python_ddd_framework/services/provider.py +23 -1
- python_ddd_framework/services/relocation.py +96 -0
- python_ddd_framework/settings/refresh.py +1 -1
- python_ddd_framework/settings/sqlalchemy/store.py +1 -1
- python_ddd_framework/sqlalchemy/metadata.py +11 -413
- python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
- python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
- python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
- python_ddd_framework/sqlalchemy/migration.py +2 -1
- python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
- python_ddd_framework/sqlalchemy/module.py +2 -1
- python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
- python_ddd_framework/unit_of_work/contracts.py +68 -7
- python_ddd_framework/unit_of_work/manager.py +4 -7
- python_ddd_framework/unit_of_work/module.py +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {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) · [
|
|
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
|
|
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.
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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),
|
|
79
|
+
UUID(bytes=row.id.bytes), OrderTitle(value=row.title),
|
|
80
|
+
status=OrderStatus(row.status), version=row.version
|
|
79
81
|
)
|
python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md
CHANGED
|
@@ -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.
|
|
10
|
-
4. Read versions and dependencies from `backend/pyproject.toml
|
|
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
|
|
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`
|
|
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
|
-
"""
|
|
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(
|
|
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
|
+
)
|