Back

Designing Effective REST APIs: A Guide for Software Developers

Posted by
Nate McGuire in Development category

REST APIs have become increasingly popular for their simplicity and ease of use. At the heart of every REST API lies the concept of resources. We discusses the importance of selecting the right resources, modeling them at the right granularity, and designing APIs that are both efficient and maintainable. It also explores the balance between fine-grained and coarse-grained resources, preventing the migration of business logic to API consumers, and how to handle complex business processes in a RESTful manner.

Resources and Resource Modeling

In REST API design, resources are the central abstraction. Resources can be anything that can be named, such as a document, service, or even a non-virtual object like a person. The key to designing an effective REST API is to identify and model resources at the right granularity while focusing on the needs of API consumers.

Resources can be singletons or collections, and may also contain sub-collections. Identifying resources begins by analyzing your business domain, extracting relevant nouns, and considering the needs of API consumers. Once resources are identified, interactions with the API can be modeled as HTTP verbs against these nouns.

Fine-grained CRUD Resources vs. Coarse-grained Resources

When designing an API, it is crucial to strike the right balance between fine-grained and coarse-grained resources. Fine-grained resources lead to a chattier API, with more interactions between API consumers and providers. On the other hand, overly coarse-grained resources may not provide enough variations to support all API consumers’ needs and can become difficult to use and maintain.

To illustrate this, consider a blog post API. A fine-grained approach might involve multiple APIs for creating blog posts, attaching images, and adding tags, resulting in more API requests. Conversely, a coarse-grained approach would combine these functionalities into a single API request, reducing the load on the server.

Preventing Migration of Business Logic to API Consumers

When designing a REST API, it is important to prevent business logic from spilling over to the API consumer. If the API consumers are expected to manipulate low-level resources, interactions between the API consumer and provider become chattier, and business logic can leak into the API consumer side. This can lead to data inconsistencies and increased maintenance efforts, especially when there are various types of API consumers and when APIs are public.

Coarse-grained Aggregate Resources for Business Processes

To maintain coarse-grained interactions and avoid low-level, CRUD-like service interactions, business processes can be modeled as resources. This allows complex business processes spanning multiple resources to be treated as true resources that can be tracked in their own right. Distinguishing between resources in REST API and domain entities in domain-driven design is essential, as API resource selection should not depend on the underlying domain implementation details.

Escaping CRUD: The Next Level

So you want to escape the low-level CRUD world? No problem! Let’s get down to business (operations) and talk about “intent” resources. These bad boys express a business or domain level “state of wanting something” or “state of the process towards the end result.” But first, you need to figure out who really owns all your state. With four-verb (AtomPub-style) CRUD, it’s like you’re letting random strangers mess around with your resource state through PUT and DELETE, like your service is just a low-level database. Ain’t nobody got time for that! The client should be a source of user intent, not a manipulator of internal representation.

Consider a customer in the banking domain who wants to change her address. You can either do this the CRUD way, by directly updating the address of the customer using a HTTP PUT request, or you can take the alternate route by designing the API around business processes and domain events. For example, make a POST request to a “ChangeOfAddress” resource. This resource can capture the complete address change event data, like who changed it and what was the change. It’s especially useful when you need that juicy event data for immediate business needs or long-term analytics.

Escaping CRUD means making sure that the service hosting the resource is the only agent that can directly change its state. You might need to separate resources out according to who truly owns that particular bit of state. Then, everyone just POSTs their ‘intents’ or publishes the resource states they own, maybe even for polling.

Nouns versus Verbs: The Eternal Debate

Oh, the endless argument over Nouns and Verbs! Let’s take an example: setting up a new customer in a bank. You can call the business process either EnrollCustomer (verb-ish) or CustomerEnrollment (noun-ish). In this case, CustomerEnrollment sounds better because it reads better and maintains business-relevant, independently query-able, and evolving state. It’s like a typical form you fill out in a business, which triggers a process. Think about the paper form analogy, it helps you focus on the business requirements in a technology-agnostic way.

A typical customer enrollment might involve sending a KYC request, registering the customer, creating an account, printing debit cards, sending a mail, and so on. These steps may be overlapping and the process might be long-running with several failure points. This is a more concrete example of where we may model the process as a resource. A GET request for such a process will give you the current state of the process.

Without the Customer enrollment process modeled as a resource, the API consumer has to “know” the business logic involved. If they miss a step, like the “print card request”, you’ve got an incomplete enrollment and an unhappy customer. That’s not good for business!

So, if the process needs its own state and the business wants answers to questions like “what’s the status of the process?”, “why did it fail?”, or “how long did it take?”, then model it as a resource in its own right.

