Introducing API Boundaries to Prepare for Bigger Changes

Photo of a path to an ocean beach with a temporary barrier that says "beach closed"
Photo by Florida-Guidebook.com / Unsplash

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:

  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} />;
  }  

The code that loads the data—and handles issues.

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:

  const displayName = data.myData.displayName || data.myData.username;

This line is rearranging some of our data into a shape we can use.

And then finally, it returns a component that uses the fetched data to render a UI, the View:

  return (
    <Page {...data.myData.preferences}>
      <h1>Welcome {displayName}!</h1>
      {# ... etc etc ... #}
    </Page>
  );  

And here is our UI.

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.

// This is awkward AND won't even work:
let data, loading, error;
if (window.Flags.useNewAPI) {
  const { data: _data, loading: _loading, error: _error } = useNewAPI();
  data = _data; loading = _loading; error = _error;
} else {
  const { data: _data, loading: _loading, error: _error } = useQuery(QUERY);
  data = _data; loading = _loading; error = _error;
}

A first, convoluted attempt at feature flagging the new API.

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":

interface ViewProps {
  colorScheme: ColorSchemeType;
  displayName: string;
}

interface ContainerProps {
  children: (props: ViewProps) => React.Element;
}

export const GraphContainer = ({ children }:ContainerProps) => {
  const { data, loading, error } = useQuery(QUERY);
  if (loading) {
    return <Loader />;
  }
  if (error) {
    return <ErrorPage error={error} />;
  }
  const props = {
    displayName: data.myData.displayName || data.myData.username,
    colorScheme: data.myData.preferences.colorScheme
  };
  return children(props);
};

export const View = ({ colorScheme, displayName }:ViewProps) => (
  <Page colorScheme={colorScheme}>
    <h1>Welcome, {displayName}!</h1>
    ... etc etc ...
  </Page>
);

// to hide our refactor from the rest of the application 
export default const SomeComponent = () => (
  <GraphContainer>
    {View}
  </GraphContainer>
);

One component is now two! Or three.

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.

describe('SomeComponent', () => {
  describe('View', () => {
    // <View> is now purely UI and knows nothing about Apollo
    it('matches snapshot', () => {
      const actual = renderer.create(
        // for test purposes, we can directly pass props
        <View displayName="indiana" colorScheme="bluish" />
      );
      expect(actual.toJSON()).toMatchSnapshot();
    });
  });

  describe('GraphContainer', () => {
    // <GraphContainer> can be tested in isolation from <View>
    // because it takes a child as an argument
    it('passes props to children', () => {
      // arrange
      const childrenSpy = jest.fn((props:ViewProps) => <span/>);
      // makeMocks is left as an exercise to the reader
      const mocks = makeMocks({
        colorScheme: 'burnt-umber',
        displayName: 'test user',
        username: 'testusername'
      });
      
      // act
      render(
        <MockedProvider mocks={mocks}>
          <GraphContainer>{childrenSpy}</GraphContainer>
        </MockedProvider>
      );

      // assert
      expect(childrenSpy).toBeCalledWith({
        colorScheme: 'burnt-umber',
        displayName: 'test user'
      });
    });
  });
});

Each piece of functionality can be tested independently—in particular, our <View> tests do not depend on the source of the data.

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:

export const APIContainer = ({ children }:ContainerProps) => {
  /* TODO: connect to the real API */
  const props = {
    displayName: "tester",
    colorScheme: "default"
  };
  return children(props);
};

export default const SomeComponent = () => {
  // even if we need to use a hook to get our flag state, we
  // are following the rules for hooks, and this is much
  // clearer than the first flagged example
  const { flagValue, error } = useFlag<boolean>('new-api');
  if (error) {
    console.error('error fetching new-api flag', error);
  }
  if (flagValue === true) {
    return <APIContainer>{View}</APIContainer>;
  }
  return <GraphContainer>{View}</GraphContainer>;
};

Another layer lets us make the data source conditional.

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:

// We don't actually need to define these types.
// This is just the signature of the function we
// need to write.
interface DataLoader {
  data: ViewProps;
  loading: boolean;
  error?: Error | null;
}

type DataProviderHook = () => DataLoader;

The type of the function we will eventually need, anyway.

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:

export const APIContainer = ({ children }:ContainerProps => {
  const { data: props, loading, error } = useNewAPIForMyData();
  if (loading) {
    return <Loader />;
  }
  if (error) {
    return <ErrorPage error={error} />;
  }
  return children(props);
};

Now <APIContainer> handles loading and error states, too!

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...

export const useNewAPIForMyData = (): DataLoader => {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error|null>();
  const [data, setData] = useState<ViewProps>();

  // simulate an asynchronous request
  setTimeout(() => {
    // let the tester cause an "error" with a query string parameter
    const query = new URLSearchParams(window.location.search);
    if (query.error) {
      setError(new Error(query.error));
    } else {
      setData({
        displayName: "tester",
        colorScheme: "default"
      });
    }

    // either way, loading is "done"
    setLoading(false);
  }, 1000);

  return { data, loading, error };
};

Me, playing a very-front-end engineer, implementing a fake data source.

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:

export const useGetMyData = ():DataLoader => {
  const client = useContext(ApolloContext);
  const [data, setData] = useState<ViewProps | null>();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>();

  client.query(QUERY)
    .then((result) => {
      setData({
        displayName: result.myData.displayName || result.myData.username,
        colorScheme: result.myData.preferences.colorScheme
      });
      setLoading(false);
    })
    .catch((error) => {
      setError(error);
      setLoading(false);
    });

  return { data, loading, error };
};

In which I reintroduce some boilerplate code. It's for a reason, I swear!

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:

export const useGetMyData():DataLoader => {
  // ... snip ...
  if (flags.useNewAPI) {
    newAPIClient.fetchMyData()
      .then((result) => {
        setData({
          displayName: result.where.the.displayName.is,
          colorScheme: result.and.the.colorScheme
        });
        setLoading(false);
      })
      .catch((error) => {
        setError(error);
        setLoading(false);
      });
  } else {
    apolloClient.query(QUERY)
      .then /* etc etc */
  }
  return { data, loading, error };
};

This is the reason—feature flags are ultimately an if statement.

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:

An exhaustive table of all possible states to test.
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:

Independent variables broken up into independent test cases.
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:

  1. Reduces the sizes of the systems-under-test.
  2. Makes for fewer test cases that are each easier to understand, e.g. "while in the loading state, it renders a loader."
  3. Isolates the test cases so that removing the older GraphContainer doesn't require touching any tests that don't directly reference GraphContainer, 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:

export const useGetMyData():DataLoader => {
  // ... snip ...
  newAPIClient.fetchMyData()
    .then((result) => {
      setData({
        displayName: result.where.the.displayName.is,
        colorScheme: result.and.the.colorScheme
      });
      setLoading(false);
    })
    .catch((error) => {
      // acknowledge and log the error
      console.error("error from new API", error);

      // then *fall back* to the GraphQL API
      return apolloClient.query(QUERY)
      .then((result) => {
        setData({
          displayName: result.myData.displayName || result.myData.username,
          colorScheme: result.myData.preferences.colorScheme || 'default'
        });
        setLoading(false);
      })
      .catch((error) => {
        setError(error);
        setLoading(false);
      });
    });

  return { data, loading, error };
};

Fallback code introduces a dependency between two of our parameters.

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:

class newAPIClient {
  // we want the real *Typescript* API, even if we
  // don't know yet what the back-end API will be
  fetchMyData(): Promise<APIResult> {
    // check for "errors"
    const query = new URLSearchParams(window.location.search);
    if (query.error) {
      return Promise.reject(new Error(query.error));
    }
    
    // return our fake data
    // we may also want to put in a "wait" state
    // when they're ready, our API team can implement this for real
    return Promise.resolve({
      where: { the: { name: { is: 'test name' } } },
      and: { the: { colorScheme: 'red' } }
    });
  }
}

A fake implementation of fetchMyData() allows us to disconnect front-end and back-end work.

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.