DEV Community

Dzmitry Harunou
Dzmitry Harunou

Posted on • Edited on

Use Case Interactor Unit in Clean Architecture for Frontend Applications

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 |
 -----------------        --------------------
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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)