REST has become the de-facto standard for building web APIs. Almost every tutorial, framework, and job listing treats it as the obvious choice. But what most developers call "REST" has very little to do with what Roy Fielding described in his dissertation back in 2000. Today, REST typically means HTTP verbs, JSON payloads, and CRUD operations mapped to resources. And that is exactly where the problem begins.
Because once you strip away the buzzword, what remains is a thin wrapper around database operations, exposed over HTTP. We have spent years criticizing CRUD on the data level. It is time to look at what CRUD does to our APIs.
What REST Was Supposed to Be¶
Roy Fielding defined REST as an architectural style built around constraints: statelessness, cacheability, a uniform interface, and most importantly, hypermedia as the engine of application state (HATEOAS). The idea was that a client would navigate an API the way a human navigates the web, by following links, discovering capabilities at runtime, without any out-of-band knowledge about the server's structure.
The Richardson Maturity Model captures how far an API goes toward fulfilling this vision. Level 0 is a single endpoint. Level 1 introduces resources. Level 2 adds HTTP verbs. And Level 3 adds hypermedia controls. Most APIs that claim to be RESTful stop at Level 2. They use GET, POST, PUT, and DELETE on resource URLs, return JSON, and call it a day.
What remains is not REST. It is CRUD over HTTP. And that distinction matters, because it shapes the way we think about our APIs, the way we name our operations, and the way we communicate intent across system boundaries.
The Database Language in the API¶
When you model your API around POST /orders, GET /orders/123, PUT /orders/123, and DELETE /orders/123, you are exporting the technical vocabulary of a database through your API surface. The API speaks in terms of creating, reading, updating, and deleting rows, not in terms of what actually happens in the business domain.
Consider DELETE /orders/123. What does that mean? Is the order being cancelled? Archived? Refunded? Voided? Each of these is a different business operation with different rules, different side effects, and different consequences. But the API flattens all of them into a single verb: delete. The domain intention disappears behind a generic technical operation.
This is the same problem we explored in … And Then the Wolf DELETED Grandma, where CRUD on the data level strips away the meaning of what actually happens. When the wolf eats grandma, that is not a DELETE. It is a domain event with context, consequences, and a story behind it. The same loss of intent occurs when we apply CRUD to APIs. And as Don't Kill Your Users showed, using CRUD language makes it impossible to distinguish between operations that look similar on the surface but are fundamentally different underneath.
We also see this pattern in how we name events. If your API uses PUT /user/123 to handle both a name change and a role promotion, then the events you derive from those calls are equally vague. As Naming Events Beyond CRUD discusses, good event names are domain-specific, not technical. But a CRUD-based API actively works against that, because it forces you to express rich domain operations through a vocabulary of just four words.
The API should speak the language of the domain, not the language of the storage engine. As It Was Never About the Database put it, the database is an implementation detail. So why would we let its vocabulary define our public interface?
HTTP Verbs Are a Transport Detail¶
There is a deeper issue here. HTTP verbs like GET, POST, PUT, and DELETE are features of the transport protocol. So are status codes like 200 OK, 404 Not Found, or 500 Internal Server Error. They describe what happened at the protocol level, not at the domain level.
Status codes cannot express partial failures. They cannot communicate a business-specific reason for rejection. 400 Bad Request tells you that something was wrong with your input, but not whether the order was rejected because the product is out of stock, the customer's account is frozen, or the shipping address is in a restricted region. You end up encoding domain-level error information in the response body anyway, which means the status code becomes redundant at best, and misleading at worst.
And then there are the debates. Should you use PUT or PATCH? Is 201 Created the right response for a command that does not create a resource? Should DELETE return 200 or 204? These discussions feel important, but they are entirely about protocol semantics, not about your domain. They consume time and energy that would be better spent on designing a clear, intention-revealing API.
Here is a thought experiment that makes this obvious: what happens when you switch from HTTP to WebSockets? Suddenly, there are no HTTP verbs. There are no status codes. If your entire API design was built around PUT vs. PATCH and 201 Created vs. 200 OK, you would have to reinvent all of that from scratch. The fact that a protocol change forces a redesign of your domain operations proves that those operations were tangled up with the transport layer all along.
This reveals the truth: these verbs and codes were never part of your domain. They were part of the transport protocol. And a good API design should not depend on which protocol carries it.
POST and GET Are All You Need¶
If HTTP verbs are a transport detail, then we should stop overloading them with domain semantics. Two verbs are enough. POST for writing, because commands change state. GET for reading, because queries return data.
The domain verb belongs in the URL path. Instead of DELETE /orders/123, write POST /cancel-order and put the order ID in the request body. Instead of PUT /orders/123 with an updated payload, write POST /change-shipping-address with the relevant data. Instead of wondering whether to use PUT or PATCH for a partial update, use POST /rename-customer and be explicit about what the operation actually does.
POST /cancel-order
{ "orderId": "123", "reason": "Customer request" }
POST /change-shipping-address
{ "orderId": "123", "address": { "city": "Berlin", ... } }
GET /order?id=123
Now the API reads like a conversation with the domain. Every endpoint describes an intention. The URL tells you what is happening, not how the database stores it. There is no ambiguity, no overloaded meaning, no need to guess what PUT does in this particular context. A new developer reading the API can immediately understand what each endpoint does, because the names come from the business, not from a database textbook.
This also eliminates an entire category of design debates. You no longer need to argue about whether something is a "resource" or a "sub-resource." You no longer need to decide whether updating an order's status should be PUT /orders/123/status or PATCH /orders/123. The domain tells you what the operation is called. You just use that name.
That Is Not REST? Who Cares.¶
Some developers feel uneasy about "deviating" from REST. It feels like breaking a rule, like doing something wrong. But the rule was already broken. What most teams call REST was never REST in the first place. It was CRUD over HTTP with a convenient label.
If your API clearly communicates intent, is easy to understand, and does not depend on protocol-specific features for its semantics, then it is a well-designed API. Whether or not it conforms to someone's interpretation of a term from a 20-year-old dissertation is irrelevant. We have seen too many teams waste time on debates about "RESTfulness" that have nothing to do with the quality of the API and everything to do with dogma.
Pragmatism beats dogma. Every single time.
Commands and Queries on the API Level¶
If POST means "do something" and GET means "ask something," then we have arrived at CQRS on the API level. POST carries commands, GET carries queries. The read path and the write path are separated by design, and the separation happens at the most natural boundary: the HTTP method.
This is not a new idea. GraphQL, for example, has been doing exactly this for years. Every GraphQL request is either a mutation (a command) or a query. There are no HTTP verbs involved in the semantics. The protocol is just a carrier. GraphQL proves that you do not need four verbs to build a capable API. Two concepts are enough.
As CQRS Without the Complexity explored, separating commands from queries does not require a complex infrastructure. It is a design principle, not a framework. You do not need separate databases, event buses, or eventual consistency to benefit from it. You just need to be clear about which operations change state and which operations read it.
And as Decide, Evolve, Repeat showed, commands and events are at the very heart of how event-sourced systems work: a command goes in, events come out. The Decider pattern makes this explicit with its decide function that takes a command and the current state, and returns a list of events. When your API is built around commands, it maps directly onto this model. The API endpoint receives the command, the domain logic decides what happens, and events are stored as the result.
This also connects to how we think about aggregates. As Your Aggregate Is Not a Table argued, an aggregate is not a row in a database that you update through CRUD operations. It is a consistency boundary that processes commands. A command-based API respects this, because it sends commands to aggregates, not update instructions to tables.
And as DDD: Back to Basics described, the triangle of commands, events, and state is the fundamental building block of domain-driven systems. A REST-style CRUD API obscures this triangle. A command-and-query API makes it visible.
Portable by Design¶
When your API is built around commands and queries rather than CRUD verbs, it becomes portable. The same command can be sent over HTTP, WebSockets, gRPC, or a message queue. The same query can be served from an HTTP endpoint, a GraphQL resolver, or a server-sent event stream. The domain logic stays the same. Only the transport changes.
This is not a theoretical advantage. It is a practical one. We have seen teams struggle to add WebSocket support to a REST API, because the entire design was entangled with HTTP semantics. We have seen teams duplicate business logic between their HTTP API and their message queue consumers, because the HTTP layer encoded domain rules in status codes and verb choices that did not translate.
When you design around commands and queries, these problems disappear. A command is a command, whether it arrives over HTTP or through a message broker. The handler does not care about the transport. This connects directly to how EventSourcingDB approaches API design. Its API is built around commands and queries, not around CRUD operations on resources. Writing events, reading streams, observing changes: each operation has a clear name and a clear purpose, independent of the protocol that carries it.
Let Your API Speak the Language of the Domain¶
POST and GET are enough. Two verbs. One for commands, one for queries. Put the domain intent in the path, the data in the body, and let the transport protocol do what it does best: transport. Stop exporting database semantics through your API. Stop pretending that DELETE means something specific in your domain. Start designing APIs that say what they mean, that read like the business operations they represent, and that work regardless of which wire protocol carries them.
If you would like to explore how CQRS and Event Sourcing connect to API design, take a look at cqrs.com. And if you want to discuss how to design APIs that speak the language of your domain, we would love to hear from you at hello@thenativeweb.io.