Oyam Notes

Patterns: Adapter Class

In this series I will describe some of the design patterns that I commonly use in my work. They are suited to backend projects in Python and TypeScript because that's what I mostly work on.

Besides sharing, the purpose of these posts is to have a recipe I can show to an LLM agent so that it uses the building blocks I'm most familiar with. Seeing familiar patterns in the agent's output makes code review a lot easier.

Intro

An adapter is a unit that gives the project access to some external system (a web service, a database) through a clean native API.

"Adapter" is the name for the whole class of such units. The actual unit may not be called an "adapter" but rather a "client", "repository", "store", "backend", etc.

Adapters follow many of the same rules as services. They are essentially the same kind of unit, just on a different architectural level. This post describes the similarities and differences.

In terms of clean architecture, adapters belong to the outer layer that interfaces with external systems.

Clean architecture diagram

Construction

Just like services, adapters accept their configuration and dependencies via the constructor.

export class OpenRouterClient {
  #baseUrl: string;
  #apiKey: string;
  #fetch: FetchFn;

  constructor({
    baseUrl,
    apiKey,
    fetch,
  }: {
    baseUrl: string;
    apiKey: string;
    fetch: FetchFn;
  }) {
    this.#baseUrl = baseUrl;
    this.#apiKey = apiKey;
    this.#fetch = fetch;
  }
}

The bootstrapper then creates adapter instances and passes them around as dependencies.

Or the adapter may provide a static method or a function that constructs an instance from a global config store:

export function getOpenRouterClientInstance(): OpenRouterClient {
  return new OpenRouterClient({
    baseUrl: projectSettings.openRouter.baseUrl,
    apiKey: projectSettings.openRouter.apiKey,
    fetch,
  });
}

It's a good idea to inject connections to the outside world as dependencies so that they can be mocked in tests. For example, adapters for HTTP services may accept fetch as a dependency instead of calling the global fetch implementation; database repositories may accept a database connection object instead of connecting to the database themselves.

Several Implementations

An adapter may provide several implementations of the same interface if the project needs them. In this case, the interface gets a concise name and the implementations use this name as a suffix.

For example, an LLM client interface may have implementations for the OpenAI API and the Anthropic API:

export interface LlmMessage {
  role: "system" | "user" | "assistant";
  text: string;
}

export interface LlmCompletion {
  text: string;
}

export interface LlmClient {
  complete(messages: LlmMessage[]): Promise<LlmCompletion>;
}

export class OpenAiLlmClient implements LlmClient {
  // Implementations decide on their own configuration and dependencies.
  // They don't have to match.
  #baseUrl: string;
  #apiKey: string;

  // ...
}

export class AnthropicLlmClient implements LlmClient {
  #baseUrl: string;
  #apiKey: string;

  // ...
}

The bootstrapper may select the implementation based on the project configuration:

export function getLlmClientInstance(): LlmClient {
  if (projectSettings.llm.type === "open-ai") {
    return new OpenAiLlmClient({
      baseUrl: projectSettings.llm.openAi.baseUrl,
      apiKey: projectSettings.llm.openAi.apiKey,
    });
  } else if (projectSettings.llm.type === "anthropic") {
    return new AnthropicLlmClient({
      baseUrl: projectSettings.llm.anthropic.baseUrl,
      apiKey: projectSettings.llm.anthropic.apiKey,
    });
  } else {
    throw new Error(`Invalid LLM type: ${projectSettings.llm.type}.`);
  }
}

A repository (store) interface may have implementations for several storage backends:

export interface AccountStore {
  createAccount(name: string): Promise<Account>;
  getAccount(id: number): Promise<Account>;
}

export class SqlAccountStore implements AccountStore {
  #sqlDb: SqlDb;
  // ...
}

export class RedisAccountStore implements AccountStore {
  #redisClient: RedisClient;
  // ...
}

API and Data Types

Just like services, adapters expose only the necessary API and declare the complex data types they accept or return.

This is especially important for adapters of external APIs, for example web APIs. Ideally, an API client shouldn't require the consumer to read the external API docs to use it. All the data sent and received over the wire is serialized from and to custom types with documented fields and values.

export const SmsStatus = {
  // Sending is in progress.
  Pending: "PENDING",
  Sent: "SENT",
  Failed: "FAILED",
} as const;
export type SmsStatus = (typeof SmsStatus)[keyof typeof SmsStatus];

export interface SendSmsResponse {
  status: SmsStatus;
}

/**
 * The adapter's doc comment may also link to the official API docs
 * for future developers: https://example.com/sms-api-docs/
 */
export class SmsClient {
  sendSms({
    recipient,
    text,
  }: {
    // A phone number in E.164 format, e.g. "+71234567890".
    // No other formatting is allowed.
    recipient: string;
    // No more than 140 characters. Blank text is not allowed.
    text: string;
  }): Promise<SendSmsResponse> {}
}

The adapter may copy excerpts from the API docs verbatim into its own code to pin down the contract. It must never pass raw request and response data to and from the caller.

