Reducing API Complexity in Spring Boot — Consolidation, Query Parameters, and the Endpoints Worth Removing
by Eric Hanson, Backend Developer at Clean Systems Consulting
Surface area as a liability
Every endpoint you add is infrastructure you must maintain: documentation, versioning, authentication, rate limiting, monitoring, backward compatibility. An endpoint added to satisfy one client request that becomes unused is still a contract — removing it is a breaking change for any client that calls it, regardless of traffic.
The productive mindset: every endpoint should be justified by a concrete, current use case. "We might need this" is not a justification. "The mobile app currently renders this view" is.
This isn't about being parsimonious for its own sake. Under-built APIs are as frustrating as over-built ones — clients that make five requests to assemble one view are paying for API simplicity in chatty network usage. The goal is an API with the minimum surface area that serves the actual use cases, not the minimum number of endpoints.
Consolidation pattern 1: query parameters over separate endpoints
A common accumulation pattern: separate endpoints for different filter combinations of the same underlying resource:
// Accumulated endpoints for the same resource
GET /orders
GET /orders/pending
GET /orders/shipped
GET /orders/by-customer/{customerId}
GET /orders/pending/by-customer/{customerId}
GET /orders/recent // what does "recent" mean?
Six endpoints, each a permanent contract, each requiring separate documentation, monitoring, and maintenance. The consolidation:
// One endpoint, filter by query parameters
GET /orders?status=pending&customerId=123&createdAfter=2026-01-01
One endpoint, composable filters. Adding a new filter criterion adds a query parameter — no new endpoint, no new contract. Clients that don't use a filter omit it; the endpoint returns all orders.
The implementation:
@GetMapping
public Page<OrderSummary> listOrders(
@RequestParam(required = false) OrderStatus status,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) @DateTimeFormat(iso = DATE_TIME) Instant createdAfter,
@RequestParam(required = false) @DateTimeFormat(iso = DATE_TIME) Instant createdBefore,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") @Max(100) int size) {
return orderService.findOrders(new OrderFilter(status, customerId, createdAfter, createdBefore),
PageRequest.of(page, size));
}
The OrderFilter value object carries the filter parameters to the service layer. The endpoint signature is extensible — adding assignedTo or priority filters doesn't change the endpoint URL.
The cases where separate endpoints are still correct: when the behavior (not just the filter) genuinely differs — different response shapes, different authorization rules, different rate limits. GET /orders/export that streams a CSV file is genuinely different from GET /orders that returns JSON — the behavior diverges, not just the filter.
Consolidation pattern 2: generic operations over specific ones
Specific action endpoints accumulate when each business operation gets its own URL:
// Specific action endpoints
POST /orders/{id}/approve
POST /orders/{id}/reject
POST /orders/{id}/place-on-hold
POST /orders/{id}/release-from-hold
POST /orders/{id}/escalate
POST /orders/{id}/close
Six endpoints for what is effectively a state machine transition. The consolidation: a single transition endpoint that takes the target state:
// Generic transition endpoint
PATCH /orders/{id}
{"status": "approved"}
// Or an explicit transition resource
POST /orders/{id}/transitions
{"to": "approved", "reason": "Verified customer"}
The transition resource approach is more expressive — it creates a record of the transition (who did it, when, why) that can be retrieved later. GET /orders/{id}/transitions returns the history.
The constraint: the generic endpoint must validate that the transition is permitted. The service layer enforces the state machine — PATCH /orders/{id} with {"status": "shipped"} for a cancelled order throws an error. The API surface is simpler; the business rule enforcement is unchanged.
When specific endpoints remain correct: when the additional data required per action differs substantially. POST /orders/{id}/cancellation that takes a cancellationReason and refundPolicy is more discoverable than a generic transition that accepts arbitrary JSON. Use judgment — the goal is not minimum endpoints, it's minimum unnecessary endpoints.
The endpoints worth removing
Redundant getter variants
GET /users/{id} // full user object
GET /users/{id}/profile // subset of user fields
GET /users/{id}/summary // different subset
GET /users/{id}/name // just the name
The last three are redundant if GET /users/{id} returns a complete representation. If clients need less data, use sparse fieldsets (?fields=id,name,email) rather than creating endpoint variants. If the structure genuinely differs — profile returns related resources that summary doesn't — keep them. If they're just different subsets of the same fields, consolidate.
Convenience endpoints that became debt
A convenience endpoint added for one client that other clients found and started using is debt. The original client's need should have been served by the existing endpoints with appropriate query parameters.
Identify these by usage pattern: an endpoint called by exactly one client, with a very specific response shape, that doesn't fit the general resource model. These are the endpoints to deprecate aggressively when the client is upgraded.
Duplicated write endpoints
POST /orders // create order
POST /orders/bulk // create multiple orders
POST /orders/import // create orders from CSV
POST /orders/copy/{id} // create order by copying existing
The bulk endpoint is often justified — network round trips for creating thousands of records matters. The import and copy endpoints are questionable. Can POST /orders with an array body serve the import case? Can POST /orders with a copyFromId parameter serve the copy case? If yes, the separate endpoints are unnecessary surface area.
How to remove endpoints you already have
Endpoints used by clients can't just be removed — they must be deprecated, communicated, and eventually retired.
Step 1: Measure actual usage. Before deprecating anything, verify that clients are actually calling it. An endpoint that appears in code but hasn't been called in six months is a candidate for removal with low risk. Add a request counter metric if one isn't already in place:
@GetMapping("/orders/pending") // the endpoint to deprecate
public List<OrderSummary> getPendingOrders() {
meterRegistry.counter("api.deprecated.endpoint",
"path", "/orders/pending").increment();
return orderService.findOrders(new OrderFilter(OrderStatus.PENDING, null, null, null), ...);
}
Six weeks of metrics data with zero calls is strong evidence of safe removal.
Step 2: Add deprecation headers. Signal deprecation before removal:
@GetMapping("/orders/pending")
@Deprecated
public ResponseEntity<List<OrderSummary>> getPendingOrders() {
return ResponseEntity.ok()
.header("Deprecation", "Sun, 01 Jun 2026 00:00:00 GMT")
.header("Sunset", "Sun, 01 Sep 2026 00:00:00 GMT")
.header("Link", "</api/v1/orders?status=pending>; rel=\"successor-version\"")
.body(orderService.findPending());
}
The Sunset header date is the removal date. The Link header points to the replacement. Clients that inspect response headers can find the migration path automatically. API gateways can surface deprecation warnings to developer portals.
Step 3: Notify known clients. For internal APIs, audit service-to-service calls and notify teams. For external APIs, email registered API consumers, update changelog, update documentation.
Step 4: Remove after sunset. On or after the Sunset date, remove the endpoint. Return 410 Gone instead of removing the route entirely — this tells late-migrating clients exactly what happened rather than a confusing 404:
@GetMapping("/orders/pending")
public ResponseEntity<Void> pendingOrdersGone() {
return ResponseEntity.status(HttpStatus.GONE)
.header("Link", "</api/v1/orders?status=pending>; rel=\"successor-version\"")
.build();
}
Keep the 410 handler for one or two release cycles, then remove it entirely.
The API design review question
Before adding any endpoint in a Spring Boot application, one question worth asking: does this serve a use case that can't be served by an existing endpoint with query parameters or a request body variation?
The answer is often no — especially for filter variants and action-specific endpoints. The cases where the answer is yes — meaningfully different response shapes, different authorization domains, genuinely different semantics — are the cases that justify new endpoints.
The API surface that results from this discipline is smaller, more consistent, and easier to document. More importantly, it's an API where removing endpoints is an operation that happens — rather than one where every endpoint accumulates indefinitely because removal is too risky.