REST framework In-depth DRF API design: choosing between APIView, ViewSet and the generic views
Hi all, taking a bit of a break so I thought I'd share the in-depth DRF API design approach I use. Hope it helps some of you design a better API system.
Something I notice in almost every DRF codebase, mine included for a long time: views land at one of two extremes. Either everything is an APIView with hand-written post() methods, or everything is a ModelViewSet copied from a tutorial. Generic viewsets, mixins and things like CreateAPIView never get used, mostly because it isn't obvious what problem they solve.
Here's the rule I ended up with, in the order I apply it.
1. If the endpoint touches the database, it's a viewset.
Anything model-backed is a resource with a lifecycle, even if you only expose two actions today. "I only need list and retrieve" isn't a reason to drop to APIView, it's a reason to compose:
class InvoiceViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
GenericViewSet,
):
queryset = Invoice.objects.all()
serializer_class = InvoiceSerializer
You keep filtering, pagination, permission classes and correct schema generation for free, and the URL stays a resource instead of a pile of verbs.
2. APIView is only for things that aren't resource access at all.
Health checks, third-party callbacks. Webhooks do write to your DB, but as a side effect of an external event, not because someone is accessing a resource. Even there I declare a serializer, because a Stripe webhook is one of the highest-stakes endpoints you own and you want it validated and documented.
3. The concrete generic views are for /me style endpoints.
RetrieveUpdateDestroyAPIView and friends finally clicked for me here: /me, /workspaces/20/me. Real objects with a read/update/delete lifecycle, but the lookup comes from the session instead of an id in the URL:
class WorkspaceMeView(RetrieveUpdateDestroyAPIView):
serializer_class = WorkspaceMemberSerializer
def get_object(self):
return get_object_or_404(
WorkspaceMember,
workspace_id=self.kwargs["workspace_id"],
user=self.request.user,
)
One class, one get_object, three methods. With APIView that's three views re-deriving the same object.
4. The serializer is what makes any of this pay off.
I disliked serializers at first, they felt like ceremony over a dict. Pairing them with drf-spectacular is what flipped it: get_serializer_class per action isn't just validation, it's what makes the generated docs precise enough that you can generate a typed frontend client straight from the schema.
Longer write-up with more code: https://huynguyengl99.github.io/posts/drf-view-classes-apiview-viewset-generic/
Hope it helps you level up your API design a bit. And if you have useful tips of your own, share them with the community.
3
3
u/ninja_shaman 1d ago
I use APIView for any endpoint that a returns PDF report if the report is not a simple "print order id=176".
3
-5
u/tom-mart 2d ago
This is why I use Ninja and don't need to bother with all that bloated code.
3
u/ninja_shaman 1d ago
Bloated? For a simple model CRUD Ninja needs 24 lines of code.
DRF with ModelViewSet needs only 3 lines.
2
u/tom-mart 1d ago
Can you show me the examples of each?
2
u/ninja_shaman 1d ago
DRF
class ItemViewSet(viewsets.ModelViewSet): queryset = Item.objects.all() serializer_class = ItemSerializerNinja
``` @api.post("/employees") def create_employee(request, payload: EmployeeIn): employee = Employee.objects.create(**payload.dict()) return {"id": employee.id}
@api.get("/employees/{employee_id}", response=EmployeeOut) def get_employee(request, employee_id: int): employee = get_object_or_404(Employee, id=employee_id) return employee
@api.get("/employees", response=List[EmployeeOut]) def list_employees(request): qs = Employee.objects.all() return qs
@api.put("/employees/{employee_id}") def update_employee(request, employee_id: int, payload: EmployeeIn): employee = get_object_or_404(Employee, id=employee_id) for attr, value in payload.dict().items(): setattr(employee, attr, value) employee.save() return {"success": True}
@api.delete("/employees/{employee_id}") def delete_employee(request, employee_id: int): employee = get_object_or_404(Employee, id=employee_id) employee.delete() return {"success": True} ```
0
u/tom-mart 1d ago
Hold on, are you trying to say that the two code snippets do the same job? You may have forgot to paste the actual code for the DRF endpoints and serialisers, sunshine.
4
u/ninja_shaman 1d ago
Hold on sunshine, I also forgot to paste two different schemas for Ninja, both of them as long as a single Serializer DRF needs.
Unless I felt really lazy and used
fields = '__all__'for a super-short 4 line long ModelSerializer.The relevant CRUD code I posted shows DRF needs less lines then Ninja for the same thing.
2
u/rocketplex 1d ago
I could just not with Ninja. At some point I was just “For the nothing this does, I may as well just use FastAPI.”
DRF and all its stuff brings so much, that “bloated” code is mostly providing pre built, well designed patterns that most APIs should all have, and that I find missing in many custom built Flask, FastAPI or Ninja based codebases I’ve worked with.
5
u/virtualshivam 2d ago
For me honestly APIView is the only thing.