RizTech Academy logo
RizTech Academy
Capstone: Tiffin Service TrackerLesson 3 of 535 min

Storage: CSV, atomic writes and bad rows

The domain is safe: an invalid Delivery cannot exist. The data file is not safe, because a file is whatever somebody put in it — half a row, a name with a comma, a spreadsheet's byte order mark, a count of 99.

This lesson is the layer between those two worlds, and it is where a program either becomes a tool or stays a script.

What DeliveryStore promises

  • Reading a missing file gives no rows and no error.
  • Every row that can be read, is read.
  • Every row that cannot is reported with its line number and the reason.
  • Writing is atomic: a crash leaves the old file or the new one, never half.
  • What it writes, it can read back.

That last one is the promise most often broken, and the one a test catches.

Reading

public Parsed<Delivery> load() {
    if (Files.notExists(file)) {
        return new Parsed<>(List.of(), List.of());
    }
    List<String> lines;
    try {
        lines = Files.readAllLines(file, StandardCharsets.UTF_8);
    } catch (IOException e) {
        throw new UncheckedIOException("could not read " + file, e);
    }

    List<Delivery> rows = new ArrayList<>();
    List<String> problems = new ArrayList<>();
    for (int i = 0; i < lines.size(); i++) {
        int lineNumber = i + 1;
        String line = stripBom(lines.get(i));
        if (line.isBlank() || (lineNumber == 1 && line.equalsIgnoreCase(HEADER))) {
            continue;
        }
        String[] parts = splitCsv(line);
        if (parts.length != 3) {
            problems.add("line %d: expected 3 fields, found %d".formatted(lineNumber, parts.length));
            continue;
        }
        try {
            rows.add(new Delivery(
                    LocalDate.parse(parts[0].strip()),
                    parts[1],
                    Integer.parseInt(parts[2].strip())));
        } catch (RuntimeException e) {
            problems.add("line %d: %s".formatted(lineNumber, e.getMessage()));
        }
    }
    return new Parsed<>(rows, problems);
}

Six decisions in that method.

A missing file is empty, not an error. The first add should work on a fresh machine. Distinguishing "no file" from "unreadable file" is the whole point of the notExists check — the second still throws.

IOException is wrapped in UncheckedIOException with the path, at the boundary where the path is known. Module 7's rule: wrap where you can add context.

The loop is indexed — the only reason being that lineNumber is needed in every message, which module 5 listed as the legitimate case for an index loop.

The try is inside the loop, so one bad row does not abandon the file.

catch (RuntimeException e) catches both DateTimeParseException and NumberFormatException from parsing and IllegalArgumentException from the Delivery constructor. The record's validation and the parser's failures become the same kind of problem, reported the same way. That is the domain layer paying off.

The BOM is stripped and blank lines skipped, but the line number still counts them — so the number in the message matches what an editor shows.

static String stripBom(String line) {
    return line.isEmpty() || line.charAt(0) != '\uFEFF' ? line : line.substring(1);
}

The splitter, and the bug it fixes

static String[] splitCsv(String line) {
    List<String> out = new ArrayList<>();
    StringBuilder current = new StringBuilder();
    boolean inQuotes = false;
    for (int i = 0; i < line.length(); i++) {
        char c = line.charAt(i);
        if (inQuotes) {
            if (c == '"') {
                if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
                    current.append('"');
                    i++;
                } else {
                    inQuotes = false;
                }
            } else {
                current.append(c);
            }
        } else if (c == '"') {
            inQuotes = true;
        } else if (c == ',') {
            out.add(current.toString());
            current.setLength(0);
        } else {
            current.append(c);
        }
    }
    out.add(current.toString());
    return out.toArray(new String[0]);
}

This method exists because of a test failure. The first version of this store used line.split(",", -1), and the round-trip test failed:

org.opentest4j.AssertionFailedError: expected:
  <[Delivery[date=2026-09-01, customer=Priya, tiffins=2],
    Delivery[date=2026-09-02, customer=Kale, Kavita, tiffins=3]]>
but was:
  <[Delivery[date=2026-09-01, customer=Priya, tiffins=2]]>

The writer quoted "Kale, Kavita" correctly. The reader split on every comma, found four fields where three were expected, rejected the row, and the customer disappeared. The writer and the reader disagreed, and neither was wrong on its own — which is precisely why round-trip tests are worth writing.

