Adapter Pattern

The Adapter Pattern is a structural design pattern that allows incompatible interfaces to work together. It acts as a bridge between two existing systems, making it possible for an object to be used as if it had a different interface.

Type Definition

/**
 * Creates an adapter with optional lazy initialization and parent traversal of adaptable objects.
 * 
 * @template T - The type of value stored in the adapter
 * @template P - The type of the context object (defaults to Record<string, unknown>)
 * @param key - Unique string identifier for the adapter within the context hierarchy
 * @param create - Optional factory function to create the adapter value if not found
 * @param getParent - Optional function to traverse to parent contexts (defaults to accessing 'parent' property)
 * @returns Tuple containing [get, set, remove] functions with hierarchy support
 */
function newAdapter<T, P = Record<string, unknown>>(
  key: string,
  create?: (context: P) => T,
  getParent?: (context: P) => P | undefined,
): [
  /** 
   * Retrieves adapter value, searching up the hierarchy if needed.
   * Creates value using factory if not found and factory is provided.
   * @param context - The context object to search in
   * @param optional - If true, returns undefined instead of throwing when not found
   */
  get: (context: P, optional?: boolean) => T,
  /** Sets the adapter value directly in the provided context */
  set: (context: P, value: T) => void,
  /** Removes the adapter property from the provided context */
  remove: (context: P) => void,
];

/**
 * Creates a lazy-initialized adapter that automatically creates values on first access.
 * This is a convenience wrapper around newAdapter that only provides get/remove functions.
 * 
 * @template T - The type of value stored in the adapter
 * @template A - The type of the adaptable object (defaults to unknown)
 * @param adapterKey - Unique string identifier for the adapter
 * @param create - Factory function to create the adapter value on first access
 * @param getParent - Optional function to traverse hierarchical structures
 * @returns Tuple containing [get, remove] functions with automatic initialization
 */
function getAdapter<T, A = unknown>(
  adapterKey: string,
  create: (adaptable: A) => T,
  getParent?: (adaptable: A) => A | undefined,
): [
  /** Retrieves adapter value, creating it automatically if not found */
  get: (adaptable: A) => T,
  /** Removes the adapter property from the adaptable object */
  remove: (adaptable: A) => void
];

The adapter pattern provides a mechanism to:

Implementation Variants

1. newAdapter - Hierarchical Context Management

This implementation extends the newAdapter functionality to support hierarchical adaptable objects. It is particularly useful for managing hierarchical FSM (Finite State Machine) contexts and providing default adapter implementations when none are found in the hierarchy.

Key Features:

function newAdapter<T, P = Record<string, unknown>>(
  key: string,
  create?: (context: P) => T,
  getParent: (context: P) => P | undefined = (context: P) =>
    (context as unknown as { parent?: P })?.parent,
): [
  get: (context: P, optional?: boolean) => T,
  set: (context: P, value: T) => void,
  remove: (context: P) => void,
] {
  const get = (context: P, optional = false): T => {
    let result: T | undefined;
    for (
      let current: P | undefined = context;
      result === undefined && current;
      current = getParent(current)
    ) {
      if (current && typeof current === "object" && key in current) {
        result = (current as Record<string, T>)[key];
      }
    }
    if (result === undefined && create) {
      result = create(context);
      if (context && typeof context === "object") {
        (context as Record<string, T>)[key] = result;
      }
    }
    if (!optional && result === undefined) {
      throw new Error(`Adapter not found: ${key}`);
    }
    return result as T;
  };

  const set = (context: P, value: T): void => {
    if (context && typeof context === "object") {
      (context as Record<string, T>)[key] = value;
    }
  };

  const remove = (context: P): void => {
    if (context && typeof context === "object" && key in context) {
      delete (context as Record<string, unknown>)[key];
    }
  };

  return [get, set, remove];
}

This implementation ensures that adapters can be retrieved or created dynamically within a hierarchy, making it ideal for scenarios where default behavior or fallback mechanisms are required.

Usage Example:

const [getFileSystem, setFileSystem, removeFileSystem] = newAdapter<FileSystem>("fileSystem");

// Define a context object to hold the file system
const context = {};

// Set up a file system adapter for the context
setFileSystem(context, new BrowserFileSystem());

// Later, retrieve the file system
const fs = getFileSystem(context);

// Use the file system to perform operations
fs.read('/example.txt').then(content => {
  console.log('File content:', content);
}).catch(error => {
  console.error('Error reading file:', error);
});

// Remove the file system from the context when no longer needed
removeFileSystem(context);

2. getAdapter - Lazy Initialization

See also https://dots.statewalker.com/adapter/getAdapter.html

The getAdapter function simplifies adapter management by ensuring that an adapter is automatically initialized the first time it is accessed. This is particularly useful for lazy initialization of dependencies.

function getAdapter<T, A = unknown>(
  adapterKey: string,
  create: (adaptable: A) => T,
  getParent: (adaptable: A) => A | undefined = (adaptable: A) =>
    (adaptable as unknown as { parent?: A })?.parent,
): [
  get: (adaptable: A) => T,
  remove: (adaptable: A) => void
] {
  const [get, set, remove] = newAdapter(adapterKey, create, getParent);
  return [get, remove];
}

This function leverages the newAdapter implementation to provide a streamlined way to retrieve or create adapters dynamically, ensuring that the required adapter is always available when needed.

Usage Example:

const [getLogger, removeLogger] = getAdapter("logger", (context) => {
  return new ConsoleLogger(context.logLevel);
});

// First access creates the logger automatically
const logger = getLogger(ProcessContext);

3. newAdapter - Basic Implementation

In certain applications, managing adaptable object hierarchies or creating default adapter instances may not be necessary. For such cases, a simpler implementation can be more suitable, as it reduces complexity and is easier to maintain.

