Asif Iqbal_
← Blog

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:

  1. 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.
  2. DAO validation. AddPartnerDao(data=request.data) either produces clean, typed attributes.data or the view returns bad_request and stops right there. Nothing downstream ever sees unvalidated input.
  3. Business logic. Plain Django ORM calls in the view for straightforward CRUD, or a call into service.py (procedural helpers) or a custom Manager subclass (queryset-shaping logic, like the per-role visibility filters in homescape/manager.py) when the logic is reused across views or genuinely belongs on the queryset.
  4. 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.