Oyam Notes

Patterns: Shared Module

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

A shared module is a module that groups together somewhat related functionality. Unlike services and adapters, shared modules belong to the outermost foundation layer of the project — the technical layer that is devoid of business logic. Higher-level layers may import shared modules, but shared modules may only import other shared modules and the APIs of the underlying platform, frameworks and third-party libraries.

Shared modules also have lower requirements for cohesion. They may be created ad hoc just to have some place to put reusable code before a better location for it is found.

Examples

A module to manage phone numbers by normalizing them into a canonical format:

// phone-numbers.ts
export class PhoneNumbersError extends Error {
  override name = "PhoneNumbersError";
}

/**
 * Accepts a freely formatted phone number and attempts to normalize it to
 * E.164 format.
 * 
 * For example:
 *   normalizePhoneNumber("+7 (999) 999 9999") // -> "+79999999999"
 * 
 * Throws PhoneNumbersError if the string is not a valid phone number.
 */
export function normalizePhoneNumber(phoneNumber: string): string {
  // ...
}

/** 
 * Returns `true` if `phoneNumber` is a phone number in E.164 format as returned
 * by `normalizePhoneNumber`.
 */
export function isNormalizedPhoneNumber(phoneNumber: string): boolean {
  // ...
}

An HTTP client tailored to the project's needs:

// http-client.ts
export class HttpClientError extends Error {
  override name = "HttpClientError";
}

export class HttpStatusCodeError extends HttpClientError {
  override name = "HttpStatusCodeError";
}

export class HttpNetworkError extends HttpClientError {
  override name = "HttpNetworkError";
}

export interface HttpClientConfig {
  baseUrl: string;
  timeout: Temporal.Duration;
  fetch?: typeof fetch;
}

export interface HttpResponse {
  status: number;
  headers: Record<string, string>;
  body: string;
}

export class HttpClient {
  constructor(config: HttpClientConfig) {
    // ...
  }

  async get(path: string): Promise<HttpResponse> {
    // ...
  }

  async post(path: string, body: unknown): Promise<HttpResponse> {
    // ...
  }
}

An implementation of a particular data structure or algorithm:

// ring-buffer.ts
export class RingBuffer<T> {
  constructor(capacity: number) {
    // ...
  }

  push(item: T): T | undefined {
    // ...
  }

  shift(): T | undefined {
    // ...
  }

  clear(): void {
    // ...
  }
}

A collection of utility functions:

// strings.ts
export function truncate(s: string, maxLen: number, ellipsis?: string): string {
  // ...
}

export function toTitleCase(s: string): string {
  // ...
}

export function slugify(s: string): string {
  // ...
}

API

Just like services and adapters, shared modules expose only the API that supports the abstraction they provide.

If a function or a method in a shared module accepts a lot of arguments, it's preferable to accept them by name rather than positionally unless the argument's position already says enough about its role:

class HttpClient {
  // 🛑 BAD
  async get(
    path: string,
    query?: URLSearchParams,
    headers?: Record<string, string>,
    timeout?: Temporal.Duration,
  ): Promise<HttpResponse> {}

  // ✅ GOOD
  async get(path: string, {
    query,
    headers,
    timeout,
  }: {
    query?: URLSearchParams,
    headers?: Record<string, string>,
    timeout?: Temporal.Duration,
  } = {}): Promise<HttpResponse> {}
}

// ✅ OK: The role of positional arguments is self-evident at the call site:
// truncate(title, 80)
// truncate(title, 80, "…")
export function truncate(s: string, maxLen: number, ellipsis?: string): string {
  // ...
}

Services and adapters are required to declare their own custom data types and error types to stay decoupled from other code. This rule also applies to shared modules, but less strictly, because sometimes a shared module is a wrapper around some other API and passing data types and errors through is more convenient and ergonomic:

// fetch-pages.ts
/**
 * Fetches the resource using fetch. If the resource is paginated in the
 * expected format, keeps fetching the following pages until they are exhausted.
 * Returns bodies of all fetched pages.
 * Rethrows the first fetch error it encounters.
 */
export async function fetchPages(...args: Parameters<typeof fetch>): Promise<unknown[]> {
  // ...
}

File Structure

Generally, a basic shared module will be a single file in a common, shared or foundation directory in the project root:

features/
└── ...
shared/
├── strings.ts
├── http-client.ts
├── ring-buffer.ts
└── phone-numbers.ts

Or in the shared or utils directory of a specific feature if the module is scoped to or closely related to that feature:

features/
└── contacts/
    └── shared/
        └── phone-numbers.ts

If the module is complex or large enough, it may be split into several files in one directory:

shared/
├── strings/
│   ├── formatting.ts
│   ├── parsing.ts
│   └── validators.ts
└── http-client/
    ├── index.ts
    ├── client.ts
    ├── errors.ts
    └── types.ts

When deciding where to put a new piece of code, default to creating a new shared module rather than trying to force it into one of the existing modules. This practice might result in a large number of files, but files are cheap. Eventually you can look over the collection of modules and find patterns to group some of them into directories or merge them into one bigger module.