Практикум — модели данных и маппинг DTO
Практикум, шаг 3 из 8. Контракт из шага 2 не равен таблицам в БД. Разделяем домен, персистентность и DTO. Теория — маппинг в проектировании API.
Три слоя данных
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ HTTP JSON │ ←→ │ DTO (API) │ ←→ │ Домен / │
│ (контракт) │ │ Pydantic / │ │ ORM row │
│ │ │ C# record │ │ │
└─────────────┘ └──────────────┘ └─────────────┘
| Слой | catalog-api | orders-api |
|---|---|---|
| Контракт (JSON) | ProductResponse, ReservationCreate | OrderResponse, OrderLineDto |
| Домен | Product, Reservation | Order, OrderLine |
| БД | SQLite products, reservations | SQLite orders, order_lines |
Поля вроде password_hash или внутреннего row_version никогда не попадают в JSON.
catalog-api — сущности
Product (домен / ORM)
| Поле | Тип | В API |
|---|---|---|
id | str (UUID prefix) | id |
sku | str | sku |
name | str | name |
price_cents | int | price (decimal в JSON) |
stock_available | int | stockAvailable |
created_at | datetime | createdAt |
Reservation
| Поле | В API |
|---|---|
id | reservationId |
product_id | productId |
quantity | quantity |
order_ref | только в запросе, в ответе опционально |
expires_at | expiresAt |
Pydantic — вход и выход (Python)
schemas.py:
Код ITЗагрузка примера кода…
Маппинг ORM → ответ:
def to_product_response(row: "ProductRow") -> ProductResponse:
return ProductResponse(
id=row.id,
sku=row.sku,
name=row.name,
price=Decimal(row.price_cents) / 100,
stockAvailable=row.stock_available,
createdAt=row.created_at,
)
Цена в БД хранится в копейках/центах (int), в API — decimal — типичный приём против ошибок float.
orders-api — сущности
Order
| Поле C# | JSON |
|---|---|
Id | id |
Status | status (draft, reserved, confirmed, cancelled, failed) |
UserId | скрыто от клиента |
Total | total |
CreatedAt | createdAt |
OrderLine
| Внутри | JSON |
|---|---|
ProductId | productId |
Quantity | quantity |
UnitPrice | unitPrice |
ReservationId | reservationId |
C# — DTO и маппер
Dtos/OrderDtos.cs:
Код ITЗагрузка примера кода…
Маппинг домен → API (явный, без AutoMapper на старте):
public static class OrderMapper
{
public static OrderResponse ToResponse(Order order) =>
new(
order.Id,
order.Status.ToString().ToLowerInvariant(),
order.Lines.Select(l => new OrderLineDto(
l.ProductId, l.Quantity, l.UnitPrice, l.ReservationId)).ToList(),
order.Total,
order.CreatedAt);
}
При приёме CreateOrderRequest игнорируем любые лишние поля (reservationId от клиента) — цены и резерв выставляет только сервер.
Соглашения JSON между Python и C#
| Тема | Соглашение |
|---|---|
| Имена полей | camelCase в wire-формате |
| C# сериализация | JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase |
| Python | alias + by_alias=True в Pydantic |
| Даты | DateTimeOffset / timezone-aware UTC |
| Идентификаторы | строки prod_*, ord_*, res_* (читаемость в логах) |
| Деньги | decimal в C#, Decimal в Python |
Маппинг ответа каталога внутри orders-api
При POST /orders orders-api вызывает каталог и собирает строки заказа:
public sealed record CatalogProductDto(
string Id,
string Sku,
string Name,
decimal Price,
int StockAvailable);
public sealed record CatalogReservationDto(
string ReservationId,
string ProductId,
int Quantity,
DateTimeOffset ExpiresAt);
CatalogClient десериализует JSON каталога в эти record и передаёт в доменный сервис OrderService.CreateAsync. Домен не знает про Pydantic или FastAPI.
Эволюция контракта
- Новые опциональные поля в ответе — допустимы без смены версии.
- Переименование — только через
/api/v2или заголовокAccept-Version: 2. - Удаление поля — сначала
deprecated: trueв OpenAPI, затем мажорная версия.
Полные схемы резерва (Python)
schemas.py — дополнение к шагу 3:
class ReservationCreate(BaseModel):
product_id: str = Field(alias="productId")
quantity: int = Field(gt=0)
order_ref: str = Field(alias="orderRef")
model_config = ConfigDict(populate_by_name=True)
class ReservationResponse(BaseModel):
reservation_id: str = Field(alias="reservationId")
product_id: str = Field(alias="productId")
quantity: int
expires_at: datetime = Field(alias="expiresAt")
model_config = ConfigDict(populate_by_name=True, by_alias=True)
Антипаттерны маппинга
| Антипаттерн | Риск | Исправление |
|---|---|---|
| Сериализовать ORM-модель напрямую в JSON | Утечка price_cents, внутренних FK | Явный ProductResponse |
Принять unitPrice от клиента в POST /orders | Подмена цены | Цену брать из GET каталога на сервере |
| Один DTO на запись и чтение | Лишние поля в input | ProductCreate / ProductResponse |
float для денег | Ошибки округления | Decimal / price_cents int |
Таблица соответствия статусов заказа
Домен (OrderStatus) | JSON status | Когда выставляется |
|---|---|---|
Draft | draft | Черновик (опционально) |
Reserved | reserved | После успешных резервов |
Confirmed | confirmed | POST …/confirm |
Cancelled | cancelled | POST …/cancel + снятие резерва |
Failed | failed | Каталог вернул ошибку, откат |
Следующий шаг
Реализуем catalog-api на Python: шаг 4.