Low-level designCore10 min
Repository and unit of work
A repository gives the domain a collection-like way to find and save its objects, and hides how they're stored. A unit of work collects the changes one operation makes and writes them all at once, so a failure halfway leaves storage untouched.
A tool library's loans
LoanDesk lends tools by reading the CSV file, checking the tool's row, and writing the file back.
1 · The situation
A tool library's loans, kept in a file
A neighborhood tool library lends drills and ladders. Its records are a small CSV file, one row per tool: an id, a name, and who has it, empty when it's on the shelf. LoanDesk lends a tool by reading the file, finding the row, checking the tool is in, and writing the file back. available() lists what's on the shelf.
The programs write the file to a temporary folder and delete it at the end. Python and JavaScript use the same names, with Python's snake case for longer ones such as lend_to().
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
class LoanDesk {
private final Path file;
LoanDesk(Path file) {
this.file = file;
}
void lend(String toolId, String member) throws IOException {
List<String> rows = new ArrayList<>(Files.readAllLines(file));
for (int i = 0; i < rows.size(); i++) {
String[] cols = rows.get(i).split(",", -1);
if (!cols[0].equals(toolId)) continue;
if (!cols[2].isEmpty()) throw new IllegalStateException(toolId + " is out");
rows.set(i, cols[0] + "," + cols[1] + "," + member);
Files.write(file, rows);
return;
}
throw new IllegalArgumentException("no tool " + toolId);
}
List<String> available() throws IOException {
List<String> out = new ArrayList<>();
for (String row : Files.readAllLines(file)) {
String[] cols = row.split(",", -1);
if (cols[2].isEmpty()) out.add(cols[0]);
}
return out;
}
}
public class Main {
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("tools", ".csv");
Files.write(file, List.of("D1,drill,", "L1,ladder,", "L2,ladder,"));
LoanDesk desk = new LoanDesk(file);
desk.lend("D1", "Ana");
desk.lend("L1", "Ben");
try {
desk.lend("D1", "Cy");
} catch (IllegalStateException e) {
System.out.println("Refused: " + e.getMessage());
}
System.out.println("Available: " + String.join(", ", desk.available()));
Files.readAllLines(file).forEach(System.out::println);
Files.delete(file);
}
}Ana and Ben borrow a drill and a ladder; Cy asks for the drill too. Then the program prints what's available and the file itself:
Refused: D1 is out Available: L2 D1,drill,Ana L1,ladder,Ben L2,ladder,
2 · The change
One rule, buried in rows and columns
Look for the library's only rule, that a tool already out can't be lent. It's the line that tests cols[2], between reading lines, splitting on commas and joining them back. The rule and the file format are tangled together, and that costs:
Change or task | What it takes in this design |
|---|---|
Test the lending rule | A real file on disk, written before and read after each test |
Move the records to a database | Rewrite |
Add a column, such as a due date | Edit every place that splits a row or counts columns |
Add a second rule, such as a limit of two tools per member | More logic between the file reads and writes |
The practice checks flag it: LoanDesk also reads and writes storage. The desk's job is lending; storing tools is a second job.
3 · The fix
Give the desk a collection of tools to work with
Start from the first version. What the desk wants is to find a tool, change it, and put it back, as if the tools lived in a collection in memory. Give it exactly that:
Tool holds its id, name and holder, and lendTo() refuses a tool that's out. The rule is now in plain code, with no file in sight.
class Tool {
private final String id;
private final String name;
private String holder;
Tool(String id, String name, String holder) {
this.id = id;
this.name = name;
this.holder = holder;
}
String id() { return id; }
String name() { return name; }
String holder() { return holder; }
void lendTo(String member) {
if (!holder.isEmpty()) throw new IllegalStateException(id + " is out");
holder = member;
}
}The file ends up exactly as before, and the last line comes from the in-memory repository:
Refused: D1 is out Available: L2 D1,drill,Ana L1,ladder,Ben L2,ladder, In memory: D1 is with Ana
The desk depends on the repository interface; the CSV file and the map are two implementations behind it.
4 · The pattern
Repository: a collection that happens to be stored
A repository sits between the domain and the code that stores data, and gives the domain a collection-like interface: find an object, list them, add or save one, remove one. The domain works with whole objects such as Tool, never with rows, columns or queries.
Part | Here | Its job |
|---|---|---|
Entity |
| The domain object, with its rules |
Repository interface |
| Find, list and save tools, in the domain's words; it belongs with the domain code |
Implementation |
| Turns tools into rows and back; it belongs with the storage code |
Client |
| Uses the interface and never learns how tools are stored |
Lending a tool: find it, let it apply its rule, save it. Nothing in the exchange mentions files.
The interface's direction matters. It's defined in the domain's terms and used by the domain, and the storage code implements it, so the domain depends on nothing about storage. The checks recognize the repository here. They also report Strategy and dependency injection, because the desk is handed one of several interchangeable implementations through its constructor, which is exactly what makes the in-memory version possible.
5 · The unit of work
Two saves, and half a swap
The library adds swaps: a member returns a broken tool and takes a replacement in one step. The broken ladder goes to the repair shop, and the replacement goes to the member. The first version of swap() saves each tool as soon as it changes:
class LoanDesk {
private final ToolRepository tools;
LoanDesk(ToolRepository tools) {
this.tools = tools;
}
void lend(String toolId, String member) {
Tool tool = tools.find(toolId);
tool.lendTo(member);
tools.save(tool);
}
void swap(String brokenId, String replacementId) {
Tool broken = tools.find(brokenId);
String member = broken.holder();
broken.sendToRepair();
tools.save(broken);
Tool replacement = tools.find(replacementId);
replacement.lendTo(member);
tools.save(replacement);
}
List<String> available() {
List<String> out = new ArrayList<>();
for (Tool tool : tools.all()) {
if (tool.holder().isEmpty()) {
out.add(tool.id());
}
}
return out;
}
}Ben's ladder L1 is broken, and he asks for L2, which Dee already has:
Refused: D1 is out Swap failed: L2 is out L1 is with: repair shop Available: L3, L4 D1,drill,Ana L1,ladder,repair shop L2,ladder,Dee L3,ladder, L4,ladder,
The swap failed, yet L1 is in the file as at the repair shop, and Ben has no ladder on record.
The first save happened before the second step failed. Checking L2 first would fix this case, but not the next one: a later step can always fail, from a rule, a full disk or a lost connection. The fix is to change nothing in storage until the whole operation has succeeded.
A unit of work keeps track of the objects an operation changes and writes them all together at the end. Here UnitOfWork hands out tools, collects each changed one with registerChanged(), and commit() writes them in one saveAll(), a single rewrite of the file. If any step throws first, commit() never runs and the file is untouched.
The unit of work sits between the desk and the repository for one operation, holding the tools it changed until commit().
class UnitOfWork {
private final ToolRepository tools;
private final Map<String, Tool> changed = new LinkedHashMap<>();
UnitOfWork(ToolRepository tools) {
this.tools = tools;
}
Tool find(String id) {
Tool tool = changed.get(id);
return tool != null ? tool : tools.find(id);
}
void registerChanged(Tool tool) {
changed.put(tool.id(), tool);
}
void commit() {
tools.saveAll(new ArrayList<>(changed.values()));
changed.clear();
}
}
class LoanDesk {
private final ToolRepository tools;
LoanDesk(ToolRepository tools) {
this.tools = tools;
}
void lend(String toolId, String member) {
Tool tool = tools.find(toolId);
tool.lendTo(member);
tools.save(tool);
}
void swap(String brokenId, String replacementId) {
UnitOfWork work = new UnitOfWork(tools);
Tool broken = work.find(brokenId);
String member = broken.holder();
broken.sendToRepair();
work.registerChanged(broken);
Tool replacement = work.find(replacementId);
replacement.lendTo(member);
work.registerChanged(replacement);
work.commit();
}
List<String> available() {
List<String> out = new ArrayList<>();
for (Tool tool : tools.all()) {
if (tool.holder().isEmpty()) {
out.add(tool.id());
}
}
return out;
}
}Refused: D1 is out Swap failed: L2 is out L1 is with: Ben Swapped L1 for L3 Available: L4 D1,drill,Ana L1,ladder,repair shop L2,ladder,Dee L3,ladder,Ben L4,ladder,
The failed swap changes nothing: L1 is still with Ben. The swap for L3 then goes through as one write.
Object-relational mappers build this in. Entity Framework's DbContext acts as both repository and unit of work, writing everything when SaveChanges is called. SQLAlchemy's Session records changes to the objects it loaded and flushes them all before a commit. With a real database the unit of work ends in one transaction, which also makes the writes all-or-nothing at the database level.
6 · When breaking it is fine
Repositories you don't need
A repository costs an interface and a class per kind of object, and every query the domain needs becomes a method. It's not always worth it:
- The framework already gives you one. Many object mappers expose repository-like objects; wrapping them again in an identical interface adds a layer and hides useful features.
- Reports and read-only screens. A page that lists overdue loans with members and phone numbers can query the data directly; it changes nothing, so it needs no domain rules.
And one shape to avoid: a generic repository for everything. One generic repository class, with every possible query method for every kind of object, exposes the storage to the whole program again, which is what the pattern was meant to hide.
Situation | Usually |
|---|---|
Domain rules over stored objects, tested often | A repository per kind of object, with an in-memory version for tests |
One operation changes several objects | A unit of work, or the mapper's own session, ending in one transaction |
Read-only reporting | Queries straight to the data, outside the domain |
A one-off script over a file | Read and write the file directly |
7 · Getting it right
Speak the domain's language, return the domain's objects
- Return domain objects, never rows.
find()returns aTool, not an array of strings. - Name queries after what the domain asks.
overdueOn(date)says more than a general filter method, and the storage can answer it however it's fastest. - One repository per cluster of objects that change together. A tool and its maintenance history can share one; a member gets their own.
- Keep rules out of the repository. The checks note a repository that calls an entity's methods for something other than storing it, because that logic belongs in the domain.
Test both sides. The in-memory repository makes domain tests fast, but it doesn't prove the CSV or database code works. Keep a few tests that run the real implementation against real storage.
8 · Look-alikes
Ways to connect objects to storage
Several patterns sit at the same boundary. What tells them apart is who knows about storage.
Pattern | Who knows about storage |
|---|---|
Repository | Only the implementation behind a collection-like interface owned by the domain |
Data access object (DAO) | A class per table or record type, offering operations shaped like the storage |
Active record | The entity itself: |
Data mapper | A separate class that moves data between objects and tables; repositories often sit on top of one |
Unit of work | Tracks what an operation changed and writes it all at once |
The line between a repository and a DAO is the vocabulary: a repository speaks in the domain's objects and questions, a DAO in the storage's records and operations. Active record suits simple data with little logic; once the rules grow, separating the entity from its storage pays off, as it did for the lending rule here.
9 · In practice
What the checks recognize
The practice checks recognize a repository as a type whose methods find, list, save and remove one kind of entity, backed by a collection of that entity or by an interface with an implementation that has one. An interface and its implementation count as one repository.
They also flag what this page started with: a class whose main job is something else that also reads and writes files or a database, reported as "also reads and writes storage". In the parking-lot problem, keep the lot's rules in the lot and give storage, if you add it, a class of its own. Design the parking lot.
Check yourself
4 questions
Takeaways
Remember this
- A repository gives the domain a collection-like interface for finding and saving its objects, and hides how they're stored.
- The interface belongs to the domain; storage code implements it, so the domain depends on nothing about storage.
- An in-memory repository lets domain rules be tested without files or databases; keep a few tests against the real storage too.
- A unit of work collects an operation's changes and writes them together, so a failure halfway changes nothing.
- Object mappers often provide both, such as a session or context that tracks changes and saves them in one transaction.