When API Migration Meets an Unfamiliar Domain: The Dwolla Migration Story
What happens if the most challenging aspect of a migration isn’t the code itself?
Hi, I’m Sahil K., and I’m back with yet another API migration story! 🙂
If you’ve read my prior migration posts: Projects API Migration (single-endpoint refactor) and Corporate Credit Cards API Migration (wide-scope migration across payment flows), you know how and why we are migrating APIs. As a developer, for me, each API migration comes with something new. And with every migration, I learned new things and thought I had a decent mental model of what these things looked like. For every other aspect, Dwolla API Migration follows the same disciplined process, but with one important twist: the Dwolla migration required learning payment-domain concepts (ACH, micro-deposits, beneficial owners) to get the frontend behavior correct.
Why am I Writing This? ✏️
Here’s my honest answer: every time I’ve written one of these kinds of blogs, it has forced me to reflect on what actually made that initiative hard. Not the surface-level “we changed endpoints …” kind of hard, but the what did I not know going in kind. And looking back, the Dwolla migration had more of those moments than any I’ve done before.
The Projects API migration was my introduction to the world of API migration as an intern, involving a single GET API, response transformation, and a couple of personas. The CCC migration was more extensive in scope, involving 10+ APIs, card and transaction workflows, and a complex export feature; however, the domain was something I could reason about fairly quickly. Credit card transactions, assignments, and statements, the words made sense.
Dwolla was different. The moment I opened the PRD (Product Requirements Document), I was staring at words I didn’t know: ACH, micro-deposit verification, beneficial owner, Dwolla customer, funding source. It felt less like an API migration and more like being handed a map in a language I hadn’t learned yet.
And that’s why I’m writing this. Not just because the technical work was interesting (though it was), but because this migration taught me something I hadn’t expected: that understanding the domain is the real prerequisite, not just reading the API docs.
This is the story of a journey where an API migration turns into a domain crash course!
What Even Is Dwolla? 📚
Before I could migrate anything, I had to understand what I was migrating for.
Dwolla is a payment infrastructure provider that powers ACH (Automated Clearing House) bank transfers. In our app, it’s what enables employees to receive their expense reimbursements directly to their bank accounts, no check, no delay, just a bank transfer.
The core entity I’d be working with is called a Dwolla Customer. A metadata record that tracks the verification status of a user or an organization. And the verification journey is surprisingly involved:
Has the customer’s identity been verified?
Has a bank account been linked?
Has that bank account been verified via micro-deposits (where the bank sends two tiny deposits and the user confirms the amounts)?
Has a beneficial owner been added and verified (required for certain business types)?
Has the account been suspended, or does it need a retry after a failed verification?
Each of these states drove different UI behaviors across more than a dozen components: dashboards, report submission dialogs, settings pages, and admin views. If I had just started swapping endpoints without understanding this, I would have been flying blind.
How Was This Different From My Previous Migrations ⁉
Let me be upfront about this,
Projects API migration taught me the basics, how to write an ED, how to transform API responses, and why we migrate in the first place. The scope was manageable: one GET endpoint, one persona, a straightforward field rename.
CCC API migration taught me the scale. 40+ API calls audited, 10+ APIs migrated, 2 categories of actions (card-level and transaction-level), and a particularly complex export feature. But the domain was familiar territory. I could look at “Assign card” or “Delete transaction” and immediately understand what the code was supposed to do.
Dwolla API migration was both at once: wide in scope and unfamiliar in domain. It added two layers of complexity I hadn’t encountered before: a subtle change in how null Responses were handled, and a persona-specific field problem that required understanding why two different user roles needed different data from what looked like the same endpoint.
More on both of those in a bit.
The Old World vs. The New 👵🏻
The old API had endpoints that required resource IDs in the URL:
GET /api/orgusers/{id}/dwolla_customers ← spender context
GET /api/orgs/{id}/dwolla_customers ← admin context
The new Platform API drops the IDs (authentication context handles scoping) and uses persona-specific paths:
GET /platform/v1/spender/dwolla_customers
GET /platform/v1/admin/dwolla_customers
Cleaner, more RESTful, and a consistent pattern with the rest of our Platform APIs. Easy enough to reason about.
But the API response key field names, that’s where things got interesting.
Why not just Find-and-Replace 🤨
The new Platform API standardized all boolean fields with a is_* prefix.
Ten boolean fields, all renamed. And these API response fields were used across 16+ component files.
My first instinct was simple global find-and-replace. Done in five minutes, right? Not quite.
This approach had two main problems: firstly, it creates a lot of noise in component files, which makes it harder to review the change systematically. And second, most importantly, if any of these field names had already been used as local variable names or component properties, a blind find-and-replace would create duplicate declaration issues. The code would break in ways that aren’t immediately obvious during review.
Hmm, okay, then I thought, why not transform the response in the service layer itself, just like I did during the Projects API migration? However, the challenge with the Projects API migration was that it involved many keys, and the changes in their naming were not as straightforward as simply adding a "is_*" prefix. We decided to transform the names to facilitate a quicker migration of the API. Unfortunately, these transformed API methods have become an oversight in the codebase, making it harder to read and maintain. Ultimately, we will need to eliminate them in the future.
So instead, I created a new model DwollaCustomerPlatform , that exactly reflected the API response shape with is_* field names. The service layer owns the HTTP call and returns this typed model. Components consume it and use is_* fields directly. This way, if the API shape ever changes again, there’s one place to update.
export interface DwollaCustomerPlatform {
is_customer_verified: boolean;
is_bank_account_added: boolean;
is_bank_account_verified: boolean;
is_customer_suspended: boolean;
// ...
}Clean, explicit, typed. Anyone reading the code knows exactly what the API returns.
The tradeoff was risk. Updating field accesses across 16+ files meant that missing even one would cause a silent failure, and Dwolla data is financial data. A missed field update is_bank_account_verified doesn’t just show a wrong value; it could break the consistency of a transaction flow entirely. So the approach demanded care: methodical file-by-file updates, TypeScript’s type checker flagging every unchecked access, and thorough testing at the end. But it was the right call, temporary pain for a codebase that would actually be maintainable on the other side.
The Null Problem (Which Was Actually a Correctness Problem) 🫙
Here’s the change that required the most careful attention.
The old API would throw an error if no Dwolla customer record existed for a user or org. Components had been written to treat that error as a signal: no record exists yet. It was an implicit convention, not great, but it worked.
The new Platform API changed this: it returns HTTP 200 with a null body when no record exists. Which is actually a better API design! But it meant that every component’s error-handling logic was now wrong.
Where components previously caught an API error as “no data,” they’d now receive a successful response with null. If you didn’t handle that, you’d get runtime errors trying to access properties on null.
The fix was systematic: update return types to Observable<DwollaCustomerPlatform | null>, add optional chaining on every property access (dwollaCustomer?.is_customer_suspended), and add explicit null guards at decision points where null was unacceptable.
switchMap((dwollaCustomer: DwollaCustomerPlatform | null) => {
if (!dwollaCustomer) {
return throwError(() => new Error('No Dwolla customer record found'));
}
// safe to proceed
})The TypeScript compiler was my best friend here. Once I updated the return type, it surfaced every unchecked access across all 16 files. I couldn’t miss one even if I tried.
The Persona Problem (The Interesting One) 🦹🏼
This is the part I find most interesting to talk about, because it required understanding the domain, not just the code.
When a spender (employee) navigates to their profile page, the app needs to check whether the organization’s bank account is verified, not the spender’s own. This determines whether the spender can even see their personal bank account section. The org’s verification status is the gate.
In the old code, both admin and spender contexts used the same field name customer_verified because the underlying data was the same, and both roles could hit the same endpoint. Nobody had documented this assumption. It was just baked into the code.
In the new Platform API, things are split. The admin endpoint (/admin/dwolla_customers) is now admin-only. Spenders can’t touch it. And the spender endpoint returns a separate field, is_org_customer_verified, specifically to handle this use case, since spenders still need to know the org’s verification status without being able to access the admin endpoint directly.
Here’s where it gets interesting: the old code in one shared component used customer_verified for both roles. For the admin role, that was correct. For the spender role, it was already semantically wrong; it just happened to work because both roles could access the same endpoint. Once the admin endpoint became inaccessible to spenders, that component would silently break: a 403 in the background, and the bank account section just… disappearing for spenders, with no obvious error to debug.
We caught this during the ED phase. And the fix had two parts.
First, flagging it as a backend dependency: the Platform API needed to be exposed is_org_customer_verified on the spender endpoint so the frontend had something to work with. This couldn’t be solved purely on the frontend side.
Second, once that field existed, fixing the condition in the shared component:
const isVerified =
this.userRole === 'ADMIN'
? dwollaCustomer.is_customer_verified
: (dwollaCustomer.is_org_customer_verified ?? false);One migration, one pre-existing bug surfaced and fixed, one silent production failure avoided. This is exactly the kind of thing that doesn’t show up in a ticket description; it only appears when you understand what each component is actually doing and why, not just what endpoint it’s calling.
What I Took Away 👨🏻🎓
Every migration teaches me something different. Here’s what this one added to the list:
Domain understanding is the real prerequisite. I couldn’t have caught the spender profile access-control gap or understood why
is_org_customer_verifiedexisted as a separate field without first understanding what ACH onboarding actually looked like for users and organizations. The code is just the implementation of business rules, and you have to know the rules to know if the implementation is right.Null contracts are API contracts. The shift from “error on missing record” to “null on missing record” isn’t a small detail. It changes how every consumer has to be written. Treating it as a minor footnote in the API changelog would have been a mistake.
The service layer is the right place to own API complexity. Field renames, payload wrapping, and endpoint selection by persona all live in the service. Components stay clean. Reviews are easier. Future changes have one place to go.
Migrations surface what the original code assumed. The old code assumed the API would not return null. It was assumed that
customer_verifiedmeant the same thing for all roles. These assumptions were not documented; they were just baked into the code. A migration forces you to read carefully enough to notice them.
Wrapping Up 🗞️
This migration was the most involved one I’ve done so far, with wider code impact than Projects, a more complex domain than CCC, and a set of subtle contract changes that required careful attention rather than mechanical execution.
But it’s also the one I learned the most from. ACH, Dwolla, micro-deposits, and beneficial owners were all new words to me when I started. By the end, I could draw the full data flow from a user clicking “Add Bank Account” to the Dwolla API call and back.
That kind of domain knowledge doesn’t come from reading API docs. It comes from slowing down, asking questions, and refusing to treat a migration as a purely mechanical task.
A big thank you to Dimple for the help, and to Kirti and Prabhakar for patiently fielding every backend query I had and keeping the API contracts tight. Couldn’t have caught half of what I caught without those conversations.
Here’s to more of that.
Happy coding! 🚀



Ah, those old APIs look familiar 😄.
Respect to you and the team for successfully migrating it. Understanding dwolla would've been half the battle. Great work! : 👏