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.
Adapter pattern allows to associate new functionality with existing objects without introducing explicit dependencies:
- Decouples code from specific implementations, making it easier to update or replace dependencies. This pattern allows to provide different APIs implementations depending on the runtime environment (e.g., Node.js vs. browser).
- Shields the rest of the application from low-level details of third-party libraries or system APIs. Could be used to standardize interfaces for external libraries so they fit seamlessly into an application.
- Enables easy mocking of dependencies by substituting real implementations with test-friendly versions, which is very useful for testing.
- It provides simple dependency injection mechanism. It allows to dynamically attach dependencies without hardcoding them.
More details:
- newAdapter – an implementation of this pattern. It provides get/set pair to retrieve an object adapter or to set a new implementation.
- getAdapter – allows to automatically create adapters for an object.
Example: Abstracting a File System API Across Environments
Let’s consider the following scenario:
An application needs to interact with a file system, but the implementation may vary depending on the environment: in a Node.js environment, it might use the native file system or an S3-based implementation, while in a browser, it could rely on a domain-specific file system or use read/write access to local folders (e.g., via Chromium-based APIs).
Suppose we define the following interfaces, with implementations tailored to different environments (Node.js, browser, etc.):
interface File {
path: string;
read(): AsyncGenerator<Uint8Array>;
}
interface FileSystem {
get(path: string): Promise<File>;
list(path: string): AsyncGenerator<File>;
}
The core application should be able to use this API without being tightly coupled to the implementation details. To achieve this, we can associate a specific FileSystem implementation with the application context like so:
// Define the application context object used throughout the app
class ApplicationContext { ... }
// Use an adapter to associate a FileSystem with the context,
// without introducing direct dependencies between FileSystem and ApplicationContext
const [getFileSystem, setFileSystem] = newAdapter<FileSystem, ApplicationContext>("sys.fileSystem");
We can then initialize the application:
// Create a new application context
const applicationContext = new ApplicationContext();
// Associate a browser-specific file system implementation
const fileSystem = new BrowserBasedFileSystem();
setFileSystem(applicationContext, fileSystem);
Later, when we need access to the file system in another part of the application:
const fileSystem = getFileSystem(applicationContext);
const configFile = await fileSystem.get("/app.config.json");
...
Benefits
- Decoupling: The application can access the API without explicitly depending on its implementation or even on the ApplicationContext itself.
- Encapsulation: The implementation details of the file system are hidden behind the interface.
- Non-intrusive integration: APIs associated via adapters do not pollute the ApplicationContext interface, keeping it clean and focused.
- Flexibility: Multiple independent APIs (e.g., logging, database, configuration) can be associated with the same ApplicationContext in a modular way.