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.

Watch it evolve1 / 10
The problem

A tool library's loans

LoanDesk lends tools by reading the CSV file, checking the tool's row, and writing the file back.

80
Start
Stage 1
Stage 2
Stage 3
Final

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().

The first versionJava
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 lend() and available(), rules and all

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.

Java
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;
    }
}
«create»*Tool− id: String− name: String− holder: String~ id(): String~ name(): String~ holder(): String~ lendTo(String)«interface»ToolRepository+ find(String): Tool+ all(): List<Tool>+ save(Tool)CsvToolRepository− file: Path+ find(String): Tool+ all(): List<Tool>+ save(Tool)− rowOf(Tool): String− read(): List<String>InMemoryToolRepository− tools: Map<String, Tool>+ find(String): Tool+ all(): List<Tool>+ save(Tool)LoanDesk− tools: ToolRepository~ lend(String, String)~ available(): List<String>

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
«create»*Tool− id: String− name: String− holder: String~ id(): String~ name(): String~ holder(): String~ lendTo(String)«interface»ToolRepository+ find(String): Tool+ all(): List<Tool>+ save(Tool)CsvToolRepository− file: Path+ find(String): Tool+ all(): List<Tool>+ save(Tool)− rowOf(Tool): String− read(): List<String>InMemoryToolRepository− tools: Map<String, Tool>+ find(String): Tool+ all(): List<Tool>+ save(Tool)LoanDesk− tools: ToolRepository~ lend(String, String)~ available(): List<String>

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

Tool

The domain object, with its rules

Repository interface

ToolRepository

Find, list and save tools, in the domain's words; it belongs with the domain code

Implementation

CsvToolRepository

Turns tools into rows and back; it belongs with the storage code

Client

LoanDesk

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:

A swap that saves as it goesJava
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.

«create»*«create»Tool− id: String− name: String− holder: String~ id(): String~ name(): String~ holder(): String~ lendTo(String)~ sendToRepair()«interface»ToolRepository+ find(String): Tool+ all(): List<Tool>+ save(Tool)+ saveAll(List<Tool>)CsvToolRepository− file: Path+ find(String): Tool+ all(): List<Tool>+ save(Tool)+ saveAll(List<Tool>)− rowOf(Tool): String− read(): List<String>UnitOfWork− tools: ToolRepository− changed: Map<String, Tool>~ find(String): Tool~ registerChanged(Tool)~ commit()LoanDesk− tools: ToolRepository~ lend(String, String)~ swap(String, String)~ available(): List<String>

The unit of work sits between the desk and the repository for one operation, holding the tools it changed until commit().

The swap as one unit of workJava
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 a Tool, 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: tool.save() writes its own row

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

1. The library moves its records from the CSV file to a database. In the repository version, what changes?
2. Why did the first swap() leave Ben with no ladder on record?
3. What does a unit of work add on top of a repository?
4. Where should the rule "a tool that's out can't be lent" live?

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.