Building an SDK
A platform team ships an SDK that product teams install: an API client, feature flags, telemetry, auth. Inside, the SDK is a graph of services with shared clients, background work and resources to release. injecute wires that graph, and consumers use it through a plain object:
import { createPlatform } from 'platform-sdk';
await using platform = createPlatform({ service: 'checkout', apiUrl, token });
await platform.start();
platform.flags.isEnabled('one-click');What the SDK gets from the container:
- Lazy services. A consumer that only calls the API never creates the telemetry pipeline.
- Start and stop. Background work (flag refresh, telemetry flush) starts in stages and stops in reverse, and everything is released with one call.
- Plugins. Consumers replace, decorate or extend the services the SDK opens to them, with typed modules, and the change reaches every service that uses them.
- A test kit. A fake transport reaches the whole graph, so consumers test their code against the real SDK without a network.
- Modules for the teams behind the SDK. Each feature is a module with typed requirements and services private to it.
What the consumers get: an object typed by the SDK's own interface. A consumer that doesn't write plugins never deals with injecute, can use another container or none, and doesn't break when the SDK changes its wiring.
The design
| Part | What it is | Public |
|---|---|---|
| The contract | interfaces of the SDK object, its options and its services | yes |
| The plugin API | the services plugins can use, the plugin type, extension points | yes |
| The modules | one module per feature, applied as a namespace | no |
| The core | the SDK's composition root: a new sealed container per call | no |
createPlatform() | builds a core and returns the SDK object | yes |
createTestPlatform | the SDK with fakes, in a testing entry (platform-sdk/testing) | yes |
The whole example is in examples/sdk/ of the repository: the SDK in platform-sdk.ts and a consumer in consumer.ts.
The contract
Write the public types first, as interfaces the SDK owns:
/** What the consumer passes to the SDK. */
export interface PlatformOptions {
/** The consuming service, sent with every request. */
service: string;
apiUrl: string;
token: string;
logger?: Logger;
fetch?: typeof fetch;
/** Applied in order, after the SDK's own registrations. */
plugins?: readonly PlatformPlugin[];
}
export interface Logger {
warn(message: string): void;
}
export interface ApiClient {
get<T>(path: string): Promise<T>;
}
export interface FeatureFlags {
isEnabled(flag: string): boolean;
}
export interface Telemetry {
track(event: string, data?: Record<string, unknown>): void;
}
/** The SDK. Consumers see this interface, never the container. */
export interface Platform extends AsyncDisposable {
readonly api: ApiClient;
readonly flags: FeatureFlags;
readonly telemetry: Telemetry;
/** Loads the flags and starts background work: flag refresh, telemetry flush. */
start(): Promise<void>;
/** Stops background work, flushes telemetry and releases everything. Safe to call twice. */
stop(): Promise<void>;
}- The SDK object is not a container. It isn't a
DIContainer, aServiceProvideror aProxyAccessor, so consumers can't resolve or replace services through it. What plugins can do is defined by the plugin API. - Expose interfaces, not classes.
flagsis aFeatureFlags, notRemoteFlags: the SDK can add methods to its classes (refresh()) without making them public. - Options for settings, plugins for services. A logger or a
fetchis an option. Replacing or wrapping how the SDK works is a plugin.
The modules and the core
Each feature is a module that declares what it needs, applied as a namespace. Services only the module uses get symbol keys, so they are private to it:
// Each feature is a module, applied as a namespace. Its symbol keys stay inside the namespace: other
// modules and the SDK object can't resolve them.
const addFlags = (
c: ServiceRegistry<{ transport: Transport; logger: Logger }>,
) =>
c.addSingleton('flags', RemoteFlags, ['transport', 'logger']).addSingleton(
lifecycle.start('refresh'),
async (flags) => {
await flags.refresh();
const timer = setInterval(() => void flags.refresh(), 60_000);
return () => clearInterval(timer);
},
['flags'],
);
const buffer = Symbol('buffer'); // private to the Telemetry module
const addTelemetry = (
c: ServiceRegistry<{
transport: Transport;
options: PlatformOptions;
logger: Logger;
}>,
) =>
c
.addSingleton(
buffer,
(transport, options, sinks) =>
new EventBuffer(transport, options.service, sinks),
['transport', 'options', collect(telemetrySink)], // every plugin's sink
)
.addSingleton(
'telemetry',
(events): Telemetry => ({
track: (name, data = {}) => events.push({ name, data }),
}),
[buffer],
)
.addSingleton(
lifecycle.start('flush'),
(events, logger) => {
const timer = setInterval(() => {
events.flush().catch((error) => logger.warn(String(error)));
}, 10_000);
return async () => {
clearInterval(timer);
await events.flush(); // the last events go out before the SDK stops
};
},
[buffer, 'logger'],
);The core is the SDK's composition root:
// The SDK's composition root. A new container per call: two SDK instances share nothing.
function createCore(options: PlatformOptions) {
const logger: Logger = options.logger ?? console;
const core = new DIContainer()
.addInstance('options', options)
.addInstance('logger', logger)
.addSingleton('transport', FetchTransport, ['options'])
.addSingleton('api', PlatformApi, ['transport'])
.namespace('Flags', addFlags)
.namespace('Telemetry', addTelemetry);
// Plugins come last: a replacement or decoration of `transport` reaches everything that uses it.
for (const plugin of options.plugins ?? []) core.extend(plugin);
return core.seal();
}- A new container per call. Two SDK instances (two tenants, two tests) share nothing. Don't create a container at the top of a module.
- Shared internals at the root.
transportandloggerare used by several modules, so they are registered in the root, before the modules that need them.extend()andnamespace()check that they are there. - Plugins last, then sealed. Plugins see everything the SDK registered. Nothing registers after them, and the sealed type is a single flat object, which is cheaper to typecheck. See Seal the composition root.
The SDK object
createPlatform() builds a core and wraps it in the SDK object:
/** The services the SDK object exposes, and what it needs to start and stop. */
type Core = ServiceProvider<{
api: ApiClient;
'Flags.flags': FeatureFlags;
'Telemetry.telemetry': Telemetry;
}> & { dispose(): Promise<void> };
/** Creates the SDK. Nothing runs until a service is used or `start()` is called. */
export function createPlatform(options: PlatformOptions): Platform {
return toPlatform(createCore(options));
}
function toPlatform(core: Core): Platform {
let running: Promise<RunningLifecycle> | undefined;
let stopped: Promise<void> | undefined;
const stop = () =>
(stopped ??= (async () => {
// a failed start already undid its hooks and disposed the container
const run = await running?.catch(() => undefined);
await (run ? run.stop() : running ? undefined : core.dispose());
})());
return {
get api() {
return core.get('api');
},
get flags() {
return core.get('Flags.flags');
},
get telemetry() {
return core.get('Telemetry.telemetry');
},
async start() {
running ??= startLifecycle(core);
await running;
},
stop,
[Symbol.asyncDispose]: stop,
};
}- One getter per exposed service. The object lists what consumers get, with the SDK's types and a place for TSDoc. A getter resolves its service on first access, and then returns the cached singleton.
start()runs the lifecycle. The flags load and the timers start. Services work without it: a consumer that only usesapinever callsstart().stop()undoes the hooks in reverse, then disposes. The last telemetry is flushed before the transport is released. Withoutstart(), it only disposes. A second call returns the same promise, soawait usingand an app'sdisposecan both call it.- After
stop(), services are gone. A getter throwsINJECUTE_DISPOSEDinstead of returning a closed client.
Why not a proxy accessor
createProxyAccessor() exposes a container as a plain object without writing getters. It suits an internal object of an app. For an SDK, write the getters:
- The SDK object also needs
start(),stop()and[Symbol.asyncDispose], and a proxy accessor only resolves services. - The accessor's type is a
ProxyAccessor<…>from injecute. Without an annotation with your own interface, it ends up in the SDK's.d.ts. - Getters show up in the debugger, in
console.log()and in "go to definition" as ordinary properties.
If the SDK exposes dozens of services, put them on a proxy accessor annotated with your interface (platform.services.api), and keep start() and stop() on the SDK object.
Plugins
A plugin is a module: a function that registers services in the SDK's container. The SDK declares which services plugins can use, and the extension points they contribute to:
/** Sends requests to the platform. Plugins replace or decorate it; tests replace it with a fake. */
export interface Transport {
send(method: 'GET' | 'POST', path: string, body?: unknown): Promise<unknown>;
}
export interface TelemetryEvent {
name: string;
data: Record<string, unknown>;
}
/** Receives every flushed batch of telemetry, next to the platform. */
export interface TelemetrySink {
send(events: readonly TelemetryEvent[]): Promise<void>;
}
/** An extension point: plugins register sinks under it, `telemetrySink('name')`. */
export const telemetrySink = createTag('telemetrySink').of<TelemetrySink>();
/**
* The services a plugin can depend on, replace or decorate. Their keys and types are public API of the
* SDK, like the plugin type itself.
*/
export interface PluginServices {
options: PlatformOptions;
logger: Logger;
transport: Transport;
}
/** A plugin is a module: it registers services in the SDK's container, and nothing else. */
export type PlatformPlugin = (
registry: ServiceRegistry<PluginServices>,
) => ServiceRegistry<any, any>;
// Plugins import injecute's helpers from the SDK, so they use the SDK's copy of injecute.
export { lifecycle, optional } from 'injecute';A consumer writes plugins with the same calls as any module:
/** Decorates the transport: records every request, then sends it. */
export const recordRequests =
(log: string[]): PlatformPlugin =>
(c) =>
c.addSingleton(
'transport',
(inner): Transport => ({
send: (method, path, body) => {
log.push(`${method} ${path}`);
return inner.send(method, path, body);
},
}),
{ dependencies: ['transport'], replace: true }, // `inner` is the previous transport
);
/** Replaces the transport: everything goes through the company's gateway. */
export const viaGateway =
(gateway: Transport): PlatformPlugin =>
(c) =>
c.addInstance('transport', gateway, { replace: true });
/** Contributes to an extension point: telemetry also goes to the consumer's analytics. */
export const analytics =
(received: TelemetryEvent[]): PlatformPlugin =>
(c) =>
c.addSingleton(telemetrySink('analytics'), (): TelemetrySink => ({
send: async (events) => {
received.push(...events);
},
}));
export const createInstrumentedPlatform = (
requests: string[],
received: TelemetryEvent[],
) =>
createPlatform({
service: 'checkout',
apiUrl: 'https://platform.internal',
token: process.env.PLATFORM_TOKEN ?? '',
plugins: [recordRequests(requests), analytics(received)],
});- Replace a service with
replace: true. The core applies plugins after its own registrations, so the API client, the flags and the telemetry buffer use the new transport. Replacing a key the SDK doesn't have throwsINJECUTE_NOTHING_TO_REPLACE, so a plugin written for another version of the SDK fails at startup instead of adding an unused service. - Decorate a service by depending on its own key:
inneris the previous definition. Plugins apply in order, so a later plugin decorates what an earlier one registered. - Contribute to an extension point: the SDK collects every
telemetrySink(…)withcollect(), and the sink's type is checked where the plugin registers it. - Start and stop with the SDK. A plugin can register a lifecycle hook (
lifecycle.start('name')), and the SDK'sstart()andstop()run it with the SDK's own hooks.
What the types check:
- A plugin receives a
ServiceRegistry<PluginServices>. A dependency on a key outsidePluginServices, such as a module's internal service, is a compile error, and the SDK's private services can't be resolved from a plugin at all. - A registry can register, but not add middlewares, fork or dispose: plugins can't take over the SDK's configuration or lifecycle. See Roles.
replace: truedoesn't check that the new service matches the old one. Annotate the factory's return type with the service's interface, as(inner): Transport => …does.
What it makes public:
PluginServices, the plugin type and the tags are public API. Renamingtransportor changingTransportis a breaking change of the SDK. Open only what plugins need; the rest stays internal and free to change.- The plugin API is typed with injecute.
PlatformPluginusesServiceRegistry, andtelemetrySinkis aTag, so the SDK's.d.tsrefers to injecute there. The SDK re-exports the helpers plugins use (lifecycle,optional): a marker such asoptional(key)is recognized only by the copy of injecute that created it, and the re-export makes plugins use the SDK's copy.
Tests for consumers
Ship a testing entry with the SDK. It creates the SDK with the fakes as the first plugin:
/** What a test can replace. Everything listed here is public API of the testing entry. */
export interface PlatformFakes {
transport?: Transport;
}
/** The SDK with fakes, for consumers' tests. Ships as `platform-sdk/testing`. */
export function createTestPlatform(
fakes: PlatformFakes = {},
options: Partial<PlatformOptions> = {},
): Platform {
// The fakes are the first plugin: the consumer's plugins decorate the fake transport like the real one.
const useFakes: PlatformPlugin = (c) =>
fakes.transport
? c.addInstance('transport', fakes.transport, { replace: true })
: c;
return createPlatform({
service: 'test',
apiUrl: 'http://platform.test',
token: 'test',
...options,
plugins: [useFakes, ...(options.plugins ?? [])],
});
}The fake transport reaches the API client, the flags and the telemetry buffer, also the private one, and the consumer's plugins decorate the fake as they would decorate the real transport. Consumers test their code against the real SDK:
/** A fake transport: answers the flags request and records what was sent. */
export class FakeTransport implements Transport {
readonly sent: { method: string; path: string; body?: unknown }[] = [];
constructor(private readonly flags: Record<string, boolean> = {}) {}
async send(method: 'GET' | 'POST', path: string, body?: unknown) {
this.sent.push({ method, path, body });
return path === '/flags' ? this.flags : {};
}
}
// A unit test of the consumer's code, with the real SDK and a fake transport.
export async function testCheckout() {
const transport = new FakeTransport({ 'one-click': true });
{
await using platform = createTestPlatform({ transport });
await platform.start();
new Checkout(platform.flags, platform.telemetry).run(); // 'one-click'
} // stop() flushes the telemetry through the fake
return transport.sent;
}
// Plugins run in tests too: they decorate the fake transport like the real one.
export async function testPlugins() {
const transport = new FakeTransport({ 'one-click': true });
const requests: string[] = [];
const received: TelemetryEvent[] = [];
{
await using platform = createTestPlatform(
{ transport },
{ plugins: [recordRequests(requests), analytics(received)] },
);
await platform.start();
new Checkout(platform.flags, platform.telemetry).run();
}
return { requests, received }; // ['GET /flags', 'POST /telemetry'], [checkout.started]
}
// A test of the whole app: replace the SDK in an isolated fork.
export async function testApp() {
const transport = new FakeTransport({ 'one-click': true });
const app = createApp()
.fork({ isolated: true })
.addSingleton('platform', () => createTestPlatform({ transport }), {
replace: true,
dispose: (platform) => platform.stop(),
});
const running = await startLifecycle(app);
const flow = app.get('checkout').run();
await running.stop();
return { flow, sent: transport.sent };
}PlatformFakes is public API of the testing entry. Keep it small: a fake transport covers the network, and the SDK's own logic runs as in production.
Using the SDK
A consumer without a container creates the SDK in its entry point:
export async function main() {
await using platform = createPlatform({
service: 'checkout',
apiUrl: 'https://platform.internal',
token: process.env.PLATFORM_TOKEN ?? '',
});
await platform.start(); // loads the flags, starts the telemetry flush
new Checkout(platform.flags, platform.telemetry).run();
} // stopped here: telemetry is flushed, timers are clearedA consumer that uses injecute registers the SDK as one service, and the parts its own code uses as keys of its app:
// In an app that uses injecute itself, the SDK is one service. Register the parts your code uses as
// keys of your app, so your services don't depend on the whole SDK.
export const createApp = () =>
new DIContainer()
.addInstance('config', { platformToken: process.env.PLATFORM_TOKEN ?? '' })
.addSingleton(
'platform',
(config) =>
createPlatform({
service: 'checkout',
apiUrl: 'https://platform.internal',
token: config.platformToken,
}),
{ dependencies: ['config'], dispose: (platform) => platform.stop() },
)
.addSingleton('flags', (platform) => platform.flags, ['platform'])
.addSingleton('telemetry', (platform) => platform.telemetry, ['platform'])
.addSingleton('checkout', Checkout, ['flags', 'telemetry'])
// started with the app; stopped by dispose, after everything that depends on it
.addSingleton(lifecycle.init('platform'), (platform) => platform.start(), [
'platform',
]);- The app's services don't depend on the SDK object.
CheckoutreceivesFeatureFlagsandTelemetry, so its tests pass fakes of two small interfaces. - The SDK starts with the app. The
inithook starts it before the app'sstarthooks (servers, consumers) run. On shutdown, the app's hooks are undone first, thendisposestops the SDK after everything that depends on it. - Tests replace the SDK. The
testApp()function in the example above replacesplatformin an isolated fork withcreateTestPlatform().
Packaging
platform-sdk/
src/
index.ts createPlatform, the contract and the plugin API: the public API
testing.ts createTestPlatform, PlatformFakes
core.ts createCore and toPlatform: not exported from the package
flags/flags.container.ts
telemetry/telemetry.container.ts
package.json{
"exports": {
".": "./lib/index.js",
"./testing": "./lib/testing.js"
},
"dependencies": {
"injecute": "^1.0.0"
}
}- A regular dependency, not a peer. The SDK never shares its container with the app, and plugins get injecute's helpers from the SDK, so the app and the SDK may use different versions of injecute, and several SDKs may bundle their own copies. A peer dependency is for a library that takes the app's container; see Libraries built on injecute.
- Check the public types. Build the SDK and search its
.d.tsfiles forinjecute: only the plugin API should mention it. - Size. In a browser bundle, the container adds about 7 KB gzipped, and the lifecycle about 2.5 KB more. The package has no runtime dependencies and is tree-shakable.
- Versioning. The SDK's semver covers its contract, its plugin API and its testing entry. A change to the rest of the wiring is not a breaking change.
When not to
If the SDK is one client class, a class with a constructor is the simplest SDK: there is no graph to wire, and nothing for plugins to replace.