Architecture Pattern

Unit of Work Pattern

Track changes to objects and coordinate writing them out as a single transaction.

Unit of Work Pattern: A Complete Guide with Examples in 11 Programming Languages

The Unit of Work Pattern is a data access design pattern that groups one or more database operations — inserts, updates, and deletes — into a single transactional unit and commits or rolls back all of them together. Instead of writing changes to the database immediately as they happen, a Unit of Work tracks every modification made to domain objects during a business operation and flushes them all in one coordinated database round-trip at the end. If any part of the operation fails, the entire unit is rolled back and nothing is persisted.

In this comprehensive guide, we'll explore the Unit of Work Pattern with real-world examples in Python, TypeScript, Java, JavaScript, C#, PHP, Go, Rust, Dart, Swift, and Kotlin — complete with language-specific best practices, common pitfalls, and when to use this powerful pattern.

Table of Contents

What is the Unit of Work Pattern?

The Unit of Work Pattern solves a fundamental problem in database-backed applications: how do you coordinate multiple related changes to domain objects so that they are all saved together, atomically, without scattering transaction management code throughout your business logic?

The naive approach — calling save() on every object the moment it changes — creates several problems. Each save opens and closes its own database connection, multiplying round-trips and destroying performance. If the third save() fails after the first two succeeded, the database is left in an inconsistent state with no automatic rollback. Business logic and persistence logic become entangled because every object needs to know when and how to flush itself.

The Unit of Work Pattern avoids this by introducing a single coordinator object that:

  1. Tracks all domain objects that have been created, modified, or deleted during a business transaction
  2. Defers all writes until the business operation is logically complete
  3. Commits all tracked changes in a single database transaction — all succeed or all fail together
  4. Rolls back the entire transaction if any operation fails, leaving the database unchanged

Think of it like a shopping cart at a supermarket checkout. You walk through the store picking up items (modifying domain objects). You don't stop to pay for each item individually — that would be absurd. You bring everything to the register at once (commit), pay in a single transaction, and either everything goes through or nothing does. The cart is your Unit of Work — it tracks everything you've picked up and coordinates the final payment.

Why Use the Unit of Work Pattern?

The Unit of Work Pattern offers several compelling benefits:

  1. Atomic Transactions: All changes in a business operation either succeed together or fail together — partial writes that leave the database in an inconsistent state are eliminated
  2. Reduced Database Round-Trips: Changes are batched and flushed in a single database round-trip at commit time rather than one round-trip per object modification — dramatically improving performance
  3. Separation of Concerns: Business logic focuses on domain rules and object manipulation; the Unit of Work handles all persistence coordination — neither layer needs to know the other's implementation details
  4. Consistent State: The database is never left in a partially-written state; the Unit of Work guarantees that either all changes are visible or none are
  5. Change Tracking: The Unit of Work knows exactly which objects changed and what changed — enabling optimistic concurrency checks, audit logging, and selective flushing
  6. Testability: Business logic can be tested without a real database by providing a fake or in-memory Unit of Work — no database connections needed in unit tests

Unit of Work Pattern Comparison

Let's compare the Unit of Work Pattern with related patterns:

PatternPurposeTransaction ScopeChange TrackingUse Case
Unit of WorkCoordinate multiple changes in one transactionExplicit business operationYes — tracks new/dirty/deletedORM integration, service layer transactions
RepositoryAbstract data access for one aggregate typePer-queryNoCRUD operations, query abstraction
Transaction ScriptOne procedure per business operationPer scriptNoSimple CRUD, scripting-style backends
Active RecordObject knows how to save itselfPer objectMinimalSimple domains, Rails-style apps
Data MapperMaps objects to DB rows with no object-DB couplingExternalNoComplex domain models, DDD

