Two callers, one store, three operations.
A small team lends out laptops, cameras, and test phones. Two pieces of code need the loan data from day one: the loan desk page, which lists overdue loans and checks equipment out, and a nightly reminder job, which emails everyone whose loan is overdue.
Both need data access code; that part isn’t in question. The choice is what sits between those callers and the tables, and where the rules go.
An in-memory store with an items table and a loans table. Days are calendar-day strings such as 2026-09-14, compared as text.
- List overdue loans
- Open loans due before the
asOfday, with the item name and borrower, sorted by due day, then loan id. - Check out
- Reject an unknown item, an item already on loan, and an item marked for repair. Otherwise open one loan.
- Return with a repair
- Close the loan and, if asked, mark the item for repair. Both changes happen, or neither does.
The rows, the rules, and the error messages are identical for every alternative below. What changes is who can reach the tables, where the checkout rule lives, and how much work each read asks of the store.
Four boundaries in front of the same tables.
These overlap, and that’s worth saying up front. A read/write split is direct query functions for reads plus a domain repository for writes; it combines the first two rather than adding a new mechanism. A generic repository can also sit underneath a domain repository as a helper. Here it’s the interface callers use, because that’s the form its critics describe.
Direct query functions
Named functions that read and write the tables, such as listOverdueLoans.
The checkout rule lives in the function the caller runs.
Domain repository
A collection-like boundary per aggregate: loans.overdue(asOf), save(equipment).
The rule lives in the Equipment aggregate; the repository persists its changes.
Generic repository
Repository<T> with get(id), find(predicate), add, and update.
The rule lives wherever each caller remembers to put it.
Read/write split
Query functions for reads; the domain repository for writes.
The rule lives in the aggregate, behind the write repository.
| Alternative | A reasonable starting point when | Cost to accept deliberately |
|---|---|---|
| Direct query functions | One or two callers, rules that fit in one function, tests against the real store. | A second writer can skip the rule, and persistence details sit next to use-case code. |
| Domain repository | Several writers share rules, and the application shouldn’t know the storage shape. | Every new read shape is a new method, and a fake repository can pass while the real one differs. |
| Generic repository | Very uniform create-read-update work with few queries. | Filtering, joins, sorting, and paging move to callers, and item names cost one lookup per loan. |
| Read/write split | Reads multiply faster than rules, and writes need one enforcement point. | Two paths to learn, and the queries can drift from the write rules if nobody owns both. |
An aggregate here is one item together with its open loans: everything the checkout rule needs to see at once. The domain repository loads and saves whole aggregates, so a change to a loan and a change to its item travel together.
Now change one condition at a time.
The baseline is fair to everyone: two callers, one writer, one read. Then a new read shape arrives, an import script starts checking equipment out, a return fails halfway, and a test swaps in a fake. Before each step, guess which column changes.
Change one condition at a time.
- Changed condition
- List overdue loans as of the day.
- Rule checked
- Rows in due-day, then loan-id order
- Ran with
- day 2026-09-14
Direct query functions
- Result
- L002 L003 L004 L006 L005
- L002 · ThinkPad X1 · due 2026-09-09
- L003 · Canon R6 · due 2026-09-10
- L004 · Fujifilm X-T5 · due 2026-09-10
- L006 · GoPro Hero 12 · due 2026-09-10
- L005 · iPhone 15 · due 2026-09-12
- Store calls
- 1
- Rows loaded
- 5
- Rule
- Held
Domain repository
- Result
- L002 L003 L004 L006 L005
- L002 · ThinkPad X1 · due 2026-09-09
- L003 · Canon R6 · due 2026-09-10
- L004 · Fujifilm X-T5 · due 2026-09-10
- L006 · GoPro Hero 12 · due 2026-09-10
- L005 · iPhone 15 · due 2026-09-12
- Store calls
- 1
- Rows loaded
- 5
- Rule
- Held
Generic repository
- Result
- L002 L003 L004 L006 L005
- L002 · ThinkPad X1 · due 2026-09-09
- L003 · Canon R6 · due 2026-09-10
- L004 · Fujifilm X-T5 · due 2026-09-10
- L006 · GoPro Hero 12 · due 2026-09-10
- L005 · iPhone 15 · due 2026-09-12
- Store calls
- 6
- Rows loaded
- 13
- Rule
- Held
Read/write split
- Result
- L002 L003 L004 L006 L005
- L002 · ThinkPad X1 · due 2026-09-09
- L003 · Canon R6 · due 2026-09-10
- L004 · Fujifilm X-T5 · due 2026-09-10
- L006 · GoPro Hero 12 · due 2026-09-10
- L005 · iPhone 15 · due 2026-09-12
- Store calls
- 1
- Rows loaded
- 5
- Rule
- Held
The rows agree. What differs is how they were fetched: direct functions, the domain repository, and the split ask the store for one joined read. The generic repository can only ask for every loan, then look up each item by id.
Result, store calls, rows loaded, and rule come from running each boundary against its own fresh copy of the store. The fake makes no store calls. The notes about files are not executed.
How the store countsStore calls and rows loaded
Store calls count every call to a store read or write, counted as the method starts, so a write that fails still counts. Rows loaded counts the rows a read hands back after the store has filtered, sorted, and paged them. A loan joined to its item is one row, and writes load none.
The store’s where stands in for a WHERE clause the store evaluates. A generic find(predicate) takes a function the store can’t translate, so it asks for every
loan and filters in memory, then fetches each overdue loan’s item by id. That’s where the generic
repository’s extra calls and rows come from.
Hold one boundary. Switch the language.
Pick an alternative and read it in one language or side by side. Changing the alternative keeps your languages, and changing a language keeps the alternative. The lab above always runs TypeScript; the Go runs the same shared cases with its own tests.
Four functions over the store. checkOut holds the rule; returnLoan owns a transaction.
export function listOverdueLoans(store: Store, asOf: string): OverdueRow[] {
validDay(asOf);
return store
.loansWithItems({ where: ({ loan }) => isOverdue(loan, asOf), orderBy: byDue })
.map(toRow);
}
export function listOverdueByKind(
store: Store,
asOf: string,
kind: Kind,
page: number,
pageSize: number
): OverdueRow[] {
validDay(asOf);
const offset = pageOffset(page, pageSize);
return store
.loansWithItems({
where: ({ loan, item }) => item.kind === kind && isOverdue(loan, asOf),
orderBy: byDue,
offset,
limit: pageSize
})
.map(toRow);
}
export function checkOut(
store: Store,
itemId: string,
borrower: string,
today: string,
days: number
): Loan {
const item = store.items({ where: (row) => row.id === itemId }).at(0);
if (!item) throw new LoanError(messages.itemNotFound);
const open = store.loans({ where: (loan) => loan.itemId === itemId && loan.returnedOn === '' });
if (open.length) throw new LoanError(messages.itemOnLoan);
if (item.needsRepair) throw new LoanError(messages.itemNeedsRepair);
return store.insertLoan(newLoan(itemId, borrower, today, days));
}
export function returnLoan(
store: Store,
loanId: string,
today: string,
needsRepair: boolean
): Loan {
return store.transaction(() => {
const loan = store.loans({ where: (row) => row.id === loanId }).at(0);
if (!loan) throw new LoanError(messages.loanNotFound);
if (loan.returnedOn !== '') throw new LoanError(messages.loanReturned);
const closed = { ...loan, returnedOn: validDay(today) };
store.updateLoan(closed);
if (needsRepair) {
const item = store.items({ where: (row) => row.id === loan.itemId }).at(0);
if (!item) throw new LoanError(messages.itemNotFound);
store.updateItem({ ...item, needsRepair: true });
}
return closed;
});
} func ListOverdueLoans(s *Store, asOf string) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
return toRows(s.LoansWithItems(Query[LoanWithItem]{
Where: func(r LoanWithItem) bool { return isOverdue(r.Loan, asOf) },
OrderBy: byDue,
})), nil
}
func ListOverdueByKind(s *Store, asOf string, kind Kind, page, pageSize int) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
offset, err := pageOffset(page, pageSize)
if err != nil {
return nil, err
}
return toRows(s.LoansWithItems(Query[LoanWithItem]{
Where: func(r LoanWithItem) bool { return r.Item.Kind == kind && isOverdue(r.Loan, asOf) },
OrderBy: byDue,
Offset: offset,
Limit: pageSize,
})), nil
}
func CheckOut(s *Store, itemID, borrower, today string, days int) (Loan, error) {
item, found := first(s.Items(Query[Item]{Where: func(row Item) bool { return row.ID == itemID }}))
if !found {
return Loan{}, ErrItemNotFound
}
open := s.Loans(Query[Loan]{Where: func(loan Loan) bool { return loan.ItemID == itemID && loan.ReturnedOn == "" }})
if len(open) > 0 {
return Loan{}, ErrItemOnLoan
}
if item.NeedsRepair {
return Loan{}, ErrItemNeedsRepair
}
loan, err := newLoan(itemID, borrower, today, days)
if err != nil {
return Loan{}, err
}
return s.InsertLoan(loan)
}
func ReturnLoan(s *Store, loanID, today string, needsRepair bool) (Loan, error) {
var closed Loan
err := s.Transaction(func() error {
loan, found := first(s.Loans(Query[Loan]{Where: func(row Loan) bool { return row.ID == loanID }}))
if !found {
return ErrLoanNotFound
}
if loan.ReturnedOn != "" {
return ErrLoanReturned
}
if err := validDay(today); err != nil {
return err
}
loan.ReturnedOn = today
if err := s.UpdateLoan(loan); err != nil {
return err
}
if needsRepair {
item, found := first(s.Items(Query[Item]{Where: func(row Item) bool { return row.ID == loan.ItemID }}))
if !found {
return ErrItemNotFound
}
item.NeedsRepair = true
if err := s.UpdateItem(item); err != nil {
return err
}
}
closed = loan
return nil
})
if err != nil {
return Loan{}, err
}
return closed, nil
} Reading the TypeScriptStructural interfaces, a generic predicate, undefined
OverdueLoans and EquipmentWriter are the interfaces the use
cases depend on. TypeScript compares shapes: implements asks the compiler
to confirm the fit where StoreEquipmentLoans and FakeEquipmentLoans are declared, but any object with a matching overdue method could be handed to reminders.
Repository<T> is a generic interface, and tableRepository<T extends { id: string }> builds one for
any row with an id. find takes (entity: T) => boolean, a predicate that runs in memory after the
store returns every row.
Not found is undefined: get returns T | undefined and forItem returns Equipment | undefined. Absence is an expected answer, so the use cases
turn it into item not found or loan not found. Broken rules
throw a LoanError, which lets the import script record the message and
move on.
Reading the GoConsumer-owned interfaces, func(T) bool, (T, bool)
OverdueLoans and EquipmentWriter are small interfaces
declared beside the use cases that need them. *StoreEquipmentLoans and *FakeEquipmentLoans satisfy them without naming them: a Go type implements
an interface by having its methods.
Repository[T any] is a generic interface. The generic table[T] implements it with an id func(T) string, and Find takes a predicate func(T) bool that runs after the
store hands back every row. The store’s shared select, selectRows, is a
generic function rather than a method: a method can declare its own type parameters
only from Go 1.27, and this module’s go.mod says go 1.22.
Not found is a second result. Get returns (T, bool) and ForItem returns (*Equipment, bool), so absence isn’t an
error. Broken rules come back as errors.New sentinels with the same
messages as TypeScript, and Transaction restores both tables through defer when the work returns an error or panics.
The shared contract, and what this store can’t showRows, errors, the store, and its limits
The TypeScript and Go versions implement the same rows, errors, counters, and transaction,
and the same 21 cases in cases.json run through both test suites, so results, rejected
checkouts, changed rows, and counters match.
The store is in memory. What a fake can hide from a real database, such as collation, NULL handling, and transactions under concurrency, is explained in this lesson rather than demonstrated, and so are query plans and ORM change tracking. The counters measure calls and rows at this store’s boundary, not network round trips or time.
Rows and errors
export type Kind = 'laptop' | 'camera' | 'phone';
export type Item = { id: string; name: string; kind: Kind; needsRepair: boolean };
// returnedOn is '' while the loan is open.
export type Loan = {
id: string;
itemId: string;
borrower: string;
outOn: string;
dueOn: string;
returnedOn: string;
};
export type OverdueRow = { loanId: string; itemName: string; borrower: string; dueOn: string };
export type LoanWithItem = { loan: Loan; item: Item };
export const messages = {
itemNotFound: 'item not found',
itemOnLoan: 'item already on loan',
itemNeedsRepair: 'item needs repair',
loanNotFound: 'loan not found',
loanReturned: 'loan already returned',
invalidDay: 'invalid day',
invalidLoanLength: 'loan length must be at least one day',
invalidPage: 'invalid page',
itemExists: 'item already exists',
loanIdAssigned: 'loan id is assigned by the store',
unsavedChange: 'equipment has an unsaved change',
nothingToSave: 'equipment has no change to save',
writeFailed: 'store write failed'
} as const;
export class LoanError extends Error {} type Kind string
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
Kind Kind `json:"kind"`
NeedsRepair bool `json:"needsRepair"`
}
// Loan.ReturnedOn is "" while the loan is open.
type Loan struct {
ID string `json:"id"`
ItemID string `json:"itemId"`
Borrower string `json:"borrower"`
OutOn string `json:"outOn"`
DueOn string `json:"dueOn"`
ReturnedOn string `json:"returnedOn"`
}
type OverdueRow struct {
LoanID string `json:"loanId"`
ItemName string `json:"itemName"`
Borrower string `json:"borrower"`
DueOn string `json:"dueOn"`
}
type LoanWithItem struct {
Loan Loan
Item Item
}
var (
ErrItemNotFound = errors.New("item not found")
ErrItemOnLoan = errors.New("item already on loan")
ErrItemNeedsRepair = errors.New("item needs repair")
ErrLoanNotFound = errors.New("loan not found")
ErrLoanReturned = errors.New("loan already returned")
ErrInvalidDay = errors.New("invalid day")
ErrInvalidLoanLength = errors.New("loan length must be at least one day")
ErrInvalidPage = errors.New("invalid page")
ErrItemExists = errors.New("item already exists")
ErrLoanIDAssigned = errors.New("loan id is assigned by the store")
ErrUnsavedChange = errors.New("equipment has an unsaved change")
ErrNothingToSave = errors.New("equipment has no change to save")
ErrWriteFailed = errors.New("store write failed")
) The store, its counters, and its transaction
export type WriteOp = 'insertItem' | 'insertLoan' | 'updateItem' | 'updateLoan';
export type Query<Row> = {
where?: (row: Row) => boolean; // Stands in for a WHERE clause the store evaluates.
orderBy?: (a: Row, b: Row) => number;
offset?: number;
limit?: number;
};
export type Stats = { storeCalls: number; rowsLoaded: number };
function replaceRow<Row extends { id: string }>(rows: Row[], row: Row, missing: string): void {
const index = rows.findIndex((found) => found.id === row.id);
if (index < 0) throw new LoanError(missing);
rows[index] = { ...row };
}
// Loan ids come from the store: L001, L002, … in insertion order. Seed loans must follow that numbering.
export class Store {
#items: Item[];
#loans: Loan[];
#storeCalls = 0;
#rowsLoaded = 0;
#failOn: WriteOp | undefined;
constructor(items: readonly Item[], loans: readonly Loan[]) {
this.#items = items.map((item) => ({ ...item }));
this.#loans = loans.map((loan) => ({ ...loan }));
}
items(query: Query<Item>): Item[] {
return this.#select(
this.#items.map((item) => ({ ...item })),
query
);
}
loans(query: Query<Loan>): Loan[] {
return this.#select(
this.#loans.map((loan) => ({ ...loan })),
query
);
}
// An inner join on loan.itemId, like one SQL query with a join.
loansWithItems(query: Query<LoanWithItem>): LoanWithItem[] {
const joined = this.#loans.flatMap((loan) =>
this.#items
.filter((item) => item.id === loan.itemId)
.map((item) => ({ loan: { ...loan }, item: { ...item } }))
);
return this.#select(joined, query);
}
insertItem(item: Item): Item {
this.#write('insertItem');
if (this.#items.some((row) => row.id === item.id)) throw new LoanError(messages.itemExists);
this.#items.push({ ...item });
return { ...item };
}
insertLoan(loan: Loan): Loan {
this.#write('insertLoan');
if (loan.id !== '') throw new LoanError(messages.loanIdAssigned);
const saved = { ...loan, id: loanId(this.#loans.length + 1) };
this.#loans.push(saved);
return { ...saved };
}
updateItem(item: Item): void {
this.#write('updateItem');
replaceRow(this.#items, item, messages.itemNotFound);
}
updateLoan(loan: Loan): void {
this.#write('updateLoan');
replaceRow(this.#loans, loan, messages.loanNotFound);
}
// One call per read or write method, including a write that fails. Rows loaded counts the
// rows a read returns after filtering and paging; a joined loan-with-item is one row.
stats(): Stats {
return { storeCalls: this.#storeCalls, rowsLoaded: this.#rowsLoaded };
}
#select<Row>(rows: Row[], query: Query<Row>): Row[] {
this.#storeCalls++;
const found = query.where ? rows.filter(query.where) : rows;
if (query.orderBy) found.sort(query.orderBy);
const start = query.offset ?? 0;
const page = found.slice(start, query.limit === undefined ? undefined : start + query.limit);
this.#rowsLoaded += page.length;
return page;
}
// Snapshot both tables; restore them if the work throws, then rethrow.
transaction<T>(work: () => T): T {
const items = [...this.#items];
const loans = [...this.#loans];
try {
return work();
} catch (error) {
this.#items = items;
this.#loans = loans;
throw error;
}
}
// Every later call to the chosen write fails before it changes anything.
failWrite(op: WriteOp | undefined): void {
this.#failOn = op;
}
#write(op: WriteOp): void {
this.#storeCalls++;
if (this.#failOn === op) throw new LoanError(messages.writeFailed);
}
} type WriteOp string
const (
InsertItem WriteOp = "insertItem"
InsertLoan WriteOp = "insertLoan"
UpdateItem WriteOp = "updateItem"
UpdateLoan WriteOp = "updateLoan"
)
// Query stands in for one statement: Where is its WHERE clause. A zero Limit means no limit.
type Query[Row any] struct {
Where func(Row) bool
OrderBy func(a, b Row) int
Offset int
Limit int
}
type Stats struct {
StoreCalls int `json:"storeCalls"`
RowsLoaded int `json:"rowsLoaded"`
}
// Store assigns loan IDs L001, L002, … in insertion order. Seed loans must follow that numbering.
type Store struct {
items []Item
loans []Loan
storeCalls int
rowsLoaded int
failOn WriteOp
}
func NewStore(items []Item, loans []Loan) *Store {
return &Store{items: slices.Clone(items), loans: slices.Clone(loans)}
}
func (s *Store) Items(q Query[Item]) []Item { return selectRows(s, s.items, q) }
func (s *Store) Loans(q Query[Loan]) []Loan { return selectRows(s, s.loans, q) }
// LoansWithItems is an inner join on Loan.ItemID, like one SQL query with a join.
func (s *Store) LoansWithItems(q Query[LoanWithItem]) []LoanWithItem {
joined := []LoanWithItem{}
for _, loan := range s.loans {
for _, item := range s.items {
if item.ID == loan.ItemID {
joined = append(joined, LoanWithItem{Loan: loan, Item: item})
}
}
}
return selectRows(s, joined, q)
}
func (s *Store) InsertItem(item Item) (Item, error) {
if err := s.write(InsertItem); err != nil {
return Item{}, err
}
if slices.ContainsFunc(s.items, func(row Item) bool { return row.ID == item.ID }) {
return Item{}, ErrItemExists
}
s.items = append(s.items, item)
return item, nil
}
func (s *Store) InsertLoan(loan Loan) (Loan, error) {
if err := s.write(InsertLoan); err != nil {
return Loan{}, err
}
if loan.ID != "" {
return Loan{}, ErrLoanIDAssigned
}
loan.ID = loanID(len(s.loans) + 1)
s.loans = append(s.loans, loan)
return loan, nil
}
func (s *Store) UpdateItem(item Item) error {
if err := s.write(UpdateItem); err != nil {
return err
}
return replaceRow(s.items, item, func(row Item) string { return row.ID }, ErrItemNotFound)
}
func (s *Store) UpdateLoan(loan Loan) error {
if err := s.write(UpdateLoan); err != nil {
return err
}
return replaceRow(s.loans, loan, func(row Loan) string { return row.ID }, ErrLoanNotFound)
}
func replaceRow[Row any](rows []Row, row Row, id func(Row) string, missing error) error {
i := slices.IndexFunc(rows, func(found Row) bool { return id(found) == id(row) })
if i < 0 {
return missing
}
rows[i] = row
return nil
}
// One call per read or write method, including a write that fails. Rows loaded counts the
// rows a read returns after filtering and paging; a joined loan-with-item is one row.
func (s *Store) Stats() Stats { return Stats{StoreCalls: s.storeCalls, RowsLoaded: s.rowsLoaded} }
// selectRows is a function because generic methods need Go 1.27 and go.mod declares 1.22. Rows are
// structs of strings and bools, so every row handed to Where, OrderBy, or the caller is a copy.
func selectRows[Row any](s *Store, rows []Row, q Query[Row]) []Row {
s.storeCalls++
found := []Row{}
for _, row := range rows {
if q.Where == nil || q.Where(row) {
found = append(found, row)
}
}
if q.OrderBy != nil {
slices.SortStableFunc(found, q.OrderBy)
}
start := min(q.Offset, len(found))
end := len(found)
if q.Limit > 0 {
end = min(start+q.Limit, end)
}
page := found[start:end]
s.rowsLoaded += len(page)
return page
}
// Transaction snapshots both tables and restores them if work returns an error or panics.
func (s *Store) Transaction(work func() error) error {
items, loans := slices.Clone(s.items), slices.Clone(s.loans)
committed := false
defer func() {
if !committed {
s.items, s.loans = items, loans
}
}()
if err := work(); err != nil {
return err
}
committed = true
return nil
}
// FailWrite makes every later call to op fail before it changes anything; "" clears it.
func (s *Store) FailWrite(op WriteOp) { s.failOn = op }
func (s *Store) write(op WriteOp) error {
s.storeCalls++
if s.failOn == op {
return ErrWriteFailed
}
return nil
} Copy the complete examplesStandard library only
Each complete file includes the seed data and this example program, which prints the overdue order and counters for every alternative, what the import script did, and the fake’s order.
export function runExample(): string[] {
const ids = (rows: readonly OverdueRow[]) => rows.map((row) => row.loanId).join(' ');
const lines = ['overdue on 2026-09-14'];
for (const alternative of alternatives) {
const store = seedStore();
const rows = openDesk(alternative, store).overdue('2026-09-14');
const { storeCalls, rowsLoaded } = store.stats();
lines.push(
`${alternative}: ${ids(rows)} (store calls ${storeCalls}, rows loaded ${rowsLoaded})`
);
}
lines.push('import checks out camera-2 again');
for (const alternative of alternatives) {
const store = seedStore();
const row = { itemId: 'camera-2', borrower: 'Import', today: '2026-09-14', days: 7 };
const [result] = importLoans([row], importWriters[alternative](store));
const open = store.loans({ where: (loan) => loan.itemId === 'camera-2' && !loan.returnedOn });
const outcome = result.loan ? `created ${result.loan.id}` : result.error;
lines.push(`${alternative}: ${outcome}, open loans ${open.length}`);
}
lines.push(
`fake on 2026-09-14: ${ids(new FakeEquipmentLoans(seedItems(), seedLoans()).overdue('2026-09-14'))}`
);
return lines;
} func RunExample() ([]string, error) {
ids := func(rows []OverdueRow) string {
loanIDs := make([]string, 0, len(rows))
for _, row := range rows {
loanIDs = append(loanIDs, row.LoanID)
}
return strings.Join(loanIDs, " ")
}
lines := []string{"overdue on 2026-09-14"}
for _, alternative := range Alternatives {
s := SeedStore()
rows, err := OpenDesk(alternative, s).Overdue("2026-09-14")
if err != nil {
return nil, err
}
stats := s.Stats()
lines = append(lines, fmt.Sprintf("%s: %s (store calls %d, rows loaded %d)", alternative, ids(rows), stats.StoreCalls, stats.RowsLoaded))
}
lines = append(lines, "import checks out camera-2 again")
for _, alternative := range Alternatives {
s := SeedStore()
row := ImportRow{ItemID: "camera-2", Borrower: "Import", Today: "2026-09-14", Days: 7}
result := ImportLoans([]ImportRow{row}, ImportWriter(alternative, s))[0]
open := s.Loans(Query[Loan]{Where: func(loan Loan) bool { return loan.ItemID == "camera-2" && loan.ReturnedOn == "" }})
outcome := "created " + result.Loan.ID
if result.Err != nil {
outcome = result.Err.Error()
}
lines = append(lines, fmt.Sprintf("%s: %s, open loans %d", alternative, outcome, len(open)))
}
fake, err := NewFakeEquipmentLoans(SeedItems(), SeedLoans()).Overdue("2026-09-14")
if err != nil {
return nil, err
}
return append(lines, "fake on 2026-09-14: "+ids(fake)), nil
} // Team equipment loans over an in-memory store. Days are ISO calendar-day strings compared as text.
export type Kind = 'laptop' | 'camera' | 'phone';
export type Item = { id: string; name: string; kind: Kind; needsRepair: boolean };
// returnedOn is '' while the loan is open.
export type Loan = {
id: string;
itemId: string;
borrower: string;
outOn: string;
dueOn: string;
returnedOn: string;
};
export type OverdueRow = { loanId: string; itemName: string; borrower: string; dueOn: string };
export type LoanWithItem = { loan: Loan; item: Item };
export const messages = {
itemNotFound: 'item not found',
itemOnLoan: 'item already on loan',
itemNeedsRepair: 'item needs repair',
loanNotFound: 'loan not found',
loanReturned: 'loan already returned',
invalidDay: 'invalid day',
invalidLoanLength: 'loan length must be at least one day',
invalidPage: 'invalid page',
itemExists: 'item already exists',
loanIdAssigned: 'loan id is assigned by the store',
unsavedChange: 'equipment has an unsaved change',
nothingToSave: 'equipment has no change to save',
writeFailed: 'store write failed'
} as const;
export class LoanError extends Error {}
function compareText(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
// Overdue order: due day, then loan id.
export function compareLoans(a: Loan, b: Loan): number {
return compareText(a.dueOn, b.dueOn) || compareText(a.id, b.id);
}
function byDue(a: LoanWithItem, b: LoanWithItem): number {
return compareLoans(a.loan, b.loan);
}
function isOverdue(loan: Loan, asOf: string): boolean {
return loan.returnedOn === '' && loan.dueOn < asOf;
}
function toRow({ loan, item }: LoanWithItem): OverdueRow {
return { loanId: loan.id, itemName: item.name, borrower: loan.borrower, dueOn: loan.dueOn };
}
export function validDay(day: string): string {
const date = new Date(`${day}T00:00:00Z`);
if (
!/^\d{4}-\d{2}-\d{2}$/.test(day) ||
Number.isNaN(date.getTime()) ||
date.toISOString().slice(0, 10) !== day
)
throw new LoanError(messages.invalidDay);
return day;
}
function dueDate(today: string, days: number): string {
const date = new Date(`${validDay(today)}T00:00:00Z`);
if (!Number.isInteger(days) || days < 1) throw new LoanError(messages.invalidLoanLength);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function newLoan(itemId: string, borrower: string, today: string, days: number): Loan {
const dueOn = dueDate(today, days);
return { id: '', itemId, borrower, outOn: today, dueOn, returnedOn: '' };
}
function pageOffset(page: number, pageSize: number): number {
if (!Number.isInteger(page) || page < 1 || !Number.isInteger(pageSize) || pageSize < 1)
throw new LoanError(messages.invalidPage);
return (page - 1) * pageSize;
}
function loanId(sequence: number): string {
return `L${String(sequence).padStart(3, '0')}`;
}
export type WriteOp = 'insertItem' | 'insertLoan' | 'updateItem' | 'updateLoan';
export type Query<Row> = {
where?: (row: Row) => boolean; // Stands in for a WHERE clause the store evaluates.
orderBy?: (a: Row, b: Row) => number;
offset?: number;
limit?: number;
};
export type Stats = { storeCalls: number; rowsLoaded: number };
function replaceRow<Row extends { id: string }>(rows: Row[], row: Row, missing: string): void {
const index = rows.findIndex((found) => found.id === row.id);
if (index < 0) throw new LoanError(missing);
rows[index] = { ...row };
}
// Loan ids come from the store: L001, L002, … in insertion order. Seed loans must follow that numbering.
export class Store {
#items: Item[];
#loans: Loan[];
#storeCalls = 0;
#rowsLoaded = 0;
#failOn: WriteOp | undefined;
constructor(items: readonly Item[], loans: readonly Loan[]) {
this.#items = items.map((item) => ({ ...item }));
this.#loans = loans.map((loan) => ({ ...loan }));
}
items(query: Query<Item>): Item[] {
return this.#select(
this.#items.map((item) => ({ ...item })),
query
);
}
loans(query: Query<Loan>): Loan[] {
return this.#select(
this.#loans.map((loan) => ({ ...loan })),
query
);
}
// An inner join on loan.itemId, like one SQL query with a join.
loansWithItems(query: Query<LoanWithItem>): LoanWithItem[] {
const joined = this.#loans.flatMap((loan) =>
this.#items
.filter((item) => item.id === loan.itemId)
.map((item) => ({ loan: { ...loan }, item: { ...item } }))
);
return this.#select(joined, query);
}
insertItem(item: Item): Item {
this.#write('insertItem');
if (this.#items.some((row) => row.id === item.id)) throw new LoanError(messages.itemExists);
this.#items.push({ ...item });
return { ...item };
}
insertLoan(loan: Loan): Loan {
this.#write('insertLoan');
if (loan.id !== '') throw new LoanError(messages.loanIdAssigned);
const saved = { ...loan, id: loanId(this.#loans.length + 1) };
this.#loans.push(saved);
return { ...saved };
}
updateItem(item: Item): void {
this.#write('updateItem');
replaceRow(this.#items, item, messages.itemNotFound);
}
updateLoan(loan: Loan): void {
this.#write('updateLoan');
replaceRow(this.#loans, loan, messages.loanNotFound);
}
// One call per read or write method, including a write that fails. Rows loaded counts the
// rows a read returns after filtering and paging; a joined loan-with-item is one row.
stats(): Stats {
return { storeCalls: this.#storeCalls, rowsLoaded: this.#rowsLoaded };
}
#select<Row>(rows: Row[], query: Query<Row>): Row[] {
this.#storeCalls++;
const found = query.where ? rows.filter(query.where) : rows;
if (query.orderBy) found.sort(query.orderBy);
const start = query.offset ?? 0;
const page = found.slice(start, query.limit === undefined ? undefined : start + query.limit);
this.#rowsLoaded += page.length;
return page;
}
// Snapshot both tables; restore them if the work throws, then rethrow.
transaction<T>(work: () => T): T {
const items = [...this.#items];
const loans = [...this.#loans];
try {
return work();
} catch (error) {
this.#items = items;
this.#loans = loans;
throw error;
}
}
// Every later call to the chosen write fails before it changes anything.
failWrite(op: WriteOp | undefined): void {
this.#failOn = op;
}
#write(op: WriteOp): void {
this.#storeCalls++;
if (this.#failOn === op) throw new LoanError(messages.writeFailed);
}
}
export function listOverdueLoans(store: Store, asOf: string): OverdueRow[] {
validDay(asOf);
return store
.loansWithItems({ where: ({ loan }) => isOverdue(loan, asOf), orderBy: byDue })
.map(toRow);
}
export function listOverdueByKind(
store: Store,
asOf: string,
kind: Kind,
page: number,
pageSize: number
): OverdueRow[] {
validDay(asOf);
const offset = pageOffset(page, pageSize);
return store
.loansWithItems({
where: ({ loan, item }) => item.kind === kind && isOverdue(loan, asOf),
orderBy: byDue,
offset,
limit: pageSize
})
.map(toRow);
}
export function checkOut(
store: Store,
itemId: string,
borrower: string,
today: string,
days: number
): Loan {
const item = store.items({ where: (row) => row.id === itemId }).at(0);
if (!item) throw new LoanError(messages.itemNotFound);
const open = store.loans({ where: (loan) => loan.itemId === itemId && loan.returnedOn === '' });
if (open.length) throw new LoanError(messages.itemOnLoan);
if (item.needsRepair) throw new LoanError(messages.itemNeedsRepair);
return store.insertLoan(newLoan(itemId, borrower, today, days));
}
export function returnLoan(
store: Store,
loanId: string,
today: string,
needsRepair: boolean
): Loan {
return store.transaction(() => {
const loan = store.loans({ where: (row) => row.id === loanId }).at(0);
if (!loan) throw new LoanError(messages.loanNotFound);
if (loan.returnedOn !== '') throw new LoanError(messages.loanReturned);
const closed = { ...loan, returnedOn: validDay(today) };
store.updateLoan(closed);
if (needsRepair) {
const item = store.items({ where: (row) => row.id === loan.itemId }).at(0);
if (!item) throw new LoanError(messages.itemNotFound);
store.updateItem({ ...item, needsRepair: true });
}
return closed;
});
}
export type Change =
{ kind: 'open'; loan: Loan } | { kind: 'close'; loan: Loan; repair: Item | undefined };
// The aggregate: one item and its open loans. The checkout rule lives here.
export class Equipment {
#item: Item;
#openLoans: Loan[];
#change: Change | undefined;
constructor(item: Item, openLoans: readonly Loan[]) {
this.#item = { ...item };
this.#openLoans = openLoans.map((loan) => ({ ...loan }));
}
checkOut(borrower: string, today: string, days: number): void {
if (this.#change) throw new LoanError(messages.unsavedChange);
if (this.#openLoans.length) throw new LoanError(messages.itemOnLoan);
if (this.#item.needsRepair) throw new LoanError(messages.itemNeedsRepair);
this.#change = { kind: 'open', loan: newLoan(this.#item.id, borrower, today, days) };
}
// loanId must belong to this item; the repository's forLoan loads the item that owns it.
return(loanId: string, today: string, needsRepair: boolean): void {
if (this.#change) throw new LoanError(messages.unsavedChange);
const loan = this.#openLoans.find((open) => open.id === loanId);
if (!loan) throw new LoanError(messages.loanReturned);
const closed = { ...loan, returnedOn: validDay(today) };
const repair = needsRepair ? { ...this.#item, needsRepair: true } : undefined;
this.#change = { kind: 'close', loan: closed, repair };
}
pendingChange(): Change {
if (!this.#change) throw new LoanError(messages.nothingToSave);
const { loan } = this.#change;
if (this.#change.kind === 'open') return { kind: 'open', loan: { ...loan } };
const { repair } = this.#change;
return { kind: 'close', loan: { ...loan }, repair: repair && { ...repair } };
}
markSaved(loan: Loan): void {
const change = this.pendingChange();
if (change.kind === 'open') this.#openLoans.push({ ...loan });
else {
this.#openLoans = this.#openLoans.filter((open) => open.id !== loan.id);
if (change.repair) this.#item = change.repair;
}
this.#change = undefined;
}
}
// The use cases own these two small interfaces; the store-backed repository and the fake both fit.
export interface OverdueLoans {
overdue(asOf: string): OverdueRow[];
}
export interface EquipmentWriter {
forItem(itemId: string): Equipment | undefined;
forLoan(loanId: string): Equipment | undefined;
save(equipment: Equipment): Loan;
}
export class StoreEquipmentLoans implements OverdueLoans, EquipmentWriter {
#store: Store;
constructor(store: Store) {
this.#store = store;
}
overdue(asOf: string): OverdueRow[] {
validDay(asOf);
return this.#store
.loansWithItems({ where: ({ loan }) => isOverdue(loan, asOf), orderBy: byDue })
.map(toRow);
}
// A new read shape is a new repository method.
overdueByKind(asOf: string, kind: Kind, page: number, pageSize: number): OverdueRow[] {
validDay(asOf);
const offset = pageOffset(page, pageSize);
return this.#store
.loansWithItems({
where: ({ loan, item }) => item.kind === kind && isOverdue(loan, asOf),
orderBy: byDue,
offset,
limit: pageSize
})
.map(toRow);
}
forItem(itemId: string): Equipment | undefined {
const item = this.#store.items({ where: (row) => row.id === itemId }).at(0);
if (!item) return undefined;
const open = this.#store.loans({
where: (loan) => loan.itemId === itemId && loan.returnedOn === ''
});
return new Equipment(item, open);
}
forLoan(loanId: string): Equipment | undefined {
const loan = this.#store.loans({ where: (row) => row.id === loanId }).at(0);
return loan && this.forItem(loan.itemId);
}
// Both writes of a change happen in one transaction.
save(equipment: Equipment): Loan {
const change = equipment.pendingChange();
const saved = this.#store.transaction(() => {
if (change.kind === 'open') return this.#store.insertLoan(change.loan);
this.#store.updateLoan(change.loan);
if (change.repair) this.#store.updateItem(change.repair);
return change.loan;
});
equipment.markSaved(saved);
return saved;
}
}
export function checkOutEquipment(
loans: EquipmentWriter,
itemId: string,
borrower: string,
today: string,
days: number
): Loan {
const equipment = loans.forItem(itemId);
if (!equipment) throw new LoanError(messages.itemNotFound);
equipment.checkOut(borrower, today, days);
return loans.save(equipment);
}
export function returnEquipment(
loans: EquipmentWriter,
loanId: string,
today: string,
needsRepair: boolean
): Loan {
const equipment = loans.forLoan(loanId);
if (!equipment) throw new LoanError(messages.loanNotFound);
equipment.return(loanId, today, needsRepair);
return loans.save(equipment);
}
// The nightly reminder job.
export function reminders(loans: OverdueLoans, asOf: string): string[] {
return loans.overdue(asOf).map((row) => `${row.borrower}: ${row.itemName} was due ${row.dueOn}`);
}
export interface Repository<T> {
get(id: string): T | undefined;
find(predicate: (entity: T) => boolean): T[];
add(entity: T): T;
update(entity: T): void;
}
type Table<T> = { select(query: Query<T>): T[]; insert(row: T): T; update(row: T): void };
function tableRepository<T extends { id: string }>(table: Table<T>): Repository<T> {
return {
// A key lookup the store can run.
get: (id) => table.select({ where: (row) => row.id === id }).at(0),
// The predicate is opaque code, so the store returns every row and the filter runs here.
find: (predicate) => table.select({}).filter(predicate),
add: (entity) => table.insert(entity),
update: (entity) => table.update(entity)
};
}
export type Repositories = { items: Repository<Item>; loans: Repository<Loan> };
export function storeRepositories(store: Store): Repositories {
return {
items: tableRepository<Item>({
select: (query) => store.items(query),
insert: (row) => store.insertItem(row),
update: (row) => store.updateItem(row)
}),
loans: tableRepository<Loan>({
select: (query) => store.loans(query),
insert: (row) => store.insertLoan(row),
update: (row) => store.updateLoan(row)
})
};
}
// Use cases written against Repository<T>. Filtering, joining, sorting, paging, and the rule are theirs.
function withItem(repos: Repositories, loan: Loan): LoanWithItem {
const item = repos.items.get(loan.itemId); // One store call per loan.
if (!item) throw new LoanError(messages.itemNotFound);
return { loan, item };
}
export function genericOverdue(repos: Repositories, asOf: string): OverdueRow[] {
validDay(asOf);
return repos.loans
.find((loan) => isOverdue(loan, asOf))
.sort(compareLoans)
.map((loan) => toRow(withItem(repos, loan)));
}
export function genericOverdueByKind(
repos: Repositories,
asOf: string,
kind: Kind,
page: number,
pageSize: number
): OverdueRow[] {
validDay(asOf);
const offset = pageOffset(page, pageSize);
return repos.loans
.find((loan) => isOverdue(loan, asOf))
.map((loan) => withItem(repos, loan))
.filter(({ item }) => item.kind === kind)
.sort(byDue)
.slice(offset, offset + pageSize)
.map(toRow);
}
export function genericCheckOut(
repos: Repositories,
itemId: string,
borrower: string,
today: string,
days: number
): Loan {
const item = repos.items.get(itemId);
if (!item) throw new LoanError(messages.itemNotFound);
if (repos.loans.find((loan) => loan.itemId === itemId && loan.returnedOn === '').length)
throw new LoanError(messages.itemOnLoan);
if (item.needsRepair) throw new LoanError(messages.itemNeedsRepair);
return repos.loans.add(newLoan(itemId, borrower, today, days));
}
// Two separate updates: nothing here groups them.
export function genericReturn(
repos: Repositories,
loanId: string,
today: string,
needsRepair: boolean
): Loan {
const loan = repos.loans.get(loanId);
if (!loan) throw new LoanError(messages.loanNotFound);
if (loan.returnedOn !== '') throw new LoanError(messages.loanReturned);
const closed = { ...loan, returnedOn: validDay(today) };
repos.loans.update(closed);
if (needsRepair) {
const item = repos.items.get(loan.itemId);
if (!item) throw new LoanError(messages.itemNotFound);
repos.items.update({ ...item, needsRepair: true });
}
return closed;
}
export const alternatives = ['direct', 'domain', 'generic', 'split'] as const;
export type Alternative = (typeof alternatives)[number];
// The three operations, plus the new read shape, bound to one alternative and one store.
export type LoanDesk = {
overdue(asOf: string): OverdueRow[];
overdueByKind(asOf: string, kind: Kind, page: number, pageSize: number): OverdueRow[];
checkOut(itemId: string, borrower: string, today: string, days: number): Loan;
returnLoan(loanId: string, today: string, needsRepair: boolean): Loan;
};
function directDesk(store: Store): LoanDesk {
return {
overdue: (asOf) => listOverdueLoans(store, asOf),
overdueByKind: (asOf, kind, page, size) => listOverdueByKind(store, asOf, kind, page, size),
checkOut: (itemId, borrower, today, days) => checkOut(store, itemId, borrower, today, days),
returnLoan: (loanId, today, repair) => returnLoan(store, loanId, today, repair)
};
}
function domainDesk(store: Store): LoanDesk {
const loans = new StoreEquipmentLoans(store);
return {
overdue: (asOf) => loans.overdue(asOf),
overdueByKind: (asOf, kind, page, size) => loans.overdueByKind(asOf, kind, page, size),
checkOut: (itemId, borrower, today, days) =>
checkOutEquipment(loans, itemId, borrower, today, days),
returnLoan: (loanId, today, repair) => returnEquipment(loans, loanId, today, repair)
};
}
function genericDesk(store: Store): LoanDesk {
const repos = storeRepositories(store);
return {
overdue: (asOf) => genericOverdue(repos, asOf),
overdueByKind: (asOf, kind, page, size) => genericOverdueByKind(repos, asOf, kind, page, size),
checkOut: (itemId, borrower, today, days) =>
genericCheckOut(repos, itemId, borrower, today, days),
returnLoan: (loanId, today, repair) => genericReturn(repos, loanId, today, repair)
};
}
export function openDesk(alternative: Alternative, store: Store): LoanDesk {
switch (alternative) {
case 'direct':
return directDesk(store);
case 'domain':
return domainDesk(store);
case 'generic':
return genericDesk(store);
case 'split':
return splitDesk(store);
}
throw new Error(`unknown alternative: ${String(alternative)}`);
}
// Reads are query functions; writes go through the write repository and its rule.
export function splitDesk(store: Store): LoanDesk {
const writes: EquipmentWriter = new StoreEquipmentLoans(store);
return {
overdue: (asOf) => listOverdueLoans(store, asOf),
overdueByKind: (asOf, kind, page, size) => listOverdueByKind(store, asOf, kind, page, size),
checkOut: (itemId, borrower, today, days) =>
checkOutEquipment(writes, itemId, borrower, today, days),
returnLoan: (loanId, today, repair) => returnEquipment(writes, loanId, today, repair)
};
}
export type ImportRow = { itemId: string; borrower: string; today: string; days: number };
export type ImportResult = { loan: Loan | null; error: string | null };
// An import script checks equipment out too. What it can call depends on the boundary it was given.
export const importWriters: Record<Alternative, (store: Store) => (row: ImportRow) => Loan> = {
// Direct functions leave the store in reach; nothing makes the script call checkOut.
direct: (store) => (row) =>
store.insertLoan(newLoan(row.itemId, row.borrower, row.today, row.days)),
// Repository<Loan>.add stores whatever it is given.
generic: (store) => (row) =>
storeRepositories(store).loans.add(newLoan(row.itemId, row.borrower, row.today, row.days)),
// The repository saves only Equipment changes, and Equipment.checkOut holds the rule.
domain: (store) => (row) =>
checkOutEquipment(
new StoreEquipmentLoans(store),
row.itemId,
row.borrower,
row.today,
row.days
),
split: (store) => (row) =>
checkOutEquipment(new StoreEquipmentLoans(store), row.itemId, row.borrower, row.today, row.days)
};
export function importLoans(
rows: readonly ImportRow[],
write: (row: ImportRow) => Loan
): ImportResult[] {
return rows.map((row) => {
try {
return { loan: write(row), error: null };
} catch (error) {
if (error instanceof LoanError) return { loan: null, error: error.message };
throw error;
}
});
}
// A hand-written fake for use-case tests. It keeps loans newest first and sorts by due day only,
// so tied due days come back newest first. The store-backed repository orders ties by loan id.
export class FakeEquipmentLoans implements OverdueLoans, EquipmentWriter {
#items: Item[];
#loans: Loan[];
constructor(items: readonly Item[], loans: readonly Loan[]) {
this.#items = items.map((item) => ({ ...item }));
this.#loans = loans.map((loan) => ({ ...loan })).reverse();
}
overdue(asOf: string): OverdueRow[] {
validDay(asOf);
return this.#loans
.filter((loan) => isOverdue(loan, asOf))
.sort((a, b) => compareText(a.dueOn, b.dueOn))
.map((loan) => ({
loanId: loan.id,
itemName: this.#items.find((item) => item.id === loan.itemId)?.name ?? '',
borrower: loan.borrower,
dueOn: loan.dueOn
}));
}
forItem(itemId: string): Equipment | undefined {
const item = this.#items.find((row) => row.id === itemId);
const open = this.#loans.filter((loan) => loan.itemId === itemId && loan.returnedOn === '');
return item && new Equipment(item, open);
}
forLoan(loanId: string): Equipment | undefined {
const loan = this.#loans.find((row) => row.id === loanId);
return loan && this.forItem(loan.itemId);
}
save(equipment: Equipment): Loan {
const change = equipment.pendingChange();
let saved = change.loan;
if (change.kind === 'open') {
saved = { ...change.loan, id: loanId(this.#loans.length + 1) };
this.#loans.unshift(saved);
} else {
this.#loans = this.#loans.map((loan) => (loan.id === saved.id ? saved : loan));
const { repair } = change;
if (repair) this.#items = this.#items.map((item) => (item.id === repair.id ? repair : item));
}
equipment.markSaved(saved);
return { ...saved };
}
}
export function seedItems(): Item[] {
return [
{ id: 'laptop-1', name: 'ThinkPad X1', kind: 'laptop', needsRepair: false },
{ id: 'laptop-2', name: 'MacBook Air', kind: 'laptop', needsRepair: false },
{ id: 'laptop-3', name: 'Dell XPS 13', kind: 'laptop', needsRepair: false },
{ id: 'camera-1', name: 'Sony A7 IV', kind: 'camera', needsRepair: false },
{ id: 'camera-2', name: 'Canon R6', kind: 'camera', needsRepair: false },
{ id: 'camera-3', name: 'Fujifilm X-T5', kind: 'camera', needsRepair: false },
{ id: 'camera-4', name: 'GoPro Hero 12', kind: 'camera', needsRepair: true },
{ id: 'phone-1', name: 'Pixel 8', kind: 'phone', needsRepair: true },
{ id: 'phone-2', name: 'iPhone 15', kind: 'phone', needsRepair: false }
];
}
export function seedLoans(): Loan[] {
const loan = (id: string, itemId: string, borrower: string, outOn: string, dueOn: string) => ({
id,
itemId,
borrower,
outOn,
dueOn,
returnedOn: ''
});
return [
{ ...loan('L001', 'laptop-3', 'Ana', '2026-09-01', '2026-09-08'), returnedOn: '2026-09-07' },
loan('L002', 'laptop-1', 'Ben', '2026-09-02', '2026-09-09'),
loan('L003', 'camera-2', 'Cho', '2026-09-03', '2026-09-10'),
loan('L004', 'camera-3', 'Dev', '2026-09-03', '2026-09-10'),
loan('L005', 'phone-2', 'Eli', '2026-09-05', '2026-09-12'),
loan('L006', 'camera-4', 'Fay', '2026-09-06', '2026-09-10'),
loan('L007', 'camera-1', 'Gus', '2026-09-10', '2026-09-20'),
loan('L008', 'laptop-2', 'Hal', '2026-09-13', '2026-09-14')
];
}
export function seedStore(): Store {
return new Store(seedItems(), seedLoans());
}
export function runExample(): string[] {
const ids = (rows: readonly OverdueRow[]) => rows.map((row) => row.loanId).join(' ');
const lines = ['overdue on 2026-09-14'];
for (const alternative of alternatives) {
const store = seedStore();
const rows = openDesk(alternative, store).overdue('2026-09-14');
const { storeCalls, rowsLoaded } = store.stats();
lines.push(
`${alternative}: ${ids(rows)} (store calls ${storeCalls}, rows loaded ${rowsLoaded})`
);
}
lines.push('import checks out camera-2 again');
for (const alternative of alternatives) {
const store = seedStore();
const row = { itemId: 'camera-2', borrower: 'Import', today: '2026-09-14', days: 7 };
const [result] = importLoans([row], importWriters[alternative](store));
const open = store.loans({ where: (loan) => loan.itemId === 'camera-2' && !loan.returnedOn });
const outcome = result.loan ? `created ${result.loan.id}` : result.error;
lines.push(`${alternative}: ${outcome}, open loans ${open.length}`);
}
lines.push(
`fake on 2026-09-14: ${ids(new FakeEquipmentLoans(seedItems(), seedLoans()).overdue('2026-09-14'))}`
);
return lines;
}
for (const line of runExample()) console.log(line);
// Team equipment loans over an in-memory store. Days are ISO calendar-day strings compared as text.
package main
import (
"cmp"
"errors"
"fmt"
"os"
"slices"
"strings"
"time"
)
type Kind string
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
Kind Kind `json:"kind"`
NeedsRepair bool `json:"needsRepair"`
}
// Loan.ReturnedOn is "" while the loan is open.
type Loan struct {
ID string `json:"id"`
ItemID string `json:"itemId"`
Borrower string `json:"borrower"`
OutOn string `json:"outOn"`
DueOn string `json:"dueOn"`
ReturnedOn string `json:"returnedOn"`
}
type OverdueRow struct {
LoanID string `json:"loanId"`
ItemName string `json:"itemName"`
Borrower string `json:"borrower"`
DueOn string `json:"dueOn"`
}
type LoanWithItem struct {
Loan Loan
Item Item
}
var (
ErrItemNotFound = errors.New("item not found")
ErrItemOnLoan = errors.New("item already on loan")
ErrItemNeedsRepair = errors.New("item needs repair")
ErrLoanNotFound = errors.New("loan not found")
ErrLoanReturned = errors.New("loan already returned")
ErrInvalidDay = errors.New("invalid day")
ErrInvalidLoanLength = errors.New("loan length must be at least one day")
ErrInvalidPage = errors.New("invalid page")
ErrItemExists = errors.New("item already exists")
ErrLoanIDAssigned = errors.New("loan id is assigned by the store")
ErrUnsavedChange = errors.New("equipment has an unsaved change")
ErrNothingToSave = errors.New("equipment has no change to save")
ErrWriteFailed = errors.New("store write failed")
)
// compareLoans is the overdue order: due day, then loan id.
func compareLoans(a, b Loan) int {
return cmp.Or(strings.Compare(a.DueOn, b.DueOn), strings.Compare(a.ID, b.ID))
}
func byDue(a, b LoanWithItem) int { return compareLoans(a.Loan, b.Loan) }
func isOverdue(loan Loan, asOf string) bool { return loan.ReturnedOn == "" && loan.DueOn < asOf }
func toRows(joined []LoanWithItem) []OverdueRow {
rows := make([]OverdueRow, 0, len(joined))
for _, r := range joined {
rows = append(rows, OverdueRow{LoanID: r.Loan.ID, ItemName: r.Item.Name, Borrower: r.Loan.Borrower, DueOn: r.Loan.DueOn})
}
return rows
}
func parseDay(day string) (time.Time, error) {
t, err := time.Parse(time.DateOnly, day)
if err != nil {
return time.Time{}, ErrInvalidDay
}
return t, nil
}
func validDay(day string) error {
_, err := parseDay(day)
return err
}
func dueDate(today string, days int) (string, error) {
t, err := parseDay(today)
if err != nil {
return "", err
}
if days < 1 {
return "", ErrInvalidLoanLength
}
return t.AddDate(0, 0, days).Format(time.DateOnly), nil
}
func newLoan(itemID, borrower, today string, days int) (Loan, error) {
dueOn, err := dueDate(today, days)
if err != nil {
return Loan{}, err
}
return Loan{ItemID: itemID, Borrower: borrower, OutOn: today, DueOn: dueOn}, nil
}
func pageOffset(page, pageSize int) (int, error) {
if page < 1 || pageSize < 1 {
return 0, ErrInvalidPage
}
return (page - 1) * pageSize, nil
}
func loanID(sequence int) string { return fmt.Sprintf("L%03d", sequence) }
func first[Row any](rows []Row) (Row, bool) {
if len(rows) == 0 {
var zero Row
return zero, false
}
return rows[0], true
}
type WriteOp string
const (
InsertItem WriteOp = "insertItem"
InsertLoan WriteOp = "insertLoan"
UpdateItem WriteOp = "updateItem"
UpdateLoan WriteOp = "updateLoan"
)
// Query stands in for one statement: Where is its WHERE clause. A zero Limit means no limit.
type Query[Row any] struct {
Where func(Row) bool
OrderBy func(a, b Row) int
Offset int
Limit int
}
type Stats struct {
StoreCalls int `json:"storeCalls"`
RowsLoaded int `json:"rowsLoaded"`
}
// Store assigns loan IDs L001, L002, … in insertion order. Seed loans must follow that numbering.
type Store struct {
items []Item
loans []Loan
storeCalls int
rowsLoaded int
failOn WriteOp
}
func NewStore(items []Item, loans []Loan) *Store {
return &Store{items: slices.Clone(items), loans: slices.Clone(loans)}
}
func (s *Store) Items(q Query[Item]) []Item { return selectRows(s, s.items, q) }
func (s *Store) Loans(q Query[Loan]) []Loan { return selectRows(s, s.loans, q) }
// LoansWithItems is an inner join on Loan.ItemID, like one SQL query with a join.
func (s *Store) LoansWithItems(q Query[LoanWithItem]) []LoanWithItem {
joined := []LoanWithItem{}
for _, loan := range s.loans {
for _, item := range s.items {
if item.ID == loan.ItemID {
joined = append(joined, LoanWithItem{Loan: loan, Item: item})
}
}
}
return selectRows(s, joined, q)
}
func (s *Store) InsertItem(item Item) (Item, error) {
if err := s.write(InsertItem); err != nil {
return Item{}, err
}
if slices.ContainsFunc(s.items, func(row Item) bool { return row.ID == item.ID }) {
return Item{}, ErrItemExists
}
s.items = append(s.items, item)
return item, nil
}
func (s *Store) InsertLoan(loan Loan) (Loan, error) {
if err := s.write(InsertLoan); err != nil {
return Loan{}, err
}
if loan.ID != "" {
return Loan{}, ErrLoanIDAssigned
}
loan.ID = loanID(len(s.loans) + 1)
s.loans = append(s.loans, loan)
return loan, nil
}
func (s *Store) UpdateItem(item Item) error {
if err := s.write(UpdateItem); err != nil {
return err
}
return replaceRow(s.items, item, func(row Item) string { return row.ID }, ErrItemNotFound)
}
func (s *Store) UpdateLoan(loan Loan) error {
if err := s.write(UpdateLoan); err != nil {
return err
}
return replaceRow(s.loans, loan, func(row Loan) string { return row.ID }, ErrLoanNotFound)
}
func replaceRow[Row any](rows []Row, row Row, id func(Row) string, missing error) error {
i := slices.IndexFunc(rows, func(found Row) bool { return id(found) == id(row) })
if i < 0 {
return missing
}
rows[i] = row
return nil
}
// One call per read or write method, including a write that fails. Rows loaded counts the
// rows a read returns after filtering and paging; a joined loan-with-item is one row.
func (s *Store) Stats() Stats { return Stats{StoreCalls: s.storeCalls, RowsLoaded: s.rowsLoaded} }
// selectRows is a function because generic methods need Go 1.27 and go.mod declares 1.22. Rows are
// structs of strings and bools, so every row handed to Where, OrderBy, or the caller is a copy.
func selectRows[Row any](s *Store, rows []Row, q Query[Row]) []Row {
s.storeCalls++
found := []Row{}
for _, row := range rows {
if q.Where == nil || q.Where(row) {
found = append(found, row)
}
}
if q.OrderBy != nil {
slices.SortStableFunc(found, q.OrderBy)
}
start := min(q.Offset, len(found))
end := len(found)
if q.Limit > 0 {
end = min(start+q.Limit, end)
}
page := found[start:end]
s.rowsLoaded += len(page)
return page
}
// Transaction snapshots both tables and restores them if work returns an error or panics.
func (s *Store) Transaction(work func() error) error {
items, loans := slices.Clone(s.items), slices.Clone(s.loans)
committed := false
defer func() {
if !committed {
s.items, s.loans = items, loans
}
}()
if err := work(); err != nil {
return err
}
committed = true
return nil
}
// FailWrite makes every later call to op fail before it changes anything; "" clears it.
func (s *Store) FailWrite(op WriteOp) { s.failOn = op }
func (s *Store) write(op WriteOp) error {
s.storeCalls++
if s.failOn == op {
return ErrWriteFailed
}
return nil
}
func ListOverdueLoans(s *Store, asOf string) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
return toRows(s.LoansWithItems(Query[LoanWithItem]{
Where: func(r LoanWithItem) bool { return isOverdue(r.Loan, asOf) },
OrderBy: byDue,
})), nil
}
func ListOverdueByKind(s *Store, asOf string, kind Kind, page, pageSize int) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
offset, err := pageOffset(page, pageSize)
if err != nil {
return nil, err
}
return toRows(s.LoansWithItems(Query[LoanWithItem]{
Where: func(r LoanWithItem) bool { return r.Item.Kind == kind && isOverdue(r.Loan, asOf) },
OrderBy: byDue,
Offset: offset,
Limit: pageSize,
})), nil
}
func CheckOut(s *Store, itemID, borrower, today string, days int) (Loan, error) {
item, found := first(s.Items(Query[Item]{Where: func(row Item) bool { return row.ID == itemID }}))
if !found {
return Loan{}, ErrItemNotFound
}
open := s.Loans(Query[Loan]{Where: func(loan Loan) bool { return loan.ItemID == itemID && loan.ReturnedOn == "" }})
if len(open) > 0 {
return Loan{}, ErrItemOnLoan
}
if item.NeedsRepair {
return Loan{}, ErrItemNeedsRepair
}
loan, err := newLoan(itemID, borrower, today, days)
if err != nil {
return Loan{}, err
}
return s.InsertLoan(loan)
}
func ReturnLoan(s *Store, loanID, today string, needsRepair bool) (Loan, error) {
var closed Loan
err := s.Transaction(func() error {
loan, found := first(s.Loans(Query[Loan]{Where: func(row Loan) bool { return row.ID == loanID }}))
if !found {
return ErrLoanNotFound
}
if loan.ReturnedOn != "" {
return ErrLoanReturned
}
if err := validDay(today); err != nil {
return err
}
loan.ReturnedOn = today
if err := s.UpdateLoan(loan); err != nil {
return err
}
if needsRepair {
item, found := first(s.Items(Query[Item]{Where: func(row Item) bool { return row.ID == loan.ItemID }}))
if !found {
return ErrItemNotFound
}
item.NeedsRepair = true
if err := s.UpdateItem(item); err != nil {
return err
}
}
closed = loan
return nil
})
if err != nil {
return Loan{}, err
}
return closed, nil
}
// Change is the one write an Equipment is waiting to have saved.
type Change struct {
Opens bool // An opening change inserts Loan; a closing change updates it.
Loan Loan
Repair bool // A closing change also writes Item, marked for repair.
Item Item
}
// Equipment is the aggregate: one item and its open loans. The checkout rule lives here.
type Equipment struct {
item Item
openLoans []Loan
change *Change
}
func NewEquipment(item Item, openLoans []Loan) *Equipment {
return &Equipment{item: item, openLoans: slices.Clone(openLoans)}
}
func (e *Equipment) CheckOut(borrower, today string, days int) error {
if e.change != nil {
return ErrUnsavedChange
}
if len(e.openLoans) > 0 {
return ErrItemOnLoan
}
if e.item.NeedsRepair {
return ErrItemNeedsRepair
}
loan, err := newLoan(e.item.ID, borrower, today, days)
if err != nil {
return err
}
e.change = &Change{Opens: true, Loan: loan}
return nil
}
// Return expects loanID to belong to this item; the repository's ForLoan loads the item that owns it.
func (e *Equipment) Return(loanID, today string, needsRepair bool) error {
if e.change != nil {
return ErrUnsavedChange
}
i := slices.IndexFunc(e.openLoans, func(open Loan) bool { return open.ID == loanID })
if i < 0 {
return ErrLoanReturned
}
if err := validDay(today); err != nil {
return err
}
closed := e.openLoans[i]
closed.ReturnedOn = today
repaired := e.item
repaired.NeedsRepair = true
e.change = &Change{Loan: closed, Repair: needsRepair, Item: repaired}
return nil
}
func (e *Equipment) PendingChange() (Change, error) {
if e.change == nil {
return Change{}, ErrNothingToSave
}
return *e.change, nil
}
func (e *Equipment) MarkSaved(loan Loan) error {
change, err := e.PendingChange()
if err != nil {
return err
}
if change.Opens {
e.openLoans = append(e.openLoans, loan)
} else {
e.openLoans = slices.DeleteFunc(e.openLoans, func(open Loan) bool { return open.ID == loan.ID })
if change.Repair {
e.item = change.Item
}
}
e.change = nil
return nil
}
// The use cases own these small interfaces; StoreEquipmentLoans and the fake both satisfy them.
type OverdueLoans interface {
Overdue(asOf string) ([]OverdueRow, error)
}
type EquipmentWriter interface {
ForItem(itemID string) (*Equipment, bool)
ForLoan(loanID string) (*Equipment, bool)
Save(e *Equipment) (Loan, error)
}
type StoreEquipmentLoans struct{ store *Store }
func NewStoreEquipmentLoans(s *Store) *StoreEquipmentLoans { return &StoreEquipmentLoans{store: s} }
func (r *StoreEquipmentLoans) Overdue(asOf string) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
return toRows(r.store.LoansWithItems(Query[LoanWithItem]{
Where: func(row LoanWithItem) bool { return isOverdue(row.Loan, asOf) },
OrderBy: byDue,
})), nil
}
// OverdueByKind: a new read shape is a new repository method.
func (r *StoreEquipmentLoans) OverdueByKind(asOf string, kind Kind, page, pageSize int) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
offset, err := pageOffset(page, pageSize)
if err != nil {
return nil, err
}
return toRows(r.store.LoansWithItems(Query[LoanWithItem]{
Where: func(row LoanWithItem) bool { return row.Item.Kind == kind && isOverdue(row.Loan, asOf) },
OrderBy: byDue,
Offset: offset,
Limit: pageSize,
})), nil
}
func (r *StoreEquipmentLoans) ForItem(itemID string) (*Equipment, bool) {
item, found := first(r.store.Items(Query[Item]{Where: func(row Item) bool { return row.ID == itemID }}))
if !found {
return nil, false
}
open := r.store.Loans(Query[Loan]{Where: func(loan Loan) bool { return loan.ItemID == itemID && loan.ReturnedOn == "" }})
return NewEquipment(item, open), true
}
func (r *StoreEquipmentLoans) ForLoan(loanID string) (*Equipment, bool) {
loan, found := first(r.store.Loans(Query[Loan]{Where: func(row Loan) bool { return row.ID == loanID }}))
if !found {
return nil, false
}
return r.ForItem(loan.ItemID)
}
// Save writes both parts of a change in one transaction.
func (r *StoreEquipmentLoans) Save(e *Equipment) (Loan, error) {
change, err := e.PendingChange()
if err != nil {
return Loan{}, err
}
saved := change.Loan
err = r.store.Transaction(func() error {
if change.Opens {
inserted, insertErr := r.store.InsertLoan(change.Loan)
saved = inserted
return insertErr
}
if err := r.store.UpdateLoan(change.Loan); err != nil {
return err
}
if change.Repair {
return r.store.UpdateItem(change.Item)
}
return nil
})
if err != nil {
return Loan{}, err
}
if err := e.MarkSaved(saved); err != nil {
return Loan{}, err
}
return saved, nil
}
func CheckOutEquipment(loans EquipmentWriter, itemID, borrower, today string, days int) (Loan, error) {
e, found := loans.ForItem(itemID)
if !found {
return Loan{}, ErrItemNotFound
}
if err := e.CheckOut(borrower, today, days); err != nil {
return Loan{}, err
}
return loans.Save(e)
}
func ReturnEquipment(loans EquipmentWriter, loanID, today string, needsRepair bool) (Loan, error) {
e, found := loans.ForLoan(loanID)
if !found {
return Loan{}, ErrLoanNotFound
}
if err := e.Return(loanID, today, needsRepair); err != nil {
return Loan{}, err
}
return loans.Save(e)
}
// Reminders is the nightly reminder job.
func Reminders(loans OverdueLoans, asOf string) ([]string, error) {
rows, err := loans.Overdue(asOf)
if err != nil {
return nil, err
}
lines := make([]string, 0, len(rows))
for _, row := range rows {
lines = append(lines, fmt.Sprintf("%s: %s was due %s", row.Borrower, row.ItemName, row.DueOn))
}
return lines, nil
}
type Repository[T any] interface {
Get(id string) (T, bool)
Find(predicate func(T) bool) []T
Add(entity T) (T, error)
Update(entity T) error
}
type table[T any] struct {
id func(T) string
selectRows func(Query[T]) []T
insert func(T) (T, error)
update func(T) error
}
// Get is a key lookup the store can run.
func (t table[T]) Get(id string) (T, bool) {
return first(t.selectRows(Query[T]{Where: func(row T) bool { return t.id(row) == id }}))
}
// Find: the predicate is opaque code, so the store returns every row and the filter runs here.
func (t table[T]) Find(predicate func(T) bool) []T {
found := []T{}
for _, row := range t.selectRows(Query[T]{}) {
if predicate(row) {
found = append(found, row)
}
}
return found
}
func (t table[T]) Add(entity T) (T, error) { return t.insert(entity) }
func (t table[T]) Update(entity T) error { return t.update(entity) }
type Repositories struct {
Items Repository[Item]
Loans Repository[Loan]
}
func StoreRepositories(s *Store) Repositories {
return Repositories{
Items: table[Item]{id: func(row Item) string { return row.ID }, selectRows: s.Items, insert: s.InsertItem, update: s.UpdateItem},
Loans: table[Loan]{id: func(row Loan) string { return row.ID }, selectRows: s.Loans, insert: s.InsertLoan, update: s.UpdateLoan},
}
}
// Use cases written against Repository[T]. Filtering, joining, sorting, paging, and the rule are theirs.
func withItem(repos Repositories, loan Loan) (LoanWithItem, error) {
item, found := repos.Items.Get(loan.ItemID) // One store call per loan.
if !found {
return LoanWithItem{}, ErrItemNotFound
}
return LoanWithItem{Loan: loan, Item: item}, nil
}
func GenericOverdue(repos Repositories, asOf string) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
loans := repos.Loans.Find(func(loan Loan) bool { return isOverdue(loan, asOf) })
slices.SortFunc(loans, compareLoans)
joined := make([]LoanWithItem, 0, len(loans))
for _, loan := range loans {
row, err := withItem(repos, loan)
if err != nil {
return nil, err
}
joined = append(joined, row)
}
return toRows(joined), nil
}
func GenericOverdueByKind(repos Repositories, asOf string, kind Kind, page, pageSize int) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
offset, err := pageOffset(page, pageSize)
if err != nil {
return nil, err
}
matching := []LoanWithItem{}
for _, loan := range repos.Loans.Find(func(loan Loan) bool { return isOverdue(loan, asOf) }) {
row, err := withItem(repos, loan)
if err != nil {
return nil, err
}
if row.Item.Kind == kind {
matching = append(matching, row)
}
}
slices.SortFunc(matching, byDue)
start := min(offset, len(matching))
end := min(start+pageSize, len(matching))
return toRows(matching[start:end]), nil
}
func GenericCheckOut(repos Repositories, itemID, borrower, today string, days int) (Loan, error) {
item, found := repos.Items.Get(itemID)
if !found {
return Loan{}, ErrItemNotFound
}
if len(repos.Loans.Find(func(loan Loan) bool { return loan.ItemID == itemID && loan.ReturnedOn == "" })) > 0 {
return Loan{}, ErrItemOnLoan
}
if item.NeedsRepair {
return Loan{}, ErrItemNeedsRepair
}
loan, err := newLoan(itemID, borrower, today, days)
if err != nil {
return Loan{}, err
}
return repos.Loans.Add(loan)
}
// GenericReturn makes two separate updates: nothing here groups them.
func GenericReturn(repos Repositories, loanID, today string, needsRepair bool) (Loan, error) {
loan, found := repos.Loans.Get(loanID)
if !found {
return Loan{}, ErrLoanNotFound
}
if loan.ReturnedOn != "" {
return Loan{}, ErrLoanReturned
}
if err := validDay(today); err != nil {
return Loan{}, err
}
loan.ReturnedOn = today
if err := repos.Loans.Update(loan); err != nil {
return Loan{}, err
}
if needsRepair {
item, found := repos.Items.Get(loan.ItemID)
if !found {
return Loan{}, ErrItemNotFound
}
item.NeedsRepair = true
if err := repos.Items.Update(item); err != nil {
return Loan{}, err
}
}
return loan, nil
}
var Alternatives = []string{"direct", "domain", "generic", "split"}
// LoanDesk binds the three operations, plus the new read shape, to one alternative and one store.
type LoanDesk struct {
Overdue func(asOf string) ([]OverdueRow, error)
OverdueByKind func(asOf string, kind Kind, page, pageSize int) ([]OverdueRow, error)
CheckOut func(itemID, borrower, today string, days int) (Loan, error)
ReturnLoan func(loanID, today string, needsRepair bool) (Loan, error)
}
func directDesk(s *Store) LoanDesk {
return LoanDesk{
Overdue: func(asOf string) ([]OverdueRow, error) { return ListOverdueLoans(s, asOf) },
OverdueByKind: func(asOf string, kind Kind, page, size int) ([]OverdueRow, error) {
return ListOverdueByKind(s, asOf, kind, page, size)
},
CheckOut: func(itemID, borrower, today string, days int) (Loan, error) {
return CheckOut(s, itemID, borrower, today, days)
},
ReturnLoan: func(loanID, today string, repair bool) (Loan, error) { return ReturnLoan(s, loanID, today, repair) },
}
}
func domainDesk(s *Store) LoanDesk {
loans := NewStoreEquipmentLoans(s)
return LoanDesk{
Overdue: loans.Overdue,
OverdueByKind: loans.OverdueByKind,
CheckOut: func(itemID, borrower, today string, days int) (Loan, error) {
return CheckOutEquipment(loans, itemID, borrower, today, days)
},
ReturnLoan: func(loanID, today string, repair bool) (Loan, error) {
return ReturnEquipment(loans, loanID, today, repair)
},
}
}
func genericDesk(s *Store) LoanDesk {
repos := StoreRepositories(s)
return LoanDesk{
Overdue: func(asOf string) ([]OverdueRow, error) { return GenericOverdue(repos, asOf) },
OverdueByKind: func(asOf string, kind Kind, page, size int) ([]OverdueRow, error) {
return GenericOverdueByKind(repos, asOf, kind, page, size)
},
CheckOut: func(itemID, borrower, today string, days int) (Loan, error) {
return GenericCheckOut(repos, itemID, borrower, today, days)
},
ReturnLoan: func(loanID, today string, repair bool) (Loan, error) {
return GenericReturn(repos, loanID, today, repair)
},
}
}
func OpenDesk(alternative string, s *Store) LoanDesk {
switch alternative {
case "direct":
return directDesk(s)
case "domain":
return domainDesk(s)
case "generic":
return genericDesk(s)
case "split":
return splitDesk(s)
}
panic("unknown alternative: " + alternative)
}
// splitDesk: reads are query functions; writes go through the write repository and its rule.
func splitDesk(s *Store) LoanDesk {
var writes EquipmentWriter = NewStoreEquipmentLoans(s)
return LoanDesk{
Overdue: func(asOf string) ([]OverdueRow, error) { return ListOverdueLoans(s, asOf) },
OverdueByKind: func(asOf string, kind Kind, page, size int) ([]OverdueRow, error) {
return ListOverdueByKind(s, asOf, kind, page, size)
},
CheckOut: func(itemID, borrower, today string, days int) (Loan, error) {
return CheckOutEquipment(writes, itemID, borrower, today, days)
},
ReturnLoan: func(loanID, today string, repair bool) (Loan, error) {
return ReturnEquipment(writes, loanID, today, repair)
},
}
}
type ImportRow struct {
ItemID string `json:"itemId"`
Borrower string `json:"borrower"`
Today string `json:"today"`
Days int `json:"days"`
}
// ImportResult has a nil Err when Loan was created.
type ImportResult struct {
Loan Loan
Err error
}
// ImportWriter is what an import script that checks equipment out can call, given each boundary.
func ImportWriter(alternative string, s *Store) func(ImportRow) (Loan, error) {
switch alternative {
case "direct":
// Direct functions leave the store in reach; nothing makes the script call CheckOut.
return func(row ImportRow) (Loan, error) {
loan, err := newLoan(row.ItemID, row.Borrower, row.Today, row.Days)
if err != nil {
return Loan{}, err
}
return s.InsertLoan(loan)
}
case "generic":
// Repository[Loan].Add stores whatever it is given.
return func(row ImportRow) (Loan, error) {
loan, err := newLoan(row.ItemID, row.Borrower, row.Today, row.Days)
if err != nil {
return Loan{}, err
}
return StoreRepositories(s).Loans.Add(loan)
}
case "domain", "split":
// The repository saves only Equipment changes, and Equipment.CheckOut holds the rule.
return func(row ImportRow) (Loan, error) {
return CheckOutEquipment(NewStoreEquipmentLoans(s), row.ItemID, row.Borrower, row.Today, row.Days)
}
}
panic("unknown alternative: " + alternative)
}
func ImportLoans(rows []ImportRow, write func(ImportRow) (Loan, error)) []ImportResult {
results := make([]ImportResult, 0, len(rows))
for _, row := range rows {
loan, err := write(row)
results = append(results, ImportResult{Loan: loan, Err: err})
}
return results
}
// FakeEquipmentLoans is a hand-written fake for use-case tests. It keeps loans newest first and
// sorts by due day only, so tied due days come back newest first. The store orders ties by loan id.
type FakeEquipmentLoans struct {
items []Item
loans []Loan
}
func NewFakeEquipmentLoans(items []Item, loans []Loan) *FakeEquipmentLoans {
newestFirst := slices.Clone(loans)
slices.Reverse(newestFirst)
return &FakeEquipmentLoans{items: slices.Clone(items), loans: newestFirst}
}
func (f *FakeEquipmentLoans) Overdue(asOf string) ([]OverdueRow, error) {
if err := validDay(asOf); err != nil {
return nil, err
}
overdue := []Loan{}
for _, loan := range f.loans {
if isOverdue(loan, asOf) {
overdue = append(overdue, loan)
}
}
slices.SortStableFunc(overdue, func(a, b Loan) int { return strings.Compare(a.DueOn, b.DueOn) })
rows := make([]OverdueRow, 0, len(overdue))
for _, loan := range overdue {
name := ""
if i := slices.IndexFunc(f.items, func(item Item) bool { return item.ID == loan.ItemID }); i >= 0 {
name = f.items[i].Name
}
rows = append(rows, OverdueRow{LoanID: loan.ID, ItemName: name, Borrower: loan.Borrower, DueOn: loan.DueOn})
}
return rows, nil
}
func (f *FakeEquipmentLoans) ForItem(itemID string) (*Equipment, bool) {
i := slices.IndexFunc(f.items, func(item Item) bool { return item.ID == itemID })
if i < 0 {
return nil, false
}
open := []Loan{}
for _, loan := range f.loans {
if loan.ItemID == itemID && loan.ReturnedOn == "" {
open = append(open, loan)
}
}
return NewEquipment(f.items[i], open), true
}
func (f *FakeEquipmentLoans) ForLoan(loanID string) (*Equipment, bool) {
i := slices.IndexFunc(f.loans, func(loan Loan) bool { return loan.ID == loanID })
if i < 0 {
return nil, false
}
return f.ForItem(f.loans[i].ItemID)
}
func (f *FakeEquipmentLoans) Save(e *Equipment) (Loan, error) {
change, err := e.PendingChange()
if err != nil {
return Loan{}, err
}
saved := change.Loan
if change.Opens {
saved.ID = loanID(len(f.loans) + 1)
f.loans = slices.Insert(f.loans, 0, saved)
} else {
for i := range f.loans {
if f.loans[i].ID == saved.ID {
f.loans[i] = saved
}
}
for i := range f.items {
if change.Repair && f.items[i].ID == change.Item.ID {
f.items[i] = change.Item
}
}
}
if err := e.MarkSaved(saved); err != nil {
return Loan{}, err
}
return saved, nil
}
func SeedItems() []Item {
return []Item{
{ID: "laptop-1", Name: "ThinkPad X1", Kind: "laptop", NeedsRepair: false},
{ID: "laptop-2", Name: "MacBook Air", Kind: "laptop", NeedsRepair: false},
{ID: "laptop-3", Name: "Dell XPS 13", Kind: "laptop", NeedsRepair: false},
{ID: "camera-1", Name: "Sony A7 IV", Kind: "camera", NeedsRepair: false},
{ID: "camera-2", Name: "Canon R6", Kind: "camera", NeedsRepair: false},
{ID: "camera-3", Name: "Fujifilm X-T5", Kind: "camera", NeedsRepair: false},
{ID: "camera-4", Name: "GoPro Hero 12", Kind: "camera", NeedsRepair: true},
{ID: "phone-1", Name: "Pixel 8", Kind: "phone", NeedsRepair: true},
{ID: "phone-2", Name: "iPhone 15", Kind: "phone", NeedsRepair: false},
}
}
func SeedLoans() []Loan {
return []Loan{
{ID: "L001", ItemID: "laptop-3", Borrower: "Ana", OutOn: "2026-09-01", DueOn: "2026-09-08", ReturnedOn: "2026-09-07"},
{ID: "L002", ItemID: "laptop-1", Borrower: "Ben", OutOn: "2026-09-02", DueOn: "2026-09-09", ReturnedOn: ""},
{ID: "L003", ItemID: "camera-2", Borrower: "Cho", OutOn: "2026-09-03", DueOn: "2026-09-10", ReturnedOn: ""},
{ID: "L004", ItemID: "camera-3", Borrower: "Dev", OutOn: "2026-09-03", DueOn: "2026-09-10", ReturnedOn: ""},
{ID: "L005", ItemID: "phone-2", Borrower: "Eli", OutOn: "2026-09-05", DueOn: "2026-09-12", ReturnedOn: ""},
{ID: "L006", ItemID: "camera-4", Borrower: "Fay", OutOn: "2026-09-06", DueOn: "2026-09-10", ReturnedOn: ""},
{ID: "L007", ItemID: "camera-1", Borrower: "Gus", OutOn: "2026-09-10", DueOn: "2026-09-20", ReturnedOn: ""},
{ID: "L008", ItemID: "laptop-2", Borrower: "Hal", OutOn: "2026-09-13", DueOn: "2026-09-14", ReturnedOn: ""},
}
}
func SeedStore() *Store { return NewStore(SeedItems(), SeedLoans()) }
func RunExample() ([]string, error) {
ids := func(rows []OverdueRow) string {
loanIDs := make([]string, 0, len(rows))
for _, row := range rows {
loanIDs = append(loanIDs, row.LoanID)
}
return strings.Join(loanIDs, " ")
}
lines := []string{"overdue on 2026-09-14"}
for _, alternative := range Alternatives {
s := SeedStore()
rows, err := OpenDesk(alternative, s).Overdue("2026-09-14")
if err != nil {
return nil, err
}
stats := s.Stats()
lines = append(lines, fmt.Sprintf("%s: %s (store calls %d, rows loaded %d)", alternative, ids(rows), stats.StoreCalls, stats.RowsLoaded))
}
lines = append(lines, "import checks out camera-2 again")
for _, alternative := range Alternatives {
s := SeedStore()
row := ImportRow{ItemID: "camera-2", Borrower: "Import", Today: "2026-09-14", Days: 7}
result := ImportLoans([]ImportRow{row}, ImportWriter(alternative, s))[0]
open := s.Loans(Query[Loan]{Where: func(loan Loan) bool { return loan.ItemID == "camera-2" && loan.ReturnedOn == "" }})
outcome := "created " + result.Loan.ID
if result.Err != nil {
outcome = result.Err.Error()
}
lines = append(lines, fmt.Sprintf("%s: %s, open loans %d", alternative, outcome, len(open)))
}
fake, err := NewFakeEquipmentLoans(SeedItems(), SeedLoans()).Overdue("2026-09-14")
if err != nil {
return nil, err
}
return append(lines, "fake on 2026-09-14: "+ids(fake)), nil
}
func main() {
lines, err := RunExample()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, line := range lines {
fmt.Println(line)
}
}
TypeScript · Node 22.18+node loans.ts
Go 1.22+go run loans.go
What the pattern promises, and what its critics point at.
Critics of repositories are rarely against organizing data access. They’re pointing at particular costs, and each one shows up somewhere in the lab.
“Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects.”
Repository, in Martin Fowler’s Patterns of Enterprise Application Architecture catalog, by Edward Hieatt and Rob Mee
In the lab: the domain repository follows that shape. StoreEquipmentLoans hands out Equipment aggregates and saves their
changes.
“I’m really not a fan of repositories, mainly because they hide the important details of the underlying persistence mechanism.” … “I don’t usually want to mock my repositories – I still need to have that integration test with the real thing.”
Jimmy Bogard, feedback quoted in Microsoft Learn, “Designing the infrastructure persistence layer”
In the lab: the Fake in tests step. FakeEquipmentLoans is a plausible fake: its use-case tests pass, and it returns tied due days newest first while
the store-backed repository returns them by loan id.
“It’s okay to query the database through other channels (as you can do following a CQRS approach), because queries don’t change the state of the database.” … “you should never create a repository for each table in the database.”
Microsoft Learn, “Designing the infrastructure persistence layer”
In the lab: the read/write split takes the first sentence at its word. The generic repository is one repository per table, and the second-writer step shows the checkout rule slipping past it.
“EF DbContext implements both the Repository and the Unit of Work patterns.”
Microsoft Learn, “Designing the infrastructure persistence layer”
Not in the lab: the examples use no ORM. If your data layer already tracks changes and saves them together, a repository of your own on top is a second layer that has to earn its place.
Our reasoning, measured by the store’s counters. Repository<T> can’t
say “overdue loans with item names, sorted”, so the caller asks for rows and does the
rest. In the baseline that cost genericOverdue 6 store calls and 13 rows, where
one joined read cost 1 and 5.
The same Microsoft Learn page uses repositories for its ordering service’s updates, and it also says: “Repositories might be useful, but they are not critical for your DDD design in the way that the Aggregate pattern and a rich domain model are.” Fans and critics mostly agree about the aggregate. They disagree about whether it needs a repository in front of it.
The desk page and the nightly job, with files that have owners.
Say the team has added an import script and settled on the read/write split. Here’s where each piece lives, who calls it, and what it’s responsible for.
| File | Called by | Owns |
|---|---|---|
loans/queries | Desk page; the reminder job, through the overdue function it is handed | listOverdueLoans, listOverdueByKind: filters, joins, order,
and paging |
loans/equipment | The use cases | Equipment: the checkout rule and one pending change |
loans/store-equipment-loans | The use cases, as EquipmentWriter | Loading an Equipment, and saving its change in one transaction |
loans/use-cases | Desk page actions, import script | checkOutEquipment, returnEquipment |
jobs/reminders | The nightly scheduler | Turning overdue rows into reminder text; setup hands it an OverdueLoans whose overdue is listOverdueLoans |
scripts/import-loans | Whoever runs the import | Reading rows, calling checkOutEquipment, reporting each result |
Now trace some changes through it. A borrower-history view needs one more function in loans/queries; Equipment, its repository, and the reminder job
don’t change. A mobile check-in endpoint calls checkOutEquipment like the desk
page does. A return that fails halfway throws from save after the transaction restores
both tables, so the desk can show the error and the loan is still open when the borrower tries
again.
The split works because the import script is handed EquipmentWriter and nothing else.
If the script could import the store module too, the only thing keeping it on the rule would be
a code review, so decide which modules may construct the store and keep that list short.
Build UIs?Your server load functions and form actions are already the loan desk’s callers.
Where it already is in your components
In a SvelteKit app, the loan desk page’s callers are its +page.server.ts.
Server load functions “always run on the server”, and form
actions let a <form> POST data to the server, so load can call listOverdueLoans and a checkOut action can call checkOutEquipment directly, with no repository in between.
Put those functions and the store in $lib/server, and SvelteKit keeps the
boundary for you. In a SvelteKit 2.70.3 dev server, a +page.svelte that
imported $lib/server/loans.ts failed to load, and the overlay read: Cannot import $lib/server/loans.ts into code that runs in the browser, as this could
leak sensitive information.
vite build stopped with the same message, for that component and for a
universal +page.ts load, which runs in the browser too. That’s the rule
behind server-only modules: browser
code can reach the loan data only through a load or an action, never by holding the store.
When you have to own it
Now several screens fetch loan data from the browser: the desk list, a borrower’s profile,
and a dashboard widget that refreshes without navigating. A client module such as $lib/api/loans.ts, with fetchOverdue(asOf) and checkOut(itemId), keeps the URLs, request encoding, and response parsing in
one place instead of in each screen.
It looks like a repository in front of the server, and for reads the resemblance is fair.
It stops at the rules. The browser module is one more caller, and anything that can send
the same request can skip it, so checkOutEquipment stays on the server, where
no client can go around it, as Client–server architecture explains. Keep the client module thin: name the requests, decode the responses, and let the
server’s item already on loan reach the screen.
“It depends” should end with a decision.
Name the dependency: how many writers share a rule, how fast the read shapes multiply, and what your tests run against. Those are enough to pick a starting point and say what would move it.
Start with direct query functions.
Keep each rule in the function that writes, and test against the real store. Accept that a new writer could skip the rule, and write down that a second writer reopens the decision.
Add a write repository. Keep reads as queries.
Give the rule one enforcement point and hand writers only the write side. Accept two paths, and name one owner for both.
Keep a generic repository out of the caller’s view.
Callers inherit the joins, sorting, paging, and rules. It can still be a helper underneath a domain repository.
Reads need rules, or a fake keeps disagreeing.
A read that needs a rule joins the write model. A fake that keeps drifting from the store is a sign to test the repository against the store instead.
Keep the reason for the boundary.
“We use repositories” tells the next person very little. A note that names the writers and the trigger for revisiting tells them when it’s safe to change.
- Why
- The desk page, the reminder job, and an import script all touch loans, and checkout must never open a second loan for an item.
- What
- Reads are query functions in
loans/queries. Writes go throughEquipmentandStoreEquipmentLoans, which saves each change in one transaction. - Constraint
- Three writers share one rule; the reads only filter, sort, and page.
- Fallback
- Two paths to learn, with one owner for both. Queries are tested against a store; the fake covers rule tests only.
- Reconsider when
- A read needs a rule, or the writers shrink back to one and the repository has nothing left to guard.
An example decision note to adapt to your own work. Nothing here is saved to an account.