@martinffx/atelier 2.0.0 → 3.0.1
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.
- package/CHANGELOG.md +21 -0
- package/README.md +94 -253
- package/package.json +1 -1
- package/skills/{spec-orchestrator → atelier-orchestrator}/SKILL.md +3 -3
- package/skills/atelier-setup/SKILL.md +80 -0
- package/skills/code-review/references/reviewers.md +3 -3
- package/skills/code-review/references/rq.md +6 -6
- package/skills/spec-brainstorm/SKILL.md +5 -5
- package/skills/spec-implement/SKILL.md +2 -3
- package/skills/spec-plan/SKILL.md +21 -29
- package/skills/spec-plan/references/plan_template.md +77 -0
- package/skills/python-architecture/SKILL.md +0 -148
- package/skills/python-architecture/references/data-modeling.md +0 -558
- package/skills/python-architecture/references/ddd.md +0 -569
- package/skills/python-architecture/references/functional-core.md +0 -337
- package/skills/python-build-tools/SKILL.md +0 -153
- package/skills/python-build-tools/references/basedpyright.md +0 -321
- package/skills/python-build-tools/references/ruff.md +0 -225
- package/skills/python-build-tools/references/uv.md +0 -257
- package/skills/python-fastapi/SKILL.md +0 -184
- package/skills/python-fastapi/references/api-design.md +0 -563
- package/skills/python-fastapi/references/dependencies.md +0 -479
- package/skills/python-fastapi/references/middleware.md +0 -475
- package/skills/python-fastapi/references/validation.md +0 -497
- package/skills/python-modern-python/SKILL.md +0 -201
- package/skills/python-modern-python/references/async.md +0 -76
- package/skills/python-modern-python/references/pattern-matching.md +0 -89
- package/skills/python-modern-python/references/typing.md +0 -96
- package/skills/python-monorepo/SKILL.md +0 -146
- package/skills/python-monorepo/references/docker.md +0 -515
- package/skills/python-monorepo/references/namespace-packages.md +0 -426
- package/skills/python-monorepo/references/workspace-config.md +0 -341
- package/skills/python-sqlalchemy/SKILL.md +0 -119
- package/skills/python-sqlalchemy/references/async.md +0 -51
- package/skills/python-sqlalchemy/references/models.md +0 -67
- package/skills/python-sqlalchemy/references/queries.md +0 -57
- package/skills/python-temporal/SKILL.md +0 -128
- package/skills/python-temporal/references/patterns.md +0 -87
- package/skills/python-temporal/references/testing.md +0 -54
- package/skills/python-testing/SKILL.md +0 -161
- package/skills/python-testing/references/boundaries.md +0 -449
- package/skills/python-testing/references/mocking.md +0 -449
- package/skills/python-testing/references/pytest.md +0 -524
- package/skills/typescript-api-design/SKILL.md +0 -396
- package/skills/typescript-api-design/references/error-responses.md +0 -469
- package/skills/typescript-build-tools/SKILL.md +0 -447
- package/skills/typescript-build-tools/references/biome.md +0 -436
- package/skills/typescript-build-tools/references/bun.md +0 -432
- package/skills/typescript-build-tools/references/turborepo.md +0 -426
- package/skills/typescript-build-tools/references/vitest.md +0 -518
- package/skills/typescript-drizzle-orm/SKILL.md +0 -398
- package/skills/typescript-drizzle-orm/references/cloudflare.md +0 -392
- package/skills/typescript-drizzle-orm/references/entity-pattern.md +0 -412
- package/skills/typescript-drizzle-orm/references/postgresql.md +0 -191
- package/skills/typescript-drizzle-orm/references/repository-pattern.md +0 -479
- package/skills/typescript-drizzle-orm/references/sqlite.md +0 -291
- package/skills/typescript-dynamodb-toolbox/SKILL.md +0 -370
- package/skills/typescript-dynamodb-toolbox/references/entity-layer.md +0 -574
- package/skills/typescript-dynamodb-toolbox/references/error-handling.md +0 -495
- package/skills/typescript-dynamodb-toolbox/references/modeling.md +0 -332
- package/skills/typescript-dynamodb-toolbox/references/repository-layer.md +0 -631
- package/skills/typescript-dynamodb-toolbox/references/testing.md +0 -718
- package/skills/typescript-dynamodb-toolbox/references/transactions.md +0 -423
- package/skills/typescript-effect-ts/SKILL.md +0 -341
- package/skills/typescript-effect-ts/references/core.md +0 -412
- package/skills/typescript-effect-ts/references/error-handling.md +0 -468
- package/skills/typescript-effect-ts/references/resources.md +0 -476
- package/skills/typescript-effect-ts/references/schema.md +0 -536
- package/skills/typescript-effect-ts/references/services.md +0 -461
- package/skills/typescript-fastify/SKILL.md +0 -405
- package/skills/typescript-fastify/references/plugins.md +0 -329
- package/skills/typescript-fastify/references/typeid.md +0 -315
- package/skills/typescript-functional-patterns/SKILL.md +0 -402
- package/skills/typescript-functional-patterns/references/adts.md +0 -497
- package/skills/typescript-functional-patterns/references/branded-types.md +0 -542
- package/skills/typescript-functional-patterns/references/migration-guide.md +0 -695
- package/skills/typescript-functional-patterns/references/option-result.md +0 -585
- package/skills/typescript-testing/SKILL.md +0 -353
- package/skills/typescript-testing/references/mocking.md +0 -675
- package/skills/typescript-testing/references/msw.md +0 -852
- package/skills/typescript-testing/references/snapshot-testing.md +0 -774
|
@@ -1,558 +0,0 @@
|
|
|
1
|
-
# Data Modeling in Python
|
|
2
|
-
|
|
3
|
-
Modern Python provides multiple tools for data modeling: `dataclasses`, `Pydantic`, and `attrs`. Each has specific use cases.
|
|
4
|
-
|
|
5
|
-
## When to Use What
|
|
6
|
-
|
|
7
|
-
**dataclasses** - Domain models and internal data
|
|
8
|
-
- Part of standard library (Python 3.7+)
|
|
9
|
-
- Fast and lightweight
|
|
10
|
-
- Good for internal domain logic
|
|
11
|
-
- Use when you control all data
|
|
12
|
-
|
|
13
|
-
**Pydantic** - API boundaries and validation
|
|
14
|
-
- Runtime validation
|
|
15
|
-
- JSON schema generation
|
|
16
|
-
- OpenAPI integration
|
|
17
|
-
- Use at system edges (HTTP requests/responses)
|
|
18
|
-
|
|
19
|
-
**attrs** - Advanced use cases
|
|
20
|
-
- More features than dataclasses
|
|
21
|
-
- Validators, converters, evolve
|
|
22
|
-
- Use when dataclasses aren't enough
|
|
23
|
-
|
|
24
|
-
## dataclasses
|
|
25
|
-
|
|
26
|
-
### Basic Usage
|
|
27
|
-
|
|
28
|
-
```python
|
|
29
|
-
from dataclasses import dataclass
|
|
30
|
-
from uuid import UUID
|
|
31
|
-
from decimal import Decimal
|
|
32
|
-
|
|
33
|
-
@dataclass
|
|
34
|
-
class Product:
|
|
35
|
-
"""Basic dataclass"""
|
|
36
|
-
id: UUID
|
|
37
|
-
name: str
|
|
38
|
-
price: Decimal
|
|
39
|
-
in_stock: bool = True # Default value
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
**Features:**
|
|
43
|
-
- Auto-generated `__init__`, `__repr__`, `__eq__`
|
|
44
|
-
- Type hints required
|
|
45
|
-
- Default values supported
|
|
46
|
-
- Minimal boilerplate
|
|
47
|
-
|
|
48
|
-
### Immutable dataclasses
|
|
49
|
-
|
|
50
|
-
Use `frozen=True` for value objects:
|
|
51
|
-
|
|
52
|
-
```python
|
|
53
|
-
@dataclass(frozen=True)
|
|
54
|
-
class Money:
|
|
55
|
-
"""Immutable value object"""
|
|
56
|
-
amount: Decimal
|
|
57
|
-
currency: str
|
|
58
|
-
|
|
59
|
-
def add(self, other: "Money") -> "Money":
|
|
60
|
-
"""Return new instance instead of mutating"""
|
|
61
|
-
if self.currency != other.currency:
|
|
62
|
-
raise ValueError("Currency mismatch")
|
|
63
|
-
return Money(self.amount + other.amount, self.currency)
|
|
64
|
-
|
|
65
|
-
# Immutable - these fail:
|
|
66
|
-
# money.amount = 100 # FrozenInstanceError
|
|
67
|
-
# money.currency = "EUR" # FrozenInstanceError
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
### field() for Advanced Configuration
|
|
71
|
-
|
|
72
|
-
```python
|
|
73
|
-
from dataclasses import dataclass, field
|
|
74
|
-
from datetime import datetime
|
|
75
|
-
from uuid import uuid4
|
|
76
|
-
|
|
77
|
-
@dataclass
|
|
78
|
-
class Order:
|
|
79
|
-
id: UUID = field(default_factory=uuid4) # Call function for default
|
|
80
|
-
created_at: datetime = field(default_factory=datetime.now)
|
|
81
|
-
items: list[str] = field(default_factory=list) # Mutable default
|
|
82
|
-
_internal: str = field(default="", repr=False) # Exclude from repr
|
|
83
|
-
metadata: dict = field(default_factory=dict, compare=False) # Exclude from equality
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
**field() parameters:**
|
|
87
|
-
- `default`: Static default value
|
|
88
|
-
- `default_factory`: Callable for default (use for mutable defaults)
|
|
89
|
-
- `init`: Include in `__init__` (default True)
|
|
90
|
-
- `repr`: Include in `__repr__` (default True)
|
|
91
|
-
- `compare`: Include in `__eq__` (default True)
|
|
92
|
-
- `hash`: Include in `__hash__` (default None)
|
|
93
|
-
|
|
94
|
-
### Validation with __post_init__
|
|
95
|
-
|
|
96
|
-
```python
|
|
97
|
-
@dataclass
|
|
98
|
-
class User:
|
|
99
|
-
email: str
|
|
100
|
-
age: int
|
|
101
|
-
|
|
102
|
-
def __post_init__(self):
|
|
103
|
-
"""Validate after initialization"""
|
|
104
|
-
if "@" not in self.email:
|
|
105
|
-
raise ValueError("Invalid email address")
|
|
106
|
-
if self.age < 0:
|
|
107
|
-
raise ValueError("Age cannot be negative")
|
|
108
|
-
if self.age < 18:
|
|
109
|
-
raise ValueError("User must be at least 18")
|
|
110
|
-
|
|
111
|
-
# Usage
|
|
112
|
-
user = User("test@example.com", 25) # OK
|
|
113
|
-
# User("invalid", 25) # ValueError: Invalid email address
|
|
114
|
-
# User("test@example.com", 15) # ValueError: User must be at least 18
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### Computed Fields
|
|
118
|
-
|
|
119
|
-
```python
|
|
120
|
-
@dataclass
|
|
121
|
-
class Rectangle:
|
|
122
|
-
width: float
|
|
123
|
-
height: float
|
|
124
|
-
|
|
125
|
-
@property
|
|
126
|
-
def area(self) -> float:
|
|
127
|
-
"""Computed property"""
|
|
128
|
-
return self.width * self.height
|
|
129
|
-
|
|
130
|
-
@property
|
|
131
|
-
def perimeter(self) -> float:
|
|
132
|
-
"""Another computed property"""
|
|
133
|
-
return 2 * (self.width + self.height)
|
|
134
|
-
|
|
135
|
-
rect = Rectangle(width=10, height=5)
|
|
136
|
-
print(rect.area) # 50.0
|
|
137
|
-
print(rect.perimeter) # 30.0
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
### Inheritance
|
|
141
|
-
|
|
142
|
-
```python
|
|
143
|
-
@dataclass
|
|
144
|
-
class Person:
|
|
145
|
-
name: str
|
|
146
|
-
age: int
|
|
147
|
-
|
|
148
|
-
@dataclass
|
|
149
|
-
class Employee(Person):
|
|
150
|
-
"""Inherits name and age fields"""
|
|
151
|
-
employee_id: str
|
|
152
|
-
salary: Decimal
|
|
153
|
-
|
|
154
|
-
emp = Employee(name="Alice", age=30, employee_id="E123", salary=Decimal("75000"))
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
## Pydantic
|
|
158
|
-
|
|
159
|
-
### Basic Models
|
|
160
|
-
|
|
161
|
-
```python
|
|
162
|
-
from pydantic import BaseModel, EmailStr, Field
|
|
163
|
-
from decimal import Decimal
|
|
164
|
-
from uuid import UUID
|
|
165
|
-
|
|
166
|
-
class CreateUserRequest(BaseModel):
|
|
167
|
-
"""Request validation at API boundary"""
|
|
168
|
-
email: EmailStr # Validates email format
|
|
169
|
-
age: int = Field(ge=18, le=120) # Min/max constraints
|
|
170
|
-
name: str = Field(min_length=1, max_length=100)
|
|
171
|
-
|
|
172
|
-
class UserResponse(BaseModel):
|
|
173
|
-
"""Response serialization"""
|
|
174
|
-
id: UUID
|
|
175
|
-
email: str
|
|
176
|
-
name: str
|
|
177
|
-
created_at: str
|
|
178
|
-
|
|
179
|
-
model_config = {"from_attributes": True} # Enable ORM mode
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
**Key features:**
|
|
183
|
-
- Runtime validation
|
|
184
|
-
- Automatic type coercion
|
|
185
|
-
- Rich error messages
|
|
186
|
-
- JSON schema generation
|
|
187
|
-
|
|
188
|
-
### Field Constraints
|
|
189
|
-
|
|
190
|
-
```python
|
|
191
|
-
from pydantic import BaseModel, Field, field_validator
|
|
192
|
-
from typing import Annotated
|
|
193
|
-
|
|
194
|
-
class Product(BaseModel):
|
|
195
|
-
name: str = Field(min_length=1, max_length=100)
|
|
196
|
-
price: Decimal = Field(gt=0, decimal_places=2) # Greater than 0, 2 decimals
|
|
197
|
-
quantity: int = Field(ge=0, le=10000) # Between 0 and 10000
|
|
198
|
-
tags: list[str] = Field(max_length=10) # Max 10 tags
|
|
199
|
-
|
|
200
|
-
# Alternative: Annotated types
|
|
201
|
-
Price = Annotated[Decimal, Field(gt=0, decimal_places=2)]
|
|
202
|
-
|
|
203
|
-
class Product(BaseModel):
|
|
204
|
-
name: str
|
|
205
|
-
price: Price # Reusable constraint
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### Custom Validators
|
|
209
|
-
|
|
210
|
-
```python
|
|
211
|
-
from pydantic import BaseModel, field_validator, model_validator
|
|
212
|
-
|
|
213
|
-
class Order(BaseModel):
|
|
214
|
-
items: list[str]
|
|
215
|
-
discount: float
|
|
216
|
-
|
|
217
|
-
@field_validator("items")
|
|
218
|
-
@classmethod
|
|
219
|
-
def items_not_empty(cls, v):
|
|
220
|
-
"""Field validator - validates single field"""
|
|
221
|
-
if not v:
|
|
222
|
-
raise ValueError("Order must have at least one item")
|
|
223
|
-
return v
|
|
224
|
-
|
|
225
|
-
@field_validator("discount")
|
|
226
|
-
@classmethod
|
|
227
|
-
def discount_range(cls, v):
|
|
228
|
-
"""Validate discount is percentage"""
|
|
229
|
-
if not 0 <= v <= 1:
|
|
230
|
-
raise ValueError("Discount must be between 0 and 1")
|
|
231
|
-
return v
|
|
232
|
-
|
|
233
|
-
@model_validator(mode="after")
|
|
234
|
-
def validate_discount_with_items(self):
|
|
235
|
-
"""Model validator - validates across fields"""
|
|
236
|
-
if self.discount > 0 and len(self.items) < 2:
|
|
237
|
-
raise ValueError("Discount requires at least 2 items")
|
|
238
|
-
return self
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
### Serialization and Deserialization
|
|
242
|
-
|
|
243
|
-
```python
|
|
244
|
-
from pydantic import BaseModel
|
|
245
|
-
from uuid import UUID
|
|
246
|
-
from datetime import datetime
|
|
247
|
-
|
|
248
|
-
class User(BaseModel):
|
|
249
|
-
id: UUID
|
|
250
|
-
email: str
|
|
251
|
-
created_at: datetime
|
|
252
|
-
|
|
253
|
-
# From JSON/dict
|
|
254
|
-
data = {"id": "123e4567-e89b-12d3-a456-426614174000", "email": "test@example.com", "created_at": "2024-01-01T12:00:00"}
|
|
255
|
-
user = User.model_validate(data) # Pydantic v2
|
|
256
|
-
# user = User(**data) # Alternative
|
|
257
|
-
|
|
258
|
-
# To dict
|
|
259
|
-
user_dict = user.model_dump()
|
|
260
|
-
|
|
261
|
-
# To JSON
|
|
262
|
-
user_json = user.model_dump_json()
|
|
263
|
-
|
|
264
|
-
# Exclude fields
|
|
265
|
-
user_dict = user.model_dump(exclude={"created_at"})
|
|
266
|
-
|
|
267
|
-
# Include only specific fields
|
|
268
|
-
user_dict = user.model_dump(include={"id", "email"})
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### ORM Integration
|
|
272
|
-
|
|
273
|
-
```python
|
|
274
|
-
from pydantic import BaseModel
|
|
275
|
-
from sqlalchemy.orm import Session
|
|
276
|
-
|
|
277
|
-
class UserResponse(BaseModel):
|
|
278
|
-
id: int
|
|
279
|
-
email: str
|
|
280
|
-
name: str
|
|
281
|
-
|
|
282
|
-
model_config = {"from_attributes": True} # Enable ORM mode
|
|
283
|
-
|
|
284
|
-
# Convert SQLAlchemy model to Pydantic
|
|
285
|
-
def get_user(session: Session, user_id: int) -> UserResponse:
|
|
286
|
-
db_user = session.get(UserModel, user_id)
|
|
287
|
-
return UserResponse.model_validate(db_user) # Reads from ORM attributes
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
### Nested Models
|
|
291
|
-
|
|
292
|
-
```python
|
|
293
|
-
class Address(BaseModel):
|
|
294
|
-
street: str
|
|
295
|
-
city: str
|
|
296
|
-
country: str
|
|
297
|
-
|
|
298
|
-
class OrderItem(BaseModel):
|
|
299
|
-
product_id: UUID
|
|
300
|
-
quantity: int
|
|
301
|
-
price: Decimal
|
|
302
|
-
|
|
303
|
-
class CreateOrderRequest(BaseModel):
|
|
304
|
-
customer_email: EmailStr
|
|
305
|
-
shipping_address: Address # Nested model
|
|
306
|
-
items: list[OrderItem] # List of nested models
|
|
307
|
-
|
|
308
|
-
@field_validator("items")
|
|
309
|
-
@classmethod
|
|
310
|
-
def validate_items(cls, v):
|
|
311
|
-
if not v:
|
|
312
|
-
raise ValueError("Order must have items")
|
|
313
|
-
return v
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### Generic Models
|
|
317
|
-
|
|
318
|
-
```python
|
|
319
|
-
from pydantic import BaseModel
|
|
320
|
-
from typing import TypeVar, Generic
|
|
321
|
-
|
|
322
|
-
T = TypeVar("T")
|
|
323
|
-
|
|
324
|
-
class PaginatedResponse(BaseModel, Generic[T]):
|
|
325
|
-
"""Generic response wrapper"""
|
|
326
|
-
items: list[T]
|
|
327
|
-
total: int
|
|
328
|
-
page: int
|
|
329
|
-
page_size: int
|
|
330
|
-
|
|
331
|
-
class UserResponse(BaseModel):
|
|
332
|
-
id: int
|
|
333
|
-
email: str
|
|
334
|
-
|
|
335
|
-
# Type-safe pagination
|
|
336
|
-
response = PaginatedResponse[UserResponse](
|
|
337
|
-
items=[UserResponse(id=1, email="test@example.com")],
|
|
338
|
-
total=1,
|
|
339
|
-
page=1,
|
|
340
|
-
page_size=10
|
|
341
|
-
)
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
## Entity Transformations
|
|
345
|
-
|
|
346
|
-
Entities should handle all data transformations.
|
|
347
|
-
|
|
348
|
-
### Pattern: from_request, to_response, from_record, to_record
|
|
349
|
-
|
|
350
|
-
```python
|
|
351
|
-
from dataclasses import dataclass
|
|
352
|
-
from uuid import UUID, uuid4
|
|
353
|
-
from decimal import Decimal
|
|
354
|
-
from pydantic import BaseModel
|
|
355
|
-
|
|
356
|
-
# ===== API Layer (Pydantic) =====
|
|
357
|
-
class CreateProductRequest(BaseModel):
|
|
358
|
-
name: str
|
|
359
|
-
price: Decimal
|
|
360
|
-
|
|
361
|
-
class ProductResponse(BaseModel):
|
|
362
|
-
id: UUID
|
|
363
|
-
name: str
|
|
364
|
-
price: Decimal
|
|
365
|
-
in_stock: bool
|
|
366
|
-
|
|
367
|
-
# ===== Database Layer (SQLAlchemy or similar) =====
|
|
368
|
-
@dataclass
|
|
369
|
-
class ProductRecord:
|
|
370
|
-
"""Database representation"""
|
|
371
|
-
id: UUID
|
|
372
|
-
name: str
|
|
373
|
-
price: Decimal
|
|
374
|
-
in_stock: bool
|
|
375
|
-
|
|
376
|
-
# ===== Domain Layer (dataclass) =====
|
|
377
|
-
@dataclass
|
|
378
|
-
class Product:
|
|
379
|
-
"""Domain entity with transformation methods"""
|
|
380
|
-
id: UUID
|
|
381
|
-
name: str
|
|
382
|
-
price: Decimal
|
|
383
|
-
in_stock: bool = True
|
|
384
|
-
|
|
385
|
-
@classmethod
|
|
386
|
-
def from_request(cls, request: CreateProductRequest) -> "Product":
|
|
387
|
-
"""Transform API request → domain entity"""
|
|
388
|
-
return cls(
|
|
389
|
-
id=uuid4(),
|
|
390
|
-
name=request.name,
|
|
391
|
-
price=request.price,
|
|
392
|
-
in_stock=True
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
def to_response(self) -> ProductResponse:
|
|
396
|
-
"""Transform domain entity → API response"""
|
|
397
|
-
return ProductResponse(
|
|
398
|
-
id=self.id,
|
|
399
|
-
name=self.name,
|
|
400
|
-
price=self.price,
|
|
401
|
-
in_stock=self.in_stock
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
@classmethod
|
|
405
|
-
def from_record(cls, record: ProductRecord) -> "Product":
|
|
406
|
-
"""Transform database record → domain entity"""
|
|
407
|
-
return cls(
|
|
408
|
-
id=record.id,
|
|
409
|
-
name=record.name,
|
|
410
|
-
price=record.price,
|
|
411
|
-
in_stock=record.in_stock
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
def to_record(self) -> ProductRecord:
|
|
415
|
-
"""Transform domain entity → database record"""
|
|
416
|
-
return ProductRecord(
|
|
417
|
-
id=self.id,
|
|
418
|
-
name=self.name,
|
|
419
|
-
price=self.price,
|
|
420
|
-
in_stock=self.in_stock
|
|
421
|
-
)
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
**Usage in router:**
|
|
425
|
-
|
|
426
|
-
```python
|
|
427
|
-
from fastapi import APIRouter
|
|
428
|
-
|
|
429
|
-
router = APIRouter()
|
|
430
|
-
|
|
431
|
-
@router.post("/products", response_model=ProductResponse)
|
|
432
|
-
def create_product(request: CreateProductRequest, service: ProductService):
|
|
433
|
-
"""Router handles HTTP, delegates to service"""
|
|
434
|
-
product = Product.from_request(request) # API → domain
|
|
435
|
-
saved_product = service.create(product) # Domain logic
|
|
436
|
-
return saved_product.to_response() # Domain → API
|
|
437
|
-
```
|
|
438
|
-
|
|
439
|
-
**Usage in repository:**
|
|
440
|
-
|
|
441
|
-
```python
|
|
442
|
-
class ProductRepository:
|
|
443
|
-
def save(self, product: Product) -> Product:
|
|
444
|
-
"""Repository handles persistence"""
|
|
445
|
-
record = product.to_record() # Domain → database
|
|
446
|
-
db.save(record)
|
|
447
|
-
return Product.from_record(record) # Database → domain
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
## Best Practices
|
|
451
|
-
|
|
452
|
-
### 1. Use dataclasses for Domain Layer
|
|
453
|
-
|
|
454
|
-
```python
|
|
455
|
-
@dataclass
|
|
456
|
-
class Order:
|
|
457
|
-
"""Internal domain model"""
|
|
458
|
-
id: UUID
|
|
459
|
-
customer_id: UUID
|
|
460
|
-
total: Decimal
|
|
461
|
-
status: str
|
|
462
|
-
|
|
463
|
-
def submit(self) -> None:
|
|
464
|
-
"""Business logic in entity"""
|
|
465
|
-
if self.status != "draft":
|
|
466
|
-
raise ValueError("Only draft orders can be submitted")
|
|
467
|
-
self.status = "submitted"
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
### 2. Use Pydantic for API Boundaries
|
|
471
|
-
|
|
472
|
-
```python
|
|
473
|
-
class CreateOrderRequest(BaseModel):
|
|
474
|
-
"""Validate incoming requests"""
|
|
475
|
-
customer_id: UUID
|
|
476
|
-
items: list[OrderItemRequest]
|
|
477
|
-
|
|
478
|
-
@field_validator("items")
|
|
479
|
-
@classmethod
|
|
480
|
-
def validate_items(cls, v):
|
|
481
|
-
if not v:
|
|
482
|
-
raise ValueError("Order must have items")
|
|
483
|
-
return v
|
|
484
|
-
|
|
485
|
-
class OrderResponse(BaseModel):
|
|
486
|
-
"""Serialize responses"""
|
|
487
|
-
id: UUID
|
|
488
|
-
total: Decimal
|
|
489
|
-
status: str
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
### 3. Immutable Value Objects
|
|
493
|
-
|
|
494
|
-
```python
|
|
495
|
-
@dataclass(frozen=True)
|
|
496
|
-
class Money:
|
|
497
|
-
"""Value objects are immutable"""
|
|
498
|
-
amount: Decimal
|
|
499
|
-
currency: str
|
|
500
|
-
```
|
|
501
|
-
|
|
502
|
-
### 4. Validate in __post_init__ for dataclasses
|
|
503
|
-
|
|
504
|
-
```python
|
|
505
|
-
@dataclass
|
|
506
|
-
class User:
|
|
507
|
-
email: str
|
|
508
|
-
age: int
|
|
509
|
-
|
|
510
|
-
def __post_init__(self):
|
|
511
|
-
if "@" not in self.email:
|
|
512
|
-
raise ValueError("Invalid email")
|
|
513
|
-
if self.age < 18:
|
|
514
|
-
raise ValueError("Must be 18+")
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
### 5. Keep Transformations in Entities
|
|
518
|
-
|
|
519
|
-
```python
|
|
520
|
-
@dataclass
|
|
521
|
-
class Product:
|
|
522
|
-
@classmethod
|
|
523
|
-
def from_request(cls, req: CreateProductRequest) -> "Product":
|
|
524
|
-
"""Entity handles transformation"""
|
|
525
|
-
return cls(id=uuid4(), name=req.name, price=req.price)
|
|
526
|
-
|
|
527
|
-
def to_response(self) -> ProductResponse:
|
|
528
|
-
"""Entity handles serialization"""
|
|
529
|
-
return ProductResponse(id=self.id, name=self.name, price=self.price)
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
## Anti-Patterns
|
|
533
|
-
|
|
534
|
-
❌ **Using Pydantic everywhere**: Pydantic is for validation, not domain logic
|
|
535
|
-
❌ **Mutable value objects**: Value objects must be immutable
|
|
536
|
-
❌ **Transformation in service layer**: Entities should handle transformations
|
|
537
|
-
❌ **Exposing database models**: Always convert to domain entities
|
|
538
|
-
❌ **Validation in service layer**: Validate at boundaries (Pydantic) or in entities
|
|
539
|
-
|
|
540
|
-
## Summary
|
|
541
|
-
|
|
542
|
-
**dataclasses:**
|
|
543
|
-
- Domain models and internal data
|
|
544
|
-
- Lightweight and fast
|
|
545
|
-
- `frozen=True` for immutability
|
|
546
|
-
- `__post_init__` for validation
|
|
547
|
-
|
|
548
|
-
**Pydantic:**
|
|
549
|
-
- API boundaries (requests/responses)
|
|
550
|
-
- Runtime validation
|
|
551
|
-
- JSON schema generation
|
|
552
|
-
- OpenAPI integration
|
|
553
|
-
|
|
554
|
-
**Entity transformations:**
|
|
555
|
-
- `from_request`: API → domain
|
|
556
|
-
- `to_response`: domain → API
|
|
557
|
-
- `from_record`: database → domain
|
|
558
|
-
- `to_record`: domain → database
|