Documents injecute 1.x · Migrating from 0.x
Skip to content

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:

ts
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 ​

PartWhat it isPublic
The contractinterfaces of the SDK object, its options and its servicesyes
The plugin APIthe services plugins can use, the plugin type, extension pointsyes
The modulesone module per feature, applied as a namespaceno
The corethe SDK's composition root: a new sealed container per callno
createPlatform()builds a core and returns the SDK objectyes
createTestPlatformthe 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:

ts
/** 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, a ServiceProvider or a ProxyAccessor, so consumers can't resolve or replace services through it. What plugins can do is defined by the plugin API.
  • Expose interfaces, not classes. flags is a FeatureFlags, not RemoteFlags: the SDK can add methods to its classes (refresh()) without making them public.
  • Options for settings, plugins for services. A logger or a fetch is 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:

ts
// 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:

ts
// 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. transport and logger are used by several modules, so they are registered in the root, before the modules that need them. extend() and namespace() 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:

ts
/** 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 uses api never calls start().
  • stop() undoes the hooks in reverse, then disposes. The last telemetry is flushed before the transport is released. Without start(), it only disposes. A second call returns the same promise, so await using and an app's dispose can both call it.
  • After stop(), services are gone. A getter throws INJECUTE_DISPOSED instead 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:

ts
/** 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:

ts
/** 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 throws INJECUTE_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: inner is 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(…) with collect(), 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's start() and stop() run it with the SDK's own hooks.

What the types check:

  • A plugin receives a ServiceRegistry<PluginServices>. A dependency on a key outside PluginServices, 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: true doesn'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. Renaming transport or changing Transport is 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. PlatformPlugin uses ServiceRegistry, and telemetrySink is a Tag, so the SDK's .d.ts refers to injecute there. The SDK re-exports the helpers plugins use (lifecycle, optional): a marker such as optional(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:

ts
/** 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:

ts
/** 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:

ts
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 cleared

A consumer that uses injecute registers the SDK as one service, and the parts its own code uses as keys of its app:

ts
// 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. Checkout receives FeatureFlags and Telemetry, so its tests pass fakes of two small interfaces.
  • The SDK starts with the app. The init hook starts it before the app's start hooks (servers, consumers) run. On shutdown, the app's hooks are undone first, then dispose stops the SDK after everything that depends on it.
  • Tests replace the SDK. The testApp() function in the example above replaces platform in an isolated fork with createTestPlatform().

Packaging ​

text
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
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.ts files for injecute: 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.

Released under the MIT License.