Key Distinctions:

  • Unit of Work vs. Repository: Repository is about where data comes from — it abstracts the data store for a single aggregate type. Unit of Work is about when data is written — it coordinates the commit of changes across multiple repositories in one transaction. They are designed to work together: Repositories handle querying; the Unit of Work handles committing. A Unit of Work typically owns one or more Repositories.
  • Unit of Work vs. Active Record: Active Record objects know how to save themselves (user.save()) — each save is its own transaction. Unit of Work separates persistence from the object entirely — objects just change state; the Unit of Work decides when and how to persist. Active Record is simpler but makes batching and transactional coordination much harder.
  • Unit of Work vs. Transaction Script: Transaction Script puts all logic for a use case in a single procedure that opens a transaction, does everything, and commits. Unit of Work separates the tracking of changes from the commit — business logic can span multiple method calls before the commit. Transaction Script is fine for simple cases; Unit of Work scales to complex multi-step business operations.

Unit of Work Pattern Explained

The Unit of Work Pattern involves four participants:

Core Components:

  1. Unit of Work Interface: Declares the contract for registering domain objects as new, dirty (modified), or deleted, and for committing or rolling back the transaction. The interface is the point of abstraction that makes business logic testable — tests inject a fake Unit of Work through this interface.

  2. Concrete Unit of Work: Implements the interface. Maintains three identity maps: one for new objects, one for dirty objects, and one for deleted objects. On commit(), it opens a database transaction, issues the appropriate SQL/ORM operations for each tracked object in the correct order (inserts, then updates, then deletes), and commits or rolls back the transaction.

  3. Repository: Works hand-in-hand with the Unit of Work. When a Repository creates or modifies a domain object, it registers it with the Unit of Work. Repositories use the Unit of Work's database session/connection so that all their operations participate in the same transaction.

  4. Domain Objects / Entities: The business objects being tracked. They contain domain logic and state but have no knowledge of how or when they are persisted. The Unit of Work observes them externally — they do not call the Unit of Work themselves.

Unit of Work Variants:

  • Explicit Registration: Business logic calls uow.register_new(obj), uow.register_dirty(obj), and uow.register_deleted(obj) explicitly — clear but verbose
  • Implicit Change Tracking: The Unit of Work uses proxies, dirty-checking (comparing snapshots), or ORM change detection to track modifications automatically — cleaner for callers but more complex to implement
  • Session-based: The Unit of Work wraps an ORM session (SQLAlchemy Session, Hibernate Session, EF DbContext) — the most common form in practice
  • Request-scoped: A new Unit of Work is created per HTTP request and committed (or rolled back) at the end of the request — used in web frameworks
  • Nested Units of Work: An outer Unit of Work spans a business operation; inner Units of Work handle sub-operations with savepoints — complex but powerful for multi-step workflows

Class Diagram

Here's the UML class diagram showing the Unit of Work structure:

Beginner-Friendly Example

Let's start with the most intuitive Unit of Work example: an e-commerce order placement system. When a customer places an order, several things must happen atomically: a new Order is created, the customer's Account balance is debited, and the Inventory for each item is decremented. If any step fails — say the inventory update fails because an item went out of stock — none of the changes should persist. The Unit of Work tracks all three changes and commits or rolls back them together.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol

# ── ENTITIES ────────────────────────────────────────────────────────────────
@dataclass
class Order:
    id: int
    customer_id: int
    total: float

@dataclass
class Account:
    id: int
    customer_id: int
    balance: float

@dataclass
class InventoryItem:
    id: int
    product_id: int
    quantity: int

# ── UNIT OF WORK INTERFACE ───────────────────────────────────────────────────
class IUnitOfWork(Protocol):
    def register_new(self, entity: object) -> None: ...
    def register_dirty(self, entity: object) -> None: ...
    def register_deleted(self, entity: object) -> None: ...
    def commit(self) -> None: ...
    def rollback(self) -> None: ...

# ── CONCRETE UNIT OF WORK ────────────────────────────────────────────────────
class UnitOfWork:
    def __init__(self) -> None:
        self._new: list[object] = []
        self._dirty: list[object] = []
        self._deleted: list[object] = []

    def register_new(self, entity: object) -> None:
        self._new.append(entity)

    def register_dirty(self, entity: object) -> None:
        if entity not in self._dirty:
            self._dirty.append(entity)

    def register_deleted(self, entity: object) -> None:
        self._deleted.append(entity)

    def commit(self) -> None:
        print("[UoW] BEGIN TRANSACTION")
        try:
            for obj in self._new:
                print(f"  INSERT {obj}")
            for obj in self._dirty:
                print(f"  UPDATE {obj}")
            for obj in self._deleted:
                print(f"  DELETE {obj}")
            print("[UoW] COMMIT")
            self._clear()
        except Exception as e:
            self.rollback()
            raise

    def rollback(self) -> None:
        print("[UoW] ROLLBACK")
        self._clear()

    def _clear(self) -> None:
        self._new.clear()
        self._dirty.clear()
        self._deleted.clear()

