Writing

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?

An orange data ribbon connecting to a frontend panel, with a crossed-out duplicate code card behind it.

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.