Note the method is package-private, not private: the test reaches it directly, and callers do not. Module 10's argument for the access level people forget.

Writing

public void save(List<Delivery> deliveries) {
    StringBuilder sb = new StringBuilder(HEADER).append('\n');
    for (Delivery d : deliveries) {
        sb.append(d.date()).append(',')
          .append(quote(d.customer())).append(',')
          .append(d.tiffins()).append('\n');
    }
    writeAtomically(sb.toString());
}

private void writeAtomically(String content) {
    Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
    try {
        if (file.getParent() != null) {
            Files.createDirectories(file.getParent());
        }
        Files.writeString(tmp, content, StandardCharsets.UTF_8);
        Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
    } catch (IOException e) {
        throw new UncheckedIOException("could not write " + file, e);
    }
}

static String quote(String field) {
    if (field.contains(",") || field.contains("\"") || field.contains("\n")) {
        return '"' + field.replace("\"", "\"\"") + '"';
    }
    return field;
}

resolveSibling puts the temporary file in the same directory, which is what makes the move atomic — across filesystems it becomes a copy and delete, and the guarantee is gone.

StringBuilder, not repeated writeString with APPEND. Module 2's rule about loops, and module 8's about reopening a file per line.

\n rather than newLine(), because this file is read by a program, and a consistent line ending is worth more than matching the platform.

The tests

@TempDir
Path dir;

@Test
void loadsGoodRowsAndReportsBadOnesWithLineNumbers() throws IOException {
    Path file = dir.resolve("deliveries.csv");
    Files.writeString(file, """
            date,customer,tiffins
            2026-09-01,Priya,2
            2026-09-01,Arjun
            2026-09-02,Kavita,notanumber
            2026-09-02,Amit,99
            2026-09-03,Priya,1
            """);

    Parsed<Delivery> parsed = new DeliveryStore(file).load();

    assertEquals(2, parsed.rows().size());
    assertEquals(3, parsed.problems().size());
    assertTrue(parsed.problems().get(0).startsWith("line 3:"), parsed.problems().get(0));
    assertTrue(parsed.problems().get(1).contains("notanumber"), parsed.problems().get(1));
    assertTrue(parsed.problems().get(2).contains("between 0 and 4"), parsed.problems().get(2));
}

Three deliberately different failures in one file: a short row, an unparseable number, and a value the domain rejects. All three are reported; the two good rows still load.

@Test
void roundTripsThroughSaveAndLoad() {
    Path file = dir.resolve("out.csv");
    List<Delivery> original = List.of(
            new Delivery(LocalDate.of(2026, 9, 1), "Priya", 2),
            new Delivery(LocalDate.of(2026, 9, 2), "Kale, Kavita", 3));

    DeliveryStore store = new DeliveryStore(file);
    store.save(original);

    assertEquals(original, store.load().rows());
}

@Test
void savingLeavesNoTemporaryFileBehind() throws IOException {
    Path file = dir.resolve("out.csv");
    new DeliveryStore(file).save(List.of(new Delivery(LocalDate.of(2026, 9, 1), "Priya", 2)));

    try (DirectoryStream<Path> entries = Files.newDirectoryStream(dir)) {
        for (Path p : entries) {
            assertFalse(p.getFileName().toString().endsWith(".tmp"),
                    "temporary file left behind: " + p);
        }
    }
}

@Test
void stripsAByteOrderMarkFromTheHeader() throws IOException {
    Path file = dir.resolve("bom.csv");
    Files.writeString(file, "\uFEFFdate,customer,tiffins\n2026-09-01,Priya,2\n");

    Parsed<Delivery> parsed = new DeliveryStore(file).load();

    assertEquals(1, parsed.rows().size());
    assertFalse(parsed.hasProblems(), "BOM should not produce a problem row");
}

@TempDir gives each test a fresh directory, deleted afterwards. No test file ever touches the project.

The round trip is the one that found the real bug. Note the customer name it uses: that comma is deliberate.

[INFO] Running com.riztech.tiffin.DeliveryStoreTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0

BillingService

The calculations, all of them module 6:

public Map<String, Integer> tiffinsPerCustomer(List<Delivery> deliveries, YearMonth month) {
    return deliveries.stream()
            .filter(d -> YearMonth.from(d.date()).equals(month))
            .collect(Collectors.groupingBy(Delivery::customer, TreeMap::new,
                    Collectors.summingInt(Delivery::tiffins)));
}

