The Problem
Have you ever worked in a project and always had to wait for the API from the BE to unblock your progress in Front-end ?
Or...
Have you ever been near the end of building some complex new set of functionality, and the requirements or API contracts you were counting on changed suddenly? I think we’ve all been there.
The Solution
Adapter pattern to the rescue
An adapter is essentially just a translation layer that connects two different interfaces together. The interfaces we are connecting in this situation are:
The data model required by our React components
The data model defined by our APIs
Let's build and explore
Let's say you are building an e-commerce web application. In the frontend, you have various components such as product listings, shopping cart, and user profile. To retrieve data from different backend services and provide a unified interface to the frontend, you decide to implement an adapter architecture.
In this example, you have the following components:
- Frontend UI: This is the user interface of your e-commerce application, consisting of different views and components.
- Product Adapter: The Product Adapter acts as an intermediary between the frontend UI and the backend services responsible for product data. It encapsulates the logic required to fetch, transform, and format product data before passing it to the frontend UI. It provides a consistent interface for the frontend to interact with the product-related functionality.
Here's how the flow would work:
Product data retrieval: When the frontend UI needs to display a list of products, it calls the methods provided by the Product Adapter.
Product Adapter interacts with backend services: The Product Adapter communicates with the appropriate backend services responsible for retrieving product data. It sends requests, handles responses, and performs any necessary data transformations.
Product data formatting: The Product Adapter formats the product data into a standardized format that can be easily consumed by the frontend UI. This may involve converting the data into a specific structure or enriching it with additional information.
Product data passed to the frontend UI: Once the product data is processed and formatted, the Product Adapter passes it to the frontend UI, which can then render the product listings based on the received data.
By utilizing the adapter architecture in this imaginary example, you can abstract the complexities of interacting with various backend services and provide a unified interface to the frontend UI
How to with Plain JavaScript
// Product Adapter
class ProductAdapter {
constructor(productService) {
this.productService = productService;
}
async getProductList() {
// Fetch product data from the ProductService
const products = await this.productService.getProducts();
// Transform and format the data as per frontend requirements
const formattedProducts = products.map((product) => ({
id: product.id,
name: product.name,
price: product.price,
}));
return formattedProducts;
}
async getProductDetails(productId) {
// Fetch product details from the ProductService
const product = await this.productService.getProductById(productId);
// Transform and format the data as per frontend requirements
const formattedProduct = {
id: product.id,
name: product.name,
price: product.price,
description: product.description,
};
return formattedProduct;
}
}
// ProductService (Backend Service)
class ProductService {
async getProducts() {
// Simulate fetching product data from the actual backend service
const response = await fetch('/api/products');
const products = await response.json();
return products;
}
async getProductById(productId) {
// Simulate fetching a specific product's details from the actual backend service
const response = await fetch(`/api/products/${productId}`);
const product = await response.json();
return product;
}
}
// Usage
const productService = new ProductService();
const productAdapter = new ProductAdapter(productService);
// Get the list of products
const productList = await productAdapter.getProductList();
console.log(productList);
// Get details of a specific product
const productId = '123';
const productDetails = await productAdapter.getProductDetails(productId);
console.log(productDetails);
The ProductAdapter
acts as an intermediary between the frontend components (such as product listings) and the ProductService
. It encapsulates the logic for fetching product data and performs any necessary transformations or formatting to meet the frontend's requirements.
The ProductService
simulates the interaction with the actual backend service by fetching product data from an API endpoint. In this example, we use fetch to make HTTP requests to the backend API and retrieve the data.
To use the ProductAdapter
, we instantiate the ProductService
and then pass it as a dependency when creating an instance of the ProductAdapter. We can then call methods on the ProductAdapter
to retrieve the product list and details, which internally interact with the ProductService to fetch the data.
This adapter architecture allows for easy integration of the actual backend services in the future. By swapping out the ProductService
implementation, the ProductAdapter
can seamlessly communicate with the real backend services without requiring any changes to the frontend components that utilize it.
How to with React and Context API
import React, { createContext, useContext, useEffect, useState } from 'react';
// Adapter Context
const AdapterContext = createContext();
// Product Adapter
const createProductAdapter = (productService) => ({
getProductList: async () => {
const products = await productService.getProducts();
const formattedProducts = products.map(({ id, name, price }) => ({
id,
name,
price,
}));
return formattedProducts;
},
getProductDetails: async (productId) => {
const product = await productService.getProductById(productId);
const { id, name, price, description } = product;
const formattedProduct = {
id,
name,
price,
description,
};
return formattedProduct;
},
});
// ProductService (Backend Service)
const createProductService = () => ({
getProducts: async () => {
const response = await fetch('/api/products');
const products = await response.json();
return products;
},
getProductById: async (productId) => {
const response = await fetch(`/api/products/${productId}`);
const product = await response.json();
return product;
},
});
// Adapter Provider
const AdapterProvider = ({ children }) => {
const productService = createProductService();
const productAdapter = createProductAdapter(productService);
return (
<AdapterContext.Provider value={productAdapter}>
{children}
</AdapterContext.Provider>
);
};
// Custom Hook to Access Adapter
const useProductAdapter = () => {
const adapter = useContext(AdapterContext);
if (!adapter) {
throw new Error('useProductAdapter must be used within AdapterProvider');
}
return adapter;
};
// ProductList Component
const ProductList = () => {
const productAdapter = useProductAdapter();
const [products, setProducts] = useState([]);
useEffect(() => {
const fetchProducts = async () => {
try {
const productList = await productAdapter.getProductList();
setProducts(productList);
} catch (error) {
console.error('Error fetching product list:', error);
}
};
fetchProducts();
}, [productAdapter]);
return (
<div>
<h2>Product List</h2>
{products.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>Price: {product.price}</p>
</div>
))}
</div>
);
};
// Usage in App Component
const App = () => {
return (
<AdapterProvider>
<ProductList />
</AdapterProvider>
);
};
export default App;
In this example, we start by creating the Adapter Context using React's createContext function. We define the createProductAdapter
function and createProductService
function as before.
The AdapterProvider
component wraps the relevant parts of the application and sets up the necessary context. It creates an instance of the productService and productAdapter, and provides the productAdapter as the value for the Adapter Context. The wrapped components can access the adapter through the context.
We then define a custom hook, useProductAdapter
, which retrieves the adapter from the context using useContext
. This hook simplifies accessing the adapter within components while ensuring that it is used within the AdapterProvider.
The ProductList
component demonstrates how to use the adapter within a functional component. It uses the useProductAdapter hook to retrieve the adapter and fetches the product list
Conclusion
In conclusion, the adapter architecture is a flexible approach for integrating backend services into a frontend web application. By implementing adapters, we can decouple the frontend from the backend, allowing us to easily switch between mock data and real backend services without breaking changes to the UI. The use of context in conjunction with adapters provides a convenient way to inject the adapter into components, enabling seamless data retrieval and manipulation. This architecture promotes modularity, reusability, and easy integration of backend services when available.
Top comments (1)
Great explanation with an example.