Giving Django's Serializer a Split Personality: DAO In, DTO Out
A naming convention that turned one overloaded DRF serializer into two single-purpose ones, and why the split held up across a multi-tenant Django API serving nine different role types.
Django REST Framework gives you one class, Serializer, and asks you to use
it for two jobs that pull in opposite directions: validating what came in
over the wire, and shaping what goes back out. On a small API that's fine.
On a multi-tenant solar-operations API we built, it wasn't. A single
"account" endpoint had to serve nine different role types (admin, cluster
admin, sales partner, channel partner, technician, discom user, and more),
and overloading one serializer per resource stopped being fine pretty fast.
The fix wasn't a framework. It was a naming convention we applied
everywhere: every serializers module splits into a dao.py and a dto.py.
Two files, two directions
dao.py holds request-shape serializers. Plain serializers.Serializer
subclasses, no Meta.model. Their only job is validating and coercing
whatever the client sent.
# account/v1/serializers/dao.py
class AddPartnerDao(serializers.Serializer):
name = serializers.CharField(max_length=100)
phone_number = serializers.CharField(max_length=14)
pan_card = serializers.CharField(max_length=255, allow_blank=True)
is_sales = serializers.BooleanField()
is_epc = serializers.BooleanField()
auto_assign_lead = serializers.BooleanField()dto.py holds response-shape serializers. ModelSerializer subclasses tied
to a Meta.model, sometimes composing other DTOs and adding computed fields
through SerializerMethodField.
# account/v1/serializers/dto.py
class PartnerAccountDto(serializers.ModelSerializer):
cluster = ClusterDto()
cluster_list = serializers.SerializerMethodField()
class Meta:
model = Partner
fields = ('name', 'uuid', 'phone_number', 'cluster', 'cluster_list',
'is_sales', 'is_epc', 'company_name', 'address', 'is_business')
def get_cluster_list(self, obj):
return ClusterDto(self.context[obj.id]['cluster_list'], many=True).data \
if obj.id in self.context else []The name does all the work here. A Dao class never touches a Meta.model.
A Dto class never shows up on the input side of a view. Nobody has to
read the class body to know which direction the data is flowing. The
suffix tells you before you've read a single field.
The failure mode this prevents
The obvious alternative is one serializer doing double duty: required=True
fields validate the request, and the same class serializes the response by
reusing whatever landed on the model. That works right up until the input
and output shapes diverge, which they always do eventually.
AddPartnerDao takes a flat is_business: bool on create. PartnerAccountDto
returns a nested cluster object built from a different model (Cluster),
populated through a SerializerMethodField that pulls from self.context
rather than the instance's own fields, because cluster membership is a
many-to-many relationship resolved separately for permission reasons. A
single serializer covering both shapes would need required=False on half
its fields just to survive being reused for output.
That's the trap. Loosening required=True to required=False so a class
can double as a response shape quietly disables the validation you added it
for in the first place. We'd actually run into that exact failure before we
split the pattern out: a field walked back to required=False so a class
could be reused, and a validation gap opened up that nobody noticed until
bad data was already sitting in the database.
Splitting DAO from DTO makes that mistake structurally impossible, because there's no shared class left for the loosening to happen in.
Where DAO/DTO sits in the request lifecycle
The convention only pays off because the rest of the app is consistent about where each piece lives. Here's a typical view:
class PartnerCrudView(APIView):
@auth_required('amplus')
def post(self, request):
attributes = AddPartnerDao(data=request.data)
if not attributes.is_valid():
return bad_request(attributes.errors)
partner = Partner.objects.create(**attributes.data)
response = {'data': PartnerAccountDto(partner).data}
return success(response, "partner created successfully", True)Four stages, always in this order:
auth_required(*roles). A decorator that resolves the session token to a model instance (Amplus,Partner,ClusterAdmin, and so on) and rejects the request before any business logic runs if the role isn't on the allowed list.- DAO validation.
AddPartnerDao(data=request.data)either produces clean, typedattributes.dataor the view returnsbad_requestand stops right there. Nothing downstream ever sees unvalidated input. - Business logic. Plain Django ORM calls in the view for
straightforward CRUD, or a call into
service.py(procedural helpers) or a customManagersubclass (queryset-shaping logic, like the per-role visibility filters inhomescape/manager.py) when the logic is reused across views or genuinely belongs on the queryset. - DTO serialization. The model instance goes back out through a DTO. Never the raw model, never the DAO.
Every response, success or failure, comes back through one of a handful of
helpers in middleware/response.py: success, bad_request, unauthorized,
forbidden. So every client, regardless of endpoint, parses { payload, message, status } and nothing else. Combined with the DAO/DTO split, a
view's shape becomes fully predictable before you've even read its body:
auth check, DAO, logic, DTO, response helper. New engineers learn the
pattern once, then can guess the structure of an endpoint they've never
opened.
The nine-role fan-out this was built to survive
A lighter convention wouldn't have held up here, because this API doesn't
serve one client type. Manager subclasses like LeadPropertyManager
filter the same underlying queryset differently depending on who's asking:
def get_visible(self, user):
if isinstance(user, Partner):
return self._parter_filter(user, ...)
if isinstance(user, ClusterAdmin):
return self._cluster_admin_filter(user)
if isinstance(user, Associate):
return self._associate_filter(user)
if isinstance(user, Amplus):
return self._amplus_filter(user)
if isinstance(user, DiscomUser):
return self._discom_filter(user)A sales partner sees only the leads assigned to them. A cluster admin sees everything in their cluster. A discom user sees only properties matching their organization. Each of those callers still hits the same DTO on the way out, because the response shape doesn't change with the role. Only the queryset does.
That separation stays clean for one reason: visibility rules live in the
Manager/service layer, and response shape lives in the DTO, full stop.
Neither one has to carry the other's concerns too. Collapse DAO and DTO
back into a single serializer and you'd have every role's input quirks and
every role's output shape fighting for space in the same class.
The pattern doesn't need a library, a metaclass, or a base class enforcing it. Just a filename convention applied without exception across every resource in the app, so the convention itself ends up being the documentation.