#What makes a good SDK
AddedAn SDK (software development kit; for an HTTP API it's usually one client library per language) is your API repackaged in the idioms of the developer's language. Instead of learning that volume lives at PATCH /api/v1/settings, that the body must be JSON, that a session token goes in an Authorization header and that a 422 carries a list of field errors, a developer writes await device.updateSettings({ volume: 55 }) and catches a ValidationError. On a device platform the SDK's users are partner integrators (room-control systems, fleet-management tools, help-desk scripts), your own admin web UI team, QA automation, and the developers you lead. Every one of them would otherwise re-implement the same protocol details, slightly differently, with slightly different bugs.
A good mental model: an SDK is a phrasebook plus a fixer. The phrasebook part translates: URLs, headers, status codes and JSON become methods, typed objects and exceptions that read naturally in JavaScript. The fixer part handles the bureaucracy so callers don't have to: logging in, logging in again when the device reboots and forgets the session, waiting and retrying when the device says it's busy, following pagination cursors, attaching request ids to errors. A thin wrapper that only renames fetch is a phrasebook with no fixer: callers still need to know everything about the protocol, so it adds a dependency without removing work.
The qualities interviewers mean by "good SDK" fall into four groups:
| Quality | What it means | What it looks like in a device SDK |
|---|---|---|
| Ergonomics | the common task is short and obvious | device.updateSettings({ volume: 55 }); an options object last; sensible defaults for timeouts and retries |
| Error model | failures are typed, actionable and safe to act on | ValidationError with the bad fields, RateLimitError with retryAfterSeconds, a request id on every error |
| Discoverability | the editor teaches the API | typed parameters and results, autocomplete for setting names, JSDoc on every method, examples that run |
| Stability | upgrading doesn't break working code | semantic versioning, deprecate before removing, one major version supporting a wide range of firmware |
Microsoft's Azure SDK General Guidelines (azure.github.io/azure-sdk, checked 27 October 2026) is the most detailed public write-up of these ideas and a good thing to name in an interview. It lists five principles: SDKs should be idiomatic ("feel natural to a developer in the target language"), consistent (within the language first, then with the service, then across languages), approachable (good docs, predictable defaults, core use cases easy to discover), diagnosable (developers can see what the SDK is doing and when network calls happen; error messages are concise and actionable), and dependable (breaking changes are avoided because they hurt users more than new features help).
Here's the difference in one task: set the speaker volume of a room device to 140, which is out of range. First with raw fetch, then through a twenty-line SDK. The device is a small Express app standing in for a real one (its API is this course's example, not any vendor's).
// A simulated device API (not a real product's), then the same task done twice:
// once with raw fetch, once through a small SDK. Task: set the speaker volume to 140.
import express from "express";
const app = express().use(express.json());
const tokens = new Set();
const settings = { roomName: "Huddle 2", volume: 40 };
const problem = (res, status, type, title, extra = {}) =>
res.status(status).type("application/problem+json").json({ type: `https://device.example/problems/${type}`, title, status, ...extra });
app.post("/api/v1/session", (req, res) => {
if (req.body.password !== "s3cret") return problem(res, 401, "bad-credentials", "Wrong username or password");
tokens.add("tok-1");
res.status(201).json({ token: "tok-1" });
});
app.patch("/api/v1/settings", (req, res) => {
if (!tokens.has(req.get("authorization")?.slice(7))) return problem(res, 401, "unauthenticated", "Log in first");
const { volume } = req.body;
if (volume !== undefined && !(Number.isInteger(volume) && volume >= 0 && volume <= 100))
return problem(res, 422, "validation-failed", "Invalid settings", { errors: [{ pointer: "#/volume", detail: "must be an integer from 0 to 100" }] });
Object.assign(settings, req.body);
res.json(settings);
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve)); // the port is known once it's listening
const base = `http://127.0.0.1:${server.address().port}`;
// 1. Raw fetch: every caller re-learns the protocol.
const login = await fetch(`${base}/api/v1/session`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ username: "admin", password: "s3cret" }) });
const { token } = await login.json();
const res = await fetch(`${base}/api/v1/settings`, { method: "PATCH", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ volume: 140 }) });
console.log("raw:", res.status, res.headers.get("content-type"), JSON.stringify(await res.json()));
// 2. Through an SDK: the protocol lives in one place.
class ValidationError extends Error {
constructor(p) { super(`${p.title}: ${p.errors.map((e) => `${e.pointer} ${e.detail}`).join("; ")}`); this.name = "ValidationError"; this.fields = p.errors; }
}
class DeviceClient {
#base; #creds; #token;
constructor({ baseUrl, username, password }) { this.#base = baseUrl; this.#creds = { username, password }; }
async #call(method, path, body) {
this.#token ??= (await this.#raw("POST", "/api/v1/session", this.#creds)).token;
return this.#raw(method, path, body, this.#token);
}
async #raw(method, path, body, token) {
const res = await fetch(this.#base + path, { method, body: JSON.stringify(body),
headers: { "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) } });
const data = await res.json();
if (res.status === 422) throw new ValidationError(data);
if (!res.ok) throw new Error(`${res.status} ${data.title}`);
return data;
}
settings = { update: (patch) => this.#call("PATCH", "/api/v1/settings", patch) };
}
const device = new DeviceClient({ baseUrl: base, username: "admin", password: "s3cret" });
try {
await device.settings.update({ volume: 140 });
} catch (err) {
console.log("sdk:", err instanceof ValidationError, err.name, "-", err.message);
console.log("sdk fields:", err.fields.map((f) => f.pointer).join(", "));
}
console.log("sdk ok:", JSON.stringify(await device.settings.update({ volume: 55 })));
server.close();raw: 422 application/problem+json; charset=utf-8 {"type":"https://device.example/problems/validation-failed","title":"Invalid settings","status":422,"errors":[{"pointer":"#/volume","detail":"must be an integer from 0 to 100"}]}
sdk: true ValidationError - Invalid settings: #/volume must be an integer from 0 to 100
sdk fields: #/volume
sdk ok: {"roomName":"Huddle 2","volume":55}- The simulated device has two endpoints:
POST /api/v1/sessionreturns a token for the right password, andPATCH /api/v1/settingschanges settings for a caller with a validAuthorization: Bearertoken. A volume outside 0–100 gets a422with an RFC 9457 problem-details body (type,title,status) plus anerrorsarray saying which field failed (see Errors with problem details). - The raw caller has to know all of that: the login path and body shape, that the token comes back as
token, theBearerprefix, thecontent-typeheader on both requests, and that a failure still parses as JSON. What it gets back is a status (422) and a body to dig through. Every integrator writes this code again, and each one decides differently whether a422throws, logs or is silently ignored. DeviceClienthides the protocol behind one method,settings.update(patch). The private#calllogs in lazily the first time (??=only runs the login when#tokenis stillundefined), then sends the request with the token. Private#fields mean callers can't reach the token or the credentials by accident.#rawis where the protocol knowledge lives once: headers, JSON encoding and, crucially, the error mapping. A422becomes aValidationErrorwhose message is built from the problem'stitleand field errors, and whosefieldsproperty keeps the structured list. The caller writescatch (err)and checkserr instanceof ValidationError; it never sees a status code unless it wants to.- The output shows both sides: the raw call printed
422 application/problem+json; charset=utf-8and the whole body; the SDK printedtrue ValidationError - Invalid settings: #/volume must be an integer from 0 to 100and the field pointer#/volume, which a form can use to highlight the right input. The second update (55) succeeds and returns the new settings. - This is the SDK's value in miniature: the protocol is written down once, in code that can be tested, versioned and documented, and every caller gets the same behaviour. The rest of this chapter grows
#rawinto a real request pipeline (re-login, timeouts, retries, paging) without changing the method callers use.
Diagnosable: an SDK that can say what it did
The Azure guidelines' "diagnosable" principle matters more on devices than anywhere else, because the question in a support ticket is always "what did your code actually send to the room?". A good SDK takes an optional logger, logs every exchange at debug level, redacts secrets, and attaches the device's request id. It also accepts an injected fetch, which is what lets this example run with a fake device and no network at all:
// "Diagnosable": an SDK that can say what it did. A logger option sees every HTTP exchange, with secrets redacted
// and the device's request id attached. The transport (fetch) is injectable, so this runs with a fake device.
const REDACT = new Set(["authorization", "cookie", "set-cookie", "x-api-key"]);
const redactHeaders = (h) => Object.fromEntries(Object.entries(h).map(([k, v]) => [k, REDACT.has(k.toLowerCase()) ? "REDACTED" : v]));
const redactBody = (b) => (b && "password" in b ? { ...b, password: "REDACTED" } : b);
function createClient({ baseUrl, fetch = globalThis.fetch, logger = null, clock = () => performance.now() }) {
let token = null;
async function send(method, path, body) {
const headers = { "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) };
const started = clock();
logger?.debug(`→ ${method} ${path} ${JSON.stringify(redactHeaders(headers))}${body ? " " + JSON.stringify(redactBody(body)) : ""}`);
const res = await fetch(baseUrl + path, { method, headers, body: body && JSON.stringify(body) });
logger?.debug(`← ${res.status} ${method} ${path} in ${Math.round(clock() - started)} ms request-id=${res.headers.get("x-request-id")}`);
return res.json();
}
return {
async login(username, password) { token = (await send("POST", "/api/v1/session", { username, password })).token; },
getSettings: () => send("GET", "/api/v1/settings"),
};
}
// A fake device behind a fake fetch, and a fake clock that advances 12 ms per reading.
let ids = 100;
const fakeFetch = async (url, init) => {
const path = new URL(url).pathname;
const body = path === "/api/v1/session" ? { token: "eyJhbGciOi.secret" } : { roomName: "Huddle 2", volume: 40 };
return new Response(JSON.stringify(body), { status: path === "/api/v1/session" ? 201 : 200, headers: { "x-request-id": `r-${ids++}` } });
};
let now = 0;
const client = createClient({ baseUrl: "https://10.0.0.21", fetch: fakeFetch, logger: { debug: (line) => console.log(line) }, clock: () => (now += 12) });
await client.login("admin", "s3cret");
console.log("settings:", JSON.stringify(await client.getSettings()));→ POST /api/v1/session {"content-type":"application/json"} {"username":"admin","password":"REDACTED"}
← 201 POST /api/v1/session in 12 ms request-id=r-100
→ GET /api/v1/settings {"content-type":"application/json","authorization":"REDACTED"}
← 200 GET /api/v1/settings in 12 ms request-id=r-101
settings: {"roomName":"Huddle 2","volume":40}createClienttakes its collaborators as options:fetch(defaulting to the global one), an optionalloggerand aclock. Nothing is hard-wired, so tests, browsers and Node can each pass what they have; this is also how the admin web UI would hand the SDK afetchthat sends cookies.- Before each request,
logger?.debugprints the method, path, headers and body, butredactHeadersreplacesauthorization,cookie,set-cookieandx-api-keywithREDACTED, andredactBodyhides apasswordfield. The first line shows the login body as{"username":"admin","password":"REDACTED"}; the third shows the bearer token header asREDACTED. - After the response, it logs the status, the duration and the device's
x-request-id. The fake clock adds 12 ms per reading, so the durations are a stable12 mshere; with the defaultperformance.now()they'd be real. The request idsr-100andr-101are what a support engineer greps for in the device's own logs. fakeFetchreturns realResponseobjects (a global in Node 18+), so the SDK code can't tell it from the network. The settings come back as{"roomName":"Huddle 2","volume":40}.- If you removed the redaction, the first debug line would contain the admin password and the third the session token, and both would end up in a partner's log aggregator. Logs leave the building; secrets mustn't go with them.
Ergonomics: the conventions that make an SDK feel obvious
These are the small, repeatable decisions that make an SDK feel designed rather than accumulated. Several come straight from the Azure TypeScript guidelines (azure.github.io/azure-sdk/typescript_design.html, checked 27 October 2026); the rest are common practice.
- A small set of verbs, used the same way everywhere.
get,list,create,update,delete, plusbegin…for long-running work such asbeginFirmwareUpdate()(Azure: "prefix method names which return a poller with …begin"). Callers guess names correctly once they've seen two. - Required things are parameters, optional things go in an options object, last.
updateSettings(patch, { abortSignal, timeoutInMs }). Adding an option later is a compatible change; adding a fourth positional parameter isn't pleasant for anyone. - Units in names.
timeoutInMs,retryAfterSeconds(Azure: "suffix durations withIn<Unit>"). A baretimeout: 5has caused real outages. - The constructor does no I/O.
new DeviceClient(options)only stores configuration; the first method call logs in. Constructors can't be awaited, and a client that connects in its constructor can't be created in tests or before the device is reachable. - Return plain data, not HTTP. Methods resolve to settings objects and arrays; the status code and headers are available on errors and, for the rare caller who needs them, through an escape hatch.
- One client per device, safe to share. A fleet tool creates one
DeviceClientper room and calls it from many places at once; the client must handle concurrent calls (see the single-flight login in the next topic).
| Approach | How | Good at | Weak at |
|---|---|---|---|
| Generated | a generator turns the OpenAPI document into a client (openapi-generator, or openapi-typescript types with its companion openapi-fetch) | always matches the spec; cheap for many languages | names and shapes mirror the HTTP API, not the developer's task; little "fixer" behaviour |
| Handwritten | engineers write the client by hand | idiomatic, task-shaped methods; good error model | drifts from the spec unless contract-tested; costs time per language |
| Hybrid | generated types (and maybe a low-level client) underneath, a handwritten convenience layer on top | types can't drift, methods still feel designed | two layers to maintain and document |
For a device platform with one main language (JavaScript or TypeScript for the web UI and partner scripts), the hybrid is usually the sweet spot: generate the types from the spec so they can't drift (see Generating types, clients and docs), and handwrite the session handling, retries, errors and paging, which a generator doesn't know about.
Which of these belong inside a device SDK, and which in the application using it? (a) logging in again after the device reboots; (b) retrying POST /api/v1/calls (dial a call) when it times out; (c) showing a "device busy" toast; (d) following nextCursor through the call history; (e) caching settings for five minutes.
Show answer
SDK: (a) and (d). Application: (b), (c) and (e).
- (a) Re-login is pure protocol bureaucracy with exactly one right behaviour: if a call gets
401because the session vanished, log in once with the stored credentials and repeat the call. Every caller would need the same code, so it belongs in the SDK. - (d) Following cursors is also protocol detail. The SDK exposes an async iterator, and callers write
for await (const call of device.listCalls())without ever seeing a cursor. - (b) Dialling isn't idempotent: if the request reached the device and only the response was lost, a blind retry places a second call. Only the application knows whether to check the active call first or ask the user, so the SDK reports a timeout that says "it may have been applied" and doesn't retry it.
- (c) The SDK raises a typed
ServiceUnavailableError; what to show, in which language and where is a UI decision. An SDK that pops up UI or writes toconsoleuninvited is a nuisance in a server process. - (e) Caching is application policy: how stale settings may be depends on the screen. The SDK can make caching easy (return
ETags, acceptIf-None-Match) without deciding the lifetime for everyone.