InterviewKata
Design patterns · 25 of 26 · Beyond the book

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

At a supermarket checkout, items go into the basket and nothing leaves the shop until you pay for the whole basket. If your card is declined, all the items go back on the shelves, not just some. The basket is a unit of work; the shelves, where you find things by what they are, are like repositories.

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

RoleIts jobIn Pedal
RepositoryA collection-like interface for one kind of domain object; hides storage.RideRepository, PaymentRepository
Unit of workTracks the changes of one operation; commits them together or rolls them all back.UnitOfWork with commit()
Domain objectsThe things being stored; know nothing about storage.Ride, Payment
StorageThe database (here, an in-memory stand-in).Database
ClientThe 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't

With 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 changeWithout the patternWith it
A card is declinedA ride saved without a paymentNeither saved; both rolled back
Save a receipt tooA third save that can fail on its ownuow.receipts.add(...), same transaction
Test end_ride()Needs a real database with tablesIn-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 databasesA saga with compensating steps (System design track)
    The app is simple forms over tablesActive record or the ORM directly
    Reads need complex reporting queriesA separate query layer, not the repository
    You only need to group writes in one placeThe ORM's session or a database transaction

    8. How it goes wrong

    One repository per table instead of per aggregateIf the ride and its stops are one business object, a stops repository invites half-saved rides. Repositories follow the domain, not the schema.
    Repositories that commit on their ownIf rides.add() saves immediately, the unit of work can't roll it back. Repositories register; the unit of work commits.
    Leaking queries into business codeA repository method that takes raw SQL strings brings the database back into the domain.
    Wrapping an ORM session in a second unit of workTwo transaction managers that disagree is worse than one. Use the ORM's if it already does the job.

    9. Remember it and say it

    Memory hook: "Pay for the whole basket"Collect the changes; commit them together or put everything back.
    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 supermarketThe code
    Shelves, found by what's on themRideRepository, PaymentRepository
    The basketUnitOfWork
    Payingcommit()
    Card declinedRoll back: nothing saved

    Watch it on an example

    1. end_ride adds R-2 and its payment to the unit of work.
    2. commit() saves the ride, then the payment: card declined.
    3. 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

    10. Check yourself

    What does a repository look like to business code?

    add, get, for_rider; no tables.

    A card is declined during commit(). What's saved?

    The unit of work rolls everything back.

    Should rides.add() write to the database immediately?

    Otherwise rollback can't undo it.

    Which ORM object is a unit of work?

    Also JPA's EntityManager and EF's DbContext.

    A ride and payment live in two different services. What replaces the unit of work?

    There's no shared transaction across services.

    11. Go deeper

    ResourceWhy
    System design trackTransactions, sagas and consistency across services.
    Architecture styles 03: HexagonalRepositories are the classic storage port.
    SQLAlchemy: Session basicsA unit of work you may already be using.
    Martin Fowler: Unit of Work (catalog entry)The pattern's short definition.