Практикум — модели данных и маппинг 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:
from decimal import Decimal
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
class ProductCreate(BaseModel):
sku: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=200)
price: Decimal = Field(gt=0)
stock_available: int = Field(ge=0, alias="stockAvailable")
model_config = ConfigDict(populate_by_name=True)
class ProductResponse(BaseModel):
id: str
sku: str
name: str
price: Decimal
stock_available: int = Field(alias="stockAvailable")
created_at: datetime = Field(alias="createdAt")
model_config = ConfigDict(populate_by_name=True, by_alias=True)
Маппинг 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:
public sealed record OrderLineDto(
string ProductId,
int Quantity,
decimal UnitPrice,
string? ReservationId);
public sealed record OrderResponse(
string Id,
string Status,
IReadOnlyList<OrderLineDto> Lines,
decimal Total,
DateTimeOffset CreatedAt);
public sealed record CreateOrderRequest(
IReadOnlyList<CreateOrderLineRequest> Lines);
public sealed record CreateOrderLineRequest(string ProductId, int Quantity);
Маппинг домен → 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.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Два сервиса OrderDesk: каталог на Python и заказы на C#, границы ответственности, потоки REST и WebSocket. Ресурсы OrderDesk, таблица методов HTTP, коды ответов и фрагмент OpenAPI для catalog-api и orders-api. FastAPI, SQLite, эндпоинты товаров и резервирования, Pydantic и проверка через uvicorn. ASP.NET Core 8, Minimal API, HttpClient к catalog-api, SQLite и создание заказа с резервом. JWT, API-ключ между сервисами, HTTPS, таймауты, идемпотентность и заголовок X-Request-Id в OrderDesk. Протокол JSON-сообщений, hub в ASP.NET Core, heartbeat и подписка клиента на статусы OrderDesk. Коллекция Postman, переменные окружения и сквозной сценарий OrderDesk — товар, заказ, WebSocket.Практикум — сценарий и архитектура OrderDesk
Практикум — проектирование контракта API
Практикум — сервис каталога на Python
Практикум — сервис заказов на C#
Практикум — безопасность и устойчивость
Практикум — WebSocket и события заказов
Практикум — проверка в Postman