feat/aggregate reconstitute#291
Conversation
gbrennon
commented
Jul 23, 2026
- refactor: restructure Message into message/ subpackage with abstract from_payload_fields
- refactor: replace Serializable with MessageCodec/DictMessageCodec in infrastructure
- feat: add reconstitute classmethod to AggregateRoot
- refactor: rename _from_payload_fields to from_payload_fields across tests and scripts
- test: reorganize message tests into decorators, metadata, and serialization modules
- docs: update reference docs for message and serialization API changes
…from_payload_fields Split the monolithic message.py into message/_message.py (base Message class with abstract from_payload_fields), message/_metadata.py (MessageMetadata), and message/__init__.py (public re-exports). Make from_payload_fields @AbstractMethod on Command, Event, and Query base classes. Update @event_dataclass / @command_dataclass / @query_dataclass decorators to auto-patch the new method name.
…infrastructure Remove the structural Serializable protocol. Introduce MessageCodec (abstract encode/decode) and DictMessageCodec (concrete dict-based implementation). Update infrastructure __init__.py exports to surface the new codec types.
Add AggregateRoot.reconstitute(id, version, events) classmethod for rebuilding aggregates from event store history. This replaces the removed _validate_identity helper. Update AggregateRepository to use reconstitute() when loading aggregates, passing the aggregate type via constructor.
…ests and scripts Match the renamed abstract method on Message. All test fakes and inline subclasses now implement from_payload_fields (no leading underscore). Scripts/release command updated identically.
…zation modules Split test_serializable.py into focused test modules: test_decorators.py (message dataclass decorator behavior), test_metadata.py (MessageMetadata), and new infrastructure/serialization/ tests. Update test_aggregate_root.py for reconstitute classmethod.
Update messages.md to reflect abstract from_payload_fields and restructured message module. Update index.md and adapters.md to reference MessageCodec/DictMessageCodec instead of Serializable.
|
personal-reviewer
left a comment
There was a problem hiding this comment.
AI Code Review
Verdict: Changes Requested
The PR makes significant architectural improvements with clear separation of concerns and better documentation. However, the new reconstitute method requires corresponding tests to ensure its correctness. The decorator documentation could be slightly more explicit about inheritance handling.
Issues
- [testability] [MINOR] src/forging_blocks/domain/aggregate_root/aggregate_root.py
The new reconstitute classmethod in AggregateRoot lacks corresponding tests. This method is critical for aggregate reconstruction and should have unit tests verifying its behavior with different event sequences.
Current:
@classmethod
def reconstitute(
cls,
aggregate_id: TId,
events: Sequence[Event[EventPayloadType]],
) -> Self:
"""Reconstitute an aggregate from a sequence of stored events."""
instance = cls(aggregate_id)
for event in events:
instance.replay(event)
return instance
Suggested:
Add unit tests in test/aggregate_root_test.py that:
1. Verify reconstitute with empty event list
2. Test with single event
3. Test with multiple events in order
4. Validate version increments correctly
- [design] [INFO] src/forging_blocks/foundation/messages/decorators.py
The message_dataclass decorator could benefit from clearer documentation about how it handles type variance and inheritance. The current docstring could be more explicit about how it interacts with abstract base classes.
Current:
def message_dataclass(
frozen: bool = True,
slots: bool = True,
) -> Callable[[type[_M]], type[_M]]:
"""Decorate a class as a message dataclass."""
...
Suggested:
Enhance the docstring to explicitly state: 'This decorator is compatible with ABCs and will properly handle abstract method inheritance when used with @abstractmethod'
Praise
-
src/forging_blocks/domain/aggregate_root/aggregate_root.py: The reimplementation of
_validate_identitywith clear error conditions demonstrates good defensive programming practices and proper error handling. -
docs/reference/foundation/messages.md: The documentation update clearly explains the new codec architecture and removes outdated references to Serializable, maintaining accurate technical documentation.
Suggestions
No suggestions to apply.
Reviewed by code-review:latest
Explicitly describe how the decorator patches abstract methods (_payload, value, from_payload_fields) inherited from Event, Command, and Query bases, removing them from __abstractmethods__ so decorated subclasses are instantiable without manual stubs.
…_dataclass Replace markdown bold **ABC interaction:** header with plain prose paragraph, matching the project docstring style shown in value_object.py.
There was a problem hiding this comment.
Pull request overview
This PR updates Forging Blocks’ messaging and serialization APIs by splitting Message into a dedicated message/ subpackage, replacing the old structural Serializable protocol with an explicit codec abstraction (MessageCodec + DictMessageCodec), and adding AggregateRoot.reconstitute() so repositories can rebuild aggregates from stored events. It also aligns tests, scripts, and reference docs to the new reconstruction method (from_payload_fields) and the codec-based serialization approach.
Changes:
- Refactors foundation messages: moves
Message/MessageMetadataintofoundation/messages/message/and standardizes reconstruction onfrom_payload_fields. - Replaces
SerializablewithMessageCodecand addsDictMessageCodecfor dict-based message encoding/decoding. - Adds
AggregateRoot.reconstitute()and updatesAggregateRepositoryto rehydrate aggregates from the event store when not present in snapshot storage.
Reviewed changes
Copilot reviewed 42 out of 43 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/scripts/release/infrastructure/bus/test_release_event_bus.py | Updates script-level bus tests to use from_payload_fields and MessageMetadata. |
| tests/forging_blocks/infrastructure/unit_of_work/test_in_memory_unit_of_work.py | Renames _from_payload_fields to from_payload_fields in unit-of-work tests. |
| tests/forging_blocks/infrastructure/test_event_bus.py | Updates event bus tests to the new reconstruction method name. |
| tests/forging_blocks/infrastructure/serialization/test_dict_message_codec.py | Adds round-trip and edge-case tests for DictMessageCodec. |
| tests/forging_blocks/infrastructure/serialization/init.py | Test package init adjustment for serialization test module organization. |
| tests/forging_blocks/infrastructure/repositories/test_aggregate_repository.py | Updates repository tests for reconstitution behavior and new aggregate_type parameter. |
| tests/forging_blocks/infrastructure/message_bus/test_message_bus_query_fetcher.py | Renames _from_payload_fields to from_payload_fields in message bus tests. |
| tests/forging_blocks/infrastructure/message_bus/test_message_bus_event_publisher.py | Renames _from_payload_fields to from_payload_fields in message bus tests. |
| tests/forging_blocks/infrastructure/message_bus/test_message_bus_command_sender.py | Renames _from_payload_fields to from_payload_fields in message bus tests. |
| tests/forging_blocks/infrastructure/message_bus/test_in_memory_message_bus.py | Updates in-memory message bus tests to from_payload_fields. |
| tests/forging_blocks/foundation/messages/test_serializable.py | Re-scopes tests from Serializable serialization to decorator/payload behavior. |
| tests/forging_blocks/foundation/messages/test_metadata.py | Adds dedicated unit tests for MessageMetadata. |
| tests/forging_blocks/foundation/messages/test_message.py | Updates Message tests for the new base class behavior and reconstruction naming. |
| tests/forging_blocks/foundation/messages/test_event.py | Renames event reconstruction hook to from_payload_fields. |
| tests/forging_blocks/foundation/messages/test_decorators.py | Extracts decorator internal tests into a dedicated module. |
| tests/forging_blocks/foundation/messages/test_command.py | Updates command tests to from_payload_fields and trims old dict-serialization expectations. |
| tests/forging_blocks/domain/aggregate_root/test_aggregate_root.py | Adds tests for AggregateRoot.reconstitute() and refines identity validation coverage. |
| tests/forging_blocks/application/ports/outbound/test_query_fetcher.py | Updates outbound port tests to from_payload_fields. |
| tests/forging_blocks/application/ports/outbound/test_event_publisher.py | Updates outbound port tests to from_payload_fields. |
| tests/forging_blocks/application/ports/outbound/test_command_sender.py | Updates outbound port tests to from_payload_fields. |
| tests/fixtures/simple_fake_command.py | Updates fixture to implement from_payload_fields. |
| tests/fixtures/simple_fake_command_with_value.py | Updates fixture to implement from_payload_fields. |
| tests/fixtures/fake_event_with_value.py | Updates fixture to implement from_payload_fields. |
| tests/fixtures/fake_event_with_name.py | Updates fixture to implement from_payload_fields. |
| src/forging_blocks/infrastructure/serialization/serializable.py | Removes the old Serializable protocol. |
| src/forging_blocks/infrastructure/serialization/_message_codec.py | Introduces MessageCodec ABC (encode/decode contract). |
| src/forging_blocks/infrastructure/serialization/_dict_message_codec.py | Adds dict-based message codec implementation. |
| src/forging_blocks/infrastructure/serialization/init.py | Updates serialization exports to codec-based API. |
| src/forging_blocks/infrastructure/repositories/aggregate_repository.py | Adds aggregate_type and reconstitutes aggregates from event store when needed. |
| src/forging_blocks/infrastructure/init.py | Re-exports codecs from infrastructure package and updates module docs. |
| src/forging_blocks/foundation/messages/query.py | Updates Query payload typing to be generic (not dict-only). |
| src/forging_blocks/foundation/messages/message/_metadata.py | Moves MessageMetadata into the new message/ subpackage. |
| src/forging_blocks/foundation/messages/message/_message.py | Moves Message base class into the new message/ subpackage and adds abstract from_payload_fields. |
| src/forging_blocks/foundation/messages/message/init.py | Exposes Message and MessageMetadata from the new subpackage. |
| src/forging_blocks/foundation/messages/message.py | Removes the old monolithic message module. |
| src/forging_blocks/foundation/messages/event.py | Updates Event payload typing to be generic (not dict-only). |
| src/forging_blocks/foundation/messages/decorators.py | Renames decorator wiring to from_payload_fields and patches abstractness accordingly. |
| src/forging_blocks/foundation/messages/command.py | Updates Command docs to reflect broader “system” messaging terminology. |
| src/forging_blocks/domain/aggregate_root/aggregate_root.py | Adds AggregateRoot.reconstitute() for event-sourced rebuilds. |
| scripts/release/domain/commands/open_pull_request_command.py | Updates release script command reconstruction to from_payload_fields. |
| docs/reference/infrastructure/adapters.md | Updates docs from Serializable to codec-based serialization. |
| docs/reference/index.md | Updates reference index to mention MessageCodec/DictMessageCodec. |
| docs/reference/foundation/messages.md | Updates message docs to reflect codec-based serialization and from_payload_fields. |
Comments suppressed due to low confidence (3)
src/forging_blocks/foundation/messages/decorators.py:52
- message_dataclass’s internal typing is inconsistent with the behavior: the decorator always produces a dict payload (get_payload_fields / from_payload_fields), but _M is currently bound to Message[Any]. Also, _PatchedMessage.from_payload_fields is annotated as returning type[Self], but it returns an instance (Self). Tightening these annotations prevents incorrect usage and helps pyright/ruff catch mistakes earlier.
_M = TypeVar("_M", bound="Message[Any]")
@runtime_checkable
class _PatchedMessage(Protocol):
"""Structural type describing a message class after decorator patching.
This protocol allows pyright to verify that the patched attributes exist
and have the correct signatures, replacing attribute assignment suppressions
with a proper type-safe cast boundary.
"""
def get_payload_fields(self) -> dict[str, object]: ...
@classmethod
def from_payload_fields(
cls,
data: dict[str, object],
metadata: MessageMetadata,
) -> type[Self]: ...
src/forging_blocks/foundation/messages/query.py:38
- Query._payload is now generic (QueryPayloadType), but the docstring still says it returns a dictionary. Updating the wording avoids misleading API documentation for non-dict payload queries.
def _payload(self) -> QueryPayloadType:
"""Retrieve the query's payload as a dictionary.
Subclasses MUST implement this property to return the query-specific
data.
"""
src/forging_blocks/foundation/messages/event.py:51
- Event._payload is now generic (RawEventType), but the docstring still says it returns a dictionary. Updating the wording keeps the public API docs accurate for non-dict payload events.
@abstractmethod
def _payload(self) -> RawEventType:
"""Retrieve the event's payload as a dictionary.
Subclasses MUST implement this property to return the event-specific
data.
"""
| Contains technical-level information about messages such as: | ||
| - Unique message identifier | ||
| - When the message was created | ||
| - correlation_id is used to trace related messages across systems. | ||
| - correlation_id is used to link messages that belong to the same business process. | ||
| - causation_id is used to identify the immediate predecessor message that caused | ||
|
|
||
| This separation allows messages to focus on foundational data while keeping | ||
| infrastructure handling concerns in metadata without foundation understand anything about | ||
| infrastructure rules. |
… created_at fallback - Constrain DictMessageCodec generic from Message[Any] to Message[dict[str, object]] to match the actual cast on message.value - Remove .get() fallback to datetime.min for created_at metadata field; use direct key access consistent with message_id, causation_id, and correlation_id - Fix _PatchedMessage.from_payload_fields return annotation from type[Self] to Self (classmethod returns an instance, not a type)
- Deduplicate correlation_id bullets in MessageMetadata docstring, merge into single item covering tracing and process linking - Complete truncated causation_id sentence: 'that caused this message' - Fix grammar: 'without needing to understand' instead of 'without foundation understand anything about' - Update Event._payload and Query._payload docstrings from stale 'as a dictionary' to generic 'event/query-specific payload data'
…ghtening The Message[Any] -> Message[dict[str, object]] change made message.value already dict[str, object], so the cast became redundant.
…thods Remove the internal _to_data/_from_data indirection in MessageCodec. encode and decode are now @AbstractMethod directly — subclasses implement them instead of the private helpers. DictMessageCodec renamed its methods accordingly. Removed unused runtime_final import.
…metaclass Since encode/decode are now the public abstract methods and no @runtime_final methods remain, FinalABCMeta adds no value. Using ABC directly is the idiomatic pattern for non-port ABCs.
| aggregate = self._aggregate_type.reconstitute(id, events) | ||
|
|
||
| return aggregate |
| """Get the timestamp when this message was created. | ||
|
|
||
| Returns: | ||
| When the message was created (UTC timezone). | ||
|
|
||
| """ |
| @@ -30,7 +29,7 @@ class OrderCreated(Event[dict[str, object]]): | |||
|
|
|||
| from forging_blocks.foundation.messages.message import Message, MessageMetadata | |||
|
|
|||
| _M = TypeVar("_M", bound="Message[dict[str, object]]") | |||
| _M = TypeVar("_M", bound="Message[Any]") | |||
AggregateRepository.get_by_id() now saves the reconstituted aggregate to the in-memory store so subsequent reads don't replay the full event stream from the event store.
…move Update documentation to reflect the move of ValueObject, Messages, and Specifications from Foundation to Domain: - foundation.md: remove moved sections from Foundation overview - domain.md: update overview to list new domain modules - domain/value-objects.md, specifications.md: update origin references - auto-eq.md, auto-freeze.md: fix value-objects.md cross-reference links - Guide docs: align import examples with new domain module paths
AggregateRoot and AggregateVersion are now loaded via module __getattr__ to avoid the foundation → context → domain → aggregate_root → foundation cycle introduced by moving ValueObject to the domain layer.
Remove stale Foundation nav entries for Messages, Value Objects, and Specifications. Add Messages to Domain nav section.
…t on ABCMeta classes
…t safety The circular import is real: foundation/__init__ imports foundation.context.authorization_context which imports domain.value_object which triggers domain/__init__.py which attempts to import aggregate_root.aggregate_root which needs forging_blocks.foundation.FinalABCMeta -- but foundation is still loading. __getattr__ lazy-imports AggregateRoot and AggregateVersion to break the cycle.
…ields, and frozen enforcement
AuthorizationContext, ServiceContext, and TransactionContext are too application-specific for the reusable library. Downstream code should define its own context types and supply them via the generic type parameters on PermissionChecker, AuthorizationPort, and TransactionManagerPort.
ResourcePermissionChecker and RoleBasedPermissionChecker are too application-specific for the reusable library. Downstream code should implement PermissionChecker directly with its own permission logic.
…rameters Replace deleted concrete context types with domain-appropriate type parameters: PermissionCheckContext (permission checkers), AuthorizationCheckContext (authorization port), and TransactionSessionContext (transaction manager port). Docs updated to reflect generic signatures and remove stale references to deleted concrete classes.
…ePermissionChecker Align shipped implementation with documented convention in docs/permissions.md. Functionally equivalent — PermissionChecker is @runtime_checkable — but explicit inheritance improves code navigation and removes a doc/code inconsistency.
personal-reviewer
left a comment
There was a problem hiding this comment.
AI Code Review
Verdict: Approved
The PR demonstrates excellent refactoring with clear separation of concerns, improved documentation, and proper use of SOLID principles. The introduction of auto-decorators for immutability and the restructuring of message handling show strong architectural improvements. All changes appear well-tested and documented.
Issues
No issues found.
Praise
-
docs/reference/foundation/auto-decorators.md: Excellent introduction of
@auto_freeze,@auto_eq, and@auto_hashdecorators. These provide lightweight immutability and equality handling without requiring inheritance from base classes, improving flexibility and reducing boilerplate. -
docs/reference/domain/messages.md: Clear separation of message types (Command, Event, Query) with explicit documentation. The refactoring to use
MessageCodecin infrastructure layer adheres to Dependency Inversion Principle by decoupling message serialization from domain logic. -
docs/reference/domain/permissions.md: Clean redesign of permission checkers with generic type parameters (
PermissionChecker[PermissionCheckContext]). This improves testability and allows application-specific context implementations without modifying base classes.
Suggestions
No suggestions to apply.
Reviewed by code-review:latest
| f"in docstring code example (block line {line_no})" | ||
| ) | ||
|
|
||
| return warnings |
| @@ -0,0 +1,42 @@ | |||
| """Message codec ABC for encoding/decoding foundation messages.""" | |||
|
|
||
| def decode(self, data: dict[str, object], message_type: type[M]) -> M: | ||
| """Decode *data* back into a message of *message_type*.""" | ||
| raw_metadata = cast(dict[str, object], data["metadata"]) |
| case ast.Import(names=names): | ||
| for alias in names: | ||
| imported[alias.asname or alias.name] = n.lineno | ||
| case ast.ImportFrom(names=names): |
|
|
||
| @pytest.mark.unit | ||
| class TestMain: |
| @@ -0,0 +1,48 @@ | |||
| """The `auto_hash` decorator generates `__hash__` for class instances. | |||
| @@ -0,0 +1,33 @@ | |||
| """The `auto_freeze` decorator enforces immutability after `__init__` completes. | |||
| @@ -0,0 +1,43 @@ | |||
| """The `auto_eq` decorator generates `__eq__` for class instances. | |||
| @@ -0,0 +1,129 @@ | |||
| """Tests for message decorators and frozen instance behavior.""" | |||