Resolving Ambiguity in Complex API Design
Twice I’ve encountered these issues, and twice I’ve cobbled together something that works. Still, I don’t feel like the solutions were elegant or developer-friendly.
Problem 1: Object Model Ambiguity
In systems with user models that involve multiple roles, especially when some users (producers) are curating collections of objects and others (consumers) are interacting with these objects, it can be challenging to define the relationships. If a producer is curating a set of playlists, one might assume that user.playlists would contain the playlist objects the producer owns. On the other end, the consumer might want to follow a set of playlists curated by others. From the consumer’s perspective, user.playlists might contain playlists the consumer is following.
Solution 1: Functional Relationships
In the example given above, the ambiguity lies in the naming of the relationships between User and Playlist. Instead of trying to combine the relationships into a conditional model, where the relationship means one thing in one situation and another thing in another situation, we simply separate the relationships into distinct associations. We further clarify the relationships by choosing names that more precisely describe the functional relationship between the objects. For playlists curated by a producer, we define user.curated_playlists. For playlists followed by a consumer, we define user.followed_playlists.
Problem 2: Single vs Multiple Requests
When interacting with an API, there are different ways to specify the focus of client requests. RESTful design patterns recommend representing the relationship of objects in their resource paths. Requesting /user may return only the user attributes, or it may return a set of nested collections or objects. Requesting /user/playlists returns playlists for the current user.
GET /user
{
user: {
first_name: '...',
last_name: '...',
email: '...',
playlists: [{id: 1, name: '...'}, {id: 2, name: '...'}]
}
}
or
GET /user
{
user: {
first_name: '...',
last_name: '...',
email: '...'
}
}
GET /user/playlists
[
{playlist: {id: 1, name: '...'}},
{playlist: {id: 2, name: '...'}}
]
Solution 2: Offer Both and Let Client Decide
API design focuses on meeting the needs of many. Some clients will want to make a single request to sync content. Others will want to interact with specific collections in the object model hierarchy. By providing both mechanisms, the client can choose the endpoint(s) that best fit their specific needs.
Bonus Points
In a perfect world, the client can configure the response content at the interface level. Using the example given above, we might design the endpoint controller to respond to query string params to specify which fields to include.
GET /user?only=id,email&includes[playlists][only]=id,name&includes[playlists][methods]=follower_ids
{
user: {
id: 1,
email: '...',
playlists: [{id: 1, name: '...', follower_ids: [2,3,4]}, {id: 2, name: '...', follower_ids: [4]}]
}
}