Back to Blog

Stop Creating an Endpoint for Every Button

How a few innocent-looking REST endpoints taught me to separate UI actions, resource state, and real domain commands.

Ismail ZAHIR
September 7, 2026
12 min read
Architecture#api-design#rest#domain-driven-design#backend#java
Stop Creating an Endpoint for Every Button

There is a pattern that feels completely natural when building an API.

The frontend gets an Approve button, so the backend gets an endpoint:

http
POST /appointments/42/approve

Then more requirements arrive:

http
POST /appointments/42/reject
POST /appointments/42/cancel
POST /appointments/42/archive

Each endpoint makes sense individually. The intent is explicit, authorization can be attached to individual operations, and OpenAPI documents exactly what the client can call.

But as the workflow grows, so does the controller:

java
@PostMapping("/{id}/approve")
@PostMapping("/{id}/reject")
@PostMapping("/{id}/cancel")
@PostMapping("/{id}/archive")
@PostMapping("/{id}/mark-missed")

When several of these methods eventually do little more than validate a transition and assign a different status, I start asking a different question:

Am I exposing real domain operations, or am I turning every UI action into an HTTP endpoint?

That distinction matters more than whether a URL contains a verb.

Start with the domain, not the button

Imagine an appointment with these states:

java
public enum AppointmentStatus {
    PENDING,
    APPROVED,
    REJECTED,
    CANCELED,
    MISSED,
    ARCHIVED
}

The frontend might expose Approve, Reject, and Cancel buttons, but those buttons are only one way of interacting with the workflow.

MISSED, for example, might not come from a button at all. A scheduled process could detect appointments whose time has passed and transition them automatically. Another client might expose the workflow through a menu, while an integration might have no UI at all.

The domain still contains the same states and transition rules.

That was the useful shift for me:

The UI triggers domain behavior, but it shouldn't define the domain model.

When status really is the resource being changed

If several operations fundamentally mean "move this resource into another valid state," exposing the transition directly can make sense:

http
PATCH /appointments/42/status
json
{
  "status": "APPROVED"
}

There are plenty of workflows where this model is natural:

text
DRAFT → ACTIVE
ACTIVE → INACTIVE

OPEN → CLOSED
CLOSED → ARCHIVED

The transition has no special payload and no independent business meaning beyond moving the resource through its lifecycle.

Appointments can contain transitions like this too. The endpoint describes the requested state rather than mirroring whichever button happened to trigger it.

But accepting a target status should not turn the application into unrestricted CRUD:

text
.                ┌──→ REJECTED
                 │
PENDING ─────────┼──→ CANCELED
                 │
                 └──→ APPROVED
                         │
                         ├──→ CANCELED
                         ├──→ MISSED
                         └──→ ARCHIVED

CANCELED → APPROVED might be forbidden. APPROVED → MISSED might only become valid after the appointment time.

Once those rules exist, status is no longer just an enum field. It is part of a state machine.

For rules that depend only on aggregate state, the model can remain simple:

java
public void transitionTo(AppointmentStatus target) {
    if (!canTransitionTo(target)) {
        throw new InvalidStatusTransitionException(status, target);
    }

    this.status = target;
}

When a transition carries its own contextual invariant, a named domain method often becomes clearer:

java
public void markMissed(Instant now) {
    if (status != AppointmentStatus.APPROVED) {
        throw new InvalidStatusTransitionException(
            status,
            AppointmentStatus.MISSED
        );
    }

    if (scheduledAt.isAfter(now)) {
        throw new TransitionNotYetAllowedException(
            AppointmentStatus.MISSED,
            scheduledAt
        );
    }

    this.status = AppointmentStatus.MISSED;
}

The application layer can obtain now from an injected Clock, perform authorization, and orchestrate persistence while the aggregate protects the transition invariant.

That distinction between uniform transitions and operations with their own inputs or invariants will matter again in a moment.

Don't replace many endpoints with one god endpoint

Once endpoint proliferation becomes visible, the opposite extreme is tempting:

http
POST /appointments/42/action
json
{
  "action": "APPROVE"
}

Soon it becomes:

json
{
  "action": "SEND_REMINDER"
}

or:

json
{
  "action": "RESCHEDULE",
  "date": "2026-09-10T10:30:00"
}

Now /action is an RPC dispatcher with an increasingly polymorphic request schema.

Approve, cancel, reschedule, send a reminder, export, and generate a document do not become the same operation because they share an endpoint.

Consolidation only helps when the operations actually share semantics.

The strongest counterexample: different transitions need different data

