Engineering Journal · PRODUCT ENGINEERING · March 6, 2024 · 16 min read
The UI Will Invent the Business Rule You Did Not Write
If the API will accept a state the screen refuses, the real product is whichever client is in a hurry. That is how frontend assumptions become policy.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
I have watched a screen become the law. The button is hidden. The date picker will not allow Sunday. The confirm step asks for a reason. Meanwhile the endpoint will accept the Sunday, the missing reason, and the status the button was hiding — because a colleague called it from a job, or from an admin tool, or from a mobile build that never got the new form.
The UI did not malfunction. It invented a rule the server never owned. For a while everyone is grateful. The product feels careful. Then a second client arrives, or a script, or a tired admin, and the organisation discovers that carefulness was a theme, not a contract.
This is not a Vue or React tutorial. It is about the boundary that decides whether the interface is enforcing a decision or merely decorating one.
Context
Most of the products I work on have more than one mouth. A staff SPA or Blade-heavy admin. A student-facing site. A Flutter app. Jobs that write the same records. Sometimes a partner. The backend is Laravel. The frontend stack has been Vue, React, a bit of both, depending on the surface.
That mix is ordinary now. It makes the old habit — "we will validate in the form, the API is for us" — more expensive every year. "Us" keeps growing.
I include Blade admin pages in this. They feel like the server, so people put rules in the template or in a livewire-ish click and skip the policy. A server-rendered hide is still a hide. The next JSON client will not read the template.
The problem
The apparent problem is duplication. We already check the field in the UI. Why again on the server. It feels slow and pedantic.
The real problem is authority. If the rule exists only in the screen, it exists only for people who use that screen, in that version, in that browser, with that feature flag. Everyone else is on a different product that happens to share a database.
There is a second problem: the UI is a terrible archive. Six months later nobody remembers why Sunday was blocked. The picker is just how the form feels. The server, if it never knew, cannot tell you.
There is a third: time. A rule that lived in a release of the SPA is a rule that dies when the bundle is replaced. Jobs and old mobile builds do not get the new bundle. If the only copy of the law was in JavaScript, you have a legal system with a cache-busting problem.
The tempting solution
Put the cleverness in the client because that is where the friction is felt. Disable the button. Prefill the only legal option. Trust that "nobody uses the API directly."
Or the opposite extreme: dump every rule into the JSON and make the UI a stupid renderer. That sounds pure. It also produces chatty endpoints and a frontend that cannot sketch a decent empty state without a round trip.
Why that is not enough
Clients are not a security boundary. Hiding a button is not authorisation. I have seen "we do not show delete" stand in for "delete is not allowed." It is allowed. You just made it a privilege for people who can open a console.
Validation that only lives in the UI also fails closed for good users and open for everyone else. The honest student hits a red border. The import job writes garbage.
And frontend assumptions compound. A status colour becomes a meaning. A default tab becomes a workflow. A local filter becomes "the list of people who need attention." Support starts speaking in UI nouns. Then you change the UI and the business thinks you changed the policy.
I have watched a colour — amber, green, a badge — become the only way a team talks about risk. The API had a status. The colour was a designer's mapping. When the mapping changed, people thought the business rule changed. That is not a design nit. That is an unwritten policy with a hex code.
The same happens with defaults. Prefill "this campus" because it is usually right, and you will create a year of records in the wrong place for the person who did not notice. A default is a decision. If the server does not know it happened, you cannot audit it.
Options
UI as the only rule-keeper. Advantage: fast, tactile. Disadvantage: every new client reimplements the company. Risk: the admin bypasses it on day two.
Server as the only intelligence, UI as a dumb shell. Advantage: one place to test. Disadvantage: poor interaction design, extra latency, and a temptation to over-fetch.
Shared rule, different jobs. The server decides what is legal. The UI decides how to teach, how to sequence, how to recover from a no. Duplicate the cheap checks for courtesy — required fields, format — and never the expensive ones: eligibility, money, tenancy, who may approve. Advantage: users get a kind screen and a honest API. Disadvantage: you must keep the courtesy checks from drifting into a second policy.
Generate the UI from a schema. Advantage: less drift. Disadvantage: the worst forms I have used were honest and unusable. Schema-driven UI is a tool, not a product strategy.
A "may I" endpoint plus a write that re-checks. Advantage: the screen can be kind without being the law. Disadvantage: two calls. I pay that on eligibility, money, and approval. I do not pay it on whether the email field looks like an email.
Trade-offs
You accept some duplicated validation. A required email in both places is cheap. A pricing rule in both places is how you get two prices.
You accept that some UI behaviour is not a rule. Animation, density, which field is first. Those belong to the client. If you pull them into the API you will version button padding.
You should not accept a 200 from the API for a state the product considers impossible. That is how jobs and future clients become attackers.
Decision
The API decides. The UI explains. I want the server to reject the illegal write with a contract a client can display. I want the screen to make the legal path obvious so we do not use error messages as the design.
When I review a frontend change that "just hides" a dangerous action, I ask where the same hide exists for the job and the mobile client. If the answer is nowhere, we have not implemented a rule. We have implemented a vibe.
I am willing to duplicate cheap checks. Required, format, "pick one of these four." I am not willing to duplicate eligibility, price, or "may approve." Those belong in one function the UI may call and the write must call. If the UI cannot afford the round trip, the action was not ready to be a single tap.
Implementation / Thinking process
I keep write validation at the application boundary — form requests, command objects, domain services — not only in Vue components. The UI may call a lightweight "may I" endpoint when the flow is expensive to get wrong, but the write still checks.
Error shapes matter here. If every failure is a 422 with a human sentence and a stable code, the web and the phone can behave like they work at the same company. If every controller invents a JSON dialect, the UI will start swallowing errors to look polished.
I let the frontend own interaction state: wizards, dirty forms, optimistic lists that reconcile on failure. I do not let it own eligibility. Optimistic UI that assumes the server will agree is a good animation and a bad accountant.
Shared TypeScript types or OpenAPI help. They do not replace tests on the write path. Types describe the conversation. Tests describe the law.
I also want the frontend to receive reasons it can show. A boolean "not allowed" trains designers to invent copy. A code plus a sentence lets the same rule serve web and mobile without a second policy document in Figma.
Watch local state that outlives the server. A wizard that keeps "approved" in memory and then posts a different resource is how you get a UI that thinks it finished. On submit, the server is the story. The wizard is a draft.
Failure modes
A "temporary" admin screen that talks to Eloquent directly. It will be the real product on the last day of the month.
A mobile build that caches an old allow/deny. The user is not hostile. They are on a train with last week's binary.
Frontend tests that assert the button is disabled, with no server test that the action is rejected. You have tested the wallpaper.
A designer-led flow that adds a step the API cannot see — "I confirm I checked the passport" — stored only in component state. The organisation thinks it has a control. It has a click.
Operational consequences
Support will file tickets against the screen. Engineering will look at the API and say it is fine. Both can be right. The organisation is running two products. The meeting will be about tone. The fix is to put the rule in the place both clients already obey.
When you finally move the rule to the server, someone will complain that a script they loved is broken. That script was the second client. Thank them. They found your missing test.
You will also find that some "frontend rules" were never rules. They were taste. Those can stay in the client. The work is to notice which is which before the taste calcifies into a meeting about "why did you allow that."
Lessons
The interface is allowed to be stricter than the API in the sense of being kinder. It is not allowed to be the only place the company says no.
If a second client would be able to do the thing, you do not have a business rule. You have a layout.
What I would do differently today
I would have added a second, ugly client earlier — a command or an admin action — in review, just to see whether the rule survived. One screen is a story. Two writers are a contract.
I would also have stopped treating "we will do it in the frontend first" as a phase. It is a fork. Forks do not merge themselves.
I would have treated admin "shortcuts" as clients. The ugly screen that posts to Eloquent is the most honest consumer you have. If it can violate the rule, the rule is optional. Make the shortcut go through the same write path, or admit you have two products and stop being surprised when they disagree.
Closing thought
A good UI teaches the path. A good API refuses the wrong one. If you only have the first, you have a product for the people who click where you expected. Everyone else is in a different business that shares your tables.