API Design Anti-Pattern: Infinite Customization

Published on
API Design Anti-Pattern: Infinite Customization

I’d like to share an API design anti-pattern that I’ve run across twice this week. I’m sure I’ve seen it before, and maybe it already has a better name1, but I’m calling it the Infinite Customization Anti-Pattern.

The Infinite Customization anti-pattern described

In this anti-pattern, an API offers some amount of customization via a regular parameter list or configuration object. At some point though, there’s a desire to offer even more customization.

The API is extended to accept a callback function or exposes a method to override some default behavior and offer near infinite customization. The default behavior that is replaced, however, is already somewhat customizable based on the other parameters/configuration supplied to the API.

When the infinite customization feature is used, some of the other parameters/configuration that were previously part of default behavior are ignored unless the custom implementation also reimplements the default behavior2.

The anti-pattern demonstrated

Imagine an API that offers customization of a fast food order.

It accepts some options to decide what to fill the bag with.

export interface OrderOptions {
    sandwich: "burger" | "chicken" | "fish",
    side: "fries" | "chips" | "fruit",
    size: "kids" | "small" | "medium" | "large",
};

/** Fills a bag with the order - kids meals also receive a plastic toy. **/
export const fillBag = (orderOptions: OrderOptions) => {
    const bag = newBag();
    bag.add(makeSandwich(orderOptions.sandwich, orderOptions.size));
    bag.add(makeSide(orderOptions.side, orderOptions.size));
    // Special bag addition if we're making a kids meal.
    if (orderOptions.size === "kids") {
        bag.add(plasticToyOfTheWeek());
    }
    return bag;
}

In short, it fills the bag with the ordered food and includes a plastic toy if it’s a kids meal.

The engineering team is sometimes asked to change the code to include other special items in the bag depending on various conditions (time of the year, agreements with partners, etc.) and even to replace the kid’s plastic toy with an educational book. They don’t want to clutter the core API with all kinds of one-off behavior, so they add the ability for others working on these special item integrations to customize what’s added to the bag.

/** Fills a bag with the order - kids meals also receive a plastic toy.
 * 
 * You can customize what's included in the bag with `addCustomBagInserts`.
 **/
export const fillBag = (
    orderOptions: OrderOptions,
    addCustomBagInserts?: (orderOptions: OrderOptions, bag: Bag))
  => {
    const bag = newBag();
    bag.add(makeSandwich(orderOptions.sandwich, orderOptions.size));
    bag.add(makeSide(orderOptions.side, orderOptions.size));

    // Special bag additions.
    addCustomBagInserts ??= (orderOptions, bag) => {
        if (orderOptions.size === "kids") {
            bag.add(plasticToyOfTheWeek());
        }
    };
    addCustomBagInserts(orderOptions, bag);
    return bag;
}

With this change, any manner of items can be added via addCustomBagInserts and the default behavior of providing a plastic toy to kids is maintained if no customization is needed.

But then complaints start rolling in. A promotion was launched where everyone who orders a large meal gets a free cookie and all of a sudden kids aren’t getting their plastic toys! This is what the fillBag call looks like:

const bag = fillBag(
    orderOptions,
    addCustomBagInserts: (orderOption, bag) => {
        if (orderOptions.size == "large") {
            bag.add(tastyCookie());
        }
    });

The default bag-insert behavior of providing toys with kids meals was replaced by the cookie promo code. The API requires that anyone using the infinitely customizable addCustomBagInserts callback must also implement the default behavior if they want it to remain.

Avoiding the anti-pattern

The specifics will vary - I can’t just show a solution for the contrived example above and say “do this”. But I think the following two principles will help in most cases.

  1. Don’t offer customization to the point that it changes the default behavior of the code in question.

    In the example code, the default kids-meal behavior shouldn’t be customizable. If there’s a need to replace the toy with something else, that can be done without wholesale replacing the default behavior (addCustomBagInserts could remove the toy and then add a book instead).

  2. If you must offer customization that replaces default behavior, document it thoroughly.

    This is definitely the less desirable option, but if you must, make it clear to the users of your API. Specifically, note which other parameters or config values will stop working if the infinite customization is enabled. This might be the best you can do for a publicly available API that already offers infinite customization. That is until you’re able to deprecate it and eventually replace it.

Closing thoughts

Good API design is hard. Being opinionated about how things should be done often leads to better APIs though. Don’t be too accommodating by providing infinite customization, even if it feels like you’re doing your users a service. You might not be.

Footnotes

  1. Maybe it’s a type of violation of the Open—closed principle? Instead of offering an extension to the default behavior, it modifies default behavior instead.

  2. It’s striking that both instances of infinite customization I came across recently had callbacks that didn’t accept the params/ config as an argument. There was no indication that they were important for the replacement to the default behavior.