Django Serializers — Transport Layer¶
serializers.py— це межа між HTTP і доменом. Serializer не знає бізнес-логіки. Він лише: приймає сирі HTTP-дані → валідує → повертаєvalidated_data, або приймає доменний об'єкт → форматує → повертає JSON.
Що таке Serializer архітектурно¶
HTTP Request
│ {"region_name": "Kyiv", "severity": "high"}
▼
InputSerializer.is_valid()
│ validated_data = {"region_name": "Kyiv", "severity": "high"}
▼
Service
│ FloodEvent object
▼
OutputSerializer(event)
│ {"id": 1, "region_name": "Kyiv", ...}
▼
HTTP Response
Serializer — це охоронець на вході та форматувальник на виході.
| Клас | Напрям | Роль |
|---|---|---|
InputSerializer |
HTTP → Domain | Валідація типів, форматів, cross-field правил |
OutputSerializer |
Domain → HTTP | Форматування відповіді, вибір полів |
Проблема: ModelSerializer антипатерн¶
ModelSerializer — це зручний клас, що автоматично генерує поля з моделі.
# ПОГАНО — ModelSerializer тісно прив'язаний до схеми БД
class FloodEventSerializer(serializers.ModelSerializer):
class Meta:
model = FloodEvent
fields = '__all__'
Що ламається:
- Зміна моделі → автоматична зміна API (несподівано для клієнтів)
- Один серіалайзер для читання І запису → неявний контракт
- Бізнес-поля (наприклад, created_by як ForeignKey) протікають у відповідь як ID
- Логіка в serializer.save() — бізнес-логіка у transport layer
Коли ModelSerializer прийнятний¶
ModelSerializer підходить для простих CRUD-ендпоінтів де модель і API збігаються. Для складних workflow — явні InputSerializer і OutputSerializer.
InputSerializer — Валідація вхідних даних¶
Основа: serializers.Serializer¶
# apps/flood/serializers.py
class FloodEventInputSerializer(serializers.Serializer):
region_name = serializers.CharField(max_length=200)
severity_level = serializers.ChoiceField(choices=['low', 'medium', 'high'])
detected_at = serializers.DateTimeField()
Базовий Serializer (не ModelSerializer) дає явний контракт:
- Поля не залежать від схеми БД
- Зміна моделі не змінює API автоматично
- Одразу зрозуміло що очікується від клієнта
Використання у View¶
# apps/flood/views.py
class FloodEventCreateAPIView(APIView):
def post(self, request):
serializer = FloodEventInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# validated_data — гарантовано чисті, перевірені дані
event = flood_event_create(**serializer.validated_data, created_by=request.user)
output = FloodEventOutputSerializer(event)
return Response(output.data, status=status.HTTP_201_CREATED)
raise_exception=True — якщо валідація не пройшла, DRF автоматично повертає HTTP 400 з описом помилок. Без зайвого if not serializer.is_valid(): return Response(...).
OutputSerializer — Форматування відповіді¶
class FloodEventOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
region_name = serializers.CharField()
severity_level = serializers.CharField()
detected_at = serializers.DateTimeField()
created_by = serializers.EmailField(source='created_by.email')
created_at = serializers.DateTimeField()
source='created_by.email' — замість того щоб повертати created_by: 42 (ID), повертаємо email. Клієнт отримує що потрібно, не внутрішні деталі БД.
Список об'єктів¶
# many=True — серіалізація QuerySet або list
events = flood_events_get_list()
output = FloodEventOutputSerializer(events, many=True)
return Response(output.data)
Вкладені об'єкти¶
class RegionOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
display_name = serializers.CharField()
class FloodEventOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
region = RegionOutputSerializer() # вкладений об'єкт
severity_level = serializers.CharField()
Якщо вкладені серіалайзери провокують N+1 запити — переходь на pure Python функцію (див. розділ нижче).
Поля серіалайзера — довідник¶
Базові типи¶
| Поле | Python тип | Приклад |
|---|---|---|
CharField |
str |
назви, описи |
IntegerField |
int |
ID, кількості |
FloatField |
float |
координати, числа |
BooleanField |
bool |
прапорці |
DateTimeField |
datetime |
мітки часу |
DateField |
date |
дати без часу |
EmailField |
str (з валідацією email) |
адреси |
URLField |
str (з валідацією URL) |
посилання |
UUIDField |
UUID |
унікальні ідентифікатори |
Вибір зі списку¶
severity = serializers.ChoiceField(choices=['low', 'medium', 'high'])
# Або через константи з моделі
severity = serializers.ChoiceField(choices=FloodEvent.SEVERITY_CHOICES)
Опціональні та дефолтні поля¶
# Поле не обов'язкове — якщо не передано, буде None
description = serializers.CharField(required=False, allow_null=True)
# Поле не обов'язкове — якщо не передано, буде дефолтне значення
page_size = serializers.IntegerField(required=False, default=20)
Тільки для читання / тільки для запису¶
# read_only — поле є у відповіді, але не приймається у запиті
id = serializers.IntegerField(read_only=True)
created_at = serializers.DateTimeField(read_only=True)
# write_only — поле приймається у запиті, але не повертається у відповіді
password = serializers.CharField(write_only=True)
SerializerMethodField — обчислювані поля¶
class FloodEventOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
severity_label = serializers.SerializerMethodField()
def get_severity_label(self, obj) -> str:
labels = {'low': 'Низький', 'medium': 'Середній', 'high': 'Високий'}
return labels.get(obj.severity_level, obj.severity_level)
validate() — Крос-польова валідація¶
validate(self, data) виконується після того як всі окремі поля перевірено. Це точка для:
- валідації залежностей між полями
- автогенерації значень (slug, token)
- трансформації даних перед validated_data
class EventRangeInputSerializer(serializers.Serializer):
start_date = serializers.DateField()
end_date = serializers.DateField()
def validate(self, data: dict) -> dict:
if data['end_date'] < data['start_date']:
raise serializers.ValidationError(
"end_date не може бути раніше ніж start_date"
)
return data
Автогенерація поля в validate()¶
class FloodEventInputSerializer(serializers.Serializer):
region_name = serializers.CharField()
slug = serializers.SlugField(read_only=True) # генерується сервером
def validate(self, data: dict) -> dict:
data['slug'] = slugify(data['region_name'])
return data
read_only=Trueобов'язково — інакше DRF вимагатимеslugу запиті і впаде до викликуvalidate().
validate_<field>() — Валідація одного поля¶
class UserRegisterInputSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True)
def validate_email(self, value: str) -> str:
if User.objects.filter(email=value).exists():
raise serializers.ValidationError("Email вже зареєстрований")
return value.lower() # трансформація — нормалізуємо до нижнього регістру
def validate_password(self, value: str) -> str:
if len(value) < 8:
raise serializers.ValidationError("Мінімум 8 символів")
return value
Порядок виконання валідації¶
1. Парсинг тіла запиту (JSON → Python dict)
│
▼
2. Перевірка кожного поля окремо
CharField(max_length=200)? → перевіряє
ChoiceField(choices=[...])? → перевіряє
│
▼
3. validate_<field>() для кожного поля
validate_email() → перевіряє унікальність
│
▼
4. validate() → крос-польова валідація
end_date > start_date?
│
▼
5. validated_data — гарантовано чисті дані
│
▼
6. View передає validated_data у Service
Якщо будь-який крок кидає ValidationError — подальші кроки не виконуються, клієнт отримує HTTP 400.
Маппінг помилок домену на HTTP¶
Service викликає full_clean() і кидає django.core.exceptions.ValidationError.
DRF очікує rest_framework.exceptions.ValidationError.
Без налаштування Django поверне HTML-сторінку 500 замість JSON 400.
# shared/exceptions/handlers.py
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework.views import exception_handler
from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.serializers import as_serializer_error
def custom_exception_handler(exc, context):
if isinstance(exc, DjangoValidationError):
exc = DRFValidationError(detail=as_serializer_error(exc))
return exception_handler(exc, context)
# settings.py
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'shared.exceptions.handlers.custom_exception_handler',
}
Після цього: full_clean() у будь-якому Service → автоматично JSON 400.
Складна серіалізація — Pure Python функція¶
Коли OutputSerializer провокує N+1 або структура відповіді занадто складна:
# apps/flood/serializers.py (або selectors.py)
def flood_feed_serialize(events) -> list[dict]:
"""
Замість вкладеного OutputSerializer — Python функція
після одного оптимізованого SQL-запиту.
"""
region_cache: dict[int, str] = {}
result = []
for event in events:
region_id = event.region_id
if region_id not in region_cache:
region_cache[region_id] = event.region.display_name # вже в select_related
result.append({
'id': event.id,
'region': region_cache[region_id],
'severity': event.severity_level,
'detected_at': event.detected_at.isoformat(),
'image_count': event.image_count, # з annotate()
})
return result
# views.py
events = flood_events_get_list() # selector: select_related + annotate
data = flood_feed_serialize(events)
return Response(data)
Перевага: повний контроль над SQL footprint. Selector робить один оптимізований запит, Python будує структуру — без прихованих запитів від вкладених серіалайзерів.
Повний приклад: CRUD endpoint¶
# apps/flood/serializers.py
class FloodEventInputSerializer(serializers.Serializer):
region_name = serializers.CharField(max_length=200)
severity_level = serializers.ChoiceField(choices=['low', 'medium', 'high'])
detected_at = serializers.DateTimeField()
def validate_region_name(self, value: str) -> str:
return value.strip().title()
class FloodEventUpdateInputSerializer(serializers.Serializer):
severity_level = serializers.ChoiceField(choices=['low', 'medium', 'high'])
detected_at = serializers.DateTimeField(required=False)
class FloodEventOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
region_name = serializers.CharField()
severity_level = serializers.CharField()
detected_at = serializers.DateTimeField()
created_by = serializers.EmailField(source='created_by.email')
created_at = serializers.DateTimeField()
# apps/flood/views.py
class FloodEventCreateAPIView(APIView):
def post(self, request):
serializer = FloodEventInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
event = flood_event_create(
**serializer.validated_data,
created_by=request.user,
)
return Response(
FloodEventOutputSerializer(event).data,
status=status.HTTP_201_CREATED,
)
class FloodEventDetailAPIView(APIView):
def get(self, request, event_id: int):
event = flood_event_get_by_id(event_id=event_id)
return Response(FloodEventOutputSerializer(event).data)
def patch(self, request, event_id: int):
event = flood_event_get_by_id(event_id=event_id)
serializer = FloodEventUpdateInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
event = flood_event_update(event=event, **serializer.validated_data)
return Response(FloodEventOutputSerializer(event).data)
Типові помилки¶
Логіка в save()¶
# ПОГАНО — бізнес-логіка у transport layer
class FloodEventSerializer(serializers.ModelSerializer):
def save(self):
instance = super().save()
send_notification_email(instance) # side effect у серіалайзері
return instance
# ДОБРЕ — логіка у Service
event = flood_event_create(**serializer.validated_data)
# Service відповідає за notification через on_commit + Celery
Прямий queryset у серіалайзері¶
# ПОГАНО — серіалайзер робить запити до БД
class FloodEventOutputSerializer(serializers.Serializer):
related_events = serializers.SerializerMethodField()
def get_related_events(self, obj):
return FloodEvent.objects.filter(region=obj.region).count() # N+1
# ДОБРЕ — annotate у Selector, передати в серіалайзер
class FloodEventOutputSerializer(serializers.Serializer):
related_count = serializers.IntegerField() # з annotate() у selector
Один серіалайзер для запису і читання¶
# ПОГАНО — невиразний контракт
class FloodEventSerializer(serializers.ModelSerializer):
class Meta:
model = FloodEvent
fields = ['id', 'region_name', 'severity_level', 'created_by']
# id — тільки для читання, created_by — тільки для читання,
# але це не очевидно з коду
# ДОБРЕ — явні окремі класи
class FloodEventInputSerializer(serializers.Serializer):
region_name = serializers.CharField()
severity_level = serializers.ChoiceField(choices=[...])
class FloodEventOutputSerializer(serializers.Serializer):
id = serializers.IntegerField()
region_name = serializers.CharField()
severity_level = serializers.CharField()
created_by = serializers.EmailField(source='created_by.email')
Таблиця: Serializer не повинен¶
| Дія | Чому ні |
|---|---|
Зберігати у БД (save() з логікою) |
Це Service |
Робити ORM-запити у SerializerMethodField |
N+1, це Selector |
| Містити бізнес-правила | Це Service або full_clean() на моделі |
Знати про transaction.atomic |
Це Service |
| Знати про Celery tasks | Це Service |
Повертати Response |
Це View |
Швидка шпаргалка¶
# serializers.py — шаблон
class <Model>InputSerializer(serializers.Serializer):
"""Контракт вхідних даних. Незалежний від моделі."""
<field_1> = serializers.<FieldType>(...)
<field_2> = serializers.<FieldType>(...)
def validate_<field_1>(self, value):
# Валідація одного поля
return value
def validate(self, data: dict) -> dict:
# Крос-польова валідація або трансформація
return data
class <Model>OutputSerializer(serializers.Serializer):
"""Контракт вихідних даних. Що клієнт бачить у відповіді."""
id = serializers.IntegerField()
<field_1> = serializers.<FieldType>()
<computed> = serializers.SerializerMethodField()
def get_<computed>(self, obj):
return ...
Зв'язок з рештою документації¶
| Тема | Файл |
|---|---|
Services (що робити з validated_data) |
DJANGO_SERVICES.md |
| Selectors (як підготувати дані для OutputSerializer) | DJANGO_SELECTORS.md |
| Services + Selectors + Serializers (повна картина) | DJANGO_SERVICES_SELECTORS.md |
| Views: як поєднати все разом | Django_Views.md |