---
url: https://masyaka.github.io/injecute/guide/sdk.md
description: >-
  A platform SDK that other teams install, built on a hidden container.
  Consumers get a plain object with lazy services, start and stop, plugins that
  replace, decorate or extend its services, and a test kit.
---

# 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

| 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`](https://github.com/Masyaka/injecute/blob/main/examples/sdk/platform-sdk.ts) and a
consumer in [`consumer.ts`](https://github.com/Masyaka/injecute/blob/main/examples/sdk/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](#plugins).
* **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](./containers.md#modules) that declares what it needs, applied as a
[namespace](./containers.md#namespaces). Services only the module uses get symbol keys, so they are
[private](./containers.md#private-services) 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](./typescript.md#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](./startup-shutdown.md).** 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`](../errors/disposed.md) instead of returning a closed client.

### Why not a proxy accessor

[`createProxyAccessor()`](./recipes.md#exposing-a-container-as-a-plain-object) 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](./containers.md#modules): 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()`](./tags.md), 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](./startup-shutdown.md#writing-a-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](./containers.md#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](../concepts/roles.md).
* `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](./containers.md#isolated-forks) 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](../concepts/roles.md#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.
