ZYVOPMulti-Platform Sync
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZYVOPMulti-Platform Sync

The Developer Publishing Hub. Write once, publish everywhere, and make your work citation-ready with built-in SEO, AEO, and GEO discovery support. Zero reader paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Author Handbook
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeArchitectureStop Creating an Endpoint for Every Button
Architecture

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
Ismail ZAHIR
Software Engineer
September 7, 2026
9 min read
Series

Engineering in Practice

Part 6 of 6

Prev
Next
Stop Creating an Endpoint for Every Button
#API Design#rest#domain-driven-design#backend#java
๐Ÿ‘1

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

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

POST /appointments/42/approve

Then more requirements arrive:

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:

@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:

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:

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

There are plenty of workflows where this model is natural:

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:

.                โ”Œโ”€โ”€โ†’ 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:

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:

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:

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

Soon it becomes:

{
  "action": "SEND_REMINDER"
}

or:

{
  "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:

{
  "status": "APPROVED"
}

But rejection might require a reason:

{
  "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:

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

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

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:

POST /appointments/42/cancel

with its own contract:

{
  "reason": "Schedule conflict"
}

Internally, that doesn't have to mean:

appointment.setStatus(CANCELED);

The domain can expose the operation explicitly:

appointment.cancel(reason);

while simpler lifecycle transitions can still use:

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:

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:

PATCH /invoices/42/status
{
  "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:

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:

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:

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:

POST /approve
POST /reject
POST /cancel

the operations are visible at design time.

With:

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:

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:

{
  "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:

{
  "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:

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:

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:

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:

@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:

{
  "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:

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:

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:

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.

Series

Engineering in Practice

Part 6 of 6

Prev
Next

Comments (0)

Login to post a comment.

Ismail ZAHIR
Ismail ZAHIR

Software Engineer

Iโ€™m a software engineer who loves turning ideas into real, useful products. I enjoy building things that make life easier and more enjoyable.

Subscribe to Ismail ZAHIR's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.

Stop Creating an Endpoint for Every Button

More from Ismail ZAHIR

View profile

My Pull Request Failed โ€” Because GitHub Actions Was Protecting the Repository

Sometimes the most useful security lessons don't start with a security audit. They start with a failed CI job. Recently, while contributing to an open-source...

9 minSep 4

I Built an Angular Authentication Layer for the Signals Era

How I designed `ngx-auth-client` around reactive state, provider-agnostic authentication, functional guards, and safer token handling.

16 minAug 31

Building a Reusable Keycloak Theme Architecture

Customizing a Keycloak login theme is styling. Doing it for a second brand without copying the first one is architecture. Here is how a base theme, four theme-resolution rules, and 58 design tokens turn a new branded login and email experience into twenty lines of properties and a logo.

9 minAug 15

OAuth 2.0, OpenID Connect, and Keycloak: Understanding Modern Authentication

OAuth 2.0, OpenID Connect, JWT, access tokens, refresh tokens, Keycloak. These get mentioned in the same breath so often that they blur into a single vague thin...

10 minAug 7

Why I Validate Angular Compatibility Using the Published npm Package (Not the Source Code)

Most Angular libraries claim compatibility across multiple Angular versionsโ€”but how many actually verify it? Here's why I stopped testing my source code and started validating the packaged npm artifact that users really install.

5 minJul 31