export class SmsClient {
  // ๐Ÿ›‘ BAD. Requests and responses must be serialized from and to
  // the client's own data types.
  async sendSms(
    request: Record<string, string>,
  ): Promise<Record<string, string>> {
    const response = await fetch("https://example.com/sms-api/", {
      method: "POST",
      body: JSON.stringify(request),
    });
    return await response.json();
  }
}

Errors

The adapter must declare its own error types, just like services do.

It's important that adapters doing IO wrap the IO errors they don't handle in their own error types. An ItemRepository must never throw SqlConnectionError or RedisConnectionError; it must throw ItemError, ItemBackendError, ItemServerError or another custom error class.

// The base class for all errors of ItemRepository.
export class ItemError extends Error {
  override name = "ItemError";
}

export class ItemRepository {
  // ...

  async getItem(id: number): Promise<Item> {
    let itemRow: ItemRow;
    try {
      itemRow = await this.#db.query(`
        SELECT foo, bar FROM items WHERE id = $1;
      `, id);
    } catch (err) {
      throw new ItemError(`Failed to query the DB: ${err}`, {cause: err});
    }

    return rowToItem(itemRow);
  }
}

Unlike services, adapters don't usually report errors to end users: they are at too low a level for that. So a "safe error message" error class is uncommon for them. All adapter errors are supposed to be technical, not user-facing.

Adapters shouldn't inflate their error hierarchy just for the sake of having one. If consumers don't need to distinguish between error kinds, all errors may be thrown as the same type, and the adapter can make do with as little as a single error class.

Dependencies

Unlike services, adapters may safely depend on their peer adapters. Circular dependencies are still to be avoided, but if adapters are split into clean sublayers, a higher-level client may use a lower-level one. For example, a client for a specific HTTP API may use a generic HTTP client; a repository backed by Redis may use a generic Redis client.

Naturally, adapters must not depend on units of higher levels, such as services.

File Structure

A basic adapter lives in a single file together with its data types, error types and private members. Adapter files may be grouped into an adapters directory:

a-feature/
โ””โ”€โ”€ adapters/
    โ”œโ”€โ”€ account-repository.ts
    โ”œโ”€โ”€ email-backend.ts
    โ”œโ”€โ”€ http-client.ts
    โ”œโ”€โ”€ item-repository.ts
    โ”œโ”€โ”€ llm-client.ts
    โ””โ”€โ”€ sms-backend.ts

Or they may be grouped by kind:

a-feature/
โ”œโ”€โ”€ clients/
โ”‚   โ”œโ”€โ”€ http-client.ts
โ”‚   โ””โ”€โ”€ llm-client.ts
โ”œโ”€โ”€ repositories/
โ”‚   โ”œโ”€โ”€ account-repository.ts
โ”‚   โ””โ”€โ”€ item-repository.ts
โ””โ”€โ”€ backends/
    โ”œโ”€โ”€ email-backend.ts
    โ””โ”€โ”€ sms-backend.ts

If an adapter outgrows its file, it may be split like this:

adapters/
โ””โ”€โ”€ llm-client/
    โ”œโ”€โ”€ client.ts  <-- The client implementation.
    โ”œโ”€โ”€ types.ts  <-- Data types.
    โ”œโ”€โ”€ errors.ts  <-- Error types.
    โ”œโ”€โ”€ serialization.ts  <-- Serialization code if needed.
    โ””โ”€โ”€ xyz.ts  <-- Other files may be added to split private functions.

If such an adapter provides several implementations of a single interface, each implementation gets its own file. Implementation-specific serialization code goes into the same file as its implementation so that the implementations stay separate.

adapters/
โ””โ”€โ”€ llm-client/
    โ”œโ”€โ”€ client.ts  <-- The interface definition.
    โ”œโ”€โ”€ open-ai-llm-client.ts  <-- The OpenAI impl.
    โ”œโ”€โ”€ anthropic-llm-client.ts  <-- The Anthropic impl.
    โ”œโ”€โ”€ types.ts
    โ”œโ”€โ”€ errors.ts
    โ””โ”€โ”€ xyz.ts

Implementations may be decomposed one level further:

adapters/
โ””โ”€โ”€ llm-client/
    โ”œโ”€โ”€ client.ts  <-- The interface definition.
    โ”œโ”€โ”€ open-ai/
    โ”‚   โ”œโ”€โ”€ client.ts  <-- The OpenAI client.
    โ”‚   โ”œโ”€โ”€ serialization.ts  <-- OpenAI serialization logic.
    โ”‚   โ””โ”€โ”€ xyz.ts  <-- Other files if necessary.
    โ”œโ”€โ”€ anthropic/
    โ”‚   โ”œโ”€โ”€ client.ts  <-- The Anthropic client.
    โ”‚   โ”œโ”€โ”€ serialization.ts  <-- Anthropic serialization logic.
    โ”‚   โ””โ”€โ”€ xyz.ts  <-- Other files if necessary.
    โ”œโ”€โ”€ types.ts
    โ””โ”€โ”€ errors.ts