No Abstractions: an Increase API design principle

API resources are the nouns of your API. Deciding how to name and model these nouns is arguably the hardest and most important part of designing an API. The resources you expose organize your users’ mental model of how your product works and what it can do. At Increase, our team has used a principle called “no abstractions” to help. What do we mean by this?

Much of our team came from Stripe, and when designing our API we considered the same values that have been successful there. Stripe excels at designing abstractions in their API — extracting the essential features of a complex domain into something their users can easily understand and work with. In their case this most notably means modeling payments across many different networks into an API resource called a PaymentIntent. For example, Visa and Mastercard have subtly different reason codes for why a chargeback can be initiated, but Stripe combines those codes into a single enum so that their users don’t need to consider the two networks separately.

This makes sense because many of Stripe’s users are early startups working on products totally unrelated to payments. They don't necessarily know, or need to know, about the nuances of credit cards. They want to integrate Stripe quickly, get back to building their product, and stop thinking about payments.

“For Increase users, trying to hide the underlying complexity of these networks would irritate them, not simplify their lives.”

Increase’s users are not like this. They often have deep existing knowledge of payment networks, think about financial technology all the time, and come to us because of our direct network connections and the depth of integration that lets them build. They want to know exactly when the FedACH window closes and when transfers will land. They understand that setting a different Standard Entry Class code on an ACH transfer can result in different return timing. Trying to hide the underlying complexity of these networks (by, for example, modeling ACH transfers and wire transfers with a single API resource) would irritate them, not simplify their lives.

Early conversations with these users helped us articulate what we dubbed the “no abstractions” principle as we built the first version of our API. Some examples of the way this mindset has subsequently affected its design:

Real-world naming

Instead of inventing our own names for API resources and their attributes, we tend to use the vocabulary of the underlying networks. For example, the parameters we expose when making an ACH transfer via our API are named after fields in the Nacha specification.

Immutability

Similar to how we use network nomenclature, we try to model our resources after real-world events like an action taken or a message sent. This results in more of our API resources being immutable. An approach that’s worked well for our API is to take a cluster of these immutable resources (all of the network messages that can be sent as part of the ACH transfer lifecycle, for example) and group them together under a state machine “lifecycle object”. For example, the ach_transfer object in our API has a field called status that changes over time, and several immutable sub-objects that are created as the transfer moves through its lifecycle. A newly-minted ach_transfer object looks like:

{
"id": "ach_transfer_abc123",
"created_at": "2024-04-24T00:00:00+00:00",
"amount": 1000,
"status": "pending_approval",
"approval": null,
"submission": null,
"acknowledgement": null
// other fields omitted here for clarity
}

After that same transfer has moved through our pipeline and we’ve submitted it to FedACH, it looks like:

{
"id": "ach_transfer_abc123",
"created_at": "2024-04-24T00:00:00+00:00",
"amount": 1000,
"status": "submitted",
// immutable, populated when the transfer is approved
"approval": {
"approved_by": "administrator@yourcompany.com",
"approved_at": "2024-04-24T01:00:00+00:00"
},
// immutable, populated when the transfer is submitted
"submission": {
"trace_number": "058349238292834",
"submitted_at": "2024-04-24T02:00:00+00:00"
},
// immutable, populated when the transfer is acknowledged
"acknowledgement": {
"acknowledged_at": "2024-04-24T03:00:00+00:00"
}
// other fields omitted for clarity
}

Separating resources by use case

If, for a given API resource, the set of actions a user can take on different instances of the resource varies a lot, we tend to split it into multiple resources. For example, the set of actions you can take on an originated ACH transfer is different (the complete opposite, really) than the actions you can take on a received ACH transfer, so we separate these into ach_transfer and inbound_ach_transfer resources.

This approach can make our API more verbose and intimidating at first glance — there are a lot of resources on the left-hand side of our documentation page! We think it makes things more predictable over the long-term, though.

Importantly, our engineering team has committed to this approach. When you design a complex API over several years, you make small incremental decisions all the time. Committing to foundational principles upfront has reduced the cognitive load for these decisions. For example, when sending a wire transfer to the Federal Reserve, there’s a required field called Input Message Accountability Data which serves as a globally-unique ID for that transfer. When building support for wire transfers, an engineer in an abstraction-heavy API might have to deliberate how to name this field in a “user-friendly” way — trace_number? reference_number? id? At Increase that hypothetical engineer names the field input_message_accountability_data and moves on. When an Increase user encounters this field for the first time, while it might not be the most immediately recognizable name at first, it helps them understand immediately how this maps to the underlying system.

No Abstractions isn’t right for every API, but considering the level of abstraction that’s appropriate for the developers integrating against it is a valuable exercise. This will depend on their level of experience working with your product domain and the amount of energy they’ll be committing to the integration, among other things. If you’re building an abstraction-heavy API, be prepared to think hard before adding new features. If you’re building an abstraction-light API, commit to it and resist the temptation to add abstractions when it comes along.