This is where a generic status endpoint starts becoming less attractive.

Approval might require nothing more than:

json
{
  "status": "APPROVED"
}

But rejection might require a reason:

json
{
  "status": "REJECTED",
  "reason": "Provider unavailable"
}

Cancellation might require a reason and information related to the cancellation policy.

Trying to force all of that through one request eventually produces something like:

json
{
  "status": "CANCELED",
  "reason": "...",
  "refundPolicy": "...",
  "comment": null
}

Now the schema contains fields that are optional syntactically but conditionally required semantically:

text
if status == REJECTED → reason required
if status == CANCELED → reason required

At that point, the generic endpoint may be hiding domain concepts rather than simplifying them.

This gives me a useful heuristic:

When a transition needs its own meaningful payload, it is often a domain command wearing a status change.

Cancellation might therefore deserve:

http
POST /appointments/42/cancel

with its own contract:

json
{
  "reason": "Schedule conflict"
}

Internally, that doesn't have to mean:

java
appointment.setStatus(CANCELED);

The domain can expose the operation explicitly:

java
appointment.cancel(reason);

while simpler lifecycle transitions can still use:

java
appointment.transitionTo(target);

That's the distinction I find useful: uniform lifecycle transitions can share a transition abstraction; operations with their own inputs or invariants can become named domain behavior.

And this is also why I wouldn't invent a RESCHEDULED status just to fit rescheduling through the same endpoint. Rescheduling changes the appointment's schedule. It is behavior, not necessarily another lifecycle state.

Some status changes are really outcomes

Consider:

http
POST /invoices/42/send

Sending an invoice might generate a document, create an immutable snapshot, contact an external mail provider, record a delivery attempt, and publish an event.

Reducing all of that to:

http
PATCH /invoices/42/status
json
{
  "status": "SENT"
}

misrepresents what the client is asking the system to do.

The request isn't "make this field equal SENT." It is send this invoice. SENT is an outcome of the operation.

The same reasoning applies to:

http
POST /orders/{id}/refund
POST /reports/{id}/generate
POST /users/{id}/reset-password

Trying to eliminate verbs simply for REST purity can make an API less expressive.

Valid doesn't mean authorized

There is another dimension that shouldn't be hidden inside the state machine.

Suppose this transition is valid:

text
APPROVED → MISSED

That doesn't mean every authenticated user is allowed to perform it.

A provider might be allowed to mark an appointment as missed. The patient probably shouldn't be able to mark their own appointment as missed. A scheduled system process might also be authorized to perform the same transition.

So I treat these as separate questions:

text
Is APPROVED → MISSED a valid domain transition?

                 ≠

Is this actor allowed to perform it?

The aggregate can protect its lifecycle invariants, while the application or authorization layer determines whether the current actor is permitted to request the operation.

A transition can therefore be valid but unauthorized.

This also closes one apparent advantage of action endpoints from the introduction. Having /approve and /cancel gives you convenient places to attach different authorization rules, but the URL shape itself doesn't solve authorization. A generic transition endpoint can still authorize based on the actor, the current resource, and the requested transition.

That distinction becomes particularly important when the API tells clients which operations are currently available.

How does the frontend know what's allowed?

Explicit action endpoints have an advantage: discoverability.

If OpenAPI exposes:

http
POST /approve
POST /reject
POST /cancel

the operations are visible at design time.

With:

http
PATCH /status

the schema might tell the client which status values exist without telling it which transitions are valid from the current state for the current caller.

The naive solution is to reproduce the workflow in Angular:

typescript
if (appointment.status === 'PENDING') {
  // show approve/reject/cancel
}

But now the frontend contains another copy of business rules that already exist on the backend.

For simple workflows, the API can expose permitted transitions:

json
{
  "id": "42",
  "status": "PENDING",
  "allowedTransitions": [
    "APPROVED",
    "REJECTED"
  ]
}

Notice that CANCELED is absent here even though it is structurally valid from PENDING. That could be intentional: this representation is for the current caller, not merely a dump of every transition in the state machine.

For richer workflows, operation descriptors can span both state transitions and commands:

json
{
  "id": "42",
  "status": "PENDING",
  "actions": [
    {
      "rel": "approve",
      "method": "PATCH",
      "href": "/appointments/42/status",
      "body": {
        "status": "APPROVED"
      }
    },
    {
      "rel": "cancel",
      "method": "POST",
      "href": "/appointments/42/cancel"
    }
  ]
}

The exact representation isn't the important part. The principle is: the backend should remain authoritative about what the current caller can do.

