Ship the policy, not the code
A rule has to hold on both sides of an API boundary. Do you duplicate the code, share it, or let the backend drive the invariant?
Imagine you have an API (REST, GraphQL, or otherwise) powering a separate frontend.
You have this innocuous-looking helper:
function canCancelOrder(order: Order) {
return ['PENDING', 'PAID'].includes(order.status)
}TypeScript
The backend needs to reject POST /orders/:id/cancel when the invariant is violated, while the frontend should disable/hide the relevant button.
This is a simple example, but rules like this have a habit of creeping across boundaries:
// API
function handleCancelOrder(ctx: Context, payload: Payload) {
const order = ctx.orderRepository.get(payload.id)
if (!canCancelOrder(order)) {
throw new Error(`Cannot cancel an order in state: ${order.status}`)
}
// etc.
}TypeScript
// Frontend
function CancelButton({ status }: { status: OrderStatus }) {
const disabled = !['PENDING', 'PAID'].includes(status);
return (
<button
disabled={disabled}
name="action"
value="cancel"
>
Cancel order
</button>
)
}TSX
Two copies of the same rule which are bound to diverge.
Your options are:
- Sharing the code (a package, or a shared module in a monorepo).
- Shipping the rule (as data), letting the backend remain the source of truth.
Sharing the code
Sharing static context-free helpers is fine (think stuff like normalizePostcode()).
Contextual rules are where things break down. The moment you have two separately deployable units (i.e. frontend and backend), you can’t really rely on them being in sync:
- A shared package can be a different version on each side.
- An ‘atomic’ commit in a monorepo still results in two different deployments. If one fails you’re left with different contracts.
None of this is impossible to mitigate, but in practice it’s hard to avoid drift.
Sharing code is also a non-starter if the two sides don’t share a language. A Go API and a TypeScript frontend can’t (easily) share a function, so you’d have to compile down to some neutral spec like JSON anyway… at which point you’re just shipping data.
Which nicely leads into…
Ship the data, not the code
Rather than sharing the function/class, serialize and send down the wire either its result, or the spec which drives it.
Ship the decision
The backend can already evaluate the rule, just return it:
{
"id": "123",
"customer": { "name": "Jay" },
"items": [{ "name": "Product one" }],
"status": "PENDING",
"allowedActions": ["CANCEL"]
}JSON
Now the frontend renders state instead of re-deriving it:
function CancelButton({ allowedActions }: { allowedActions: Action[] }) {
const disabled = !allowedActions.includes('CANCEL');
return (
<button
disabled={disabled}
name="action"
value="cancel"
>
Cancel order
</button>
)
}TSX
A bare boolean is usually too little, though. The UI almost always needs the why (‘already shipped’ etc.):
{
"status": "SHIPPED",
"allowedActions": [],
"disabledReasons": { "CANCEL": "Orders can't be canceled once shipped" }
}JSON
If you’re thinking that this smells a lot like HATEOAS then you’d be right. The server is explicitly telling the client which transitions are available, instead of the client having to infer them:
{
"class": ["order"],
"properties": {
"id": "123",
"status": "PENDING"
},
"actions": [
{
"name": "cancel-order",
"title": "Cancel order",
"method": "POST",
"href": "https://api.example.com/orders/123/cancel"
}
]
}JSON
Ship the policy, not the evaluator
When the invariants are more involved, serialize the policy itself and evaluate it on both sides with a small, shared evaluator. Permissions libraries like CASL are built for this: you can pack the rules and send them down the wire:
import { packRules } from '@casl/ability/extra';
import { defineRulesFor } from '../services/appAbility';
app.post('/authz', (req, res) => {
res.send({ rules: packRules(defineRulesFor(req.user)) });
});js
Similar story with JSON Schema for validation:
{
"type": "object",
"properties": {
"name": { "type": "string" },
"credit_card": { "type": "number" },
"billing_address": { "type": "string" }
},
"required": ["name"],
"dependentRequired": {
"credit_card": ["billing_address"]
}
}JSON
Here you do share code, but only the evaluator (the CASL ability, the JSON Schema validator etc.), not the policy. The evaluator is usually stable, while your business logic probably isn’t.
Of course, not everything is easily expressible as data. A zod schema is code (although there are several attempts out there to support serialization), so sharing it implies a shared module, or a transformation stage.
Sharing is caring
Whichever way you go, share something. Don’t let the same domain rule live in two places. WET is fine, but not for your invariants.
The next time you find yourself writing a guard on the frontend to mirror something the backend already enforces, ask whether you can share it as data instead.