feat: add generic Value[T] base class for value objects

- Create Value[T] (frozen dataclass + ABC + Generic) in domain/framework/
- Migrate ClassifiedAdTitle and ClassifiedAdText to inherit Value[str]
- Standardize all value access via .value instead of type-specific fields
This commit is contained in:
2026-07-29 11:13:17 -04:00
parent 05229910cd
commit d345e522bd
9 changed files with 150 additions and 102 deletions

View File

@@ -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
- `pytest tests/` — all 63 tests pass
- `ruff check .` && `ruff format --check .` — clean

View File

@@ -0,0 +1 @@
from .value import Value as Value

View File

@@ -0,0 +1,7 @@
from abc import ABC
from dataclasses import dataclass
@dataclass(frozen=True)
class Value[T](ABC):
value: T

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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}"
)

View File

@@ -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")),
)

View File

@@ -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."

View File

@@ -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")
ClassifiedAdTitle(
value="efbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzzaefbqzzqmsjdlhkovmnmyykrjvlftrzza"
)