One Order Table, Three Front Doors, No Shared View

A hotel procurement platform I worked on for about eight months has one Order table and three audiences who can look at it - a supplier, a store manager, and back-office staff. There is no single view that serves all three. That was a deliberate choice about where a Django app's boundaries belong, and it is the one decision from that project worth writing down.

The problem

The platform ran store setup, supplier groups, product catalogues, order routing, delivery costing, and reporting for several hotel groups at once. One Django monolith, one set of models, and three very different readers of the same rows. A supplier needs to see the orders routed to them and update fulfilment status. A store manager needs every order, user, and report scoped to their one store, and nothing from any other store. Back-office staff need all of it, across every store, plus the controls to reassign suppliers and adjust delivery costing.

Same Order row. Three different sets of fields a reader is allowed to see, three different filters on which rows exist at all, and two of the three audiences should never even learn the others' data exists.

The shortcut this is not

The fast way to build this is one endpoint per resource with a role check inside it - get_order loads the row, then branches on request.user.role to decide which fields to serialize and whether the store id matches. It is less code to write once, and for a while it works.

def order_detail(request, order_id):
    order = Order.objects.get(id=order_id)
    if request.user.role == "supplier":
        if order.supplier_id != request.user.supplier_id:
            raise PermissionDenied
        return render(request, "supplier/order.html", {...})
    if request.user.role == "store_manager":
        if order.store_id != request.user.store_id:
            raise PermissionDenied
        return render(request, "store/order.html", {...})
    return render(request, "backoffice/order.html", {...})  # staff fall through here

The failure mode is in that last line. Every branch above it is a scope check that has to be remembered and kept correct as the view grows. Add a fourth role, or a new field on the back-office template that a store manager's browser should never receive, and the mistake that leaks it is a missing if, not a missing route. One function serving three audiences means the audiences are only as separated as the discipline of whoever edits that function next.

The decision: three route trees, not one branching view

The platform instead drew the boundary at the URL and view layer. Back office, front store, and the store-manager surface are three separate route trees, each with its own views and its own queryset over the same underlying models -Order, Store, User, Report. No view in the store-manager tree can render a row from another store, because it never asks the ORM for one:

# store-manager surface
def order_list(request):
    orders = Order.objects.filter(store_id=request.store_manager.store_id)
    return render(request, "store/orders.html", {"orders": orders})

# back office - a structurally different query, not a wider branch of the same one
def order_list(request):
    orders = Order.objects.select_related("store", "supplier").all()
    return render(request, "backoffice/orders.html", {"orders": orders})

Both functions are named order_list. Neither one is a special case of the other, and that is the point - a store manager's view has no code path that reaches the unscoped queryset, because that queryset is written in a different module it never imports. Store setup follows the same split: a staged wizard (basic details, tag management, configuration, finish) lives in the back-office tree, and a parallel edit flow reuses the same steps without the two ever sharing a view function.

What middleware is for, and what it is deliberately not

The project authenticated through Firebase rather than Django's built-in session auth - pyrebase4 on the client side, firebase-admin to verify tokens on the server. That verification happens in middleware, once, before any view runs:

class FirebaseAuthMiddleware:
    def __call__(self, request):
        token = request.headers.get("Authorization")
        request.firebase_user = verify_id_token(token) if token else None
        return self.get_response(request)

Middleware answers exactly one question - who is making this request - and stops there. It does not decide whether a store manager may see a particular order; that stays with the view and its queryset, in the route tree that already scopes it. Collapsing those two questions into one layer is tempting, because a single Firebase claim could in principle carry a role and settle everything at once. It was kept separate on purpose: identity can be wrong in ways that are safe to retry (an expired token, a network blip verifying it against Firebase), while authorization being wrong is a data leak. Mixing a retryable failure mode into the one that is not makes both harder to reason about during an incident.

The tradeoff

Three route trees over the same five or six models means real duplication.order_list is written three times, not once, and a field that needs to appear in all three templates has to be added three times too. That cost showed up constantly in review, and it is the honest price of this design - roughly 720 of the project's 2,500 commits were mine, and a noticeable share were the same kind of change landing in two or three sibling views in the same afternoon.

The alternative cost is worse and lands later. A single branching view that accumulates roles over a project's lifetime accumulates risk the same way - every new role is one more place the existing branches could be wrong for, and the failure is invisible until a store manager reports seeing a store that is not theirs. Three views that duplicate a query are a maintenance tax, visible on every pull request. One view with a growing set of conditionals is a liability that only announces itself when it has already leaked something. For a platform handling several hotel groups' order and pricing data, the tax was the one worth paying.

Where it would not be worth it

This only pays off because the three audiences diverge in what they can see, not just in how it is styled. A project with one audience and a thin admin flag for a handful of staff does not need three route trees - that is the shape StudySmart AI's own admin surface takes, where the student and staff contracts differ by a few fields rather than by an entire scoping dimension. The decision here was specific to a platform where getting the boundary wrong meant one hotel group's supplier pricing showing up in another's dashboard, and the duplication bought a boundary that a missing if statement could not quietly erase.

StaySource / Hotsource and its HSPP store module were client engagements; the code referenced here describes the architecture rather than reproducing production source.

See the full project journey

Was this useful?

Corrections, counter-arguments and benchmark disagreements all welcome.

Protected by reCAPTCHA. Google's privacy policy and terms apply.