public Optional<LocalDate> busiestDay(List<Delivery> deliveries) {
    return deliveries.stream()
            .collect(Collectors.groupingBy(Delivery::date, Collectors.summingInt(Delivery::tiffins)))
            .entrySet().stream()
            .max(Map.Entry.<LocalDate, Integer>comparingByValue()
                    .thenComparing(Map.Entry.comparingByKey(Comparator.reverseOrder())))
            .map(Map.Entry::getKey);
}

TreeMap::new so the report is alphabetical rather than in HashMap order. Optional for the busiest day, because an empty month has none. And the comparator has a tie-break, so two equally busy days resolve deterministically instead of arbitrarily — which is the difference between a report you can regression-test and one you cannot.

public String renderReport(List<Delivery> deliveries, YearMonth month) {
    Map<String, Integer> counts = tiffinsPerCustomer(deliveries, month);
    StringBuilder sb = new StringBuilder();
    String title = "Tiffin bill - " + month;
    sb.append(title).append('\n').append("=".repeat(title.length())).append('\n');
    sb.append("%-20s%10s%14s%n".formatted("Customer", "Tiffins", "Amount"));
    long total = 0;
    for (Map.Entry<String, Integer> e : counts.entrySet()) {
        long paise = billPaise(e.getKey(), e.getValue());
        total += paise;
        sb.append("%-20s%10d%14s%n".formatted(e.getKey(), e.getValue(), Money.format(paise)));
    }
    sb.append("%-20s%10s%14s%n".formatted("Total", "", Money.format(total)));
    return sb.toString();
}

It returns the text. It does not print. That single decision is why the next lesson's Main is four lines per command and why this can be tested at all.

Check your work

Why is a missing file not an error? The first add must work on a fresh machine. An unreadable file still throws — the notExists check distinguishes them.

Why is the read loop indexed rather than enhanced? The line number is needed in every message, which is module 5's legitimate case for an index loop.

Why catch (RuntimeException e) rather than the specific types? It catches both the parse failures and the Delivery constructor's validation, so domain rules and parse errors are reported identically.

Why does resolveSibling matter for the temp file? An atomic move requires the same filesystem. Writing the temp file elsewhere silently turns it into a copy and delete.

Why is splitCsv package-private? The test reaches it directly; callers should not. That is what package-private is for.

What did the round-trip test find? The writer quoted a comma-containing name correctly and the reader split on every comma, so that row was rejected and lost. Neither half was wrong alone.

Why does busiestDay have a tie-break in its comparator? So two equally busy days resolve the same way every run, making the report regression-testable.

Why does renderReport return a String? So it can be asserted in a test. Printing belongs to Main.

Practice 2, the three bad rows. A five-line file with a short row, a non-numeric count and a count of 99 gives two good rows and three problems: line 3: expected 3 fields, found 2, line 4: For input string: "notanumber", and line 5: tiffins must be between 0 and 4, got 99. If your line numbers are off by one, you are numbering from zero or skipping the header before counting.

Practice 4, the atomic write. Writing directly to the target and killing the program mid-write leaves a truncated CSV that still parses — fewer rows, no error. With the temp-then-move version the target is always complete. The test for it asserts no .tmp file survives, which catches the other half: a write that fails after creating the temp file but before the move.

Practice

  1. Write DeliveryStore.load. Missing file, header, blank lines, field count, and the try inside the loop.

  2. Feed it three kinds of bad row. A short row, a non-numeric count and a count of 99. Assert two good rows and three problems with the right line numbers.

  3. Write save with quoting, then the round-trip test. Use a customer name containing a comma. If it passes first time, check that your writer is actually quoting.

  4. Make the write atomic. Then write the test that asserts no .tmp file is left behind. Then break it deliberately by writing straight to the target and killing the program mid-write.

  5. Write BillingService. tiffinsPerCustomer, billPaise, busiestDay and renderReport. Assert that the report's total equals the sum of the individual bills, rather than a hard-coded number.

  6. Harder — handle a reordered file. Make the loader read the header and build a Map<String, Integer> of column name to index, so a file with the columns in a different order still loads. Then write a test with the columns swapped, and another with a missing required column that reports the problem by name. This is what separates a loader that works from one that keeps working when somebody re-saves the file in Excel.

Next: the command line, and failing in a way the person running it can act on.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship