diff --git a/.agent/current-plan.md b/.agent/current-plan.md index 31f793c..4ed2a77 100644 --- a/.agent/current-plan.md +++ b/.agent/current-plan.md @@ -1,18 +1,45 @@ -# Plan: Add FastAPI to the Project +# Plan: Value Object Base Class ## Goal -Add FastAPI as the web framework with a minimal health check endpoint. +Create a generic `Value[T]` base class in the framework layer. All value objects standardize on `.value` access via inheritance. -## Changes +## Design -### 1. Dependencies -- Add `fastapi` and `uvicorn[standard]` to `pyproject.toml` under `[project.dependencies]` -- Install dependencies into the virtual environment +### `Value[T]` base class (`domain/framework/value.py`) +```python +@dataclass(frozen=True) +class Value(ABC, Generic[T]): + value: T +``` -### 2. Application Entry Point -- Create `main.py` with a FastAPI app instance -- Add a health check endpoint (`GET /`) that returns `{"status": "ok"}` +- Frozen dataclass → immutability + structural equality (core Value Object semantics) +- `Generic[T]` → type-safe `.value` access in subclasses (e.g., `Value[str]`) +- `ABC` → mirrors the `Entity` pattern + +### Subclasses +- `ClassifiedAdTitle(Value[str])` — drops `title: str`, inherits `value: T` bound to `str` +- `ClassifiedAdText(Value[str])` — drops `text: str`, inherits `value: T` bound to `str` + +### Ripple effects +Every `.title` / `.text` reference on these objects becomes `.value`: +- `classified_ad.py` — `self.title.title` → `self.title.value`, construction calls, method bodies +- Tests — assertions, constructions, equality checks + +## Files changed +| File | Change | +|------|--------| +| `domain/framework/value.py` | New — `Value[T]` base class | +| `domain/framework/__init__.py` | Add `Value` re-export | +| `domain/marketplace/classified_ad_title.py` | Inherit `Value[str]`, rename field | +| `domain/marketplace/classified_ad_text.py` | Inherit `Value[str]`, rename field | +| `domain/marketplace/classified_ad.py` | Update `.title`/`.text` refs → `.value` | +| `tests/test_classified_ad_title.py` | Update refs | +| `tests/test_classified_ad_text.py` | Update refs | +| `tests/test_classified_ad.py` | Update refs | + +## Not in scope +`ClassifiedAdId`, `UserId`, `Money`, `Price` — stay as-is. ## Verification -- `pytest tests/` — all tests pass -- `uvicorn main:app --reload` — server starts and health endpoint responds \ No newline at end of file +- `pytest tests/` — all 63 tests pass +- `ruff check .` && `ruff format --check .` — clean diff --git a/domain/framework/__init__.py b/domain/framework/__init__.py index e69de29..496ab65 100644 --- a/domain/framework/__init__.py +++ b/domain/framework/__init__.py @@ -0,0 +1 @@ +from .value import Value as Value diff --git a/domain/framework/value.py b/domain/framework/value.py new file mode 100644 index 0000000..b9f41f0 --- /dev/null +++ b/domain/framework/value.py @@ -0,0 +1,7 @@ +from abc import ABC +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Value[T](ABC): + value: T diff --git a/domain/marketplace/classified_ad.py b/domain/marketplace/classified_ad.py index 8aaaae1..fb9c916 100644 --- a/domain/marketplace/classified_ad.py +++ b/domain/marketplace/classified_ad.py @@ -28,23 +28,27 @@ from domain.marketplace.user_id import UserId class ClassifiedAd(Entity): id: ClassifiedAdId _ownerId: UserId - title: ClassifiedAdTitle = field(default_factory=lambda: ClassifiedAdTitle(title="")) - text: ClassifiedAdText = field(default_factory=lambda: ClassifiedAdText(text="")) + title: ClassifiedAdTitle = field( + default_factory=lambda: ClassifiedAdTitle(value="") + ) + text: ClassifiedAdText = field(default_factory=lambda: ClassifiedAdText(value="")) price: Price = field(default_factory=lambda: Price(amount=Decimal("0.00"))) state: ClassifiedAdState = ClassifiedAdState.Inactive _changes: tuple[DomainEvent, ...] = () def __post_init__(self): if not self._changes: - object.__setattr__(self, '_changes', ( - ClassifiedAdCreated(id=self.id.value, owner_id=self._ownerId.value), - )) + object.__setattr__( + self, + "_changes", + (ClassifiedAdCreated(id=self.id.value, owner_id=self._ownerId.value),), + ) def _ensure_valid_state(self): errors = [] - if not self.title.title: + if not self.title.value: errors.append("title") - if not self.text.text: + if not self.text.value: errors.append("text") if self.price.amount <= 0: errors.append("price") @@ -57,9 +61,9 @@ class ClassifiedAd(Entity): def when(self, event: DomainEvent) -> Self: match event: case ClassifiedAdTitleChanged(): - return replace(self, title=ClassifiedAdTitle(title=event.title)) + return replace(self, title=ClassifiedAdTitle(value=event.title)) case ClassifiedAdTextUpdated(): - return replace(self, text=ClassifiedAdText(text=event.ad_text)) + return replace(self, text=ClassifiedAdText(value=event.ad_text)) case ClassifiedAdPriceUpdated(): return replace(self, price=Price(amount=event.price)) case ClassifiedAdSentForReview(): @@ -70,10 +74,12 @@ class ClassifiedAd(Entity): return self.apply(ClassifiedAdSentForReview(id=self.id.value)) def set_title(self, title: ClassifiedAdTitle) -> ClassifiedAd: - return self.apply(ClassifiedAdTitleChanged(id=self.id.value, title=title.title)) + return self.apply(ClassifiedAdTitleChanged(id=self.id.value, title=title.value)) def update_text(self, text: ClassifiedAdText) -> ClassifiedAd: - return self.apply(ClassifiedAdTextUpdated(id=self.id.value, ad_text=text.text)) + return self.apply(ClassifiedAdTextUpdated(id=self.id.value, ad_text=text.value)) def update_price(self, price: Price) -> ClassifiedAd: - return self.apply(ClassifiedAdPriceUpdated(id=self.id.value, price=price.amount)) + return self.apply( + ClassifiedAdPriceUpdated(id=self.id.value, price=price.amount) + ) diff --git a/domain/marketplace/classified_ad_text.py b/domain/marketplace/classified_ad_text.py index 0ffc56f..a72b0d5 100644 --- a/domain/marketplace/classified_ad_text.py +++ b/domain/marketplace/classified_ad_text.py @@ -1,6 +1,8 @@ from dataclasses import dataclass +from domain.framework.value import Value + @dataclass(frozen=True) -class ClassifiedAdText: - text: str +class ClassifiedAdText(Value[str]): + pass diff --git a/domain/marketplace/classified_ad_title.py b/domain/marketplace/classified_ad_title.py index 7efd6e1..18dd4a1 100644 --- a/domain/marketplace/classified_ad_title.py +++ b/domain/marketplace/classified_ad_title.py @@ -1,10 +1,12 @@ from dataclasses import dataclass +from domain.framework.value import Value + @dataclass(frozen=True) -class ClassifiedAdTitle: - title: str - +class ClassifiedAdTitle(Value[str]): def __post_init__(self): - if len(self.title) > 100: - raise ValueError(f"Title cannot be longer than 100 characters: {self.title}") + if len(self.value) > 100: + raise ValueError( + f"Title cannot be longer than 100 characters: {self.value}" + ) diff --git a/tests/test_classified_ad.py b/tests/test_classified_ad.py index 0fdd2d9..cc84865 100644 --- a/tests/test_classified_ad.py +++ b/tests/test_classified_ad.py @@ -30,15 +30,15 @@ def test_classified_ad_creation(): ad = ClassifiedAd( id=ad_id, _ownerId=owner_id, - title=ClassifiedAdTitle(title="Test Title"), - text=ClassifiedAdText(text="Test text content"), + title=ClassifiedAdTitle(value="Test Title"), + text=ClassifiedAdText(value="Test text content"), price=Price(amount=Decimal("99.99")), ) assert ad.id == ad_id assert ad._ownerId == owner_id - assert ad.title == ClassifiedAdTitle(title="Test Title") - assert ad.text == ClassifiedAdText(text="Test text content") + assert ad.title == ClassifiedAdTitle(value="Test Title") + assert ad.text == ClassifiedAdText(value="Test text content") assert ad.price == Price(amount=Decimal("99.99")) @@ -46,8 +46,8 @@ def test_classified_ad_can_not_be_created_without_id(): with pytest.raises(TypeError): ClassifiedAd( _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -56,8 +56,8 @@ def test_classified_ad_can_not_be_created_without_owner_id(): with pytest.raises(TypeError): ClassifiedAd( id=ClassifiedAdId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -69,14 +69,14 @@ def test_classified_ad_can_be_created_without_price(): ad = ClassifiedAd( id=ad_id, _ownerId=owner_id, - title=ClassifiedAdTitle(title="Test Title"), - text=ClassifiedAdText(text="Test text content"), + title=ClassifiedAdTitle(value="Test Title"), + text=ClassifiedAdText(value="Test text content"), ) assert ad.id == ad_id assert ad._ownerId == owner_id - assert ad.title == ClassifiedAdTitle(title="Test Title") - assert ad.text == ClassifiedAdText(text="Test text content") + assert ad.title == ClassifiedAdTitle(value="Test Title") + assert ad.text == ClassifiedAdText(value="Test text content") assert ad.price == Price(amount=Decimal("0.00")) @@ -85,8 +85,8 @@ def test_classified_ad_can_not_have_negative_price(): ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("-10.00")), ) @@ -95,8 +95,8 @@ def test_classified_ad_is_immutable(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -114,15 +114,15 @@ def test_classified_ad_equality(): ad1 = ClassifiedAd( id=ad_id, _ownerId=owner_id, - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) ad2 = ClassifiedAd( id=ad_id, _ownerId=owner_id, - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -135,8 +135,8 @@ def test_classified_ad_title_validation(): ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title=long_title), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value=long_title), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -145,8 +145,8 @@ def test_request_to_publish_sets_state_to_pending_review(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -165,8 +165,8 @@ def test_request_to_publish_fails_when_title_empty(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title=""), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value=""), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -180,8 +180,8 @@ def test_request_to_publish_fails_when_text_empty(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text=""), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value=""), price=Price(amount=Decimal("10.00")), ) @@ -195,8 +195,8 @@ def test_request_to_publish_fails_when_price_is_zero(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), ) with pytest.raises(InvalidEntityStateException) as exc_info: @@ -209,8 +209,8 @@ def test_request_to_publish_reports_all_errors(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title=""), - text=ClassifiedAdText(text=""), + title=ClassifiedAdTitle(value=""), + text=ClassifiedAdText(value=""), ) with pytest.raises(InvalidEntityStateException) as exc_info: @@ -225,8 +225,8 @@ def test_request_to_publish_does_not_mutate_original(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -240,8 +240,8 @@ def test_classified_ad_is_instance_of_entity(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -252,8 +252,8 @@ def test_classified_ad_can_use_entity_apply(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) event = ClassifiedAdSentForReview(id=ad.id.value) @@ -269,32 +269,32 @@ def test_set_title_updates_title(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Old Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Old Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) - new_title = ClassifiedAdTitle(title="New Title") + new_title = ClassifiedAdTitle(value="New Title") updated = ad.set_title(new_title) assert updated.title == new_title - assert ad.title == ClassifiedAdTitle(title="Old Title") + assert ad.title == ClassifiedAdTitle(value="Old Title") def test_set_title_preserves_other_fields(): ad_id = ClassifiedAdId(value=uuid4()) owner_id = UserId(value=uuid4()) - text = ClassifiedAdText(text="Text") + text = ClassifiedAdText(value="Text") price = Price(amount=Decimal("10.00")) ad = ClassifiedAd( id=ad_id, _ownerId=owner_id, - title=ClassifiedAdTitle(title="Old Title"), + title=ClassifiedAdTitle(value="Old Title"), text=text, price=price, ) - updated = ad.set_title(ClassifiedAdTitle(title="New Title")) + updated = ad.set_title(ClassifiedAdTitle(value="New Title")) assert updated.id == ad_id assert updated._ownerId == owner_id @@ -306,13 +306,13 @@ def test_set_title_fails_when_new_title_empty(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) with pytest.raises(InvalidEntityStateException) as exc_info: - ad.set_title(ClassifiedAdTitle(title="")) + ad.set_title(ClassifiedAdTitle(value="")) assert "title" in str(exc_info.value) @@ -321,12 +321,12 @@ def test_set_title_raises_classified_ad_title_changed_event(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Old Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Old Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) - updated = ad.set_title(ClassifiedAdTitle(title="New Title")) + updated = ad.set_title(ClassifiedAdTitle(value="New Title")) changes = updated.get_changes() assert len(changes) == 2 @@ -340,29 +340,29 @@ def test_update_text_updates_text(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Old text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Old text"), price=Price(amount=Decimal("10.00")), ) - new_text = ClassifiedAdText(text="New text") + new_text = ClassifiedAdText(value="New text") updated = ad.update_text(new_text) assert updated.text == new_text - assert ad.text == ClassifiedAdText(text="Old text") + assert ad.text == ClassifiedAdText(value="Old text") def test_update_text_fails_when_new_text_empty(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) with pytest.raises(InvalidEntityStateException) as exc_info: - ad.update_text(ClassifiedAdText(text="")) + ad.update_text(ClassifiedAdText(value="")) assert "text" in str(exc_info.value) @@ -371,12 +371,12 @@ def test_update_text_raises_classified_ad_text_updated_event(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Old text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Old text"), price=Price(amount=Decimal("10.00")), ) - updated = ad.update_text(ClassifiedAdText(text="New text")) + updated = ad.update_text(ClassifiedAdText(value="New text")) changes = updated.get_changes() assert len(changes) == 2 @@ -390,8 +390,8 @@ def test_update_price_updates_price(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) new_price = Price(amount=Decimal("25.00")) @@ -406,8 +406,8 @@ def test_update_price_fails_when_price_zero(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) @@ -421,8 +421,8 @@ def test_update_price_raises_classified_ad_price_updated_event(): ad = ClassifiedAd( id=ClassifiedAdId(value=uuid4()), _ownerId=UserId(value=uuid4()), - title=ClassifiedAdTitle(title="Title"), - text=ClassifiedAdText(text="Text"), + title=ClassifiedAdTitle(value="Title"), + text=ClassifiedAdText(value="Text"), price=Price(amount=Decimal("10.00")), ) diff --git a/tests/test_classified_ad_text.py b/tests/test_classified_ad_text.py index d2a4bd0..c9cf850 100644 --- a/tests/test_classified_ad_text.py +++ b/tests/test_classified_ad_text.py @@ -2,6 +2,6 @@ from domain.marketplace.classified_ad_text import ClassifiedAdText def test_classified_ad_text_creation(): - text = ClassifiedAdText(text="This is a classified ad description.") + text = ClassifiedAdText(value="This is a classified ad description.") - assert text.text == "This is a classified ad description." + assert text.value == "This is a classified ad description." diff --git a/tests/test_classified_ad_title.py b/tests/test_classified_ad_title.py index 194ca08..7f8d47e 100644 --- a/tests/test_classified_ad_title.py +++ b/tests/test_classified_ad_title.py @@ -4,10 +4,13 @@ from domain.marketplace.classified_ad_title import ClassifiedAdTitle def test_classified_ad_title_creation(): - title = ClassifiedAdTitle(title="Test Ad") + title = ClassifiedAdTitle(value="Test Ad") + + assert title.value == "Test Ad" - assert title.title == "Test Ad" def test_classified_ad_title_can_not_be_longer_then_100_chars(): with pytest.raises(ValueError): - ClassifiedAdTitle(title="efbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzza") \ No newline at end of file + ClassifiedAdTitle( + value="efbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzza" + )