DTOs (Data Transfer Objects)¶
What is a DTO?¶
A DTO (Data Transfer Object) is a simple object that carries data between layers.
It has no business logic, no ORM methods - just data.
Why Not Return Django Models?¶
The Problem: Returning Models¶
Issues:
- ORM Coupling - Callers depend on Django ORM
- Lazy Loading - Can trigger N+1 queries unexpectedly
- Mutation Risk - Callers can call
.save(),.delete() - Unclear Contract - What fields are actually loaded?
posts = get_posts()
for post in posts:
# Surprise! Each access triggers a query (N+1)
print(post.author.name)
# Surprise! Caller can mutate data
post.status = 'deleted'
post.save()
The Solution: Returning DTOs¶
Benefits:
- No ORM - Pure Python dataclass
- No Lazy Loading - Data is there or it's UNSET
- Immutable - No
.save(),.delete()methods - Clear Contract - Type hints show exactly what's available
posts = get_posts()
for post in posts:
print(post.author.name) # Works if expanded, UNSET if not
# post.save() ← Doesn't exist, can't accidentally mutate
DTO Structure in FC Selector¶
from dataclasses import dataclass
from typing import Optional, List
from fc_selector.core.dtos import BaseODataDTO, UNSET
@dataclass
class BlogPostDTO(BaseODataDTO):
id: int = UNSET
title: str = UNSET
content: str = UNSET
status: str = UNSET
author: Optional[AuthorDTO] = UNSET
categories: Optional[List[CategoryDTO]] = UNSET
The UNSET Sentinel¶
UNSET is a special value that means "this field was not selected".
Why Not Use None?¶
# Problem with None
dto.content = None
# Does this mean:
# A) Content was not selected ($select didn't include it)
# B) Content is actually null in the database
#
# You can't tell!
UNSET Makes It Clear¶
from fc_selector.django.selector import QueryBuilder
# UNSET = field was not selected
# None = field was selected, value is null
query = QueryBuilder().select("id", "title")
dto = selector.get_one(query)
dto.id # 1 (selected, has value)
dto.title # "My Post" (selected, has value)
dto.content # UNSET (not selected - we didn't ask for it)
dto.excerpt # UNSET (not selected)
query2 = QueryBuilder().select("id", "excerpt")
dto2 = selector.get_one(query2)
dto2.excerpt # None (selected, but value is null in DB)
Checking UNSET¶
from fc_selector.core.dtos import UNSET
if dto.content is not UNSET:
print(dto.content)
else:
print("Content was not loaded")
DTOs and Serialization¶
When serializing to JSON, UNSET fields are omitted:
# Query: $select=id,title
dto = BlogPostDTO(id=1, title="Hello", content=UNSET, status=UNSET)
# Serialized JSON:
{
"id": 1,
"title": "Hello"
// content and status not included - they're UNSET
}
Nested DTOs¶
For relationships, use Optional[DTOType]:
@dataclass
class AuthorDTO(BaseODataDTO):
id: int = UNSET
name: str = UNSET
email: str = UNSET
@dataclass
class BlogPostDTO(BaseODataDTO):
id: int = UNSET
title: str = UNSET
author: Optional[AuthorDTO] = UNSET # ForeignKey
categories: Optional[List[CategoryDTO]] = UNSET # ManyToMany
When expanded:
When not expanded:
Summary¶
| Aspect | Django Model | DTO |
|---|---|---|
| ORM methods | .save(), .delete(), etc. |
None |
| Lazy loading | Yes (N+1 risk) | No |
| Mutation | Possible | Not possible |
| Unselected fields | Deferred (confusing) | UNSET (explicit) |
| Type safety | Runtime | Compile-time hints |
| Serialization | Complex | Simple |
DTOs give you: - Clear contracts - What you see is what you get - No surprises - No lazy loading, no mutations - Clean separation - Persistence layer stays hidden