Introducing API Boundaries to Prepare for Bigger Changes
Alternatively: API Boundaries for Fun and Profit
I routinely misquote Kent Beck, who said "for each desired change, make the change easy (warning: this may be hard), then make the easy change." He said this on Twitter (RIP) so I won't be linking to it, but at least for now you can find the tweet with Duck Duck Go easily enough.
To be fair, though, my misquote is a small addition: "first make the change easy." I say this a lot—you can ask my coworkers.
Martin Fowler called this kind of work "preparatory refactoring."
Whatever you call it, the idea is that you'd like to make some change, but that change carries too much risk for you to feel confident. The only thing to do is decompose the change into a series of smaller, safer changes.
An example and a (small) rant
React hooks are an incredibly useful alternative pattern to managing asynchronous tasks in React applications. Some libraries, however, go so far in trying to be easy to use that they end up in a land of actively encourage bad practices. The one that is top-of-mind right now is Apollo—which isn't to say I wouldn't use it again. But you need a lot of discipline to avoid spaghetti code that mingles responsibility for data and for rendering UI.
A pattern Apollo encourages looks something like:
import { useQuery, gql } from "@apollo/client";
const QUERY = gql`
query GetMyData {
myData {
username
displayName
email
preferences {
colorScheme
uiLanguage
}
}
}
`;
export default const SomeComponent = () => {
const { data, loading, error } = useQuery(QUERY);
if (loading) {
return <Loader />;
}
if (error) {
// or `throw error;` and let an <ErrorBoundary> deal with it
return <ErrorPage error={error} />;
}
const displayName = data.myData.displayName || data.myData.username;
return (
<Page colorScheme={data.myData.preferences.colorScheme}>
<h1>Welcome {displayName}!</h1>
{# ... etc etc ... #}
</Page>
);
};
Certainly it's a pattern I've seen repeated—and repeated myself—often in production code.
The component above is doing at least three things: fetching data, constructing a (lightweight) ViewModel, and rendering UI. It's the whole Model-View-ViewModel (MVVM) together. We can isolate each piece.
Fetching the data (the Model or repository part) uses the Apollo useQuery
hook:
Then it does some massaging and reshaping of the data to get the display values we want—effectively acting like a small ViewModel. In the real world, this can be anywhere from a handful of lines of code to dozens:
And then finally, it returns a component that uses the fetched data to render a UI, the View:
I want to be very clear: this can be totally fine a lot of the time. I don't think this is the best pattern. But it's effective and can be a useful way to ship a lot of small, independent components or pages quickly. If you have a React/Apollo app that looks like this, I'm not trying to make you feel bad! But I am going to talk about some preparatory refactors, in case you need to do them.
So what's the problem?
Problems come up when we're testing, when we're trying to work in parallel, and when we're trying to change something significant. Since the last case usually includes the first two, we'll focus there.
Let's say we want to move away from GraphQL for whatever reason. Maybe our core business objects are not well-represented by a graph structure. Or we're not really seeing the benefits of GraphQL since we can't easily decide what data to fetch at runtime (without writing very complex queries, at least). Or some opinionated staff engineer (couldn't be me 😅) has a bee in their bonnet about a "better" approach to APIs.
If this drags on for too long, we'll be in a frustrating state where we have to build new UI components against different APIs, or maybe even both. So we are going to parallelize building out the new API backend for this UI (the "backend-for-frontend" or BFF) and updating the front-end. Let's get this done quickly.
But... there's no API yet, so how can I update this component to use it? (This also applies when we are building out a new API or new product or feature.) React doesn't like us putting hooks into a condition, so introducing a feature flag is hard.
Separating our new concerns
When we wrote this component, we weren't prioritizing separating these concerns. It wasn't an issue at the time. Now, though, the existing patterns are causing us problems. How do we "make the change easy?"
I tipped my hand a bit here—I already described this component as a MVVM. We can break it down into multiple parts along those lines. Let's do that—without worrying about our new API yet.
First, let's separate the data fetching "model" from the "view":
We can do this refactor without changing any of our existing tests—except possibly Jest snapshots—which is perfect. If we have tests that used Apollo's testing tools or mocked useQuery()
, those should run as-is. However, this also gives us an opportunity to write clearer tests with much smaller systems-under-test (SUT).
Making the <GraphContainer>
take an argument, rather than hard-coding the <GraphContainer>
to call the <View>
(e.g. return <View {...props}/>
) is not always an obvious choice. People sometimes push back on changes like this as unnecessary—and strictly speaking, it is. So why bother?
Adding the argument means we can shrink the SUTs and write narrower tests—some of which will survive the rest of our changes. Instead of needing to control what data is returned from useQuery()
, we can test the <View>
component directly by giving it props. And we can test the <GraphContainer>
as a purely functional component, rather than as UI, by giving it a stub child function and making assertions about the arguments it receives.
Thanks to TypeScript, we've also more-or-less created our ViewModel: the ViewProps
interface defines what it has to look like. If it was larger or more complex, we might actually write a dedicated mapping function. A dedicated mapper could be a pure function that is trivial to unit test.
This split also lets us work independently of our new API. The front-end work can continue in the <View>
component using fake data, until the new API is ready. We can introduce another layer of indirection to use a feature flag and pass us some dummy data:
Once this is safely deployed, now is a good time to go remove any tests of <SomeComponent>
that assumed it used useQuery
. In fact, <SomeComponent>
is so thin at this point that we may not feel the need to test it at all—if we are confident in useFlag()
—and instead rely on the unit tests of <View>
and our containers. If we do want to test <SomeComponent>
, the only behaviors we have are "it returns the correct container based on the flag value" and "it logs any error."
We can go deeper: boundaries all the way down
Introducing an API boundary—i.e. the <View>
component with a defined interface for its props—lets us write UI tests and snapshots that will stay the same throughout our refactor. Since those tests won't change, we can be more confident in our changes as long as they continue to pass. We've reduced the risk of changes by ensuring that even if we break fetching or transforming the data, our UI will stay consistent.
But our new <APIContainer>
component with its mock data is somewhat unsatisfying. It provides data synchronously, which is not what we really expect. It's reasonable that the loading
and error
values may need to be be passed down to the <View>
component, especially if this doesn't represent a whole page.
We can introduce another boundary here, one that returns our dummy data asynchronously. That is, we want an interface like:
The DataProviderHook
interface is ever-so-slightly different than what useQuery
gives us: it assumes the ViewModel conversion happens before the data is returned to our component. Moving the conversion inside the hook means that we don't need to know exactly what the API response looks like yet. Not only can we work in parallel with the API development, but this would let us rewrite the <APIContainer>
slightly:
This version of the <APIContainer>
is already complete—we won't need to return to this when the API gets finished! Check that off the list. But to do this, we need to implement a DataProviderHook
. If we want to enable human testing of the full set of states, we might do something like...
Cleaning up after ourselves
With useNewAPIForMyData()
(please, please don't really use that name) we have fully removed the dependency our UI work had on our API work. The engineers doing the API work can come in whenever they're ready—when the API exists and is tested—and replace the setTimeout()
call with one or more real API calls and deal with mapping the response to our ViewProps
interface. In the meantime, development and testing of the UI can continue. Even new UI features! The UI engineers don't need to worry about the details of the API design. All that's required is alignment on what data will be available or needed.
After our new API is fully rolled out, and our intermediate flag-checking container is removed, we can delete the <GraphContainer>
and all its tests, without touching any other tests. Since we've limited its behavior and tested it in isolation, we can just drop it. The tests for <View>
and <APIContainer>
don't change—and if we removed the <SomeComponent>
tests earlier, that means no tests change—so there's very little risk of one of our new components breaking.
No one thinks that they're going to have to deal with a major API refactor two years from now. And most of the time, they're right. We simply don't have the time to go back and replace GraphQL with something else if it's not going to be a significant improvement in quality, velocity, or capability. But eventually, an architectural shifts will happen. Introducing API boundaries within an application can be a powerful tool for decomposing big changes and reducing risk.
Appendix: An alternative way to implement Apollo queries and encapsulate data fetching
I started this off with a small rant about Apollo, saying that it encourages the kind of "spaghetti code" we started with. Three responsibilities, a Model, View, and ViewModel, were combined because useQuery
makes doing that so easy. And it is easy! I've written code exactly like that. There are absolutely times when that's the right thing to do.
In a less-easy world, if I wanted to use a single custom hook as my API boundary, I might start with something like this:
An even smaller step might even be to write this custom hook with useQuery()
at first—which is something I've seen people do anyway because it can make testing easier. Avoiding useQuery()
here lets me put a conditional in here:
However, while this is an alternative, and may make sense for given constraints, it lacks some of the nice testing properties. If we encapsulate the API details inside useGetMyData
but don't split out the <View>
and <APIContainer>
components, we'll have to mock more—the flag state, the newAPIClient
, the apolloClient
's provider—resulting in more complex setup and at least as many test cases.
We need test cases for both values of flags.useNewAPI
. For each provider, we should test three states (loading = {data: null, loading: true, error: null}
, errored = {data:null, loading: false, error: new Error()}
, and data = {data: mockData, loading: false, error: null}
). Since the flag changes independently of the providers and the providers can change independently of each other, an exhaustive test suite for useGetMyData()
(and potentially for <SomeComponent />
) would need up to 2 * 3 * 3 = 18 cases:
Case | useNewAPI |
newAPIClient state |
apolloClient state |
---|---|---|---|
1 | false |
loading | loading |
2 | false |
loading | error |
3 | false |
loading | data |
4 | false |
error | loading |
5 | false |
error | error |
6 | false |
error | data |
7 | false |
data | loading |
8 | false |
data | error |
9 | false |
data | data |
10 | true |
loading | loading |
11 | true |
loading | error |
12 | true |
loading | data |
13 | true |
error | loading |
14 | true |
error | error |
15 | true |
error | data |
16 | true |
data | loading |
17 | true |
data | error |
18 | true |
data | data |
Since we didn't split out the <View>
, each of these will need to be a direct test of the UI that results from the state of the three test parameters.
We happen to know that many of these cases are not important, but it's not immediately clear which ones without digging deeper into the component—i.e. opening up the "black box." Splitting the data providers into distinct components and separating means we can reduce this to 8 tests (2 + 3 + 3), remain exhaustive, and clearly see which cases don't matter:
SomeComponent |
||
---|---|---|
case | useNewFlag |
renders a... |
1 | false |
GraphContainer |
2 | true |
APIContainer |
GraphContainer |
||
case | provider state | renders a... |
3 | loading | Loader |
4 | error | ErrorPage |
5 | data | child with expected props |
APIContainer |
||
case | provider state | renders a... |
6 | loading | Loader |
7 | error | ErrorPage |
8 | data | child with expected props |
This does three things:
- Reduces the sizes of the systems-under-test.
- Makes for fewer test cases that are each easier to understand, e.g. "while in the loading state, it renders a loader."
- Isolates the test cases so that removing the older
GraphContainer
doesn't require touching any tests that don't directly referenceGraphContainer
, providing better confidence that the change is safe.
When would the useGetMyData()
alternative make sense, then? The main reason would be if we wanted to introduce a fallback mechanism—i.e. that the newAPIClient
and apolloClient
states were not independent:
Because of this fallback logic, certain states are more clearly excluded (any non-error from newAPIClient
with any call to apolloClient
at all) and (in this case) there's no flag, which cuts the cases we do care about in half.
In order to disconnect our actual API work from any UI changes, we would most likely want to put a break inside newAPIClient.fetchMyData()
, like we did in the useNewAPIForMyData()
hook:
In the rest of the front-end code, now we can create everything else "for real," assuming that we will not break the Promise<APIResult>
contract. Or at least not break it by much.