This article shares the concept and implementation of the gateway unit in frontend applications within the Clean Architecture.
Repository with example:
https://github.com/harunou/react-tanstack-react-query-clean-architecture
The gateway unit decouples the application core from external data sources and services. It manages interactions with external systems like APIs, browser storage, or Web Workers, handling data mapping, error isolation, and caching.
Gateway Implementation
The gateway implements an interface defined by the application core. The primary development context focuses on the interface implementation.
Let's look into an example of a gateway that interacts with an external API.
The gateway interface starts out empty or minimal. As the application core defines new requirements for interacting with the outside world, the interface is extended with new methods. This process continues until the interface fully represents all the external interactions needed by the application core. Interface methods use primitive types or DTOs as parameters and return types.
export interface ItemEntity {
id: string;
productId: string;
quantity: number;
}
export interface OrderEntity {
id: string;
userId: string;
items: ItemEntity[];
}
// Interface defined by the application core
export interface OrdersGateway {
getOrders(): Promise<OrderEntity[]>;
deleteOrder(orderId: string): Promise<void>;
deleteItem(orderId: string, itemId: string): Promise<void>;
}
Next, when the external resource units are sufficiently defined, the gateway implementation that connects the application to external resources might look as follows:
// External resource DTO to application entity mapper
export function toOrderEntity(dto: ApiOrderDto): OrderEntity {
return {
id: makeOrderEntityId(dto.id),
userId: dto.userId,
items: dto.items.map(toItemEntity),
};
}
// External resource DTO to application entity mapper
export function toItemEntity(dto: ApiOrderItemDto): ItemEntity {
return {
id: makeItemEntityId(dto.id),
productId: dto.productId,
quantity: dto.quantity,
};
}
// RemoteOrdersGateway connects application core to the external resource
export class RemoteOrdersGateway implements OrdersGateway {
static make(): RemoteOrdersGateway {
return new RemoteOrdersGateway(OrdersResourceApi.make());
}
constructor(private ordersResourceApi: OrdersResourceApi) {}
async getOrders(): Promise<OrderEntity[]> {
const ordersDto = await this.ordersResourceApi.getOrders();
return ordersDto.map(toOrderEntity);
}
deleteOrder(orderId: string): Promise<void> {
return this.ordersResourceApi.deleteOrder(orderId);
}
async deleteItem(orderId: string, itemId: string): Promise<void> {
// The gateway implements the `deleteItem` method, required by the
// application core, using the available external resource API operations,
// `getOrder` and `updateOrder`, because the API does not provide a direct
// deleteItem endpoint. The approach can be revised and improved in future
// API updates without impacting the application core.
const orderDto = await this.ordersResourceApi.getOrder(orderId);
const updatedOrder: ApiOrderDto = {
...orderDto,
items: orderDto.items.filter((item) => item.id !== itemId),
};
await this.ordersResourceApi.updateOrder(orderId, updatedOrder);
}
}
Clients of the gateway should rely exclusively on the gateway interface rather than on a concrete implementation. This approach enables seamless replacement of gateway implementations and simplifies mocking for development and integration testing. To achieve this, clients can use a factory function that returns an instance of a gateway conforming to the gateway interface.
// useOrdersGateway.ts
export const useOrdersGateway = (): OrdersGateway => {
return RemoteOrdersGateway.make();
};
// useDeleteOrderUseCase.ts
export const useDeleteOrderUseCase = (): UseCase<{ orderId: OrderEntityId }> => {
const gateway = useOrdersGateway();
const deleteOrderTransaction = useDeleteOrderTransaction(params);
const displayHttpErrorTransaction = useDisplayHttpErrorTransaction();
const execute = async (params: { orderId: OrderEntityId }) => {
try {
await gateway.deleteOrder(params);
deleteOrderTransaction(params);
} catch (error: unknown) {
if (isHttpError(error)) {
displayHttpErrorTransaction(error);
return;
}
throw new Error("Unknown error");
}
};
return { execute };
};
At this stage, the gateway implementation is ready to be used.
Q&A
How should the gateway unit be tested?
The gateway unit can be tested both integrated with dependent units and in isolation using mocks. An example integration test is available here: RemoteOrdersGateway.spec.ts
Where should gateways be placed?
Gateways are suggested to be placed in a dedicated gateways
directory.
Is a factory function required for the gateway unit?
To allow the application core to depend on gateway interfaces rather than their concrete implementations, you can use a factory function, dependency injection, or a service locator. A factory function is the simplest approach, but more advanced patterns can be used if your project requires them.
Should the gateway unit always be implemented as a class?
Gateways can be implemented either as classes or as sets of functions, depending on team and application requirements. An example using functions:
const getOrders: OrdersGateway["getOrders"] = async () => {
const api = OrdersResourceApi.make();
const ordersDto = await api.getOrders();
return ordersDto.map(toOrderEntity);
};
const deleteOrder: OrdersGateway["deleteOrder"] = (orderId) => {
const api = OrdersResourceApi.make();
return api.deleteOrder(orderId);
};
const deleteItem: OrdersGateway["deleteItem"] = async (orderId, itemId) => {
const api = OrdersResourceApi.make();
const orderDto = await api.getOrder(orderId);
const updatedOrder: ApiOrderDto = {
...orderDto,
items: orderDto.items.filter((item) => item.id !== itemId),
};
await api.updateOrder(orderId, updatedOrder);
};
const gateway: OrdersGateway = {
getOrders,
deleteOrder,
deleteItem,
};
export const makeRemoteOrdersGateway = () => gateway;
The gateway unit depends on entities store unit. Why?
Gateways may depend on entities store units to read the current application state and adjust their behavior accordingly. For example, a gateway might need to decide which external resource to use based on the current state. In practice, gateway access to the store should be limited and mostly read-only. However, in some cases, allowing the gateway to perform write operations can be useful.
It's important to note that the gateway should not take on the responsibilities of use case interactor unit. The gateway's main role is to interact with external systems, not to implement business logic or coordinate application workflows.
What can I do if an external resource is not yet defined?
If the external resource is not yet defined or available, you can still proceed with application core and gateway development by using a stub or mock implementation of the gateway interface.
For example, you can implement a LocalOrdersGateway
that fulfills the OrdersGateway
interface and returns mock data, which can be used in development and testing environments. This will not somehow affect the application core implementation.
// LocalOrdersGateway.ts
// LocalOrdersGateway is a mock implementation of OrdersGateway
export class LocalOrdersGateway implements OrdersGateway {
static make(): LocalOrdersGateway {
const fakeOrders = ordersFactory.list({ count: 10 });
return new LocalOrdersGateway(fakeOrders);
}
constructor(private orders: OrderEntity[] = []) {}
getOrders(): Promise<OrderEntity[]> {
return Promise.resolve([...this.orders]);
}
deleteOrder(orderId: string): Promise<void> {
this.orders = this.orders.filter((order) => order.id !== orderId);
return Promise.resolve();
}
deleteItem(orderId: string, itemId: string): Promise<void> {
this.orders = this.orders.map((order) =>
order.id === orderId
? {
...order,
items: order.items.filter((item) => item.id !== itemId),
}
: order
);
return Promise.resolve();
}
}
// useOrdersGateway.ts
export const useOrdersGateway = (): OrdersGateway => {
if (process.env.NODE_ENV === "development") {
// Use LocalOrdersGateway in development for faster iteration
return LocalOrdersGateway.make();
}
return RemoteOrdersGateway.make();
};
This strategy improves development speed, enables parallel work between frontend and backend teams.
Conclusion
Gateway interfaces effectively decouple application core from specific data sources and services, significantly improving flexibility, maintainability, and testability. This approach ensures stable integration tests and protects core logic from external dependency changes.
Top comments (2)
Pretty cool - keeping the core separate like this always saves me from headaches later on.
Been cool seeing steady progress in frontend patterns like this - always makes me wonder what keeps a project maintainable years down the line. solid stuff.