Rethinking REST API Design
Go Beyond CRUD to Uncover Domain Behavior
For many years I enforced a strict resource-oriented style for REST APIs. Endpoints had to be nouns. HTTP methods carried the verbs. The API exposed stable resources such as users, offers, reservations, invoices, or bookings, and clients interacted with them only through GET, POST, PUT, PATCH, and DELETE.
The goal was consistency and predictability. Resource orientation supplied a shared set of rules that made the external contract easier to understand and to evolve across teams. Over time, however, this discipline produced an unintended effect. The stricter the adherence to resource-oriented conventions, the more domain behavior had to be expressed as mutations on resource representations. Registration, booking, cancellation, approval, withdrawal, or refund turned into updates against a single resource. The actual business meaning moved into request bodies, attributes, or status fields.
The same compression happens inside applications when teams translate business situations directly into tables, entities, and status columns. Resource-oriented REST simply performs that translation one layer earlier. A database row becomes a resource. A database operation becomes an HTTP method. The business situation disappears behind the technical contract.
REST itself does not require this outcome. The common resource-oriented interpretation simply makes generic CRUD operations the easiest default. REST does not force us to expose CRUD over HTTP, but the common resource-oriented interpretation makes it very easy to do exactly that. If the API boundary should continue to speak the language of the domain, it must surface intentions, decisions, and meaningful state transitions instead of reducing every change to a resource mutation.

