Repository and unit of work
A repository makes stored objects look like a collection, and a unit of work saves all the changes of one operation together or not at all. This page uses both to end Pedal's rides, so a declined card can never leave a ride without its payment.
1. The pain
Ending a Pedal ride saves two things: the ride itself (who, how long) and its payment. The first end_ride() wrote both with SQL statements in the middle of the business logic: INSERT INTO rides …, then INSERT INTO payments …, each as its own save.
When a card was declined, the ride row was already saved and the payment row wasn't. Finance's report showed rides that had never been paid for, and the support team cleaned them up by hand. Meanwhile SQL was spread across a dozen business functions, so testing any of them needed a real database.
2. The idea in one picture
Two patterns that usually travel together. A repository looks like a collection of domain objects (rides.add(ride), rides.for_rider("mia")) and hides how they're stored. A unit of work collects every change made during one business operation and saves them all in one transaction on commit(), or none of them.
Neither is from the 1994 book; both are classics of enterprise application design, and most ORMs build them in (SQLAlchemy's Session, Entity Framework's DbContext, JPA's EntityManager). Knowing the patterns tells you what those tools are doing.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Repository | A collection-like interface for one kind of domain object; hides storage. | RideRepository, PaymentRepository |
| Unit of work | Tracks the changes of one operation; commits them together or rolls them all back. | UnitOfWork with commit() |
| Domain objects | The things being stored; know nothing about storage. | Ride, Payment |
| Storage | The database (here, an in-memory stand-in). | Database |
| Client | The business operation. | end_ride() |
4. Before and after
Ending a ride both ways (Python sketch; the full program is in section 5).
Without the pattern
def end_ride(ride, payment):
db.execute("INSERT INTO rides ...", ride)
db.commit()
db.execute("INSERT INTO payments ...",
payment) # declined!
db.commit()
# the ride is saved, the payment isn'tWith it
def end_ride(ride, payment):
uow = UnitOfWork(db)
uow.rides.add(ride)
uow.payments.add(payment)
uow.commit() # both, or neither
# reading, without SQL:
uow.rides.for_rider("mia")| The next change | Without the pattern | With it |
|---|---|---|
| A card is declined | A ride saved without a payment | Neither saved; both rolled back |
| Save a receipt too | A third save that can fail on its own | uow.receipts.add(...), same transaction |
| Test end_ride() | Needs a real database with tables | In-memory repositories |
Business code talks about rides and payments; the repositories know about tables; the unit of work knows about transactions.
5. The code
Two repositories register changes with a UnitOfWork, which commits them in one go. R-2's declined card rolls everything back; R-3, saved without a unit of work, leaves the tables out of step.
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Ride:
ride_id: str
rider: str
minutes: int
@dataclass
class Payment:
ride_id: str
pence: int
card_ok: bool
class Database:
"""Stands in for SQL tables. Inserts happen one at a time; a transaction is all-or-nothing."""
def __init__(self) -> None:
self.rides: dict[str, Ride] = {}
self.payments: dict[str, Payment] = {}
def insert_ride(self, r: Ride) -> None:
self.rides[r.ride_id] = r
def insert_payment(self, p: Payment) -> None:
if not p.card_ok:
raise ValueError("card declined")
self.payments[p.ride_id] = p
def counts(self) -> str:
return f"rides: {len(self.rides)}, payments: {len(self.payments)}"
# ---- Repositories: collections of domain objects. Callers never write SQL or see tables. ----
class RideRepository:
def __init__(self, uow: UnitOfWork) -> None:
self.uow = uow
def add(self, ride: Ride) -> None:
self.uow.pending.append(("ride", ride))
def for_rider(self, rider: str) -> list[Ride]:
return [r for r in self.uow.db.rides.values() if r.rider == rider]
class PaymentRepository:
def __init__(self, uow: UnitOfWork) -> None:
self.uow = uow
def add(self, payment: Payment) -> None:
self.uow.pending.append(("payment", payment))
# ---- Unit of work: collects the changes of one business operation and saves them together. ----
class UnitOfWork:
def __init__(self, db: Database) -> None:
self.db = db
self.pending: list[tuple[str, Ride | Payment]] = []
self.rides, self.payments = RideRepository(self), PaymentRepository(self)
def commit(self) -> None:
saved_rides, saved_payments = dict(self.db.rides), dict(self.db.payments) # begin transaction
try:
for kind, obj in self.pending:
if kind == "ride" and isinstance(obj, Ride):
self.db.insert_ride(obj)
elif isinstance(obj, Payment):
self.db.insert_payment(obj)
except ValueError:
self.db.rides, self.db.payments = saved_rides, saved_payments # roll back everything
raise
finally:
self.pending.clear()
def end_ride(db: Database, ride: Ride, payment: Payment) -> str:
uow = UnitOfWork(db)
uow.rides.add(ride)
uow.payments.add(payment)
try:
uow.commit()
return f"end {ride.ride_id}: committed ({db.counts()})"
except ValueError as e:
return f"end {ride.ride_id}: {e}, rolled back ({db.counts()})"
if __name__ == "__main__":
db = Database()
print(end_ride(db, Ride("R-1", "mia", 42), Payment("R-1", 120, True)))
print(end_ride(db, Ride("R-2", "mia", 18), Payment("R-2", 280, False)))
print("mia's rides: " + ", ".join(f"{r.ride_id} ({r.minutes} min)" for r in UnitOfWork(db).rides.for_rider("mia")))
db.insert_ride(Ride("R-3", "leo", 10)) # without a unit of work: two separate saves
try:
db.insert_payment(Payment("R-3", 200, False))
except ValueError as e:
print(f"without a unit of work: R-3 saved, payment failed ({e}) -> {db.counts()}, out of step")
interface Ride { rideId: string; rider: string; minutes: number; }
interface Payment { rideId: string; pence: number; cardOk: boolean; }
class Declined extends Error {}
// Stands in for SQL tables. Inserts happen one at a time; a transaction is all-or-nothing.
class Database {
rides = new Map<string, Ride>();
payments = new Map<string, Payment>();
insertRide(r: Ride): void { this.rides.set(r.rideId, r); }
insertPayment(p: Payment): void {
if (!p.cardOk) throw new Declined("card declined");
this.payments.set(p.rideId, p);
}
counts(): string { return `rides: ${this.rides.size}, payments: ${this.payments.size}`; }
}
type Change = { kind: "ride"; obj: Ride } | { kind: "payment"; obj: Payment };
// ---- Repositories: collections of domain objects. Callers never write SQL or see tables. ----
class RideRepository {
constructor(private readonly uow: UnitOfWork) {}
add(ride: Ride): void { this.uow.pending.push({ kind: "ride", obj: ride }); }
forRider(rider: string): Ride[] { return [...this.uow.db.rides.values()].filter((r) => r.rider === rider); }
}
class PaymentRepository {
constructor(private readonly uow: UnitOfWork) {}
add(payment: Payment): void { this.uow.pending.push({ kind: "payment", obj: payment }); }
}
// ---- Unit of work: collects the changes of one business operation and saves them together. ----
class UnitOfWork {
readonly pending: Change[] = [];
readonly rides = new RideRepository(this);
readonly payments = new PaymentRepository(this);
constructor(readonly db: Database) {}
commit(): void {
const savedRides = new Map(this.db.rides), savedPayments = new Map(this.db.payments); // begin transaction
try {
for (const c of this.pending) {
if (c.kind === "ride") this.db.insertRide(c.obj);
else this.db.insertPayment(c.obj);
}
} catch (e) {
this.db.rides = savedRides; this.db.payments = savedPayments; // roll back everything
throw e;
} finally {
this.pending.length = 0;
}
}
}
function endRide(db: Database, ride: Ride, payment: Payment): string {
const uow = new UnitOfWork(db);
uow.rides.add(ride);
uow.payments.add(payment);
try {
uow.commit();
return `end ${ride.rideId}: committed (${db.counts()})`;
} catch (e) {
if (!(e instanceof Declined)) throw e;
return `end ${ride.rideId}: ${e.message}, rolled back (${db.counts()})`;
}
}
const db = new Database();
console.log(endRide(db, { rideId: "R-1", rider: "mia", minutes: 42 }, { rideId: "R-1", pence: 120, cardOk: true }));
console.log(endRide(db, { rideId: "R-2", rider: "mia", minutes: 18 }, { rideId: "R-2", pence: 280, cardOk: false }));
console.log("mia's rides: " + new UnitOfWork(db).rides.forRider("mia").map((r) => `${r.rideId} (${r.minutes} min)`).join(", "));
db.insertRide({ rideId: "R-3", rider: "leo", minutes: 10 }); // without a unit of work: two separate saves
try {
db.insertPayment({ rideId: "R-3", pence: 200, cardOk: false });
} catch (e) {
console.log(`without a unit of work: R-3 saved, payment failed (${(e as Error).message}) -> ${db.counts()}, out of step`);
}import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Main {
record Ride(String rideId, String rider, int minutes) {}
record Payment(String rideId, int pence, boolean cardOk) {}
static class Declined extends Exception {
private static final long serialVersionUID = 1L;
Declined(String message) { super(message); }
}
// Stands in for SQL tables. Inserts happen one at a time; a transaction is all-or-nothing.
static class Database {
Map<String, Ride> rides = new LinkedHashMap<>();
Map<String, Payment> payments = new LinkedHashMap<>();
void insertRide(Ride r) { rides.put(r.rideId(), r); }
void insertPayment(Payment p) throws Declined {
if (!p.cardOk()) throw new Declined("card declined");
payments.put(p.rideId(), p);
}
String counts() { return "rides: " + rides.size() + ", payments: " + payments.size(); }
}
// ---- Repositories: collections of domain objects. Callers never write SQL or see tables. ----
static class RideRepository {
private final UnitOfWork uow;
RideRepository(UnitOfWork uow) { this.uow = uow; }
void add(Ride ride) { uow.pending.add(ride); }
List<Ride> forRider(String rider) { return uow.db.rides.values().stream().filter(r -> r.rider().equals(rider)).toList(); }
}
static class PaymentRepository {
private final UnitOfWork uow;
PaymentRepository(UnitOfWork uow) { this.uow = uow; }
void add(Payment payment) { uow.pending.add(payment); }
}
// ---- Unit of work: collects the changes of one business operation and saves them together. ----
static class UnitOfWork {
final Database db;
final List<Record> pending = new ArrayList<>();
final RideRepository rides = new RideRepository(this);
final PaymentRepository payments = new PaymentRepository(this);
UnitOfWork(Database db) { this.db = db; }
void commit() throws Declined {
Map<String, Ride> savedRides = new LinkedHashMap<>(db.rides); // begin transaction
Map<String, Payment> savedPayments = new LinkedHashMap<>(db.payments);
try {
for (Record r : pending) {
if (r instanceof Ride ride) db.insertRide(ride);
else if (r instanceof Payment p) db.insertPayment(p);
}
} catch (Declined e) {
db.rides = savedRides; db.payments = savedPayments; // roll back everything
throw e;
} finally {
pending.clear();
}
}
}
static String endRide(Database db, Ride ride, Payment payment) {
UnitOfWork uow = new UnitOfWork(db);
uow.rides.add(ride);
uow.payments.add(payment);
try {
uow.commit();
return "end " + ride.rideId() + ": committed (" + db.counts() + ")";
} catch (Declined e) {
return "end " + ride.rideId() + ": " + e.getMessage() + ", rolled back (" + db.counts() + ")";
}
}
public static void main(String[] args) {
Database db = new Database();
System.out.println(endRide(db, new Ride("R-1", "mia", 42), new Payment("R-1", 120, true)));
System.out.println(endRide(db, new Ride("R-2", "mia", 18), new Payment("R-2", 280, false)));
List<String> mine = new ArrayList<>();
for (Ride r : new UnitOfWork(db).rides.forRider("mia")) mine.add(r.rideId() + " (" + r.minutes() + " min)");
System.out.println("mia's rides: " + String.join(", ", mine));
db.insertRide(new Ride("R-3", "leo", 10)); // without a unit of work: two separate saves
try {
db.insertPayment(new Payment("R-3", 200, false));
} catch (Declined e) {
System.out.println("without a unit of work: R-3 saved, payment failed (" + e.getMessage() + ") -> " + db.counts() + ", out of step");
}
}
}Output (the same in all three languages)
end R-1: committed (rides: 1, payments: 1) end R-2: card declined, rolled back (rides: 1, payments: 1) mia's rides: R-1 (42 min) without a unit of work: R-3 saved, payment failed (card declined) -> rides: 2, payments: 1, out of step
6. Try it
End a ride that fails
Ending a ride saves three things in order: the ride, its payment, and a receipt. Choose what fails and whether a unit of work is used.
7. When not to use it
What you get
- All-or-nothing: an operation's changes commit together.
- Business code without SQL: repositories read like collections.
- Testable: swap in in-memory repositories.
- One place for storage concerns: queries, mapping and caching live in repositories.
What you pay
- More layers: for simple CRUD screens, it can be ceremony.
- Leaky abstractions: complex queries strain a collection-like interface.
- ORM overlap: most ORMs already are a unit of work; wrapping them again adds little.
- Long units of work: holding a transaction open for a slow operation blocks others.
| If… | Use instead |
|---|---|
| Changes span several services or databases | A saga with compensating steps (System design track) |
| The app is simple forms over tables | Active record or the ORM directly |
| Reads need complex reporting queries | A separate query layer, not the repository |
| You only need to group writes in one place | The ORM's session or a database transaction |
8. How it goes wrong
rides.add() saves immediately, the unit of work can't roll it back. Repositories register; the unit of work commits.9. Remember it and say it
How this hook works: what “Pay for the whole basket” means
What the slogan means, phrase by phrase
- “the whole basket”
- Every change in one business operation.
- “Pay”
commit(): one transaction.- “put everything back”
- Roll back all of it.
Why this picture fits: Nobody leaves a shop with half a basket because the card was declined halfway through scanning.
The story → the concept
| The supermarket | The code |
|---|---|
| Shelves, found by what's on them | RideRepository, PaymentRepository |
| The basket | UnitOfWork |
| Paying | commit() |
| Card declined | Roll back: nothing saved |
Watch it on an example
end_rideadds R-2 and its payment to the unit of work.commit()saves the ride, then the payment: card declined.- The unit of work restores both tables. No ride without a payment.
The trap it saves you from
Saving related objects one at a time, so a failure halfway leaves them out of step.
It still works when…
Yes: SQLAlchemy's Session, Entity Framework's DbContext and JPA's EntityManager are units of work with repositories on top.
In an interview or code review
- Say what must be atomic: "A ride and its payment commit together, so ending a ride uses a unit of work."
- Say what repositories hide: tables and SQL; business code asks for
rides.for_rider("mia"). - Know the ORM mapping: SQLAlchemy's
Session, JPA'sEntityManager, EF'sDbContext. - Know the limit: across services there's no shared transaction; use a saga.
10. Check yourself
What does a repository look like to business code?
A card is declined during commit(). What's saved?
Should rides.add() write to the database immediately?
Which ORM object is a unit of work?
A ride and payment live in two different services. What replaces the unit of work?
11. Go deeper
| Resource | Why |
|---|---|
| System design track | Transactions, sagas and consistency across services. |
| Architecture styles 03: Hexagonal | Repositories are the classic storage port. |
| SQLAlchemy: Session basics | A unit of work you may already be using. |
| Martin Fowler: Unit of Work (catalog entry) | The pattern's short definition. |