I'm building digital_menu, a backend platform that replaces third-party delivery apps for a small business I run. It's a real system, with real orders and real stock to manage — which means the mistakes I make in it are real too.
This post is about one of those mistakes: a view that grew way beyond what a view should do, and the messy path I took to fix it.
The Problem: A View Doing Too Much
The order creation endpoint started simple. Then it needed to check stock. Then validate a minimum order price. Then create order items. Then update the total. Before I noticed, OrdersView was responsible for almost everything happening in the system — HTTP handling, business rules, and data manipulation, all tangled together.
The moment I really felt the pain was when I tested the full checkout flow from the frontend and realized the minimum-price validation simply wasn't running. It wasn't a bug in the validation logic itself — the logic just wasn't being called from the right place anymore. The view had gotten complex enough that I'd lost track of what it was actually doing.
That's usually the signal that logic needs to move somewhere else.
The Extraction: apps/orders/services/create_order.py
I moved the order-creation logic into its own file — not part of the orders app's usual structure (models, serializers, views), but a new kind of file entirely: a service.
# apps/orders/views.py
class OrdersView(generics.CreateAPIView):
authentication_classes = [JWTAuthentication]
permission_classes = [IsAuthenticated]
serializer_class = OrderCreateSerializer
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = create_order(
user=request.user,
items_data=serializer.validated_data["items"]
)
return Response({"order_id": order.id}, status=status.HTTP_201_CREATED)
# apps/orders/services/create_order.py
def create_order(user, items_data):
with transaction.atomic():
order = Order.objects.create(user=user)
for item in items_data:
menu_item_id = item.get("menu_item_id")
quantity = item.get("quantity")
try:
menu_item = MenuItem.objects.get(id=menu_item_id)
except MenuItem.DoesNotExist:
raise ValidationError(
f"Menu item with id {menu_item_id} does not exist."
)
if not menu_item.is_available:
raise ValidationError("This product is not available")
try:
stock = Stock.objects.select_for_update().get(menu_item=menu_item)
if stock.quantity is not None and quantity > stock.quantity:
raise ValidationError(
f"Insufficient stock for '{menu_item.name}'. "
f"Available: {stock.quantity}"
)
if stock.quantity is not None:
stock.quantity -= quantity
stock.save(update_fields=["quantity"])
except Stock.DoesNotExist:
pass
OrderItem.objects.create(
order=order,
menu_item=menu_item,
quantity=quantity
)
order.update_total_price()
order.confirm_order()
return order
The view's job shrank down to what a view should actually do: receive the request, validate the input shape, call the service, return a response. Everything about what counts as a valid order now lives in one place.
I didn't have a clear folder convention for this at first — services/ isn't part of Django's default app structure. I ended up asking an LLM how similar projects tend to organize this kind of logic, landed on apps/<app>/services/, and it stuck.
The Test: Proving the Transaction Actually Rolls Back
Wrapping the whole operation in transaction.atomic() is easy to write and easy to get wrong silently — the only way to know it actually protects your data is to test the failure path, not just the success path.
@pytest.mark.django_db
def test_create_order_service_rolls_back_transaction_when_validation_fails(
user,
menu_item,
stock,
):
initial_quantity = stock.quantity
with pytest.raises(ValidationError):
create_order(
user=user,
items_data=[
{
"menu_item_id": menu_item.id,
"quantity": 1,
}
]
)
stock.refresh_from_db()
assert Order.objects.count() == 0
assert OrderItem.objects.count() == 0
assert stock.quantity == initial_quantity
This test doesn't check that an error is raised — it checks that nothing was left behind when it was. No half-created order, no order item floating without a parent, no stock silently decremented for an order that never actually went through. That's the property transaction.atomic() is supposed to guarantee, and now it's asserted, not assumed.
Extracting the logic into a plain function (not a view) made this almost trivial to test — no request/response cycle to mock, no client to set up. Just call create_order() directly and check what happened.
The Error I Made: Two Different Kinds of ValidationError
While wiring up error handling on the frontend, I hit something confusing: some errors from the API had a clean, structured shape I could branch on, and others were just a flat list of strings I had to string-match against.
// this one is different than the others because it's a Django ValidationError.
// the others are the serializer's ValidationError (DRF).
// they have different behavior from each other.
if (error[0] === "The total price should be R$30,00 or more.") {
orderWarning.textContent = "O pedido precisa ultrapassar o limite de R$30,00.";
return;
}
if (error.items.code === "empty_cart") {
orderWarning.textContent = "O carrinho não pode ser concluído sem itens.";
}
else if (error.items.code === "stock_unavailable") {
orderWarning.textContent = `O item ${error.items.menu_item} não tem estoque o suficiente. Disponíveis: ${error.items.remaining}`;
}
// ...
It turned out I was raising two different exceptions that both happen to be called ValidationError, but come from different places and behave differently:
-
Django's
ValidationError(django.core.exceptions) — raised insidecreate_order(), deep in the service layer. -
DRF's
ValidationError(rest_framework.exceptions) — raised inside the serializer, during input validation.
DRF's version lets you pass a structured detail dict, which becomes a predictable JSON shape on the frontend. Django's version, raised from inside the service and caught by DRF's exception handler, gets serialized as a plain list of strings — no structure, no room to attach an error code.
That mismatch is exactly why the frontend needed a special case for one specific string match instead of a clean code check like the rest. It worked, but it was a patch, not a fix — the real fix is making sure business-rule violations raise DRF's ValidationError with a structured detail, consistently, regardless of which layer they're raised from.
I only understood this by installing Postman and inspecting the raw responses directly — it wasn't obvious from the frontend alone that two structurally different errors were being treated as if they were the same thing.
The Result
The view is now a thin coordinator, not a decision-maker. Business rules live in one file I can test in isolation, without needing an HTTP client. And the errors that do reach the frontend are — mostly — structured enough that adding a new one doesn't mean touching JavaScript at all, just adding a new code on the backend.
The ValidationError mismatch above is still an open item — the next step is standardizing every business-rule failure to go through DRF's exception path with a structured detail, so the frontend never has to guess which "kind" of error it's looking at.
This is part of the devlog series for digital_menu, a backend platform built to automate a real small business's order operations.
Top comments (2)
Service layers help when they clarify ownership, not when they just move code to another file. The useful test is whether business rules become easier to name and test.
Yes. I didn't just move some code and called it "The Service Layer". After I adopted it, everything was clearer, especially the error treatment. I have never been happier than when I organized the codebase like this and finally understood where every error was coming from.