Sometimes the feature is refusing to let them skip a step, and showing why
An admin asked to invoice an order before it was delivered. The right feature was to say no, out loud, before they ever reached the button.
An admin asked whether they could add an order to an invoice before it had actually been delivered. The honest answer was no, and I want to be clear about something: enforcing that no was the feature they were asking me to build, even though it looked like I was taking a capability away.
That sounds backwards. They wanted a shortcut and I shipped a wall. But on a logistics platform, billing for goods that haven’t moved isn’t a convenience, it’s a dispute three weeks from now when the customer points out they never received the thing on the invoice. The shortcut past the process is a future argument with money attached. So the rule stays, and the only real question is how you enforce it.
There’s a lazy no and a respectful no
Every rule like this can be enforced two ways, and they cost the same to a machine and wildly different amounts to a human.
- The lazy no. Let the admin pick the order, fill in the line items, set the terms, get all the way to the bottom of the form, and hit the big confident Dispatch button. Then throw a red error: “Order not yet delivered.” Everything they typed is now suspect, they don’t know if it’s still there, and the system waited until the most expensive possible moment to tell them a thing it knew from the very first click.
- The respectful no. Gray out Dispatch the instant they select an undelivered order. Put the reason right next to the dead button, in plain words: “This order hasn’t been delivered yet, so it can’t be invoiced.” Live, no reload, no submit. The wall is still there, but now it’s a sign you can read on the approach instead of one you walk into in the dark.
Same rule. Same constraint. One of them treats the user like a suspect to be caught, the other like a colleague to be guided.
Validation is teaching, not gatekeeping
The lazy version is so common because it maps cleanly to how we think about backend code. The server validates, the server rejects, the form surfaces whatever the server said. Clean separation, one source of truth, and a genuinely terrible experience, because the validation only speaks after the human has already committed effort.
Validation isn’t rejecting bad input. It’s teaching the user what ready looks like before they spend anything trying to reach it.
When you move the no forward in time, the no changes character. A grayed button with a reason attached isn’t a rejection, it’s documentation that happens to live exactly where the question gets asked. The admin learns the rule once, in context, and never hits it again. They internalize that orders flow delivered-then-invoiced, not because a manual said so but because the screen quietly taught them the shape of the workflow the first time they tried to bend it. You’ve turned an error message into onboarding.
The honest version costs more, and that’s the point
I won’t pretend the respectful no is free. The lazy no is one server check and a toast. The respectful one means the front end has to know the delivered state of the order the moment it’s selected, react to it live, disable the action, render the reason, and stay in sync if the order’s status changes underneath. It’s real work, and it’s the kind of work that’s easy to argue yourself out of because the lazy version technically prevents the bad outcome too.
But “technically prevents the bad thing” and “respects the person doing the work” are different bars, and the people hitting these walls hit them forty times a day. The cost of the lazy no isn’t paid by me, it’s paid by an ops lead in lost minutes and small daily frustration, which is exactly the cost that never shows up in a code review.
So no, I didn’t give them the shortcut. I gave them a button that refuses, kindly, with its reason in hand, before they’ve spent a thing reaching for it. The feature wasn’t the invoice. The feature was the refusal, said early and said clearly.