REST Was Never Just CRUD Over HTTP
REST was defined as an architectural style for network-based systems, not as a convention for naming controllers. Its constraints aim at properties such as scalability, visibility, modifiability, and independent evolution of components. The constraint that matters most for API design is the uniform interface: resources are identified, messages exchange representations, and state transitions can be made discoverable through those representations.
A resource is whatever the server chooses to make addressable. HTTP does not restrict it to a database row. A resource can be a stored document, a computed view, a gateway to another system, or a business concept with its own processing rules. A representation is what gets exchanged about that resource, not the internal state itself.
This leaves more room than the dominant CRUD reading suggests. A resource does not have to be limited to User, Offer, or Reservation as passive containers of fields. It can also stand for a registration, a booking request, a cancellation request, an approval, or a refund request. These are still nouns, but they come from the business process rather than from storage.
The limitation appears when HTTP methods are treated as the domain vocabulary. GET, POST, PUT, PATCH, and DELETE carry protocol semantics. They do not express whether the business situation is a cancellation, a withdrawal, or an approval. Once those distinctions are collapsed into generic updates, the interface stays uniform while the behavior becomes harder to see.
Command and Query at the API Boundary
A clearer model at the boundary is to treat changing state and reading state as separate concerns. Commands express an intention to change something in the system. Queries ask for useful representations of current state. These two responsibilities can be designed and evolved independently.
This view makes POST more useful than simple creation. A POST request can submit a booking request, start a cancellation, request a refund, approve a payout, or ask a target resource to process an intention according to its own rules. The application receives the intention, applies the relevant business decision, and produces an outcome. Queries can then be made separately whenever a client needs current information.
CRUD-shaped:
Resource → Reservation
Operation → PATCH
Payload → { "status": "cancelled" }
Mental model → Change a field
Command / Query:
Command → CancelReservation
HTTP → POST /reservations/123/cancellation-requests
Decision → Apply cancellation rules
Outcome → ReservationCancelled or CancellationRejected
Query → GET /reservations/123/summary
PUT, PATCH, and DELETE still have valid uses when the domain genuinely involves document replacement, partial editing, or removal of an addressable thing. They become misleading when they are used to hide a business decision that carries rules, authority, consequences, and resulting facts.
Seen this way, REST can remain the delivery style while Command and Query become the model underneath it. The client does not need to know how state is stored. It needs a clear way to express an intention and a clear way to retrieve the representations that matter afterwards.
In a behavior-oriented API, most domain operations can be expressed using only two HTTP methods. POST is used to submit intentions (commands). GET is used to retrieve useful representations (queries). PUT, PATCH, and DELETE retain limited roles for simple document-oriented operations, but they are not the primary tools for expressing business behavior.
Address the Intention
A behavior-oriented API still needs stable addresses. The difference lies in what those addresses represent. In resource-oriented APIs, the address usually points to the object that is to be modified. In a behavior-oriented design the address can point to the business intention itself.
Consider a cancellation expressed as a field change:
PATCH /reservations/123
Content-Type: application/json
{
"status": "cancelled"
}
This contract presents cancellation as a status mutation. The server may still enforce rules internally, but the public language already frames the behavior as data editing. Clients are encouraged to think in terms of changing fields.
A clearer boundary gives the intention its own address:
POST /reservations/123/cancellation-requests
Content-Type: application/json
{
"reason": "guest_requested",
"requestedBy": "guest"
}
The client submits a business intention. The application receives that intention, evaluates the relevant state and rules, and records the resulting fact only if the decision is valid. The outside world does not assert a new state. It requests an outcome under the rules of the business.
The same pattern applies to other meaningful actions:
POST /user-registrations
POST /stay-booking-requests
POST /offers/123/withdrawal-requests
POST /payouts/456/approvals
POST /refund-requests
Although these endpoints still use nouns, they now represent records of business intent rather than passive data containers. The client expresses what it wants to achieve. The application determines which facts follow from that intent.
The Client Submits an Intention
There is an important boundary between asking the system to do something and telling the system what already happened. A command expresses intent. A fact records an outcome after the business rules have been applied. Mixing both at the API boundary gives the caller authority that usually belongs inside the application.
This is why an API should be careful with endpoints that let callers assert outcomes directly. When the client declares what already happened instead of what it wants to happen, it bypasses the application’s responsibility to validate rules and make decisions.
A better public boundary accepts the intention and lets the application own the decision:
POST /stay-booking-requests
Content-Type: application/json
{
"offerId": "offer_123",
"guestId": "guest_456",
"from": "2026-09-01",
"to": "2026-09-07",
"guests": 2
}
The response can then describe the outcome:
201 Created
Location: /stay-booking-requests/request_789
{
"requestId": "request_789",
"outcome": "accepted",
"reservationId": "reservation_555"
}
or reject the intention with a domain reason:
409 Conflict
Content-Type: application/problem+json
{
"title": "Stay unavailable",
"detail": "The selected offer is unavailable for the requested dates."
}
This distinction matters beyond any particular storage model. The resulting fact may be stored as an event, written into a relational model, sent to another system, or represented in a projection. The architectural point stays the same: the client expresses intent, the application applies the decision, and only the application records the outcome as fact.
Query the Business Situation
Reading data through queries should receive the same level of attention as changing state through commands. A GET request may be safe from a protocol perspective, but the representation it returns still shapes how clients understand the system. When every query exposes users, offers, reservations, payments, or invoices as generic collections, clients have to assemble business meaning from technical fragments.
This works for simple administration. It becomes weak when the client needs to understand a situation. A guest looking for a stay does not need a raw list of offers. A host preparing for arrivals does not need to reconstruct the day from reservation records. A support agent handling a cancellation does not need to fetch several resources and interpret status fields before seeing what happened.
GET /offers
GET /reservations?guestId=guest_456
GET /payments?reservationId=reservation_555
These endpoints expose stored things. A domain-oriented query exposes the representation needed for the task:
GET /available-stays?from=2026-09-01&to=2026-09-07&guests=2
GET /guests/guest_456/itinerary
GET /hosts/host_123/upcoming-arrivals
GET /support/reservations/reservation_555/context
GET /reservations/reservation_555/cancellation-context
A query representation can combine information from several places. It can hide internal structure, remove irrelevant fields, and present the facts that matter for the current decision or screen. This keeps the client from rebuilding domain meaning out of storage-shaped resources.
A useful representation can also show which intentions are available from the current situation:
{
"reservationId": "reservation_555",
"state": "confirmed",
"guestId": "guest_456",
"stay": {
"from": "2026-09-01",
"to": "2026-09-07"
},
"availableIntentions": [
{
"name": "Request cancellation",
"method": "POST",
"href": "/reservations/reservation_555/cancellation-requests"
}
]
}
This is where REST can support the domain instead of hiding it. The client retrieves a representation of the current situation, then follows the intentions made available by the server. Commands express what the client wants to achieve. Queries explain the situation in which those commands make sense.
Domain Rejections Are Part of the Contract
When an API accepts intentions, it must also return meaningful outcomes. Acceptance and rejection belong to the same business conversation. A rejected intention is still a decision, and the client needs enough information to continue in a useful way.
Generic error responses hide that decision. A plain 400 Bad Request can be correct for malformed input, and a 500 Internal Server Error can be correct for an unexpected failure. They are weak responses when the application understood the intention and rejected it because of business rules.
A domain-oriented boundary returns a stable reason that reflects the actual decision. The response should identify the kind of problem, explain the rejection at the right level of detail, and include structured fields when the client can act on them.
409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/stay-unavailable",
"title": "Stay unavailable",
"status": 409,
"detail": "The selected offer has no availability for the requested dates.",
"offerId": "offer_123",
"from": "2026-09-01",
"to": "2026-09-07"
}
This response carries business meaning. The client can show the reason to the user, suggest different dates, or continue with another flow. It does not need to parse database errors, validation traces, or handler-specific messages.
The same principle applies to other rejected intentions: cancellation period expired, payout already approved, refund not allowed, offer incomplete, account closure blocked by open invoices. These are part of the API contract because they are part of the business behavior. A command boundary should make successful and rejected outcomes equally explicit.
Pragmatism
Not every modification to data deserves to be modeled as a command. Some changes are simple, local, and carry little business consequence. Treating them as intentions adds unnecessary formality without improving clarity.
A command is useful when the operation involves a meaningful business decision. It usually has rules to evaluate, authority to check, consequences to produce, and facts to record. Changing a user’s display name or updating a description field rarely meets this threshold. These are closer to document edits than to business actions.
In such cases, a direct update remains appropriate. A PATCH against a resource can be the right tool when the change is small, the rules are minimal, and the system does not need to treat the modification as a distinct business event.
The distinction is pragmatic. Use a command when the operation represents an intention that the application must evaluate. Use a simpler update when the change is mostly about maintaining current data. The goal is not to replace every modification with a command, but to reserve commands for the operations that actually benefit from explicit decision-making.
This keeps the approach focused. Commands carry weight because they are not used for everything. When everything becomes a command, the boundary loses its ability to highlight what truly matters.
Conclusion
If an API only offers resource mutations, clients typically need to manage the business workflow themselves. They must know which steps to take, in which order, and how to handle failures and compensations across multiple calls.
An behavior-oriented boundary reverses this. The client expresses what it wants to achieve. The server receives the intention, applies the relevant rules, manages the necessary steps, and produces the outcome. The workflow stays inside the application, where the business decisions belong.
Consider a booking process. In many CRUD-shaped APIs, the client must create a reservation, block availability, handle payment, and confirm the booking, often while managing failures across calls. With an behavior-oriented approach, the client submits a single request. The application decides whether the offer is available, whether the guest meets the requirements, whether payment is needed, and what facts to record.
This shift does not remove work. It moves the work to where the rules and context are available. The client becomes simpler. The server becomes responsible for correctness. Workflows that previously lived in client code or orchestration layers can now be expressed and enforced at the boundary where they are best understood.
Cheers!