Software
Stop exposing your tables as REST
ZenHorizon Engineering · 7 May 2025 · 4 min
We can usually spot the first integration a company ever shipped. `GET /users`. `GET /users/:id/orders`. `PATCH /orders/:id` with a body that is the row. A partner starts writing to `status` because the field was there. Six months later you cannot rename a column without a legal conversation.
That is not API design. That is shipping the schema and hoping the other team is polite.
Polite partners still have deadlines. They will bind to whatever you documented, and to whatever they discovered in a 200 that was not documented. Both become your contract. The undocumented one is worse, because you will break it by accident and they will still be right.
Resources are not tables
A partner does not want your `order_items` join. They want to place an order, cancel it, or ask whether it shipped. Those are actions with rules. The rules are the product. If the only way to cancel is to `PATCH` a status field to `CANCELLED`, you have taught every client to guess your state machine.
We design around verbs the business already uses. `POST /orders/{id}/cancel`. The server decides whether that is legal at 4 p.m. on a bank holiday. The client does not get a vote.
The payload can still be JSON. REST is fine. GraphQL is fine when the client shape really does fan out. Neither excuses `PATCH` as a backdoor to SQL. If you need a BFF for your own admin, build a BFF. Do not enlarge the public surface so a dropdown can render.
A small before and after
Before: PATCH /orders/88 with a body that sets status to CANCELLED and refund to true. Half your partners send canceled. One sends refund as a string. One learned they can set status to SHIPPED and skip the warehouse. You will find that last one in a quarterly review, not in a test.
After: POST /orders/88/cancel with a reason code and an Idempotency-Key. The response is the order as the partner is allowed to see it, plus a cancellation object with a timestamp and who initiated it. Illegal transitions are 409 with a stable code. There is no field they can poke to invent a shipment.
Contracts that can age
- Version in the path or a header, and mean it. `/v1` that you mutate in place is a lie.
- Additive changes only inside a version. New fields are optional. Never reuse a name for a new meaning.
- Idempotency keys on anything that moves money or creates a record a human will see twice.
- A small, stable error shape. “Validation failed” with a field list. Not a 500 with a stack trace you forgot to strip.
- Pagination that cannot drift: cursor, not `page=3`, unless the list is tiny and static.
- Webhooks with signatures, a replay window, and an event id. “We will POST JSON to their URL” is not a design.
OpenAPI is worth generating from the code, not the other way around, unless you have a team that will keep the YAML honest. We have never seen a hand-written spec survive the third sprint. We have seen Zod or a schema-first stack keep producers and consumers from drifting.
Give partners a changelog they can subscribe to. Not a Twitter account. A feed or an email that says what field became optional and on which date the old version dies. Dates that slip are better than dates that were never published.
Webhooks are part of the contract
If partners must poll `/orders/{id}` every thirty seconds, you did not finish the API. You finished the read model. Send `order.cancelled` with a signature, an event id, and a timestamp. Let them retry. Deduplicate on the event id. Publish a list of events you will not remove inside a version. That list is as binding as the URLs.
We still see unsigned callbacks on a public URL “because it is only staging”. Staging URLs leak. Sign them. Rotate the secret. Put the rotation in the changelog next to the field you made optional.
If a partner cannot verify a signature, they will ask you to turn it off. Do not. Offer a test key and a working example in curl. The cost of a half-day sample is lower than the cost of an unsigned production webhook you will one day regret.
Internal APIs rot the same way
This is not just for partners. Your own mobile app, your own admin, your own “temporary” Next.js server actions — they all ossify around whatever you expose. If the admin needs a god endpoint that returns the universe, give it a BFF. Keep the public contract small enough that you could move a table next year.
A useful test: could you move the table to another service without changing the URL a partner calls? If no, you do not have a boundary. You have a leak.
Draw the actions. Hide the joins. Be boring on errors. That is most of API work. The rest is documentation you will actually update when a field becomes optional — which, if you did the first part, happens less often than you think.