Перейти к основному содержимому

Практикум — модели данных и маппинг DTO

Разработчику

Практикум, шаг 3 из 8. Контракт из шага 2 не равен таблицам в БД. Разделяем домен, персистентность и DTO. Теория — маппинг в проектировании API.


Три слоя данных

┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ HTTP JSON │ ←→ │ DTO (API) │ ←→ │ Домен / │
│ (контракт) │ │ Pydantic / │ │ ORM row │
│ │ │ C# record │ │ │
└─────────────┘ └──────────────┘ └─────────────┘
Слойcatalog-apiorders-api
Контракт (JSON)ProductResponse, ReservationCreateOrderResponse, OrderLineDto
ДоменProduct, ReservationOrder, OrderLine
БДSQLite products, reservationsSQLite orders, order_lines

Поля вроде password_hash или внутреннего row_version никогда не попадают в JSON.


catalog-api — сущности

Product (домен / ORM)

ПолеТипВ API
idstr (UUID prefix)id
skustrsku
namestrname
price_centsintprice (decimal в JSON)
stock_availableintstockAvailable
created_atdatetimecreatedAt

Reservation

ПолеВ API
idreservationId
product_idproductId
quantityquantity
order_refтолько в запросе, в ответе опционально
expires_atexpiresAt

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
Idid
Statusstatus (draft, reserved, confirmed, cancelled, failed)
UserIdскрыто от клиента
Totaltotal
CreatedAtcreatedAt

OrderLine

ВнутриJSON
ProductIdproductId
Quantityquantity
UnitPriceunitPrice
ReservationIdreservationId

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
Pythonalias + 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 на запись и чтениеЛишние поля в inputProductCreate / ProductResponse
float для денегОшибки округленияDecimal / price_cents int

Таблица соответствия статусов заказа

Домен (OrderStatus)JSON statusКогда выставляется
DraftdraftЧерновик (опционально)
ReservedreservedПосле успешных резервов
ConfirmedconfirmedPOST …/confirm
CancelledcancelledPOST …/cancel + снятие резерва
FailedfailedКаталог вернул ошибку, откат

Следующий шаг

Реализуем catalog-api на Python: шаг 4.

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").