Design Patterns

Unit of Work Design Pattern

I had a checkout flow that created an order, deducted inventory, and charged a payment — three separate database calls. If the payment failed after invent...

23 Mar 2024

Unit of Work Design Pattern

I had a checkout flow that created an order, deducted inventory, and charged a payment — three separate database calls. If the payment failed after inventory was already deducted, the data was inconsistent. Rolling back manually was fragile and error-prone.

The Unit of Work pattern groups multiple data operations into a single transaction. You register changes — inserts, updates, deletes — and commit them all at once. Either everything succeeds or everything rolls back. No partial state.

Think of it like a shopping cart. You add items, remove items, change quantities. Nothing actually happens until you click "checkout." Then all the changes are processed together.

Javascript
class Task {
  constructor(id, title) {
    this.id = id;
    this.title = title;
  }
}

class UnitOfWork {
  constructor() {
    this.newEntities = [];
    this.dirtyEntities = [];
    this.deletedEntities = [];
  }

  registerNew(entity) {
    this.newEntities.push(entity);
  }

  registerDirty(entity) {
    this.dirtyEntities.push(entity);
  }

  registerDeleted(entity) {
    this.deletedEntities.push(entity);
  }

  commit() {
    console.log('Starting transaction...');

    this.newEntities.forEach(entity => {
      console.log('INSERT:', entity);
    });

    this.dirtyEntities.forEach(entity => {
      console.log('UPDATE:', entity);
    });

    this.deletedEntities.forEach(entity => {
      console.log('DELETE:', entity);
    });

    console.log('Transaction committed.');
    this.clear();
  }

  rollback() {
    console.log('Rolling back...');
    this.clear();
  }

  clear() {
    this.newEntities = [];
    this.dirtyEntities = [];
    this.deletedEntities = [];
  }
}

const uow = new UnitOfWork();

uow.registerNew(new Task(null, 'Write tests'));
uow.registerNew(new Task(null, 'Deploy to staging'));
uow.registerDeleted(new Task(1, 'Old task'));

uow.commit();
// Starting transaction...
// INSERT: Task { id: null, title: 'Write tests' }
// INSERT: Task { id: null, title: 'Deploy to staging' }
// DELETE: Task { id: 1, title: 'Old task' }
// Transaction committed.

The Unit of Work tracks three categories: new entities to insert, dirty (modified) entities to update, and entities to delete. commit() executes all changes in a single transaction. If anything fails, rollback() discards everything.

How It Works in Real ORMs

Most ORMs implement Unit of Work internally:

  • Prisma batches operations with $transaction().
  • TypeORM tracks entity changes through its EntityManager.
  • Sequelize supports managed transactions.
  • Entity Framework (.NET) and Hibernate (Java) are built around this pattern.

You're often using Unit of Work without knowing it. Understanding the pattern helps you use these tools more effectively.

When It Matters Most

  • Multi-entity operations. Creating an order with line items and a payment record — all or nothing.
  • Batch processing. Importing 1000 records — commit them in batches, not one at a time.
  • Complex workflows. Any operation where partial completion leaves your data in a broken state.

The benefit: Data consistency. All changes succeed or none do. Reduces the number of database round trips by batching operations. Clear transactional boundaries.

The cost: Holds changes in memory until commit. For large batches, memory usage can spike. The pattern also adds complexity — you need to track entity states and handle rollback logic. For single-entity CRUD operations, a simple INSERT statement is cleaner.

I use Unit of Work (directly or through an ORM) whenever a business operation touches multiple database tables. For simple single-record operations, it's unnecessary overhead.