Core principles of API Design - Part 2

Core principles of API Design - Part 2

This is the sequel to:

API Design 1

I laid out the principles behind designing an API and wanted to show those principles in play in a non-trivial example.

Turns out it's difficult to find the “goldilocks example” i.e. not too hard not too easy. So instead of wasting my time further, I figured I would build out an API for TodoList.

Let's think through what requirements would be needed to build such a TodoList:

  1. Create a to-do item.

  2. Update a to-do item.

  3. Add a to-do item.

  4. Get a list of to-do items.

  5. ...

I will focus on the Update functionality to step through the process of building out an API.

Step 1: Design the Data Structure

Depending on how you design your data structure the code will change accordingly. For example, if you choose a list instead of a map all your code will have ripple effects based on that single change.

Let's go with a basic version for todo-item first then iterate on it if need arises:

// We will structure the todo item in object literal with id and title properties

const basicItem = { id: "1", title: "todo something" };

// We will hold all our todo items in a list

const todoList = [];

Step 2: Write a Failing Test

Here I start doing things using Test Driven Development (TDD).

We will start off by assuming that there is a function to add a todo-item to a to-do list.

Function code:

// We will assume this is the function we will be going with.

function updateTodo(todoList = [], todoId, params) {}

Test code:

// Using Jest

test("should update todo based on id", () => {
  const currentList = [
    { id: 1, title: "something" },
    { id: 2, title: "something other than something" },
  ];
  const result = updateTodo(currentList, 2, { title: "updated todo" });
  expect(currentList).toMatchSnapshot();
  const [_, second] = result;
  expect(second).toMatchObject({ id: 2, title: "updated todo" });
});

Well as you would expect the test will fail:

failing test

But the point here is to use tests as a client which consumes the API and verifies your requirements along with it. This is how TDD works. I recommend readers unfamiliar with this to read further online.

Step 3: Pass the test

Let’s fill in the blanks for the function we set up in the previous setup and make sure the test passes.

I am skipping the part where I fumble around to get the code right 😃 :

function updateTodo(todoList = [], todoId, params) {
  const updatedList = [...todoList];
  const itemIndex = todoList.findIndex(({ id }) => id === todoId);
  const item = todoList[itemIndex];
  const updatedItem = { ...item, ...params };
  updatedList.splice(itemIndex, 1, updatedItem);
  return updatedList;
}

And the test 🎉 :

passing test for update functionality

Step 4: Refactor

Now given the implementation of the update function I set a few constraints:

  1. The function is pure! - if you have been following my posts this shouldn’t come as a surprise 😃

  2. I am not mutating the input data to the function. - Well it won’t be pure if we did!

  3. Using the id to find out the item and updating with the params passed as input to the function.

Now given these requirements the native JS code became quite verbose and we can use a library like immer to make it more concise!

import produce from "immer";

function updateTodo(todoList = [], todoId, params) {
  return produce(todoList, (draftTodo) => {
    let item = draftTodo.find(({ id }) => id === todoId);
    item.id = todoId;
    item.title = params.title;
  });
}

Now let's see how the test looks:

tests green post refactor

It’s still passing and green 🎉. Dopamine much?

Constraints and Heuristics

See what I meant by saying that API hides away implementation details? We completely changed the code and yet the test remains green!

This implies that any consumer using this function doesn’t have to make any changes!

We haven't talked about Constraints and Heuristics here. Let's see in the context of this example and the approach we took:

Here I set the constraints using a test and made sure whatever code I wrote adhered to those constraints!

In doing so we made it easier to guess how the code needs to be and also verified that our assumptions are right as well.

As per the constraints we need to make sure to use that function to return a new to-do list with an updated item. Also, the original array needs to be as is.

Given this the heuristic here is to:

  • copy the array

  • make the changes to the copied array

  • return the copied array with the changes.

The initial version of the code did this as is. Once the test gave us the feedback that it worked I went ahead and used immer library to make the code more succinct! The test remained green!

Some food for thought - what are different ways to set constraints in programming:

  • Types! - psst typescript 😉

  • Using data structure - Sets, Maps, Lists, Trees!

  • Encapsulation mechanisms - modules, classes, closures, etc.

  • Tests!

  • Function contract! - what the input and output would be - can be asserted using types and tests!

  • .....

I hope this helped. Thanks for reading.

GitHub Link for codebase:

More about TDD:

https://www.amazon.in/dp/B095SQ9WP4/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1