Practical usage of SAGA with NestJS

 

The SAGA pattern is a design pattern used in microservices to manage distributed transactions across multiple databases or services, ensuring data consistency without relying on distributed transactions (e.g., two-phase commit). Each step in a saga represents a local transaction, and if a step fails, compensating transactions (rollbacks) are executed to undo previous steps.

What is the practical issue?

Suppose you’re building an API or GraphQL resolver with three entities, CustomerShipping Info, and Order, stored in separate databases (sample scenario).

You want to update an Order along with its Customer and Shipping Info. If you update them sequentially, Customer → Shipping Info → Order, and what will happen if the Order update fails? Because the previous updates are already persisted.

This results in a partial update (or dirty write) where some changes are saved while others are lost. Handling this properly usually requires TypeORM transactions, or you can use the Saga pattern to ensure atomicity across multiple services.

What if you are working in a single DB?

If you are working in a single DB the solution is straightforward. You can use TypeORM transactions to ensure all updates succeed or fail together.

But what if your entities live in multiple databases? A standard transaction won’t work across them, so you’ll need to use a distributed transaction pattern, like the Saga pattern, to coordinate updates and handle rollbacks across different databases or services.

Here, I will provide a sample class that uses this simple mechanism to ensure data consistency. Look at the sample code below.

async updateOrderWorkflow_NoRollback(orderDto: UpdateOrderDto): Promise<{ orderId: string }> {
try {
// Update Customer (NO BACKUP)
const customer = await this.customerService.getCustomerById(orderDto.customerId);
if (!customer) throw new Error('Customer not found');

const updatedCustomer: UpdateCustomerDto = {
...customer,
id: customer.id,
email: orderDto.customerEmail,
contactNumber: orderDto.customerContact,
modifiedBy: orderDto.modifiedBy,
};
await this.customerService.updateCustomer(updatedCustomer);

// Update Shipping Info (NO SAFETY NET)
const shippingInfo = await this.shippingService.getShippingByOrderId(orderDto.orderId);

if (orderDto.shippingAddress) {
const updatedShipping: UpdateShippingDto = {
id: shippingInfo?.id,
orderId: orderDto.orderId,
address: orderDto.shippingAddress,
updatedBy: orderDto.modifiedBy,
isActive: true,
};

if (shippingInfo) {
await this.shippingService.updateShipping(updatedShipping);
} else {
const newShipping: CreateShippingDto = {
orderId: orderDto.orderId,
address: orderDto.shippingAddress,
createdBy: orderDto.modifiedBy,
};
await this.shippingService.createShipping(newShipping);
}
}

// Update Order (NO ROLLBACK IF FAILS)
const order = await this.orderRepository.getOrderById(orderDto.orderId);
if (!order) throw new Error('Order not found');

order.customerId = orderDto.customerId;
order.status = orderDto.status;
order.notes = orderDto.notes;
order.modifiedBy = orderDto.modifiedBy;

const savedOrder = await this.orderRepository.updateOrder(order);

return { orderId: savedOrder.id };

} catch (err) {
throw new Error(`Order update failed: ${err.message}`);
}
}

As you can see, if anything happens in saving order, the data will be saved partially. We can avoid this using SAGA.

Now look at the code below, it is very easy to understand.

  • Save the original state of the Customer.
  • Update the customer with new data.
  • Save the original state of the Shipping details.
  • Then update the shipping details.
  • Save the original order.
  • Then update the order.
  • If anything happens, handle the rollback functionality in the catch block.
async updateOrderWorkflow(orderDto: UpdateOrderDto): Promise<{ orderId: string }> {
const compensations: (() => Promise<void>)[] = []; // rollback stack
const errors: any[] = [];

try {
// Update Customer
const customer = await this.customerService.getCustomerById(orderDto.customerId);
if (!customer) throw new Error('Customer not found');

// backup for rollback
const originalCustomer = { ...customer };

const updatedCustomer: UpdateCustomerDto = {
...customer,
id: customer.id,
email: orderDto.customerEmail,
contactNumber: orderDto.customerContact,
modifiedBy: orderDto.modifiedBy,
};

await this.customerService.updateCustomer(updatedCustomer);

// rollback action
compensations.push(() => this.customerService.updateCustomer(originalCustomer));

// Update or Create Shipping Info
const shippingInfo = await this.shippingService.getShippingByOrderId(orderDto.orderId);
const originalShipping = shippingInfo ? { ...shippingInfo } : null;

if (orderDto.shippingAddress) {
const updatedShipping: UpdateShippingDto = {
id: shippingInfo?.id,
orderId: orderDto.orderId,
address: orderDto.shippingAddress,
updatedBy: orderDto.modifiedBy,
isActive: true,
};

if (shippingInfo) {
await this.shippingService.updateShipping(updatedShipping);
} else {
const newShipping: CreateShippingDto = {
orderId: orderDto.orderId,
address: orderDto.shippingAddress,
createdBy: orderDto.modifiedBy,
};
await this.shippingService.createShipping(newShipping);
}
}

// rollback for shipping
compensations.push(async () => {
if (originalShipping) {
await this.shippingService.updateShipping(originalShipping);
}
});

// Update Order
const order = await this.orderRepository.getOrderById(orderDto.orderId);
if (!order) throw new Error('Order not found');

// backup for rollback
const originalOrder = { ...order };

order.customerId = orderDto.customerId;
order.status = orderDto.status;
order.notes = orderDto.notes;
order.modifiedBy = orderDto.modifiedBy;

const savedOrder = await this.orderRepository.updateOrder(order);

// rollback action for order
compensations.push(() => this.orderRepository.updateOrder(originalOrder));

return { orderId: savedOrder.id };

} catch (err) {
errors.push(err.message);

// Rollback all in reverse order
for (const rollback of compensations.reverse()) {
try {
await rollback();
} catch (rollbackError) {
console.error('Rollback failed:', rollbackError);
}
}
throw new Error(`Order update failed: ${errors.join('; ')}`);
}
}

In this way, we can simply handle complete transactions using SAGA.

More articles: https://medium.com/@raviyasas



Practical usage of SAGA with NestJS Practical usage of SAGA with NestJS Reviewed by Ravi Yasas on 12:50 AM Rating: 5

No comments:

Powered by Blogger.