Richard Lindsey @Velveeta
https://bit.ly/dx-in-api-design
Developers are to DX what users are to UX. They are your consumers, and when your interface is lacking in some way, they're the ones that experience the pain of that.
Just like any internet consumer, developers are real people, and have their own motivations to which you can appeal.
Pros
Cons
Pros
Cons
Products live and die by their feature sets and adoption rates of their users. APIs aren't any different, from a developer's perspective.
If your product's API is:
There will be fallout. As much as possible, you want to reduce the friction to easily adopt and to easily adapt to changes as they inevitably occur over the lifetime of your product.
Get to know your users:
UX designers often conduct user surveys, interviews, study NPS feedback, etc. The way our customers use products isn't always the way we intend when we publish them, and can help inform ongoing product evolution.
API designers should similarly learn how their consumers are actually using their products, which can help inform where unused cruft can be deprecated or obsoleted, which helps reduce API surface area. It can also help authors determine where functionality can be combined, separated, or otherwise augmented to be more in line with the habits of their actual users.
The React team realized over time that a lot of their user base were using lifecycle functions like componentWillMount and componentWillUpdate to sync local state variables with incoming prop values, so they provided a single function, getDerivedStateFromProps, to handle that use case as part of their deprecation strategy of the componentWill* functions in version 16.
"Make the change easy, then make the change."
-- Joshua Semar
Array sort methods don't typically give you the option to select your particular sorting algorithm. They simply give you a function to call when you want your array sorted. Internally, the language may opt to use an insertion, merge, or any other kind of sorting algorithm, or a mix based on its own internal rules, but as a consuming developer, those aren't necessary details, unless they're really trying to squeeze performance, in which case they'll probably implement their own custom sort function.
Whenever possible, begin with low-level functions, and write higher-level functions that consume them. This gives your consumers a variety of granularity from which to choose.
import get from 'lodash.get';
import set from 'lodash.set';
export default ({
prop: (prop, value) => {
if (value === undefined) {
return get(this, prop);
}
set(this, prop, value);
},
data: (id, value) => this.prop(`dataset${id === undefined ? '' : `.${id}`}`, value),
html: (html) => this.prop('innerHTML', html),
});Whenever multiple functions share similar or overlapping functionality, see if you can consolidate them to reduce the overall API surface and internalize complexities.
// MooTools
const myNode = $('.some-identifier'); // returns 1 node
const myNodes = $$('.some-identifier'); // returns N nodes
// jQuery
const myNode = $('.some-identifier').first(); // returns 1 node
const myNode = $('.some-identifier:first'); // returns 1 node
const myNode = $('.some-identifier:eq(0)'); // returns 1 node
const myNodes = $('.some-identifier') // returns N nodesWhenever possible, stick to a single paradigm for the API input/output
const myJankyApi = {
someFunction = () => true,
someOtherFunction = () => new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, 1000);
})
};
const myAwesomeApi = {
someFunction = () => Promise.resolve(true),
someOtherFunction = () => new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, 1000);
}),
};Futureproof your inputs and/or make them extensible where it makes sense.
const sum = (a, b) => a + b;
const betterSum = (...args) => args.reduce((acc, arg) => acc + arg, 0);
const originalFn = (param1, param2, param3, param4 = true) => {
// do some stuff
};
// Uh oh, new requirements for someOtherParam!
const newFn = (param1, param2, param3, someOtherParam, param4 = true) => {
// do some stuff
};
const betterNewFn = ({ param1, param2, param3, param4 = true, someOtherParam }) => {
// do some stuff
};
// Now our consumers don't need to modify their call signatures until they opt-inDon't be afraid to add guardrails to protect your own systems from careless consumers, and to protect users from themselves where it might make sense. Rate-limiting can help ensure that your servers aren't performing expensive operations past the point of entry, but won't help ensure that your servers aren't being hammered by pointless requests that are going to be dropped. Request batching and throttling within the client application can help with the latter.
import SomeExpensiveService from './service';
const clientRateMap = new Map();
const MAX_PER_SECOND = 20;
setInterval(() => {
clientRateMap.clear();
}, 1000);
const someEndPoint = (req, res) => {
if (!clientRateMap.has(req.clientId)) {
clientRateMap.set(req.clientId, 0);
}
const numRequests = clientRateMap.get(req.clientId);
if (numRequests === MAX_PER_SECOND) {
res.status(429).json({ error: `You have exceeded your plan's rate limits.` })
return;
}
clientRateMap.set(req.clientId, numRequests + 1);
res.status(200).json(SomeExpensiveService.someOperation());
};If a user is doing something within a client application that might lead to overly-aggressive DOM manipulations, consider providing a Promise-based API that batches incoming calls and defers the DOM output to the next animation frame. This will help to enhance browser performance, with a negligent impact on the application behavior.
Part of the allure of the React library is that all of the logic for flushing changes to the DOM is encapsulated within the library itself, removed from the developer's purview entirely. All they need to do is write their code to recognize that given a specific state, the UI should look and function a specific way, without worrying about the timing of flushing to the DOM, or any ongoing performance optimizations of future releases.
const $ = element => ({
css: function (prop, value) {
// This will cause a repaint/reflow every time it's called
element.style[prop] = value;
return this;
},
});
// Consumers
$(element).css('background-color', 'red').css('margin', '10px').css('padding', '10px');
const weakMap = new WeakMap();
const $ = element => {
let promise;
return {
css: function (prop, value) {
if (!weakMap.has(element)) {
weakMap.set(element, []);
}
weakMap.get(element).push(`${prop}: ${value};`);
if (!promise) {
console.log('initializing promise');
promise = new Promise(resolve => {
window.requestAnimationFrame(() => {
const styles = weakMap.get(element);
console.log('animation frame fired', styles);
if (styles && styles.length) {
element.style.cssText = `${styles.join(' ')}`;
console.log('setting cssText', element.style.cssText);
weakMap.delete(element);
}
promise = null;
});
});
}
return this;
},
get promise() {
return promise;
},
};
};
// Consumers
await $(element).css('background-color', 'red').css('margin', '10px').css('padding', '10px').promise;Inversion of control allows you to provide a more flexible interface by giving up power over the implementation of a dependency (is-a) in favor of specifying a necessary interface on a dependency (has-a).
This can still be combined with sane defaults
for your most-common use case(s).
/* logger-middleware */ /* sentry-logger */
class LoggerMiddleware { import Sentry from 'sentry/singleton';
constructor({ logger = console } = {}) { class SentryLogger {
this._logger = logger; log(message) {
} Sentry.captureMessage(message);
}
log(...args) {
this._logger.log(...args); error(message, error) {
} Sentry.captureEvent({
message,
error(...args) { stacktrace; error.stack,
this._logger.error(...args); });
} }
} }
export default new SentryLogger();
/* console-logger-middleware.js */
export default new LoggerMiddleware();
/* sentry-logger-middleware.js */
import sentryLogger from './sentry-logger';
export default new LoggerMiddleware({ logger: sentryLogger });
// Differentiating by environment
/* logger-middleware.js */
let logger;
if (process.env.NODE_ENV === 'production') {
logger = require('./sentry-logger-middleware');
} else {
logger = require('./console-logger-middleware');
}
/* downstream consumer */
this.logger.log('Something went bonkers!');
try {
doSomething();
} catch (error) {
this.logger.error('Bailed out early!', error);
}
Class/method decorators can help inform consumers of ongoing changes:
class ThisClassIsInFlux {
@deprecate('oldAndBustedFn has been deprecated. Please call newHotnessFn in the future.')
oldAndBustedFn(param1) {
/**
* If you're simply replacing one function with another, and the new one has a
* similar interface, consider making oldAndBustedFn a proxy to the new one, so
* that the consumer is already using the new function and simply needs to be
* alerted to any outstanding calls to the oldAndBustedFn to be replaced. If
* this isn't the case, consider including a link in the deprecation message,
* to direct the developer to information on how to migrate their function calls.
**/
return this.theNewHotnessFn({ param1 });
}
theNewHotnessFn({ param1 }) {
return whateverThisFnDoes(param1);
}
}
When authoring APIs, think about your own favorite API experiences, and what you loved most about them. Draw inspiration where it makes sense, even if it feels derivative. In software engineering, people care more about the comfort of familiar and predictable interfaces than they do about original thinking for the sake of being original.
Richard Lindsey @Velveeta
https://bit.ly/dx-in-api-design