The frontend can use that information to render controls without reproducing the entire state machine. And none of this replaces server-side authorization; clients can construct arbitrary requests, so the server still validates every operation.

At this point, the modeling question is mostly settled. The remaining question is whether the chosen design behaves correctly when requests fail, overlap, or get retried.

Failure semantics matter too

A generic endpoint doesn't require generic errors.

These are three different failures:

text
CANCELED → APPROVED
The transition itself is not allowed.

PENDING → APPROVED by this actor
The transition is valid, but the actor isn't authorized.

PENDING → APPROVED against an old resource version
The request was valid, but the resource changed first.

They should remain distinguishable to the client.

An authorization failure naturally maps to 403 Forbidden. An invalid domain transition can be represented as a domain conflict such as 409 Conflict, or 422 Unprocessable Content if that convention better matches the API. A failed If-Match precondition has the more specific 412 Precondition Failed.

The exact status-code policy should be consistent across the API. What matters here is that consolidating several state changes behind one endpoint doesn't mean collapsing their failure semantics into a generic "status update failed" response.

State transitions have a concurrency problem

Consider:

java
Appointment appointment = repository.findById(id);
appointment.transitionTo(target);
repository.save(appointment);

Now two requests arrive almost simultaneously. One coordinator approves the appointment while another cancels it.

Both load:

text
status = PENDING

Both transitions are individually valid, so both pass validation. Without concurrency control, the last write can silently overwrite the first.

For JPA applications, optimistic locking is one common protection:

java
@Version
private long version;

At the HTTP layer, an ETag combined with If-Match can express the same expectation while keeping the precondition in HTTP metadata.

For state-machine APIs, another option is to make the expected state explicit in the request:

json
{
  "from": "PENDING",
  "status": "APPROVED"
}

Conceptually, that is a domain-level compare-and-swap:

Change this to APPROVED, but only if it is still PENDING.

The trade-off is partly about layering. If-Match keeps the precondition at the transport level, but usually works with a version or opaque ETag. A from field expresses the expected domain state directly, but puts that precondition into the request body.

They also detect different things: an ETag or version can detect any relevant resource modification, while from only expresses an expectation about the current workflow state.

If a conflict is detected, blindly retrying inside the service is dangerous. The resource should be re-read and the operation reconsidered against its new state.

Validating a transition isn't enough if the state you validated is no longer current when you commit it.

Commands have the mirror-image problem: retries

State transitions force us to think about concurrent updates. Commands with external side effects force us to think about duplicate execution.

Consider:

http
POST /invoices/42/send

The server successfully sends the email, but the client times out before receiving the response and retries. Without protection, the customer may receive the invoice twice.

Depending on the operation, idempotency might involve an idempotency key, a persisted command identifier, or checking whether the operation has already completed.

So the two sides of the design have related correctness concerns:

text
State transition → Is the state I am changing still current?

Domain command  → Have I already executed this request?

Choosing the right HTTP shape doesn't solve either problem. It makes the semantics clearer so they can be handled deliberately.

Remove the UI and ask again

When I'm unsure about an endpoint, I find it useful to mentally remove the frontend.

Operations such as generate report, refund order, and send invoice clearly still exist without a button.

Now consider Archive. Is archiving a meaningful business operation with its own inputs, invariants, and side effects? Or is ARCHIVED simply another lifecycle state?

There is no universal answer, and that's the point.

The API shouldn't acquire /archive merely because somebody added an Archive button. The operation should exist because the domain gives "archive" that meaning.

MISSED makes the distinction particularly clear. If a scheduled job can move an appointment into that state, the workflow exists independently of any button that might also expose it.

The rule I use now

When a frontend requirement arrives, I try not to start with:

What endpoint does this button need?

I start with:

What happened in the domain?

If the answer is simply:

This resource moved from one valid lifecycle state to another.

then a state-oriented API such as:

http
PATCH /appointments/{id}/status

may be the clearest representation.

If the answer is:

The system performed a business operation with its own inputs, invariants, or side effects.

then an explicit command may be the better abstraction.

That doesn't mean verbs in URLs are bad. It doesn't mean every status deserves PATCH. And it doesn't mean the HTTP contract has to mirror the internal domain API method for method.

The rule I ended up with is simpler:

Don't create an endpoint because a button exists. Create it because the domain operation exists.

Buttons change. Clients change. Some transitions eventually happen without a user at all.

The domain is the more stable boundary.

That's the boundary I want the API to represent.

Share:Share on Twitter / XShare on LinkedInShare on Facebook