Clean Architecture in Frontend Applications. Transaction

This article shares the concept and implementation of the transaction unit in frontend applications within the Clean Architecture. Repository with example: https://github.com/harunou/react-tanstack-react-query-clean-architecture The transaction unit is responsible for transitioning a store between two valid states while ensuring that business rules are maintained. It encapsulates the logic required to perform these state transitions, ensuring the application remains consistent. The transaction unit is crucial for making cross-entity transitions and controlling view rerendering. Additionally, it enables sharing transition logic between different use cases. The transaction unit does not return any data because, according to the unified data flow principle, data flows from the transaction unit into the view unit through entities and selectors. Transaction Implementation The transaction implements an interface provided by a consumer (usecase). The interface could be just a function which the usecase 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 transaction | ---> | extracted transaction | -------------------- ----------------------- Any transaction implementation starts from a simple inline function in a consumer. All development context is focused on the store transitioning logic. Inline Transaction Implementation Let's look at a basic example, where we have already implemented the view, controller and partially inline usecase. interface OrderProps { orderId: string; } interface Controller { deleteOrderButtonClicked(id: string): Promise; } const useController = (params: { orderId: string }): Controller => { const resource = useOrdersResourceSelector(); const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) }); const deleteOrderButtonClicked = async () => { // inline usecase without transaction await deleteOrder({ id: params.orderId }); }; return { deleteOrderButtonClicked }; }; export const Order: FC = (props) => { const presenter = usePresenter(props); const controller = useController(props); return ( Delete {presenter.itemIds.map((itemId) => ( ))} ); }; In the usecase, we need to clean the filter of items and reset the sort order when the order is deleted. Observing the codebase, we found that filter values are stored in one store and sort order in another. The transaction implementation will look like this: interface OrderProps { orderId: string; } interface Controller { deleteOrderButtonClicked(id: string): Promise; } const useController = (params: { orderId: string }): Controller => { const setFilterById = useItemsFilterStore((state) => state.setFilterById); const setSortOrder = useOrdersSortOrderStore((state) => state.setSortOrder); const resource = useOrdersResourceSelector(); const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) }); const deleteOrderButtonClicked = async () => { // inline usecase with transaction await deleteOrder({ id: params.orderId }); // inline transaction performs pessimistic update setFilterById(null); setSortOrder('asc'); }; return { deleteOrderButtonClicked }; }; export const Order: FC = (props) => { const presenter = usePresenter(props); const controller = useController(props); return ( Delete {presenter.itemIds.map((itemId) => ( ))} ); }; Extracted Transaction Implementation The final step is to observe the codebase for the need of transaction extraction and reuse. The extraction happens if any other consumer unit already has the same logic implemented or the transaction becomes more complex. In this case, the inline transaction evolves to an extracted one. interface OrderProps { orderId: string; } interface Controller { deleteOrderButtonClicked(id: string): Promise; } // extracted transaction const useResetSortOrderAndFilterTransaction = (): { commit: () => void } => { const setFilterById = useItemsFilterStore((state) => state.setFilterById); const setSortOrder = useOrdersSortOrderStore((state) => state.setSortOrder); return { commit: () => { setFilterById(null); setSortOrder('asc'); }, }; }; const useController = (params: { orderId: string }): Controller => { const resource = useOrdersResourceSelector(); const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) }); const { commit: resetSortOrderAndFilterCommit } = useResetSortOrderAndFilterTransaction(); const deleteOrderButtonClicked = async () => { await deleteOrder({ id: params.orderId }); // ex

Apr 15, 2025 - 09:23
 0
Clean Architecture in Frontend Applications. Transaction

This article shares the concept and implementation of the transaction unit in frontend applications within the Clean Architecture.

Repository with example:
https://github.com/harunou/react-tanstack-react-query-clean-architecture

The transaction unit is responsible for transitioning a store between two valid states while ensuring that business rules are maintained. It encapsulates the logic required to perform these state transitions, ensuring the application remains consistent.

The transaction unit is crucial for making cross-entity transitions and controlling view rerendering. Additionally, it enables sharing transition logic between different use cases.

The transaction unit does not return any data because, according to the unified data flow principle, data flows from the transaction unit into the view unit through entities and selectors.

Transaction Implementation

The transaction implements an interface provided by a consumer (usecase). The interface could be just a function which the usecase 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 transaction | ---> | extracted transaction |
 --------------------        -----------------------

Any transaction implementation starts from a simple inline function in a consumer.

All development context is focused on the store transitioning logic.

Inline Transaction Implementation

Let's look at a basic example, where we have already implemented the view, controller and partially inline usecase.

interface OrderProps {
  orderId: string;
}

interface Controller {
  deleteOrderButtonClicked(id: string): Promise<void>;
}

