REST API field selection allows clients to request only the data they need. Learn to implement GraphQL-style patterns to stop data over-fetching today.
Last month, we spent three days debugging a latency spike on our user-profile service that turned out to be a classic case of data over-fetching. Our mobile client was pulling a 450KB JSON blob just to display a username and a profile picture, forcing the backend to join four different tables and aggregate data from a secondary cache. We were drowning in unnecessary I/O.
If you’re building a REST API, you’ve likely hit the wall where your resources become too bloated to be efficient. While we often look at REST API Resource Partial Updates to fix write operations, the read side—specifically how we handle complex resource fetching—is where most systems bleed performance.
The goal isn't to abandon REST for GraphQL; it's to borrow the best parts of its type system to improve your current architecture. We want to give the client control over the response shape without introducing the overhead of a full GraphQL engine.
We started by implementing a fields query parameter that acts as a projection layer. Instead of returning the full resource, the API inspects the fields parameter and prunes the response object before serialization.
We first tried a naive approach: regex-based filtering on the final JSON string. It broke almost immediately.
user.orders, but orders required a specific auth scope or a different database shard, the logic became spaghetti.To solve this, we moved to a dependency-aware resolver. Each field in our resource model now maps to a "provider" function. When a request comes in, we parse the fields string into a tree structure and execute only the providers required for that specific request.
Here’s a simplified look at how we handle this in a Go-based service:
Gotype User struct { ID string `json:"id"` Name string `json:"name"` Orders []Order `json:"orders,omitempty"` } // Resolver logic snippet func ResolveUser(ctx context.Context, fields []string) (*User, error) { user := &User{ID: "123", Name: "Mahamudul"} // Check if 'orders' is in the requested fields if contains(fields, "orders") { user.Orders = fetchOrdersForUser(user.ID) } return user, nil }
This pattern forces you to be explicit about your API Design Schema Evolution. By mapping fields to specific data-loading functions, you naturally create a dependency graph. If orders is requested, the system knows it needs to hit the order database; if not, it skips that query entirely.
Adopting this pattern provides a massive boost to system scalability. By reducing payload sizes and avoiding unnecessary database joins, we cut our average response time by roughly 1.8x.
However, there is a cost. You’re moving complexity from the database to the application layer. You must ensure that your resolver functions are idempotent and don't introduce N+1 query problems. I recommend using a data loader pattern (similar to the Facebook/DataLoader implementation) to batch requests if multiple fields require data from the same source.
Q: Doesn't this make caching harder?
A: Yes. Your cache keys must now include the fields parameter. We use a hashed version of the requested field list as part of our cache key to avoid collisions.
Q: How do you handle validation for the fields parameter?
A: We treat the fields parameter as a whitelist. If a client requests a field that doesn't exist or isn't allowed for their role, the API returns a 400 Bad Request. Never let the client dictate arbitrary internal data access.
Q: Is this overkill for simple CRUD? A: Probably. If your resources are small and stable, just stick to standard REST. This pattern pays for itself only when your resource graphs become deep and the cost of fetching a "full" resource starts impacting your database throughput.
We’re still refining how we document these dynamic responses. OpenAPI/Swagger doesn't handle conditional field selection perfectly, so we’ve had to supplement our documentation with custom examples.
If I were starting this from scratch, I’d spend more time on the parsing layer. We spent about two days just fixing edge cases in how nested fields were parsed from the query string. It’s not a perfect solution, but for our scale, it’s the bridge that keeps our REST architecture performant without the complexity of switching tech stacks entirely.
Master REST API design using content negotiation. Learn how to leverage Accept headers to handle schema evolution and multiple data formats like a pro.