-
-
Notifications
You must be signed in to change notification settings - Fork 1
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.
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.
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 inline 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>
</>
);
};
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}>Delete</button>
<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.
Transaction units can be tested both in integration with other units they depend on and in isolation by mocking dependencies.
Transactions are suggested to be placed in a dedicated transactions
directory.
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 };
};
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.
Yes, multiple transactions can be used at once. The main concern here the same as for nested transaction usage.
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.