# ── REPOSITORIES ─────────────────────────────────────────────────────────────
class OrderRepository:
    def __init__(self, uow: UnitOfWork) -> None:
        self._uow = uow

    def add(self, order: Order) -> None:
        self._uow.register_new(order)

class AccountRepository:
    def __init__(self, uow: UnitOfWork) -> None:
        self._uow = uow
        # Simulated in-memory store
        self._store: dict[int, Account] = {
            1: Account(id=1, customer_id=1, balance=500.0)
        }

    def get_by_customer(self, customer_id: int) -> Account:
        return self._store[customer_id]

    def update(self, account: Account) -> None:
        self._uow.register_dirty(account)

class InventoryRepository:
    def __init__(self, uow: UnitOfWork) -> None:
        self._uow = uow
        self._store: dict[int, InventoryItem] = {
            101: InventoryItem(id=1, product_id=101, quantity=10)
        }

    def get_by_product(self, product_id: int) -> InventoryItem:
        return self._store[product_id]

    def update(self, item: InventoryItem) -> None:
        self._uow.register_dirty(item)

# ── SERVICE / BUSINESS LOGIC ──────────────────────────────────────────────────
class OrderService:
    def __init__(
        self,
        uow: UnitOfWork,
        orders: OrderRepository,
        accounts: AccountRepository,
        inventory: InventoryRepository,
    ) -> None:
        self._uow = uow
        self._orders = orders
        self._accounts = accounts
        self._inventory = inventory

    def place_order(self, customer_id: int, product_id: int, qty: int, price: float) -> None:
        total = qty * price

        # 1. Debit account
        account = self._accounts.get_by_customer(customer_id)
        if account.balance < total:
            raise ValueError("Insufficient funds")
        account.balance -= total
        self._accounts.update(account)

        # 2. Decrement inventory
        item = self._inventory.get_by_product(product_id)
        if item.quantity < qty:
            raise ValueError("Insufficient inventory")
        item.quantity -= qty
        self._inventory.update(item)

        # 3. Create order
        order = Order(id=1001, customer_id=customer_id, total=total)
        self._orders.add(order)

        # 4. Commit all three changes atomically
        self._uow.commit()

# ── USAGE ─────────────────────────────────────────────────────────────────────
uow = UnitOfWork()
service = OrderService(
    uow,
    OrderRepository(uow),
    AccountRepository(uow),
    InventoryRepository(uow),
)
service.place_order(customer_id=1, product_id=101, qty=2, price=49.99)

Production-Ready Example

