The rise in popularity of frontend libraries and frameworks like React, Vue and Angular make it easier than ever before to build rich and interactive web apps. Pair these powerful libraries with a nice API to pull some data, and you can pretty quickly build out complex use cases. However, the ability to do so many things on the client side doesn't always mean you should.
In this article we'll reflect on API design, separation of concerns, and how to build APIs that better serve the clients they're intended to be used by.
Taking it back to RPC
In the days of RPC APIs, clients would call functions on the backend that did exactly what they needed to do. This resulted in a somewhat "client-first" approach, where these functions could be tailor-made to support the client's functionality.
For example, if a client needed to get a list of blog posts from a certain author, a route
/getAllPostsByAuthor could be made. If they instead needed to get the most recent posts,
/getMostRecentPosts could be added. If an action to delete a post was necessary,
/deletePostById would do the job.
Implementation on the client side would be pretty simple, since clients could just call the functions on the server that accomplished what they needed to do. However, this also meant that the client and server end up being tightly coupled, which introduces a whole other set of issues. It could also be easy to end up with tons of similar functions (and therefore API routes) to support slightly different use cases, which can be hard to maintain.
Somewhere along the way, we got tired of having all these functions and routes that were hard to discover and maintain, so we started moving over to REST. The more common RESTful approach used today leads to building more general APIs to support a wide range of use cases with the same interface. This means that your frontend and backend can evolve independently as long as that contract is maintained.
It works by exposing "resources" instead of functions, and leveraging standard HTTP methods like
DELETE to define consistent APIs that can easily and neatly cover most use cases.
All three example endpoints we saw for managing posts with an RPC approach could be boiled down into a single
/posts endpoint. Then, we could request
GET /posts?authorId=1 to get all posts by a certain author,
GET /posts?limit=5 to get the five latest posts and
DELETE /posts/12 to delete a certain post.
With this approach, if the client needed data for a post and its comments, they could issue multiple requests to get the data they need. For example,
GET /posts/20 and
While this works great for simple CRUD operations, things can get messy when clients need to execute actions or make multiple requests to different resources to accomplish a given task.
When clients are too smart
As a tool for keeping important data synchronized between systems, Grouparoo's various React apps need to support some pretty complex flows. They're used to manage and configure what exactly happens when profiles are being synced, which has real effects on your data.
For example, in the page used to configure a given destination, you are asked to select which group of profiles the destination should track. This determines who gets synced to the destination and who does not.
This may seem simple at first, but it means that when doing a simple action like changing the group, a number of things need to happen. If the tracked group has changed, we may need to kick off a run to get the latest data for all the affected profiles and export them to the destination. Similarly, if the destination is no longer tracking a group, we need to go to the destination and remove all the profiles that had been sent over in previous exports.
We were leaning more on the REST / reusability side, so to support this our API had exposed three endpoints:
- One for updating the destination's data, setting options and configuring which properties would be sent over.
- Another for setting the newly-tracked group and kicking off an import/export of profiles, if needed.
- Another for removing the previously-tracked group from the destination and kicking off an export to remove the profiles for the destination, if needed.
Our simple button at the bottom of the page to save destination data now had to determine if the group had been changed and execute a number of API requests depending on what had been done. This architecture later caused some weird bugs that were hard to track down.
What could possibly go wrong?
Having a nice API that could support so many use cases and a smart frontend that could decide what things to do now led to executing multiple, sequential API requests from a single button click. At first glance this may not seem so bad since we're bringing more flexibility to how the API can be used. But as always, the devil is in the details.
⏳ Network speeds
A somewhat obvious issue that comes with having to do more requests is that you end up having to do multiple roundtrips to the server. If the client has a spotty connection, this could lead to longer response times and increases the amount of things that can go wrong.
❌ Partial failures
Something could always go wrong when issuing any of those requests. If your first request fails, then maybe everything's fine. But what if the first one succeeds but the second one doesn't? Do we need to rollback the first one? What if that fails? Nasty.
↔️ State changes between requests
Another problem that comes with issuing multiple requests is that there's no guarantee that the state will be the same when that second request arrives to the backend. What if another user issued a request to update the same resource and it happened to be processed before yours? The state of that resource could be different at this point, meaning that your second request could produce unintended results.
A very similar issue was happening at Grouparoo, where the group that the client thought the destination was tracking was actually some other group. This meant that we could potentially miss triggering an export in certain scenarios.
Building better APIs
Fine, maybe it's not such a good idea to have the client make these decisions. But then how could we represent these actions if we want to build a RESTful API? Ultimately, the solution lies in good API design.
Though there are things that can and should be controlled in the frontend, others should really be decided directly in the backend. Actions that are dependent on the current state, sensitive to interactions between multiple clients or those that involve complex business logic could be good candidates for things that shouldn't be decided on the client. However, this can vary depending on what you're building. The most important thing is to keep the clients and consumers in mind while designing the API that they'll end up using.
Make it part of the resource
Your API resources don't have to match 1:1 to your internal models. Well-designed APIs should abstract away internal details and expose a simple interface that can be used to talk to your service. If that means adding an additional field to make things easier on the clients, that's totally fine.
This is actually what we ended up doing to resolve our bug. Instead of having a separate route to track and un-track a group, we added a
trackedGroupId property to the destination's
PUT request that we could use to execute the necessary side effects that had to take place.
Or, maybe RPC isn't all bad
Fun fact: It's actually pretty common for so-called "RESTful" APIs to expose actions as additional endpoints for a resource. While REST purists may frown upon it, there are some cases where actions that need to be performed just can't be represented as a resource or as part of one. In these cases, it's fine to take some inspiration from RPC.
For example, in the context of syncing data, you could imagine calling
POST on an endpoint
/destination/hubspot/export to trigger an action that exports all profiles to the destination, even though this may not map to a resource. Of course, it's important to keep in mind how many of these actions you're creating and if they really can't be better expressed as resources. If all you're doing is creating actions, maybe REST isn't an appropriate choice for your API at all and you're better off using RPC.
At Grouparoo we deal with lots of APIs to integrate with third-party systems, and we've seen our fair share of excellent and not-so-great APIs. It's always great when integrating with a platform that has thought through these use cases and exposes an API that's easy to work with.
Top comments (2)
I think when reposting you need to update links, ie. integrate goes to dev.to/integrations :-)
A lot of good points touched, and i think some of them are reason why graphql exists - maybe for quicker development, maybe for better end-user experience, but using graphql is pretty refreshing after years of thinking about resources and calling 5 different endpoints to gather what i need for my ui.
Nice post! Totally agree that good API design is something that should be emphasized more. Exposing domain models directly from your infrastructure creates the spread of business logic in the interaction layer versus keeping the business logic in the domain layer. Without good API design you are inviting unintentional technical debt to build up over time.