const useController = (params: { orderId: string }): Controller => {
  const resource = useOrdersResourceSelector();
  const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) });

  const deleteOrderButtonClicked = async () => {
    // inline usecase without transaction
    await deleteOrder({ id: 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>
    </>
  );
};

In the usecase, we need to clean the filter of items and reset the sort order when the order is deleted. Observing the codebase, we found that filter values are stored in one store and sort order in another. The transaction implementation will look like this:

interface OrderProps {
  orderId: string;
}

interface Controller {
  deleteOrderButtonClicked(id: string): Promise<void>;
}

const useController = (params: { orderId: string }): Controller => {
  const setFilterById = useItemsFilterStore((state) => state.setFilterById);
  const setSortOrder = useOrdersSortOrderStore((state) => state.setSortOrder);

  const resource = useOrdersResourceSelector();
  const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) });

  const deleteOrderButtonClicked = async () => {
    // inline usecase with transaction
    await deleteOrder({ id: params.orderId });

    // inline transaction performs pessimistic update
    setFilterById(null);
    setSortOrder('asc');
  };

  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 Transaction Implementation

The final step is to observe the codebase for the need of transaction extraction and reuse. The extraction happens if any other consumer unit already has the same logic implemented or the transaction becomes more complex. In this case, the inline transaction evolves to an extracted one.

interface OrderProps {
  orderId: string;
}

interface Controller {
  deleteOrderButtonClicked(id: string): Promise<void>;
}

// extracted transaction
const useResetSortOrderAndFilterTransaction = (): { commit: () => void } => {
  const setFilterById = useItemsFilterStore((state) => state.setFilterById);
  const setSortOrder = useOrdersSortOrderStore((state) => state.setSortOrder);
  return {
    commit: () => {
      setFilterById(null);
      setSortOrder('asc');
    },
  };
};

const useController = (params: { orderId: string }): Controller => {
  const resource = useOrdersResourceSelector();
  const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) });
  const { commit: resetSortOrderAndFilterCommit } = useResetSortOrderAndFilterTransaction();

  const deleteOrderButtonClicked = async () => {
    await deleteOrder({ id: params.orderId });
    // extracted transaction execution
    resetSortOrderAndFilterCommit();
  };

  return { deleteOrderButtonClicked };
};

export const Order: FC<OrderProps> = (props) => {
  const presenter = usePresenter(props);
  const controller = useController(props);

  return (
    <>
      <button onClick={controller.deleteOrderButtonClicked}>Deletebutton>
      <div style={{ padding: "5px" }}>
        {presenter.itemIds.map((itemId) => (
          <OrderItem key={itemId} itemId={itemId} />
        ))}
      div>
    
  );
};

Naming of an extracted transaction is suggested to be based on the logic it performs followed by the suffix Transaction.

At this stage the transaction unit implementation is considered complete.

Q&A

How to test the transactions?

Transaction units can be tested both in integration with other units they depend on and in isolation by mocking dependencies.

Where to place it?

Transaction are suggested to be placed in the transactions directory.

What is the transaction interface and do I need a specific one?

The minimal requirement for a transaction 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 transaction type where the function returns void.

export type Transaction<T = void> = {
  commit: (params: T) => void;
};

const useResetSortOrderAndFilterTransaction = (): Transaction => {
  const setFilterById = useItemsFilterStore((state) => state.setFilterById);
  const setSortOrder = useOrdersSortOrderStore((state) => state.setSortOrder);
  return {
    commit: () => {
      setFilterById(null);
      setSortOrder('asc');
    },
  };
};

const useController = (params: { orderId: string }): Controller => {
  const resource = useOrdersResourceSelector();
  const { mutateAsync: deleteOrder } = useMutation({ ...useDeleteOrderOptions(resource) });
  const { commit: resetSortOrderAndFilterCommit } = useResetSortOrderAndFilterTransaction();

  const deleteOrderButtonClicked = async () => {
    await deleteOrder({ id: params.orderId });
    // extracted transaction execution
    resetSortOrderAndFilterCommit();
  };

  return { deleteOrderButtonClicked };
};

Can I use a transaction inside another transaction?

Yes, as practice shows, transactions can be used inside other transactions. The main concern here is whether the store manager will be able to batch such transactions or provide API to batch em. If not, then it is suggested to implement a custom transaction that reflects the current need.

Can I use multiple transactions at once?

Yes, multiple transactions can be used at once. The main concern here the same as for nested transaction usage.

Conclusion

Transaction is simple but yet powerful unit for managing state transitions. It ensures consistency, encapsulates complex logic, and enables reuse across different usecases. Starting with inline transaction and evolving to extracted one allows for flexibility and scalability as the application grows.