This article shares the concept and implementation of the use case interactor unit in frontend applications within the Clean Architecture.
Repository with example:
https://github.com/harunou/react-tanstack-react-query-clean-architecture
The use case interactor is a unit that orchestrates the application by coordinating entities, gateways, and transactions to fulfill specific user goals. It implements application business rules.
The unit does not return any data because, according to unified data flow, the data flows from the unit into the view unit through entities, gateways, and selectors.
Use Case Interactor Implementation
The implements an interface provided by a consumer. The interface could be just a function which the use case interactor should provide or a more complex one used globally across the application.
The unit has two possible implementation types: inline
and extracted
. In practice, the unit evolves in the following way:
----------------- --------------------
| inline Use Case | ---> | extracted Use Case |
----------------- --------------------
Any use case interactor implementation starts from a simple inline function in a consumer (controller).
All development context is focused on the application orchestration logic only.
Inline Use Case Interactor Implementation
Let's look at a basic example, where we have already implemented the view and
controller.
interface OrderProps {
orderId: string;
}
interface Controller {
deleteOrderButtonClicked(id: string): Promise<void>;
}
const useController = (params: { orderId: string }): Controller => {
// mocked controller handler
const deleteOrderButtonClicked = async () => {};
return { deleteOrderButtonClicked };
};
export const Order: FC<OrderProps> = (props) => {
const presenter = usePresenter(props);
const controller = useController(props);
return (
<>
<button onClick={controller.deleteOrderButtonClicked}>Delete</button>
<div style={{ padding: "5px" }}>
{presenter.itemIds.map((itemId) => (
<OrderItem key={itemId} itemId={itemId} />
))}
</div>
</>
);
};
The use case interactor should delete an order in a remote source and reset the items filter. Observing the codebase, we found that filter values are stored in the store and remote orders can be deleted with a mutation call. Then implementation of inline use case interactor could look next:
interface OrderProps {
orderId: string;
}
interface Controller {
deleteOrderButtonClicked(id: string): Promise<void>;
}
const useController = (params: { orderId: string }): Controller => {
const setItemsFilterById = useOrdersPresentationStore((state) => state.setItemsFilterById);
const resource = useOrdersResourceSelector();
const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) });
// controller handler
const deleteOrderButtonClicked = async () => {
// inline use case interactor
await deleteOrder({ id: params.orderId }); // order deletion through the gateway
setItemsFilterById(null); // pessimistic update of the entity store
};
return { deleteOrderButtonClicked };
};
export const Order: FC<OrderProps> = (props) => {
const presenter = usePresenter(props);
const controller = useController(props);
return (
<>
<button onClick={controller.deleteOrderButtonClicked}>Delete</button>
<div style={{ padding: "5px" }}>
{presenter.itemIds.map((itemId) => (
<OrderItem key={itemId} itemId={itemId} />
))}
</div>
</>
);
};
Extracted Use Case Interactor Implementation
The final step is to observe the codebase for the need of use case interactor extraction and reuse. The extraction happens if any other consumer unit already has the same logic implemented or the use case interactor becomes more complex. In this case, the inline use case interactor evolves to an extracted use case interactor.
interface OrderProps {
orderId: string;
}
interface Controller {
deleteOrderButtonClicked(id: string): Promise<void>;
}
// extracted use case interactor
export const useDeleteOrderUseCase = (): { execute: (params: { orderId: string }) => Promise<void> } => {
const setItemsFilterById = useOrdersPresentationStore((state) => state.setItemsFilterById);
const resource = useOrdersResourceSelector();
const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) });
const execute = async (params: { orderId: string }) => {
await deleteOrder({ id: params.orderId }); // order deletion through the gateway
setItemsFilterById(null); // // pessimistic update of the entity store
};
return { execute };
};
const useController = (params: { orderId: string }): Controller => {
const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();
// controller handler
const deleteOrderButtonClicked = async () => {
// use case interactor execution
await executeDeleteOrderUseCase({ orderId: params.orderId });
};
return { deleteOrderButtonClicked };
};
export const Order: FC<OrderProps> = (props) => {
const presenter = usePresenter(props);
const controller = useController(props);
return (
<>
<button onClick={controller.deleteOrderButtonClicked}>Delete</button>
<div style={{ padding: "5px" }}>
{presenter.itemIds.map((itemId) => (
<OrderItem key={itemId} itemId={itemId} />
))}
</div>
</>
);
};
Naming of an extracted unit is suggested to be based on the logic it performs followed by the suffix UseCase
.
At this stage the use case interactor unit implementation is considered complete.
Q&A
How to test the Use Case Interactor?
Use case interactor units can be tested both in integration with other units they depend on and in isolation by mocking dependencies. An example can be found here: useDeleteOrderUseCase.spec.tsx
Where to place it?
Use case interactors are suggested to be placed in a dedicated useCases
directory.
What is the Use Case Interactor interface and do I need a specific one?
The minimal requirement for a use case interactor type is a function which does not return anything, because control flow goes reactively to the view unit through the store unit. As practice shows, it's best to have a globally defined use case interactor type where the function returns Promise<void>
.
export type UseCase<T = void> = {
execute: (params: T) => Promise<void>;
};
export const useDeleteOrderUseCase = (): UseCase<{ orderId: string }> => {
const setItemsFilterById = useOrdersPresentationStore((state) => state.setItemsFilterById);
const resource = useOrdersResourceSelector();
const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) });
const execute = async (params: { orderId: string }) => {
await deleteOrder({ id: params.orderId });
setItemsFilterById(null);
};
return { execute };
};
const useController = (params: { orderId: string }): Controller => {
const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();
const deleteOrderButtonClicked = async () => {
await executeDeleteOrderUseCase({ orderId: params.orderId });
};
return { deleteOrderButtonClicked };
};
Can I use a Use Case Interactor inside another Use Case Interactor? Can I use multiple Use Case Interactors at once?
Yes, you can technically use multiple use case interactors at once, but doing so can create a complex dependency graph and make the codebase difficult to maintain. Keep in mind that executing these units may trigger asynchronous state changes in the entities store, which can cause intermediate re-renders of the view unit. Additionally, if one of several interactors fails with an error, it may lead to an inconsistent application state. In practice, it is recommended to build a custom use case interactor from shared selectors, effects, and transaction units that reflect the current need.
// Example of using multiple subsequent use case interactors
const useController = (params: { orderId: string }): Controller => {
// useDeleteOrderUseCase is a Use Case Interactor that pessimistically deletes
// order triggering update of the store unit and the view unit rerender
const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();
// useUpdateOrdersUseCase is a Use Case Interactor that updates orders of the
// store unit and the view unit rerender
const { execute: executeUpdateOrdersUseCase } = useUpdateOrdersUseCase();
const deleteOrderButtonClicked = async () => {
await executeDeleteOrderUseCase({ orderId: params.orderId });
// rerender of view unit
// should this be executed, if the deleteOrderUseCase fails?
await executeUpdateOrdersUseCase();
// rerender of view unit
};
return { deleteOrderButtonClicked };
};
The CLI interface is not reactive. How can you use a use case interactor in this context?
To use a use case interactor in a non-reactive context, such as a CLI, you can extend its type to return a value that indicates whether the execution was successful or not.
type UseCaseSuccess = void;
type UseCaseFailure = Error;
export type UseCase<T = void> = {
execute: (params: T) => Promise<UseCaseSuccess | UseCaseFailure>;
};
Then use case interactor which implements the interface could be used like this.
declare global {
interface Window {
deleteOrder?: (id: unknown) => Promise<number[]>;
}
}
export const DeleteOrderCliCommand: FC = memo(() => {
const presenter = usePresenter();
const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();
useEffect(() => {
window.deleteOrder = async (id: unknown) => {
if (!isOrderEntityId(id)) {
return "Invalid order id";
}
const result = await executeDeleteOrderUseCase({ orderId: id });
// depending on result presenter returns remaining order ids or error
const output = await presenter({ orderId: id, result });
console.log(output)
};
return () => {
delete window.deleteOrder;
};
}, [executeDeleteOrderUseCase, presenter]);
return null;
});
Conclusion
The use case interactor unit is one of the most important and powerful tools that orchestrates the application. The unit encapsulates the application business logic. Starting with inline use case interactor and extract them only when necessary, allowing your architecture to evolve naturally with your application's complexity.
Top comments (0)