export function newAdapter<T, A = unknown>(
  adapterKey: string,
): [
  get: (adaptable: A) => T,
  set: (adaptable: A, value: T) => T,
  remove: (adaptable: A) => void
] {
  return [
    function get(adaptable: A) {
      return (adaptable as Record<string, T>)[adapterKey];
    },
    function set(adaptable: A, value: T) {
      if (value === undefined) {
        delete (adaptable as Record<string, T>)[adapterKey];
      } else {
        (adaptable as Record<string, T>)[adapterKey] = value;
      }
      return value;
    },
    function remove(adaptable: A) {
      delete (adaptable as Record<string, T>)[adapterKey];
    }
  ];
}

Architectural Applications

1. Cross-Environment Compatibility

Problem: An application needs to work across different environments (Node.js, browser, mobile) with different API implementations.

Solution: Use adapters to abstract environment-specific implementations:

interface FileSystem {
  read(path: string): Promise<string>;
  write(path: string, content: string): Promise<void>;
}

// Environment-specific implementations
class NodeFileSystem implements FileSystem { ... }
class BrowserFileSystem implements FileSystem { ... }
class MobileFileSystem implements FileSystem { ... }

// Adapter setup
const [getFileSystem, setFileSystem] = newAdapter<FileSystem>("fileSystem");

// Environment detection and setup
if (typeof window !== 'undefined') {
  setFileSystem(context, new BrowserFileSystem());
} else if (typeof process !== 'undefined') {
  setFileSystem(context, new NodeFileSystem());
}

2. Dependency Injection

Problem: Components need access to services without tight coupling.

Solution: Use adapters as a lightweight dependency injection mechanism:

// Service definitions
interface DatabaseService { ... }
interface CacheService { ... }
interface AuthService { ... }

// User model (run-time data)
class UserForm {
  get userId(): string { ... }
  set userId(value: string) { ...; this.notifyListeners(); }
  onChange: (listener: () => void) => () => {};
  ...
}

// Adapter creation
const [getDatabase, setDatabase] = newAdapter<DatabaseService>("api.database");
const [getCache, setCache] = newAdapter<CacheService>("api.cache");
const [getAuth, setAuth] = newAdapter<AuthService>("api.auth");
const [getUserForm, removeUserForm] = getAdapter<UserForm>("form.user", () => new UserForm());

// Service registration
function SetupServices(context: ProcessContext) {
  setDatabase(context, new PostgreSQLService());
  setCache(context, new RedisCache());
  setAuth(context, new JWTAuthService());
}

// Service consumption
function ProcessUser(context: ProcessContext) {
  const db = getDatabase(context);
  const cache = getCache(context);
  const auth = getAuth(context);
  const userForm = getUserForm(context);
  // Use services...
}

3. Testing and Mocking

Problem: Need to replace real implementations with test doubles during testing.

Solution: Use adapters to inject mock implementations:

// Production setup
setFileSystem(context, new ProductionFileSystem());

// Test setup
setFileSystem(context, new MockFileSystem({
  '/config.json': '{"theme": "dark"}',
  '/data.txt': 'test data'
}));

// The same code works in both contexts
const fs = getFileSystem(context);
const config = await fs.read('/config.json');

4. Plugin Architecture

Problem: Need to support extensible functionality through plugins.

Solution: Use adapters to register and access plugin implementations:

type ProcessContext = Record<string, unknown>;

interface RendererPlugin {
  canRender(type: string): boolean;
  render(content: any): string;
}

const [getRenderers, setRenderers] = getAdapter<RendererPlugin[]>("renderers", () => []);

// Plugin registration
function registerRenderer(context: ProcessContext, plugin: RendererPlugin) {
  const renderers = getRenderers(context);
  renderers.push(plugin);
}

// Plugin usage
function renderContent(context: ProcessContext, type: string, content: any) {
  const renderers = getRenderers(context);
  const renderer = renderers.find(r => r.canRender(type));
  return renderer?.render(content) || `Unknown type: ${type}`;
}

Creation of complex adapters:

// 
type UserInfo = { ... }
interface UserManagementApi {
  validateUser(userData: UserInfo): Promise<boolean>;
  createAccount(userData: UserInfo): Promise<string>;
  ...
}
type ProcessContext = Record<string, unknown>;

function createUserManagementApi(context: ProcessContext) : UserManagementApi {
  const db = getDatabase(context);
  const email = getEmailService(context);
  const auth = getAuthService(context);
  
  return {
    async validateUser(userData) {
      return await db.validateUser(userData);
    },
    async createAccount(userData) {
      const user = await db.createUser(userData);
      await email.sendWelcomeEmail(user.email);
      return auth.generateToken(user);
    },
    ...
  };
}
const [getUserManagementApi, removeUserManagementApi] = getAdapter<UserManagementApi>("api.users.management");

// Use the API normally:
...
const userApi = getUserManagementApi(context);
...

Benefits in Current Architecture

1. Modular Design

2. Testability

3. Flexibility

4. Maintainability

Best Practices

  1. Define Clear Interfaces: Always use TypeScript interfaces to define contracts
  2. Use Descriptive Keys: Choose meaningful adapter keys that reflect their purpose
  3. Handle Missing Adapters: Implement proper error handling for required adapters
  4. Document Dependencies: Clearly document which adapters a component requires
  5. Lazy Initialization: Use getAdapter to instantiate default models and services
  6. Cleanup Resources: Use the remove function to clean up adapters when needed

Integration with State Machines

In the context of finite state machines and orchestrators, adapters can be used to:

This pattern ensures that state machines remain focused on business logic while adapters handle the integration with external systems.