Repository & Unit of Work
Abstracting data access behind a collection-like interface, and committing changes across repositories as one atomic transaction — for decoupling, testability, and consistency.
The Repository pattern hides persistence behind a collection-like interface so business code depends on an abstraction, not on SQL or an ORM. The Unit of Work coordinates changes across multiple repositories and commits them in a single atomic transaction. Together they decouple the domain from infrastructure, make logic testable with in-memory fakes, and keep multi-entity changes consistent.
A repository exposes a collection-like interface for a domain type — getById, add, remove, query — and hides how data is actually stored. Business code talks to IOrderRepository, not to SQL, an ORM, or an HTTP API. The persistence technology becomes a swappable detail.
Because the rest of the app depends on an interface, you can swap a SqlOrderRepository for an InMemoryOrderRepository in tests — fast, deterministic, no database. Data-access concerns stay in one place instead of being smeared through the codebase.
Real operations touch multiple repositories (insert an order AND update the customer). The Unit of Work tracks all those changes in memory and commits them together as a single atomic transaction — so either everything persists or nothing does. It owns the transaction boundary the repositories share.
Staged changes live in memory until commit() flushes them in one transaction. If anything fails, rollback discards them all and the database is untouched. This keeps invariants that span entities (an order and its customer's balance) consistent without scattering transaction code through business logic.
Key Concepts
A collection-like abstraction over persistence for one aggregate/type, hiding the storage mechanism.
Tracks changes across repositories and commits them as one atomic transaction (or rolls them all back).
The scope within which changes are all-or-nothing. The Unit of Work owns it so business code doesn't.
Domain and application code that knows nothing about how (or where) data is stored.
A generic Repository<T> covers CRUD; specific repositories add query methods that fit the domain.
EF Core's DbContext is already a Unit of Work and DbSet<T> a repository — wrapping them can be redundant.
1// Repository = a collection-like abstraction over storage.2interface IOrderRepository {3 getById(id: OrderId): Promise<Order | null>;4 add(order: Order): void; // staged, not yet saved5 remove(order: Order): void;6}78// Unit of Work = one transaction across many repositories.9interface IUnitOfWork {10 orders: IOrderRepository;11 customers: ICustomerRepository;12 commit(): Promise<void>; // flush ALL changes atomically13}1415// Application code knows nothing about SQL or transactions:16async function placeOrder(uow: IUnitOfWork, cmd: PlaceOrderCmd) {17 const customer = await uow.customers.getById(cmd.customerId);18 const order = customer.placeOrder(cmd.items);19 uow.orders.add(order);20 customer.recordOrder(order.id);21 await uow.commit(); // order INSERT + customer UPDATE in one tx22}2324// NOTE: EF Core's DbContext already IS a Unit of Work, and DbSet<T>25// is a generic repository — don't blindly wrap them in another layer.
Repository and Unit of Work are staples of layered and clean architectures, especially in .NET, and they come up constantly in design discussions. Knowing when they add value (test seams, complex queries, atomic multi-entity writes) — and when they're redundant over an ORM that already provides them — is a mark of practical seniority.
Common Pitfalls
1Half-Saved State After a Crash
Placing an order inserts an order row and then updates the customer's loyalty points in a separate call. A failure between the two leaves orders saved but points never credited — data drifts out of sync.
The two writes weren't in one transaction. Each repository (or service) managed its own save, so a partial failure left the database in an inconsistent state with no atomic boundary around the operation.
Wrap both repositories in a Unit of Work and call commit() once. The order INSERT and customer UPDATE now run in a single transaction — either both succeed or both roll back. The business code expresses intent; the Unit of Work guarantees atomicity.
Takeaway: When one operation changes multiple things that must stay consistent, a Unit of Work gives you a single transaction boundary — no more partially-applied changes from a mid-operation failure.
2The Redundant Repository Over EF Core
A team wraps EF Core's DbContext in a hand-rolled generic Repository<T> and IUnitOfWork 'for clean architecture'. The wrapper leaks IQueryable anyway, blocks useful EF features, and adds boilerplate for every entity.
DbContext already implements the Unit of Work pattern and DbSet<T> already is a repository. The extra layer duplicated existing behavior, hid the ORM's strengths, and became a leaky, maintenance-heavy abstraction that bought nothing.
Use DbContext directly as the Unit of Work and DbSet as repositories, and add small, specific repository interfaces only where they earn their keep — to encapsulate complex queries or to provide a test seam for a particular aggregate.
Takeaway: Don't add a repository layer reflexively. If your ORM already provides Unit of Work and repository semantics, abstract only where you get real value (testing seams, complex queries) rather than wrapping for ceremony.
Join the discussion
Comments
Loading comments...
Cookie preferences
We use essential cookies for sign-in and preferences. With your permission, we also use basic analytics to improve lessons.