And that’s where the noun-based approach starts getting limiting. Sure, business processes focus on the verb, but they’re also “things” to the business. The distinction between nouns and verbs starts to blur, and it’s really about how you perceive it. If you’re talking about a long-running process, it makes more sense to say “how is Sue’s enrollment coming along?” using a noun.

Reification of Abstract Concepts: Making Things Real

With a coarse-grained approach focusing on business capabilities, we’re modeling more reified abstract notions as resources. A good example is CustomerEnrollment. Instead of using the Customer resource, we’re using a resource that represents a request to enroll a customer. Let’s consider two more examples from the banking domain:

  1. Cash deposit in bank account: When a customer deposits money into their account, it involves operations like applying business rules (e.g., checking if the deposited amount is within the allowed limit), updating the customer’s account balance, adding a transaction entry, and sending notifications. While you could technically use the Account resource here, a better option is to reify the business capability or abstract concept of a transaction (or money deposit) and create a new resource called “Transaction”.
  2. Money transfer between two bank accounts: When a customer transfers money from one bank account to another, it involves updating two low-level resources (the “from” and “to” accounts), business validations, transaction entries, sending notifications, etc. If the “to” account is in another bank, the transfer might be channeled via a central bank or an external agency. Here, the abstract concept is the “money transfer” transaction. To transfer money, we can POST to /transactions or /accounts/343/transactions and create a new “Transaction” (or “MoneyTransfer”) resource.

In both cases, we’re using a resource equivalent to a command to deposit money or transfer money – the Transaction resource – instead of the Account resource. This is especially useful if the process could be long-running. This doesn’t mean you can’t have an Account resource as well; it could be updated as a result of the Transaction being processed. There might also be genuine use cases for making API requests to the Account resource, like getting account summary or balance information.

One key switch in thinking is understanding that there’s an infinite URI space you can take advantage of, but it’s good to avoid resource proliferation that may add confusion to the API design. As long as there’s a genuine need for resources with clear user/consumer “intent” that fits well in the overall API design, you can expand the URI space. Reified resources can be used as the transactional boundary for your service.

There’s another aspect to consider: how you organize server behavior is separate from how the API works. We’ve discussed having a Transaction resource for money deposits, but it’s also valid for money deposits to be handled by a POST to the Account resource. The service handling the Account is then responsible for coordinating changes and creating Transaction, Notification, and other resources (which may be in the same service or separate services). The client shouldn’t have to do all this itself. The API provider needs to pick one service to handle coordination responsibility, which in our example is given to the service handling the Transaction resource.

This is just like in-memory object design. When a client needs to coordinate changes over a bunch of objects, a common approach is to pick one object to handle the coordination.

Conclusion

Designing an effective REST API requires careful consideration of resource selection, granularity, and the balance between fine-grained and coarse-grained resources. By focusing on the needs of API consumers and modeling business processes as resources, developers can create efficient, maintainable, and user-friendly REST APIs that stand the test of time. This involves understanding the nuances of your domain and ensuring that your API reflects the true essence of the business processes.

To achieve this balance, it is important to consider the following aspects:

  1. Reify abstract concepts: Transform abstract business concepts into tangible resources that can be easily understood and manipulated by API consumers. This not only promotes a clear understanding of the domain but also simplifies coordination of complex operations.
  2. Organize resources around business processes: Focus on modeling resources that represent meaningful business events and processes, rather than simply exposing low-level data entities. This approach makes the API more intuitive and less prone to errors.
  3. Use the right mix of granularity: Strive for a balance between fine-grained and coarse-grained resources. This ensures that your API is both efficient in handling complex operations and flexible enough to support future changes.
  4. Encapsulate internal domain knowledge: Minimize the amount of internal domain knowledge required by API consumers. This keeps the client code less brittle and more resilient to changes in the underlying domain model.
  5. Embrace the infinite URI space: Take advantage of the unlimited URI space to create resources that represent clear user intents and fit well within the overall API design. This helps avoid resource proliferation and confusion.
  6. Separate server behavior from API design: Organize server-side behavior independently from the API, allowing the server to coordinate changes and manage resources as needed. This reduces the complexity and responsibility placed on API consumers.

By carefully considering these aspects and focusing on the needs of API consumers, developers can create REST APIs that are efficient, maintainable, and user-friendly. These APIs will not only be easier to work with but will also be more adaptable and resilient to changes in the underlying business processes, ensuring that they remain valuable and relevant in the long run.

Headquartered in San Francisco, our team of 50+ are fully distributed across 17 countries.