In a real-world application, the Unit of Work wraps an actual database session and participates in real transactions. The following production example demonstrates an e-commerce checkout service using a session-based Unit of Work that wraps SQLAlchemy (Python), Entity Framework Core (C#), or TypeORM (TypeScript). The key production concerns are context management, error handling, and integration with ORM change-tracking.

Key Production Concerns

  • Context manager / using / try-with-resources: Always wrap Unit of Work usage in a resource management block to guarantee commit() or rollback() is called even when exceptions occur — resource leaks leave database connections open
  • ORM integration: In practice, the Unit of Work often is the ORM session (Session in SQLAlchemy, DbContext in EF Core, EntityManager in JPA) — implementing a custom UoW on top of an ORM that already provides one adds unnecessary complexity
  • Request-scoped lifetime: In web applications, create a new Unit of Work per HTTP request and commit at the end of the request handler; roll back on any unhandled exception
  • Optimistic concurrency: Production Units of Work add a version or rowVersion field to entities; on commit, the UPDATE statement checks that the version hasn't changed since it was read — if it has, another request modified it first and a concurrency exception is raised
  • Domain events: Collect domain events raised by entities during the business operation and dispatch them after the commit succeeds — guarantees that event handlers see a consistent database state

Production Architecture

HTTP Request
    │
    ▼
Controller / Route Handler
    │
    ├── Create UnitOfWork (= ORM Session for this request)
    │
    ▼
Service Layer (business logic)
    │
    ├── Load entities via Repositories (using UoW's session)
    ├── Modify entities (business rules)
    ├── Register new/dirty/deleted entities with UoW
    │
    ▼
UnitOfWork.commit()
    │
    ├── BEGIN TRANSACTION
    ├── INSERT new entities (in dependency order)
    ├── UPDATE dirty entities (with optimistic concurrency check)
    ├── DELETE removed entities
    ├── COMMIT (or ROLLBACK on any failure)
    │
    ▼
Dispatch domain events (post-commit)
    │
    ▼
HTTP Response

Real-World Use Cases

The Unit of Work Pattern appears across every domain that involves coordinating multiple database changes within a single business operation.

1. ORM Session Management

Every major ORM implements the Unit of Work Pattern internally. SQLAlchemy's Session, Hibernate's Session, Entity Framework Core's DbContext, and Laravel's Eloquent Model::save() in a transaction are all Unit of Work implementations. Understanding the pattern helps developers use these tools correctly — knowing when to call flush(), when to call commit(), and why the session is not thread-safe (it's a Unit of Work tracking changes for one request at a time).

2. Financial Transactions

Banking and payment systems use the Unit of Work to ensure that money is never created or destroyed by a partial write. A fund transfer debits one account and credits another. Both changes must commit atomically — if the credit fails after the debit succeeds, money disappears. The Unit of Work groups both operations and rolls back both if either fails.

3. E-Commerce Order Processing

Placing an order involves creating an Order record, decrementing Inventory for each line item, creating OrderLineItem records, and updating the customer's LoyaltyPoints. All of these changes must be atomic — a customer should never receive loyalty points for an order that failed to reserve inventory. The Unit of Work commits all five writes together.

4. Event Sourcing

In event-sourced systems, the Unit of Work collects domain events raised during a business operation and appends them to the event store in a single atomic write. The current state of the aggregate is derived by replaying events — the Unit of Work ensures that a batch of events is either fully appended or not appended at all.

5. Batch Processing and ETL

Data pipelines that transform and load records use the Unit of Work to batch writes for performance. Instead of committing after each record (one transaction per record), the pipeline processes 1,000 records and commits them all in one transaction — reducing round-trips by 1,000× and dramatically improving throughput.

6. Microservices with Outbox Pattern

In microservices, a service that updates its database and publishes a message to a message broker (Kafka, RabbitMQ) faces a distributed transaction problem. The Outbox Pattern solves this by having the Unit of Work write the message to an Outbox table in the same database transaction as the business data change. A separate process reads the Outbox table and publishes messages to the broker — ensuring exactly-once delivery even if the service crashes mid-operation.

Language-Specific Mistakes and Anti-Patterns

Common Mistakes Across All Languages

  • Long-lived Unit of Work: Keeping a Unit of Work open across multiple requests or user interactions accumulates stale data and holds database connections — always scope to a single business operation
  • Missing rollback on exception: If commit() throws and rollback() is not called, the connection may be left in an undefined state — always use try/finally or context managers
  • Registering entities multiple times: Registering the same entity as both new and dirty causes duplicate writes — check for duplicates before registering
  • Ignoring return order on commit: Inserting child records before parent records violates foreign key constraints — always insert in dependency order (parents first)
  • Bypassing the Unit of Work: Calling connection.execute(sql) directly inside a service bypasses the Unit of Work's transaction — all database access must go through the Unit of Work's session

Language-Specific Pitfalls

  • Python: Never share a SQLAlchemy Session across threads — each thread needs its own session. Always use with Session() as session: (context manager) to guarantee session.close() is called. Using session.add() then calling session.commit() outside a try/except leaves the session in a broken state on failure — always catch and call session.rollback().
  • TypeScript: In TypeORM, using getRepository() without a QueryRunner means each repository operation runs in its own implicit transaction — use dataSource.createQueryRunner() and pass it to repositories explicitly for true Unit of Work behavior. Avoid mixing async/await and .then() chains in transaction code — error handling becomes unpredictable.
  • Java: Never inject a @Repository with singleton scope into a Unit of Work with request scope — Spring will use the same repository instance for all requests. Use @Transactional on the service method rather than manual transaction management unless you have a specific reason to manage it manually.
  • JavaScript: Using a single shared UnitOfWork instance in an Express app means all concurrent requests share the same dirty/new/deleted lists — create a new instance per request. Avoid using async functions without await inside a transaction block — unhandled promise rejections can leave transactions open.
  • C#: In ASP.NET Core, register DbContext with AddDbContext (scoped lifetime) not AddDbContextPool when using it as a Unit of Work — pooled contexts are reset but not always cleared of tracked entities. Avoid calling SaveChanges() multiple times within one request — either commit once at the end or wrap in a IDbContextTransaction.
  • PHP: In Laravel, using DB::transaction() with a closure is safe, but manually calling DB::beginTransaction() without a matching DB::commit() or DB::rollBack() in every code path leaves the transaction open. Eloquent's save() outside a transaction is an implicit single-operation commit — fine for simple cases but breaks atomicity for multi-model operations.
  • Go: Go has no built-in context manager — always use defer uow.Rollback() immediately after beginning a transaction, then call Commit() explicitly at the end. If Commit() succeeds, the subsequent defer Rollback() must be a no-op. Never share a *sql.Tx across goroutines — database/sql transactions are not safe for concurrent use.
  • Rust: Using Arc<Mutex<UnitOfWork>> to share a Unit of Work across threads creates contention — prefer one UoW per async task. With sqlx, use pool.begin() to start a transaction and pass &mut tx to all repository methods so they participate in the same transaction. Drop the transaction without committing to auto-rollback.
  • Dart: Dart's async/await is single-threaded but concurrent — ensure that a UnitOfWork is not await-ed across multiple isolates. When using drift (SQLite ORM), use database.transaction() blocks rather than manual UoW for simple cases — the pattern adds value mainly for complex multi-aggregate operations.
  • Swift: In Swift concurrency, never cross actor boundaries with an open Unit of Work — the same actor must own the UoW for its lifetime. With CoreData, use NSManagedObjectContext.perform or performAndWait to ensure all operations on the context happen on the correct thread. Call context.rollback() in the catch block, not just context.reset().
  • Kotlin: In Android with Room, use @Transaction annotation on DAO methods that perform multiple operations. With coroutines, use withContext(Dispatchers.IO) for all Unit of Work operations — never call database operations on the main dispatcher. Use runInTransaction from Room's RoomDatabase for explicit transaction control across multiple DAOs.

Frequently Asked Questions

Is Unit of Work the same as a database transaction?

Not exactly — they are related but distinct concepts. A database transaction is a database-level guarantee of atomicity, consistency, isolation, and durability (ACID). A Unit of Work is an application-level pattern that uses a database transaction internally but adds higher-level concerns: tracking which domain objects changed, knowing in what order to flush them, and providing a clean API for business logic to interact with. The Unit of Work coordinates the business operation; the database transaction guarantees the database-level atomicity.

Do I need Unit of Work if I'm already using an ORM?

Probably not explicitly — most ORMs implement the Unit of Work Pattern internally. SQLAlchemy's Session, Hibernate's Session, Entity Framework Core's DbContext, and GORM's transaction methods are all Unit of Work implementations. You may want to wrap them in a custom interface for testability (so you can inject a fake in tests) or to enforce consistent transaction boundaries across your service layer, but you rarely need to implement the full pattern from scratch.

How does Unit of Work relate to the Repository Pattern?

They are complementary patterns designed to work together. The Repository Pattern abstracts where data comes from — it provides a collection-like interface for a single aggregate type. The Unit of Work Pattern coordinates when data is written — it tracks changes across multiple repositories and commits them in one transaction. In practice: Repositories use the Unit of Work's session/connection; the Unit of Work owns the transaction lifecycle; the service layer coordinates both.

What is the difference between Unit of Work and Transaction Script?

Transaction Script puts all the logic for a use case in a single procedure that opens a transaction, performs every operation, and commits. It works well for simple CRUD operations. Unit of Work separates the tracking of changes from the commit — business logic can call multiple service methods, each modifying different objects, and everything is committed atomically at the end. Unit of Work scales to complex multi-step business processes; Transaction Script becomes unmanageable as complexity grows.

How do I test code that uses a Unit of Work?

Define a IUnitOfWork interface. In production, inject the real implementation backed by a database session. In tests, inject a fake implementation that stores objects in memory lists and tracks whether commit() was called. This makes service-layer tests completely independent of the database — they run in milliseconds with no database setup required. Assert on the fake's new_objects, dirty_objects, and deleted_objects lists to verify the service registered the right entities.

What is an "Outbox Pattern" and how does it relate to Unit of Work?

The Outbox Pattern is an extension of the Unit of Work for microservices that need to publish messages to a message broker (Kafka, RabbitMQ) as part of a business operation. The Unit of Work writes both the business data changes and the outgoing message to an Outbox table in the same database transaction. A separate relay process reads the Outbox and publishes messages to the broker — guaranteeing that messages are published exactly once even if the service crashes between the database commit and the message publish.

Key Takeaways

Language-Specific Best Practices

  • Python: Use SQLAlchemy Session as your Unit of Work — it tracks changes automatically via identity map; wrap session usage in with Session(engine) as session, session.begin(): for automatic commit/rollback; use session.expire_on_commit=False if you need to access entity attributes after commit
  • TypeScript: Use TypeORM's QueryRunner for explicit transaction control across multiple repositories; wrap in try/catch/finally with queryRunner.release() in the finally block; use dataSource.transaction(async manager => {...}) for simple cases
  • Java: Prefer Spring's @Transactional annotation for declarative transaction management rather than manual Unit of Work; use @Transactional(rollbackFor = Exception.class) to ensure checked exceptions also trigger rollback; keep @Transactional on the service layer, not repositories
  • JavaScript: Create a new UnitOfWork instance per request in Express middleware; use AsyncLocalStorage to pass the Unit of Work through async call chains without threading it through every function parameter; always wrap commit() in try/catch
  • C#: Register IUnitOfWork (wrapping DbContext) as scoped in the DI container; use IDbContextTransaction for explicit transaction control; implement SaveChangesInterceptor to dispatch domain events after SaveChanges succeeds
  • PHP: Use Laravel's DB::transaction() closure for simple atomic operations; implement IUnitOfWork as a thin wrapper around Eloquent's transaction methods; use afterCommit() callbacks for post-commit side effects
  • Go: Use defer tx.Rollback() immediately after db.Begin() — it is a no-op after a successful Commit(); pass *sql.Tx (not *sql.DB) to all repository functions within a Unit of Work; use context.Context to propagate cancellation into the transaction
  • Rust: Use sqlx::Transaction and pass &mut transaction to all repository functions; transaction.commit().await? on success; the transaction auto-rolls back when dropped without committing; use anyhow::Error for unified error handling across the transaction
  • Dart: Use drift's database.transaction() for ORM-backed Unit of Work; implement IUnitOfWork for domain-level change tracking on top; use Completer carefully in async transaction code to avoid leaving transactions open on cancellation
  • Swift: Use NSManagedObjectContext as the Unit of Work in CoreData apps; call context.save() only when the full business operation is complete; use child contexts for draft editing that can be discarded without affecting the main context
  • Kotlin: Use Room's @Transaction for DAO-level atomicity; wrap multi-aggregate operations in database.withTransaction {} (coroutine-friendly); use Kotlin's use {} extension on Closeable transaction objects for resource safety

📋 When to Use Unit of Work:

  • A business operation modifies multiple domain objects that must all be saved atomically
  • You need to batch database writes for performance (reduce round-trips)
  • You want to separate business logic from persistence coordination
  • You need consistent, testable transaction boundaries across your service layer
  • You are implementing the Repository Pattern and need a coordinator for multi-repository commits
  • You need to implement optimistic concurrency, audit logging, or domain event dispatch on commit

⚠️ When NOT to Use Unit of Work:

  • Your ORM already provides a Unit of Work (SQLAlchemy Session, EF DbContext) and you don't need an additional abstraction layer
  • The application is simple CRUD with single-object operations — the overhead of tracking and deferring isn't worth it
  • You need immediate write-through to the database (real-time dashboards, streaming data) — deferred commits are wrong for these use cases
  • The domain is read-heavy with rare writes — the tracking overhead of Unit of Work adds cost where there's little benefit

🛠️ Best Practices Across Languages:

  1. Scope tightly: One Unit of Work per business operation (usually per HTTP request or per command in a command handler) — never share across concurrent operations
  2. Always call rollback on failure: Use try/finally, context managers, or defer to guarantee the transaction is rolled back if commit() is not reached
  3. Depend on the interface, not the implementation: Service layer code should depend on IUnitOfWork, not the concrete class — this is what enables testing with fakes
  4. Commit once: Call commit() exactly once at the end of the business operation — multiple commits within one service method indicate the transaction boundary is in the wrong place
  5. Insert in dependency order: When flushing, always insert parent entities before child entities to respect foreign key constraints
  6. Use the ORM's built-in tracking when possible: Don't implement explicit register_dirty() calls if the ORM already detects changes automatically — leverage the tool
  7. Keep repositories stateless: Repositories should not hold references to loaded entities — the Unit of Work's identity map is the cache; repositories are just query interfaces

💡 Common Pitfalls:

  • God Unit of Work: A Unit of Work that spans multiple HTTP requests or is long-lived accumulates stale entity state and holds database connections — always scope to one business operation
  • Missing rollback: Forgetting to call rollback() on exception leaves the database connection in an undefined state — always use resource management constructs
  • Transaction too wide: Opening a transaction for the entire duration of a user session (while the user edits a form) holds database locks for minutes — commit at business operation boundaries, not UI interaction boundaries
  • Bypassing the session: Calling connection.execute(raw_sql) inside a service method that uses a Unit of Work creates writes outside the managed transaction — everything must go through the Unit of Work's session
  • Duplicate registration: Registering the same entity as both new and dirty causes a duplicate INSERT/UPDATE on commit — always check before registering
  • Not handling commit failure: commit() can fail due to concurrency conflicts, constraint violations, or network errors — always handle the exception and decide whether to retry or propagate

🎁 Advanced Techniques:

  • Identity Map: Extend the Unit of Work with an identity map (dict[id, entity]) so that loading the same entity twice returns the same object instance — prevents inconsistent state from two different copies of the same database row
  • Optimistic Concurrency: Add a version field to entities; on UPDATE, include WHERE version = :read_version in the SQL — if the row was modified by another transaction, the UPDATE affects zero rows and a ConcurrencyException is raised
  • Domain Events: Collect domain events raised by entities during the business operation; dispatch them after commit() succeeds — guarantees event handlers see consistent database state
  • Outbox Pattern: Write domain events to an Outbox table in the same transaction as business data; a relay process publishes them to a message broker — solves the dual-write problem in microservices
  • Saga + Unit of Work: For long-running business processes spanning multiple services, use a Saga coordinator; each step has its own Unit of Work; the Saga handles compensation (rollback) by issuing corrective business operations rather than database rollbacks
  • Read Model Synchronization: After committing the Unit of Work, update read models (denormalized views, search indexes, caches) from the domain events that were collected — keeps read and write models eventually consistent

The Unit of Work Pattern is the transactional backbone of any serious data-driven application. It appears as SQLAlchemy's Session, Hibernate's Session, Entity Framework Core's DbContext, and Laravel's database transaction helpers. Once you understand the pattern, you understand why ORMs behave the way they do — why changes aren't saved until you call session.commit(), why flushing is separate from committing, and why you should never share a session across threads. It is the essential link between your domain model and your database, coordinating every write so that business operations are always atomic, consistent, and auditable.


This guide is part of the Design Patterns in Action series, which covers:

Each guide covers all 11 programming languages with production-ready examples, language-specific best practices, and comprehensive anti-pattern analysis.