The previous lesson ended with a sentence worth taking seriously: reading is safe, writing destroys. If you get reading wrong, you get incorrect data and you notice. If you get writing wrong, the previous file no longer exists, and you may not notice until somebody needs it.

In this lesson BiblioTech saves for the first time. And for it to save properly, there are three things to understand before writing a line: the append parameter, which decides between adding at the end of the file and emptying it completely; the buffer, which explains why what you have written may not be on the disk yet; and atomic writing, the pattern that stops a file being left half finished when the process fails midway.

That third point is the same problem you solved in 06-05 with compensation in finally: keeping the system in a consistent state whatever happens. There the state was in memory; here it is on the disk, and the consequences last longer.

As in the whole of the previous lesson, every resource is opened with try-with-resources (06-06). It is taken as known and is not justified again. In writing it matters even more than in reading, because a writer's close() flushes the buffer to disk: if you do not close, you have not written.

Contents

  1. FileWriter: create, overwrite and append
  2. The append parameter, or how to lose a whole file
  3. Writing text with write
  4. PrintWriter: the convenient wrapper
  5. The buffer and flushing: flush versus close
  6. What happens if the program ends without closing
  7. Explicit encoding when writing
  8. The system line separator
  9. Permissions, read-only files and IOException
  10. Atomic writing: temporary file and rename
  11. Lock files and accidental overwriting
  12. BiblioTech: CatalogExporter really writes
  13. Common Mistakes and Tips
  14. Exercises

  1. FileWriter: create, overwrite and append

FileWriter is the exact counterpart of FileReader: it writes text to a file. Its behaviour on opening is the first thing to pin down:

import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FirstWrite {

    public static void main(String[] args) throws IOException {
        try (FileWriter writer = new FileWriter("output.txt", StandardCharsets.UTF_8)) {
            writer.write("BiblioTech catalogue\n");
            writer.write("Nexus Software\n");
        }
        System.out.println("Written.");
    }
}

What exactly happens on opening:

Previous situation Default behaviour
The file does not exist It is created, empty, and written to
The file exists It is truncated to zero bytes and written from the start
The parent directory does not exist IOException: FileWriter does not create directories
There is no write permission IOException (FileNotFoundException, which extends it)

Look at the second row, because it is the one that ruins your day: opening a FileWriter on an existing file empties it immediately, at construction time, before you write anything. If you open the file and then throw an exception before writing, you are left with an empty file and none of the previous data.

And look at the third row too: FileWriter does not create the directory. If you write to data/catalog.txt and data/ does not exist, you get an IOException whose typical message is "No such file or directory", which many people read as "it cannot find the file" when in fact the folder is missing. Creating directories is the job of mkdirs() in the old API or of Files.createDirectories() in NIO.2 (07-06).

The available constructors:

// Overwrites. Platform charset: DO NOT USE (07-01, section 10)
new FileWriter("output.txt");

// Overwrites, explicit charset (Java 11+). THIS is the correct form
new FileWriter("output.txt", StandardCharsets.UTF_8);

// Appends at the end, platform charset
new FileWriter("output.txt", true);

// Appends at the end, explicit charset (Java 11+). The other correct form
new FileWriter("output.txt", StandardCharsets.UTF_8, true);

Beware the ambiguity of the second parameter. new FileWriter(path, true) means "append". new FileWriter(path, StandardCharsets.UTF_8) means "overwrite with UTF-8". They look very much alike and mean different things. When you want both, use the three-parameter version, and note that the boolean goes last.

  1. The append parameter, or how to lose a whole file

This is the section to read twice. The table is short and the consequences are long:

Constructor New file Existing file with data
new FileWriter(path, UTF_8) It is created It is emptied. The previous data is lost
new FileWriter(path, UTF_8, false) It is created It is emptied. Identical to the previous one
new FileWriter(path, UTF_8, true) It is created It is kept and writing happens at the end

The real case, and it happens constantly:

// BiblioTech records every loan in an audit file.
// The file has been accumulating operations for three years.

public void recordOperation(String line) throws IOException {
    // BUG: the 'true' is missing. Every call DELETES the whole history
    // and leaves only the last line.
    try (FileWriter writer = new FileWriter("data/audit.txt", StandardCharsets.UTF_8)) {
        writer.write(line + System.lineSeparator());
    }
}

That code works: it throws no exceptions, gives no warnings, the file exists and has content. It just has one line instead of three hundred thousand. And since every run leaves it with one line again, the failure is perfectly stable and silent. It is discovered the day somebody asks for the history.

The correct version:

public void recordOperation(String line) throws IOException {
    // The final 'true': APPEND, do not overwrite
    try (FileWriter writer =
                 new FileWriter("data/audit.txt", StandardCharsets.UTF_8, true)) {
        writer.write(line + System.lineSeparator());
    }
}

Three professional defences against this mistake:

  1. Name the intent. Do not leave the true loose in the call:
private static final boolean APPEND    = true;
private static final boolean OVERWRITE = false;

new FileWriter(path, StandardCharsets.UTF_8, APPEND);   // it reads itself
  1. Use the explicit NIO.2 options when you get to 07-06. StandardOpenOption.APPEND and StandardOpenOption.TRUNCATE_EXISTING say literally what they do, and there is no way to confuse them with a charset.

  2. Always write to a temporary file and rename when you are regenerating a whole file. That is section 10, and it eliminates the entire category of problems.

And a warning that goes beyond this parameter:

Opening a FileWriter "just to check something" already destroys the file. There is no "open only if I can" mode. If you need to know whether the file exists before deciding, check it before opening, and even then remember the TOCTOU warning from 06-07: between the check and the opening, the world can change.

  1. Writing text with write

Writer —the superclass of FileWriter— offers several write overloads:

import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class WaysOfWriting {

    public static void main(String[] args) throws IOException {
        try (FileWriter w = new FileWriter("demo.txt", StandardCharsets.UTF_8)) {

            w.write("Complete text");                // whole String
            w.write('\n');                           // a single character (int)
            w.write("Just one part here", 5, 3);     // substring: from 5, 3 characters -> "one"
            w.write('\n');

            char[] buffer = { 'B', 'i', 'b', 'l', 'i', 'o' };
            w.write(buffer);                         // whole array
            w.write(buffer, 0, 3);                   // part of the array -> "Bib"

            w.append("Chainable")                    // append returns the Writer
             .append(' ')
             .append("and convenient");
        }
    }
}
Method Writes
write(String) The complete string
write(String, int start, int length) A substring, without creating intermediate objects
write(int) A single character, given by its code
write(char[]) The whole array
write(char[], int start, int length) Part of the array
append(CharSequence) The same as write(String), but returns the Writer, chainable

A detail that throws people: write(int) writes a character, not the number. w.write(65) writes the letter A, not the text 65. If you want to write the number, convert it: w.write(String.valueOf(65)). It is the same asymmetry you already saw in read() in 07-01, and for the same reason: the unit of the API is the character, and the int is there so that -1 fits.

And the most obvious practical shortcoming: FileWriter has no println. There is no method that adds the line break for you, nor any that formats. You have to write the separator by hand every time. PrintWriter solves that.

  1. PrintWriter: the convenient wrapper

PrintWriter wraps another Writer and adds all the convenience of System.out: the same print, println and printf you have been using since module 1.

import java.io.FileWriter;
import java.io.PrintWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;

public class ReportWithPrintWriter {

    public static void main(String[] args) throws IOException {
        try (PrintWriter output = new PrintWriter(
                new FileWriter("report.txt", StandardCharsets.UTF_8))) {

            output.println("FINE REPORT - BiblioTech");
            output.println("========================");
            output.println();

            // printf with the same syntax as module 1
            output.printf("%-25s %-15s %8s%n", "MATERIAL", "EMPLOYEE", "FINE");
            output.printf("%-25s %-15s %8.2f%n", "Effective Java",  "Marta Ruiz",   3.75);
            output.printf("%-25s %-15s %8.2f%n", "Design Patterns", "Diego Alonso", 0.00);
            output.printf("%-25s %-15s %8.2f%n", "Refactoring",     "Nuria Vidal", 20.00);

            // Explicit Locale: in much of Europe the decimal separator is a comma.
            // For a file another program is going to read, pin Locale.ROOT.
            output.printf(Locale.ROOT, "%nTOTAL: %.2f EUR%n", 23.75);
        }
    }
}

The result, on a machine whose locale uses the decimal comma, is:

FINE REPORT - BiblioTech
========================

MATERIAL                  EMPLOYEE            FINE
Effective Java            Marta Ruiz          3,75
Design Patterns           Diego Alonso        0,00
Refactoring               Nuria Vidal        20,00

TOTAL: 23.75 EUR

Notice the difference between the body lines (with a decimal comma, because they use the machine's Locale) and the total line (with a dot, because it pins Locale.ROOT). In a file meant to be read by another program, always pin the Locale; in one meant to be read by a person in a comma country, the comma is correct. This same conflict will come back with CSV files in 07-07 and is more important than it looks.

What PrintWriter adds over FileWriter:

Method What it does
println(x) Writes and adds the system line separator
print(x) Writes without a break. Accepts any type, including objects (it uses toString())
printf(fmt, args...) Formats like System.out.printf
format(fmt, args...) Identical to printf. It exists for symmetry with String.format
write(String) Inherited from Writer

And now the big PrintWriter trap, which you absolutely must know:

PrintWriter swallows exceptions. Its print, println and printf methods do not declare IOException. If the disk fills up or the device fails, you will not know: the error is stored in an internal flag instead of being thrown.

The only way to find out is to ask:

try (PrintWriter output = new PrintWriter(
        new FileWriter("report.txt", StandardCharsets.UTF_8))) {

    output.println("line 1");
    output.println("line 2");

    // COMPULSORY CHECK in serious code.
    // checkError() flushes the buffer and returns true if THERE HAS BEEN any
    // failure at any moment since it was opened.
    if (output.checkError()) {
        throw new IOException("Failed to write the report (detected by checkError)");
    }
}

This design comes from the fact that PrintWriter was born for System.out, where a console write failure should not force a try/catch around every println. For a file, that compromise is dangerous.

Practical rule:

  • For console output and debugging dumps: PrintWriter and nothing more, with its convenience.
  • For files whose content matters: PrintWriter with checkError() before closing, or BufferedWriter directly (07-04), whose methods do declare IOException.

One more warning about the PrintWriter constructors:

// These TWO open the file on their own, with the platform charset
// in the old versions. They are convenient and treacherous.
new PrintWriter("output.txt");
new PrintWriter(new File("output.txt"));

// With an explicit charset (Java 10+): correct
new PrintWriter("output.txt", StandardCharsets.UTF_8);

// Wrapping a Writer you control: the clearest and most flexible form
new PrintWriter(new FileWriter("output.txt", StandardCharsets.UTF_8, true));

The last one is the recommended one: you decide the charset and the append mode, and PrintWriter only contributes the formatting. That is composition, and in 07-03 you will see that this is the principle organising the whole stream API.

  1. The buffer and flushing: flush versus close

Here is the concept that separates someone who writes files from someone who writes files properly.

When you call write, the data does not go to the disk. It accumulates in a buffer in memory, and is only sent to the operating system when the buffer fills up or when somebody asks for it explicitly. And there is more than one level of accumulation:

flowchart TD
    A["output.println(...)"] --> B["PrintWriter or<br/>BufferedWriter buffer"]
    B -->|"flush or full buffer"| C["Internal FileWriter buffer"]
    C -->|"write system call"| D["Operating system<br/>page cache"]
    D -->|"fsync or the OS itself"| E["Physical disk"]

    style B fill:#e3f2fd
    style C fill:#e3f2fd
    style D fill:#fff3e0
    style E fill:#f3e5f5

And here is the key point that almost nobody is clear about:

Operation Guarantees
write(...) That the data is in the application buffer. Nothing more
flush() That the data has reached the operating system. It no longer depends on your process
close() A flush() plus the release of the file descriptor
FileDescriptor.sync() That the data is physically on the disk. The only thing that survives a power cut

In other words: close() protects against your program ending; only sync() protects against the power going off. For the vast majority of applications, close() is enough and sync() is an unnecessary cost. For a system where losing the last operation is unacceptable —a payment, an accounting entry— you have to go all the way to the disk.

A demonstration of the difference between flush and doing nothing:

import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class BufferDemo {

    public static void main(String[] args) throws Exception {
        FileWriter w = new FileWriter("demo-buffer.txt", StandardCharsets.UTF_8);

        w.write("First line\n");
        System.out.println("After write, size on disk: " + size());    // 0

        w.flush();
        System.out.println("After flush, size on disk: " + size());    // 11

        w.write("Second line\n");
        System.out.println("After 2nd write, size:     " + size());    // 11

        w.close();
        System.out.println("After close, size on disk: " + size());    // 23
    }

    private static long size() {
        return new java.io.File("demo-buffer.txt").length();
    }
}

Output:

After write, size on disk: 0
After flush, size on disk: 11
After 2nd write, size:     11
After close, size on disk: 23

The file has 0 bytes after the first write. The data exists, but it is in the process memory. If at that instant somebody looks at the file from outside, they see it empty.

A very common practical consequence: if you are writing a log file and you watch it with tail -f while the program runs, you will see nothing for a long while, because the buffer has not filled up. That is not a failure of your code; it is the buffer doing its job. For logs you need to be able to follow live, you flush() after every line, at the cost of performance —and that is exactly what the FileHandler of java.util.logging you configured in 06-07 does, and the reason logging has an appreciable cost.

When to call flush() explicitly:

  • When another process or person needs to see the data now, without waiting for the close.
  • When you are going to keep the file open for a long time and want to bound how much would be lost in a crash.
  • Before a long or risky operation, to leave what has been written so far on the disk.
  • Never just before close(): it is redundant, close() already does it.

  1. What happens if the program ends without closing

This demonstration is worth running, because the result is surprising:

import java.io.FileWriter;
import java.nio.charset.StandardCharsets;

public class NotClosed {

    public static void main(String[] args) throws Exception {
        // DELIBERATELY WRONG: no try-with-resources and no close
        FileWriter w = new FileWriter("not-closed.txt", StandardCharsets.UTF_8);
        w.write("This line may never reach the disk.\n");

        System.out.println("Ending without closing...");
        // main ends here. There is no close(). There is no flush().
    }
}

The usual result: the file exists and is empty. The data stayed in the process buffer, and when the process ended the buffer went with it.

Questions that always come up:

  • Does the garbage collector not close it? The finalize() of some I/O classes did something similar, but it is deprecated since Java 9 and removed in recent versions. It was never a guarantee: the collector does not promise to run before the process ends. Never count on it.
  • What if the system kills the process? With kill -9 or a power cut, everything that has not reached the operating system is lost. Not even the shutdown hooks from 06-05 run with kill -9.
  • What if it ends with an exception? Without try-with-resources, it is lost just the same. With try-with-resources, closing is guaranteed and the data gets through.

The correct version, and the only one written in real code:

try (FileWriter w = new FileWriter("properly-closed.txt", StandardCharsets.UTF_8)) {
    w.write("This line DOES reach the disk.\n");
}   // close() guaranteed: on success, on exception and on return (06-06)

This gives try-with-resources extra weight when writing. When reading, not closing is a resource leak: annoying but not destructive. When writing, not closing is losing data. The file is left empty or truncated, and often nobody finds out until much later.

  1. Explicit encoding when writing

Everything from 07-01 about encoding applies just the same, with a nuance that makes things worse:

An encoding failure when reading is visible and fixable. When writing, it is recorded. If you write a catalogue with the wrong charset, the file is wrong on disk. You can carry on reading it correctly as long as you use the same wrong charset, and the problem only appears when another tool —an editor, a spreadsheet, another system— tries to read it as UTF-8. By then the file has been accumulating corrupt data for months.

And there is a worse case still: writing a character that the target charset cannot represent:

import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class UnrepresentableCharacter {

    public static void main(String[] args) throws IOException {
        // US-ASCII has neither 'e' with an acute accent nor the euro sign
        try (FileWriter w = new FileWriter("ascii.txt", StandardCharsets.US_ASCII)) {
            w.write("Café Society costs 45 €");
        }
        // It does NOT throw. It replaces the unrepresentable with '?'.
        // The file ends up with: "Caf? Society costs 45 ?"
    }
}

Total silence and destroyed data. The default behaviour of the encoder is CodingErrorAction.REPLACE. If you want it to fail instead of destroying, you have to go down to the level of CharsetEncoder, which is java.nio.charset API and lies outside the scope of this lesson; the important thing is that you know that silence is the default behaviour.

The rule is the one from 07-01, repeated because it deserves repeating:

// ALWAYS, on every text write opening
new FileWriter(path, StandardCharsets.UTF_8);
new FileWriter(path, StandardCharsets.UTF_8, APPEND);
new PrintWriter(new FileWriter(path, StandardCharsets.UTF_8));

And a symmetry that must always be respected: whoever writes and whoever reads the same file must use the same charset. If CatalogExporter writes in UTF-8, CatalogLoader has to read in UTF-8. Best of all is for that charset to be declared only once in a shared constant:

package com.nexussoftware.bibliotech.infrastructure;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/** Format conventions for all BiblioTech files. */
public final class FileFormat {

    /** The single charset of every text file in the system. */
    public static final Charset CHARSET = StandardCharsets.UTF_8;

    /** Field separator for the tabulated files. */
    public static final String FIELD_SEPARATOR = ";";

    /** Prefix of comment lines. */
    public static final String COMMENT = "#";

    private FileFormat() { }
}

A single place to change, and no possibility of the reader and the writer disagreeing.

  1. The system line separator

Operating systems never agreed on how to end a line:

System Bytes Java escape Name
Linux, modern macOS 0x0A \n LF
Windows 0x0D 0x0A \r\n CRLF
Classic macOS (up to 2001) 0x0D \r CR

Java exposes the one for the current system:

String newline = System.lineSeparator();   // "\n" or "\r\n" depending on the system

When does it matter? It depends on who is going to read the file:

Consumer of the file Does \n bother it on Windows?
Your own program with BufferedReader.readLine() No: it accepts all three forms
A modern editor (VS Code, Notepad++, IntelliJ) No
The old Windows Notepad Yes: it showed everything on one line
Windows command-line tools Sometimes
Another system expecting the native format Yes

In practice:

  • PrintWriter.println() already uses the system separator. You do not have to do anything.
  • BufferedWriter.newLine() does too. It is the correct method (07-04).
  • Writing "\n" by hand in a write always produces LF, whatever the system.

That said, there is an argument in favour of always writing \n, and it is a serious one: reproducibility. If a file generated on Windows has CRLF and the same file generated on Linux has LF, a version control system will see them as completely different even though the content is identical. For data files that are versioned or compared, many teams pin LF on purpose.

The decision, summarised:

Type of file Recommended separator
Report for a person to read on their system System.lineSeparator() (or println)
Data file that is compared or versioned Fixed "\n", and document it
File whose format another system defines Whatever that system demands
Network protocol Whatever the protocol says. HTTP demands CRLF (module 9)

In BiblioTech we will use System.lineSeparator() for the reports and a fixed "\n" for the data files. The constant goes, like the charset, in FileFormat.

  1. Permissions, read-only files and IOException

Writing can fail for reasons that do not depend on your code:

Cause Exception Typical message
The parent directory does not exist FileNotFoundException No such file or directory
No write permission FileNotFoundException Permission denied
File marked read-only FileNotFoundException Access is denied (Windows)
The path is a directory FileNotFoundException Is a directory
Disk full IOException No space left on device
Network drive disconnected IOException Varies
User quota exceeded IOException Disk quota exceeded

Note an important detail: opening failures arrive as FileNotFoundException —just as in reading, with an equally misleading name— and failures during writing arrive as a generic IOException. And the most treacherous of all is the full disk, because it does not happen on opening but halfway through the file: by the time it fires, you already have half a file written.

That is precisely the definitive argument in favour of the next section.

Handling by layers, with the criterion from 06-07:

import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;

public class WriteWithHandling {

    private static final Logger LOG = Logger.getLogger(WriteWithHandling.class.getName());

    public void export(String path, String content) throws CatalogNotAccessibleException {
        try (FileWriter w = new FileWriter(path, StandardCharsets.UTF_8)) {
            w.write(content);

        } catch (FileNotFoundException e) {
            // A LOCATION or PERMISSIONS problem: the user can correct it
            LOG.log(Level.WARNING, "Cannot write to "
                    + new java.io.File(path).getAbsolutePath(), e);
            throw new CatalogNotAccessibleException(path, e);   // 06-04, with the cause

        } catch (IOException e) {
            // A problem DURING the write: disk full, network down...
            LOG.log(Level.SEVERE, "I/O failure writing " + path, e);
            throw new CatalogNotAccessibleException(path, e);
        }
    }
}

Notice that CatalogNotAccessibleException is reused, the checked exception you declared in 06-04 precisely for this: an infrastructure failure for which the application has a reasonable alternative, and which therefore deserves the compiler forcing a decision about what to do. The original cause travels inside, following the chaining rule of 06-03.

  1. Atomic writing: temporary file and rename

This is the professional pattern of the lesson. The problem is this:

// DANGEROUS: regenerates the whole catalogue by overwriting the good file
try (PrintWriter w = new PrintWriter(
        new FileWriter("data/catalog.txt", StandardCharsets.UTF_8))) {

    for (Material m : catalog.list()) {          // 10,000 materials
        w.println(serialise(m));
    }
    // If it fails at material 6,000 (disk full, an exception in serialise,
    // the process dies), the file is left with 6,000 lines and the ORIGINAL
    // catalogue NO LONGER EXISTS: it was truncated on opening.
}

The good file was destroyed at the instant of opening the FileWriter, and the new one never got finished. You have lost the catalogue. And the worst part: the resulting file looks valid —it has the correct format, it reads without errors— it just happens to be missing 4,000 materials. A silent failure, which is the worst kind.

The solution is the write-and-rename pattern:

flowchart LR
    A["catalog.txt<br/>good version"] --> B{"Write everything to<br/>catalog.txt.tmp"}
    B -->|"success"| C["Rename tmp<br/>over catalog.txt"]
    B -->|"failure"| D["Delete the tmp"]
    C --> E["catalog.txt<br/>complete new version"]
    D --> F["catalog.txt<br/>good version UNTOUCHED"]

    style A fill:#e8f5e9
    style E fill:#e8f5e9
    style F fill:#e8f5e9
    style D fill:#ffebee

Why it works: renaming within the same file system is an atomic operation as far as any reader is concerned. There is no instant at which the file is half done. A concurrent reader sees either the complete old version or the complete new one, never a mixture. And if something fails before the rename, the good file has not even been touched.

The implementation:

package com.nexussoftware.bibliotech.infrastructure;

import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.FileWriter;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Atomic writing of text files.
 *
 * Writes to a temporary file and, ONLY if everything went well, renames it
 * over the definitive one. It guarantees the target file is never half done.
 *
 * It is the on-disk equivalent of the compensation in finally from 06-05: if
 * the operation does not complete, the previous state is kept untouched.
 */
public final class AtomicWrite {

    private static final Logger LOG = Logger.getLogger(AtomicWrite.class.getName());

    private static final String TEMP_SUFFIX = ".tmp";

    private AtomicWrite() { }

    /**
     * Contract for what is going to be written. A functional interface (04-06):
     * it allows the writing logic to be passed as a lambda.
     *
     * It is not java.util.function.Consumer because we need it to be able to
     * declare IOException, and Consumer.accept() does not declare it.
     */
    @FunctionalInterface
    public interface Content {
        void writeTo(PrintWriter output) throws IOException;
    }

    /**
     * Writes atomically.
     *
     * @param target  final file
     * @param content what to write
     * @throws IOException if the write or the rename fails
     */
    public static void write(File target, Content content) throws IOException {
        File temp = new File(target.getAbsolutePath() + TEMP_SUFFIX);

        // 1. Make sure the parent directory exists (FileWriter does not create it)
        File parent = target.getAbsoluteFile().getParentFile();
        if (parent != null && !parent.exists() && !parent.mkdirs()) {
            throw new IOException("Could not create the directory " + parent.getAbsolutePath());
        }

        boolean completed = false;
        try {
            // 2. Write EVERYTHING to the temporary file
            try (PrintWriter output = new PrintWriter(
                    new FileWriter(temp, StandardCharsets.UTF_8))) {

                content.writeTo(output);

                // PrintWriter swallows IOException: you have to ask it
                if (output.checkError()) {
                    throw new IOException("Failed to write to "
                            + temp.getAbsolutePath());
                }
            }   // close() guaranteed: here the temp file is closed and complete

            // 3. Rename ONLY if we got this far
            if (!rename(temp, target)) {
                throw new IOException("Could not rename "
                        + temp.getName() + " over " + target.getName());
            }
            completed = true;
            LOG.fine(() -> "Atomic write completed: " + target.getAbsolutePath());

        } finally {
            // 4. COMPENSATION (06-05): if it did not complete, leave no rubbish.
            //    The original target is still untouched because it was never opened.
            if (!completed && temp.exists() && !temp.delete()) {
                LOG.warning(() -> "A temporary file was left undeleted: "
                        + temp.getAbsolutePath());
            }
        }
    }

    /**
     * Renames the temporary file over the target.
     *
     * File.renameTo returns a mute boolean (07-01) and on Windows it fails if
     * the target exists. Hence the prior delete. In 07-06 this is solved
     * with Files.move and ATOMIC_MOVE, which is the correct way.
     */
    private static boolean rename(File temp, File target) {
        if (target.exists() && !target.delete()) {
            return false;
        }
        return temp.renameTo(target);
    }
}

It is used like this, with a lambda:

AtomicWrite.write(new File("data/catalog.txt"), output -> {
    output.println("# BiblioTech catalogue");
    for (Material m : catalog.list()) {
        output.printf("%s;%s;%s;%b%n",
                m.getType(), m.getReference(), m.getTitle(), m.isAvailable());
    }
});

Three observations about this implementation:

  1. The finally compensates exactly as in 06-05. If it did not complete, the temporary file is deleted. And the target needs no compensation because it was never opened: that is the whole point of the pattern.
  2. checkError() is compulsory with PrintWriter. Without it, a full disk would go unnoticed and you would rename a truncated file over the good one, which is exactly what we wanted to avoid.
  3. renameTo is defective —a mute boolean, different behaviour on Windows, a window between the delete and the renameTo. In 07-06 it is replaced by Files.move(source, target, StandardCopyOption.ATOMIC_MOVE), which really is atomic and throws informative exceptions. The version here is the one you had to write before Java 7, and it serves to understand exactly what NIO.2 guarantees.

When to use atomic writing and when not:

Situation Atomic?
Regenerating a whole file (catalogue, export, configuration) Yes, always
Adding a line to an audit log No: it is opened in append mode and closed
A temporary working file that is deleted afterwards Not needed
Any file another process may be reading Yes, essential

  1. Lock files and accidental overwriting

Two more dangers, brief but real.

Two processes writing the same file. If two instances of BiblioTech export the catalogue at the same time, the result is unpredictable: interleaved lines, a truncated file, or one of the two writes lost. Neither the operating system nor Java prevents it by default.

The traditional solution is a lock file: a file whose mere existence means "busy".

package com.nexussoftware.bibliotech.infrastructure;

import java.io.File;
import java.io.IOException;

/**
 * Inter-process lock based on the existence of a file.
 *
 * AutoCloseable (06-06): the lock is released on leaving the try, however
 * you leave it. Without that guarantee, an orphaned lock leaves the system
 * unusable until somebody deletes the file by hand.
 */
public class FileLock implements AutoCloseable {

    private final File lockFile;
    private boolean acquired = false;

    public FileLock(String protectedPath) throws IOException {
        this.lockFile = new File(protectedPath + ".lock");

        // createNewFile() is ATOMIC: it checks and creates in a single operation.
        // That is why it works as a lock and an exists() + create() would NOT:
        // the other process fits between the two calls (TOCTOU, 06-07).
        if (!lockFile.createNewFile()) {
            throw new IOException("The file '" + protectedPath
                    + "' is being used by another process"
                    + " (" + lockFile.getName() + " exists)");
        }
        acquired = true;
        lockFile.deleteOnExit();     // safety net against an abrupt exit
    }

    @Override
    public void close() {
        if (!acquired) {
            return;                  // idempotent (06-06)
        }
        acquired = false;
        if (!lockFile.delete()) {
            System.err.println("Warning: could not release " + lockFile.getAbsolutePath());
        }
    }
}

Usage:

try (FileLock lock = new FileLock("data/catalog.txt")) {
    AtomicWrite.write(new File("data/catalog.txt"), output -> { /* ... */ });
}   // the lock is released here, whatever happens

Honest limitations of this mechanism, which you have to know: if the process dies abruptly, the .lock is left orphaned and blocks everybody else. Serious systems store the process identifier and its start time inside so they can detect stale locks. Java also offers FileLock through FileChannel, which is a real operating system lock. And as soon as threads come into play, the problem changes in nature: that is module 8.

Accidental overwriting. Before regenerating an important file, keep a copy:

/** Keeps the previous version before overwriting. */
private static void makeBackup(File file) {
    if (!file.exists()) {
        return;
    }
    File backup = new File(file.getAbsolutePath() + ".bak");
    if (backup.exists()) {
        backup.delete();
    }
    if (!file.renameTo(backup)) {
        LOG.warning("Could not back up " + file.getName());
    }
}

In 07-06 this becomes a rotating backup with NIO.2: .bak.1, .bak.2, .bak.3, keeping the last three versions.

  1. BiblioTech: CatalogExporter really writes

Time to settle the debt. In 06-06 you declared CatalogExporter as Closeable and explained why its close() should indeed propagate IOException —because closing flushes the buffer, and if that fails the data is not written. But its writing was a sketch. Now it is written in full, with everything from this lesson.

package com.nexussoftware.bibliotech.service;

import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.domain.Material;
import com.nexussoftware.bibliotech.domain.Loan;
import com.nexussoftware.bibliotech.infrastructure.AtomicWrite;
import com.nexussoftware.bibliotech.infrastructure.FileFormat;

/**
 * Exports the BiblioTech catalogue and loan register to text files,
 * ATOMICALLY: the target file is never left half done.
 *
 * Design change with respect to 06-06: this class is NO LONGER Closeable.
 * It keeps no file open between calls; each export opens, writes and closes
 * inside AtomicWrite. An object that owns no live resources should not be
 * closeable: it would be an empty promise.
 *
 * The format is the one CatalogLoader reads (07-01). Both share the
 * constants of FileFormat so that they cannot disagree.
 */
public class CatalogExporter {

    private static final Logger LOG = Logger.getLogger(CatalogExporter.class.getName());

    private static final String CATALOG_HEADER =
            FileFormat.COMMENT + " BiblioTech catalogue - Nexus Software";
    private static final String CATALOG_FIELDS =
            FileFormat.COMMENT + " type;reference;title;author;year";

    private final File directory;

    public CatalogExporter(String dataDirectory) {
        this.directory = new File(
                Objects.requireNonNull(dataDirectory, "The directory cannot be null"));
    }

    /**
     * Exports the complete catalogue.
     *
     * @return number of materials written
     * @throws IOException if the write could not be completed
     */
    public int exportCatalog(Catalog catalog) throws IOException {
        Objects.requireNonNull(catalog, "The catalogue cannot be null");

        List<Material> materials = catalog.list();
        File target = new File(directory, "catalog.txt");

        long start = System.nanoTime();

        AtomicWrite.write(target, output -> {
            output.println(CATALOG_HEADER);
            output.println(CATALOG_FIELDS);
            for (Material m : materials) {
                output.println(serialise(m));
            }
        });

        double ms = (System.nanoTime() - start) / 1_000_000.0;

        // Logger, never System.out for diagnostics (06-07). Lazy form.
        LOG.info(() -> String.format("Catalogue exported: %d materials to %s (%.1f ms)",
                materials.size(), target.getAbsolutePath(), ms));

        return materials.size();
    }

    /**
     * Serialises a material into a line.
     *
     * The fields are sanitised: a ';' inside a title would break the file
     * when read back. Here it is substituted; in 07-07 it will be done
     * PROPERLY, quoting and escaping per the CSV convention.
     */
    private String serialise(Material m) {
        String sep = FileFormat.FIELD_SEPARATOR;

        if (m instanceof Book book) {                    // pattern from 03-06
            return String.join(sep,
                    "BOOK",
                    sanitise(book.getIsbn()),
                    sanitise(book.getTitle()),
                    sanitise(book.getAuthor()),
                    String.valueOf(book.getPublicationYear()));
        }
        return String.join(sep,
                m.getType().toUpperCase(),
                sanitise(m.getReference()),
                sanitise(m.getTitle()),
                "-",
                "0");
    }

    /** Removes separators and line breaks that would break the format. */
    private String sanitise(String value) {
        if (value == null) {
            return "";
        }
        return value.replace(FileFormat.FIELD_SEPARATOR, ",")
                    .replace("\n", " ")
                    .replace("\r", " ")
                    .trim();
    }

    /**
     * Adds a line to the loan audit log.
     *
     * THIS file IS opened in APPEND mode: it is a cumulative history,
     * not a file that gets regenerated. It has no atomic writing because
     * there is nothing to replace; it is only added to at the end.
     */
    public void recordLoan(Loan loan, String operation) {
        Objects.requireNonNull(loan, "The loan cannot be null");

        File audit = new File(directory, "audit.txt");

        // The final 'true' is APPEND. Without it, every call would delete the history.
        try (PrintWriter output = new PrintWriter(
                new java.io.FileWriter(audit, FileFormat.CHARSET, true))) {

            output.printf("%s;%s;%s;%s%n",
                    operation,
                    loan.getReference(),
                    loan.getMaterial().getReference(),
                    loan.getBorrower().getIdentifier());

            if (output.checkError()) {
                throw new IOException("Failed writing to " + audit.getAbsolutePath());
            }

        } catch (IOException e) {
            // POLICY: the audit must NOT bring down the business operation.
            // The loan has already been registered in memory and is valid.
            // It degrades: a warning in the log and carry on (06-07).
            LOG.log(Level.WARNING, "Could not audit the operation "
                    + operation + " of " + loan.getReference(), e);
        }
    }
}

The five design decisions to understand in this class:

  1. It has stopped being Closeable. In 06-06 it kept a BufferedWriter open throughout its life. Now every export opens and closes inside AtomicWrite, so it owns no live resource. An object with nothing to close should not implement Closeable: it would be an empty promise that makes whoever uses it write a pointless try-with-resources.
  2. The catalogue is written atomically; the audit, in append mode. They are two different natures: one is regenerated in full, the other grows. Atomic writing is for regenerating; append mode is for growing. Confusing them produces, in one direction, a destroyed file, and in the other, a file that duplicates everything each time.
  3. An audit failure does not abort the operation. The loan is valid even if it could not be audited. It is the recoverable/unrecoverable distinction of 06-07 applied here: not auditing is a loss of information, not an incorrect piece of data. If company policy demanded otherwise —and in a financial system it would— this decision would be reversed and documented.
  4. The checkError() is in both places. Without it, PrintWriter says nothing when the disk fills up.
  5. sanitise() is a patch declared as such. Replacing the ; with a comma loses information: the title comes back wrong. It is a conscious compromise until 07-07, where CSV escaping will really solve it. Flagging workarounds in a comment is part of the craft.

And saving on exit, hooked to the shutdown hook of 06-05:

package com.nexussoftware.bibliotech.presentation;

import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.service.Catalog;
import com.nexussoftware.bibliotech.service.CatalogLoader;
import com.nexussoftware.bibliotech.service.CatalogExporter;
import com.nexussoftware.bibliotech.infrastructure.LogConfiguration;

public class BiblioTechApp {

    private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());
    private static final String DATA_DIRECTORY = "data";

    public static void main(String[] args) {
        LogConfiguration.initialise();

        Catalog catalog = new CatalogLoader(DATA_DIRECTORY + "/catalog.txt").load();
        CatalogExporter exporter = new CatalogExporter(DATA_DIRECTORY);

        // Save on exit. Covers the normal exit and Ctrl+C; does NOT cover kill -9.
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                int n = exporter.exportCatalog(catalog);
                LOG.info("Catalogue saved on exit: " + n + " materials");
            } catch (IOException e) {
                LOG.log(Level.SEVERE, "COULD NOT SAVE THE CATALOGUE ON EXIT", e);
            }
        }, "final-save"));

        System.out.println("BiblioTech - Nexus Software");
        System.out.println("Materials in the catalogue: " + catalog.size());

        new BiblioTechMenu(catalog, exporter).start();
    }
}

An important warning about the hook. Saving only on exit is fragile: kill -9, a power cut or an OutOfMemoryError skip it. And the hook has a limited time before the system kills the process. In a real system you save as well after every relevant operation, or periodically. The hook is the safety net, not the strategy.

BiblioTech's status at the close of this lesson: the catalogue is loaded on start-up (07-01) and saved on exit (07-02). For the first time in seven modules, the application remembers.

Common Mistakes and Tips

  • Forgetting the true of append mode. The most expensive mistake in the module. Every run deletes the history and leaves one line. It does not fail, it does not warn: it destroys silently. Use a named APPEND constant.
  • Confusing new FileWriter(path, true) with new FileWriter(path, UTF_8). They look alike and mean opposite things. When you want both, use the three-parameter version with the boolean last.
  • Believing that opening the file is harmless. Constructing a FileWriter without append truncates the file immediately, before writing anything. If it then fails, you are left with no data and no new file.
  • Not closing the writer. The file is left empty. In reading, not closing is a leak; in writing it is data loss. try-with-resources always.
  • Trusting the garbage collector to close it. finalize() is deprecated and removed, and it was never a guarantee.
  • Confusing flush() with "it is on the disk". flush() reaches the operating system; only sync() reaches the platter. For almost everything, close() is enough.
  • Ignoring that PrintWriter swallows IOException. A full disk goes unnoticed and you rename a truncated file over the good one. checkError() before closing, or use BufferedWriter.
  • Writing over the definitive file. If it fails halfway, you lose the original and get an incomplete one that looks valid. Temporary file and rename.
  • Not creating the parent directory. FileWriter does not create it; it throws an IOException with a message that seems to say something else.
  • Not specifying the charset when writing. Worse than when reading: the data is recorded wrong, and the problem shows up months later when another tool reads it.
  • Writing characters not representable in the target charset. They are replaced by ? without warning. € and é in US-ASCII disappear silently.
  • Having the reader and the writer use different charsets. Declare the charset only once in a shared constant.
  • Using \n when the consumer expects CRLF, or the other way round. println and newLine() use the system one; for files that are versioned, pin \n and document it.
  • Believing that write(65) writes "65". It writes the letter A. It is a character, not a number.
  • Two processes writing the same file. Unpredictable result. A lock file, and even so with its limits.
  • Tip: always ask yourself "is this file regenerated or does it grow?". Regenerating demands atomic writing; growing demands append mode. The whole lesson fits into that question.
  • Tip: try filling the disk. Write to a small device —a temporary partition of a few MB— and check what your code does when it runs out. Most code does not cope, and a failure halfway through a file is the scenario atomic writing exists to cover.
  • Tip: check with an external editor. Open the generated file with another program and with another encoding. If you only read it with your own code, format and charset errors are invisible.
  • Tip: do not write the password or the national ID number to the file. Everything from 06-07 about what not to log applies equally to what is persisted, and with more reason: the file stays.

Exercises

Exercise 1: append demonstrator

Write AppendDemo that empirically demonstrates the difference between the two modes:

  1. A method writeLine(String path, String text, boolean append) that writes a line in the given mode with UTF-8 charset.
  2. A method show(String path) that prints the content and the number of lines.
  3. A main that: deletes the file if it exists; writes three lines without append showing the state after each one; and repeats the experiment with append.
  4. A final comment explaining the result in one sentence.

Exercise 2: atomic writing with verification

Extend AtomicWrite with a method writeVerified(File target, Content content, int expectedLines) that, in addition to what it already does:

  1. Counts the lines actually written to the temporary file.
  2. Before renaming, checks that the number matches expectedLines; if not, throws IOException and does not rename.
  3. Also checks that the temporary file has a size greater than zero.
  4. Logs the result of the verification.

Write a main that causes the failure on purpose —a lambda that throws an exception halfway— and checks that the target file keeps its previous content.

Exercise 3: rotating audit log

Write BiblioTechAudit that appends lines to an audit file with rotation by size:

  1. record(String operation, String reference, String employeeId) that adds a line with the format operation;reference;employeeId in append mode.
  2. Before writing, if the file exceeds MAX_SIZE (use 1 KB so you can test it), rename it to audit-1.txt, shifting the previous ones up to a maximum of 3, and start a new one.
  3. Explicit charset and a fixed \n line separator (it is a data file, not a report).
  4. A write failure must not propagate: it is logged and execution continues, following the policy of CatalogExporter.
  5. A main that generates 200 operations and shows the resulting files with their sizes.

Solutions

Solution 1

package com.nexussoftware.bibliotech.demo;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

/**
 * Empirical demonstration of the append parameter.
 */
public class AppendDemo {

    /** Names for the boolean: avoids the loose, unreadable 'true'. */
    private static final boolean APPEND    = true;
    private static final boolean OVERWRITE = false;

    private static final String PATH = "demo-append.txt";

    public static void writeLine(String path, String text, boolean append)
            throws IOException {
        try (FileWriter w = new FileWriter(path, StandardCharsets.UTF_8, append)) {
            w.write(text);
            w.write('\n');
        }   // close() flushes the buffer: without it, the file would be empty
    }

    public static void show(String path) {
        File f = new File(path);
        if (!f.exists()) {
            System.out.println("    (the file does not exist)");
            return;
        }

        System.out.println("    size: " + f.length() + " bytes");
        try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
            int n = 0;
            while (sc.hasNextLine()) {
                System.out.println("      | " + sc.nextLine());
                n++;
            }
            System.out.println("    lines: " + n);
        } catch (IOException e) {
            System.out.println("    error while reading: " + e.getMessage());
        }
    }

    public static void main(String[] args) throws IOException {
        File f = new File(PATH);
        if (f.exists() && !f.delete()) {
            System.err.println("Could not delete the previous file");
            return;
        }

        System.out.println("=== EXPERIMENT 1: WITHOUT append (overwrite) ===");
        for (int i = 1; i <= 3; i++) {
            writeLine(PATH, "Operation number " + i, OVERWRITE);
            System.out.println("  After writing line " + i + ":");
            show(PATH);
        }

        f.delete();

        System.out.println();
        System.out.println("=== EXPERIMENT 2: WITH append ===");
        for (int i = 1; i <= 3; i++) {
            writeLine(PATH, "Operation number " + i, APPEND);
            System.out.println("  After writing line " + i + ":");
            show(PATH);
        }

        // CONCLUSION: without append, every write TRUNCATES the file to zero
        // at the moment of opening it, so only the last line survives.
        // With append, the pointer sits at the end and the content grows.
    }
}

Output:

=== EXPERIMENT 1: WITHOUT append (overwrite) ===
  After writing line 1:
    size: 19 bytes
      | Operation number 1
    lines: 1
  After writing line 2:
    size: 19 bytes
      | Operation number 2
    lines: 1
  After writing line 3:
    size: 19 bytes
      | Operation number 3
    lines: 1

=== EXPERIMENT 2: WITH append ===
  After writing line 1:
    size: 19 bytes
      | Operation number 1
    lines: 1
  After writing line 2:
    size: 38 bytes
      | Operation number 1
      | Operation number 2
    lines: 2
  After writing line 3:
    size: 57 bytes
      | Operation number 1
      | Operation number 2
      | Operation number 3
    lines: 3

Experiment 1 is the bug from section 2 live. Notice that the size stays constant at 19 bytes: the file does not grow because every opening empties it. Nothing fails, nothing warns, and the history does not exist.

Solution 2

package com.nexussoftware.bibliotech.infrastructure;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Atomic writing with verification prior to the rename.
 *
 * It adds an integrity check BEFORE replacing the good file: if the result
 * is not what was expected, the original is kept untouched.
 */
public final class VerifiedAtomicWrite {

    private static final Logger LOG =
            Logger.getLogger(VerifiedAtomicWrite.class.getName());

    private static final String TEMP_SUFFIX = ".tmp";

    private VerifiedAtomicWrite() { }

    @FunctionalInterface
    public interface Content {
        void writeTo(PrintWriter output) throws IOException;
    }

    /**
     * Writes atomically, verifying the result.
     *
     * @param expectedLines number of lines the result MUST have
     * @throws IOException if the write or the verification fails
     */
    public static void writeVerified(File target, Content content,
                                     int expectedLines) throws IOException {

        File temp = new File(target.getAbsolutePath() + TEMP_SUFFIX);

        File parent = target.getAbsoluteFile().getParentFile();
        if (parent != null && !parent.exists() && !parent.mkdirs()) {
            throw new IOException("Could not create the directory " + parent.getAbsolutePath());
        }

        boolean completed = false;
        try {
            // ---- PHASE 1: write to the temporary file ----
            try (PrintWriter output = new PrintWriter(
                    new FileWriter(temp, StandardCharsets.UTF_8))) {

                content.writeTo(output);

                if (output.checkError()) {
                    throw new IOException("Write failure in " + temp.getName());
                }
            }

            // ---- PHASE 2: verify BEFORE touching the target ----
            long size = temp.length();
            if (size == 0) {
                throw new IOException("The temporary file was left empty; not replacing "
                        + target.getName());
            }

            int actualLines = countLines(temp);
            if (actualLines != expectedLines) {
                throw new IOException(String.format(
                        "Verification failed in %s: expected %d lines but found %d. "
                                + "The original file has NOT been modified.",
                        target.getName(), expectedLines, actualLines));
            }

            LOG.fine(() -> String.format("Verification OK: %d lines, %d bytes",
                    actualLines, size));

            // ---- PHASE 3: replace. Only if everything above went well ----
            if (target.exists() && !target.delete()) {
                throw new IOException("Could not remove the target " + target.getName());
            }
            if (!temp.renameTo(target)) {
                throw new IOException("Could not rename " + temp.getName());
            }

            completed = true;
            LOG.info(() -> String.format("Verified write of %s: %d lines, %d bytes",
                    target.getAbsolutePath(), expectedLines, size));

        } finally {
            // COMPENSATION (06-05): no rubbish, and the original untouched
            if (!completed && temp.exists() && !temp.delete()) {
                LOG.warning(() -> "Temporary file left undeleted: " + temp.getAbsolutePath());
            }
        }
    }

    private static int countLines(File f) throws IOException {
        int n = 0;
        try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
            while (sc.hasNextLine()) {
                sc.nextLine();
                n++;
            }
        }
        return n;
    }

    // ------------------------- DEMONSTRATION -------------------------

    public static void main(String[] args) throws IOException {
        File target = new File("data/catalog-verified.txt");

        // 1. Correct write of 3 lines
        writeVerified(target, output -> {
            output.println("BOOK;978-0000000001;Effective Java;Bloch;2018");
            output.println("BOOK;978-0000000002;Design Patterns;Gamma;1994");
            output.println("BOOK;978-0000000003;Refactoring;Fowler;1999");
        }, 3);

        System.out.println("After the correct write: "
                + target.length() + " bytes, "
                + countLines(target) + " lines");

        // 2. A write that FAILS halfway
        try {
            writeVerified(target, output -> {
                output.println("BOOK;978-0000000004;New book;Author;2024");
                // Simulated failure: disk full, serialisation error, whatever
                throw new IOException("Simulated failure halfway through the export");
            }, 4);

        } catch (IOException e) {
            System.out.println("Failure caught: " + e.getMessage());
        }

        // 3. THE KEY CHECK: the good file is still there, complete
        System.out.println("After the failure:       "
                + target.length() + " bytes, "
                + countLines(target) + " lines");

        System.out.println("Any .tmp rubbish left? "
                + new File(target.getAbsolutePath() + ".tmp").exists());

        // 4. Write with an incorrect number of lines: rejected
        try {
            writeVerified(target, output -> output.println("Only one line"), 5);
        } catch (IOException e) {
            System.out.println("Verification: " + e.getMessage());
        }

        System.out.println("After the failed verification: "
                + countLines(target) + " lines (untouched)");
    }
}

Output:

After the correct write: 137 bytes, 3 lines
Failure caught: Simulated failure halfway through the export
After the failure:       137 bytes, 3 lines
Any .tmp rubbish left? false
Verification: Verification failed in catalog-verified.txt: expected 5 lines but found 1. The original file has NOT been modified.
After the failed verification: 3 lines (untouched)

The three lines that matter in that output are the third, the fourth and the last: after a failure halfway through a write and after a failed verification, the good file still has its 3 lines and no temporary rubbish has been left. That is exactly what the pattern contributes, and it is impossible to achieve by writing directly over the target.

Notice too that the verification goes between the write and the rename. That gap is what makes the pattern so valuable: you can check whatever you like —number of lines, header, format, even reread and parse it entirely— before committing to replace the original.

Solution 3

package com.nexussoftware.bibliotech.infrastructure;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * BiblioTech audit log with rotation by size.
 *
 * APPEND mode: the file grows, it is not regenerated. That is why it does NOT
 * use atomic writing: there is nothing to replace.
 *
 * Fixed '\n' line separator: it is a DATA file that can be compared between
 * machines, not a report to be read on screen (section 8).
 */
public class BiblioTechAudit {

    private static final Logger LOG = Logger.getLogger(BiblioTechAudit.class.getName());

    private static final boolean APPEND  = true;
    private static final String  NEWLINE = "\n";
    private static final String  SEP     = ";";

    /** 1 KB, deliberately small so the rotation can be tested. */
    private static final long MAX_SIZE = 1024L;

    /** Archived files that are kept: audit-1 .. audit-3. */
    private static final int MAX_ARCHIVES = 3;

    private final File directory;
    private final String baseName;

    private int operationsRecorded = 0;
    private int rotations = 0;
    private int failures = 0;

    public BiblioTechAudit(String directory) {
        this.directory = new File(
                Objects.requireNonNull(directory, "The directory cannot be null"));
        this.baseName = "audit";

        if (!this.directory.exists() && !this.directory.mkdirs()) {
            LOG.warning("Could not create " + this.directory.getAbsolutePath());
        }
    }

    /**
     * Records an operation.
     *
     * POLICY: an audit failure NEVER propagates. The business operation is
     * already valid; losing its trace is an acceptable degradation and it
     * is reported in the log (06-07).
     */
    public void record(String operation, String reference, String employeeId) {
        Objects.requireNonNull(operation, "The operation cannot be null");

        File current = new File(directory, baseName + ".txt");

        try {
            // 1. Rotate BEFORE writing, if it is due
            if (current.exists() && current.length() >= MAX_SIZE) {
                rotate(current);
            }

            // 2. Append the line. The 'true' is what makes this
            //    a history and not a one-line file.
            try (PrintWriter output = new PrintWriter(
                    new FileWriter(current, StandardCharsets.UTF_8, APPEND))) {

                output.write(String.join(SEP, operation, reference, employeeId));
                output.write(NEWLINE);

                if (output.checkError()) {
                    throw new IOException("Failed writing to " + current.getAbsolutePath());
                }
            }
            operationsRecorded++;

        } catch (IOException e) {
            failures++;
            LOG.log(Level.WARNING, "Could not audit " + operation
                    + " of " + reference, e);
            // No rethrow: the degradation is deliberate
        }
    }

    /**
     * Shifts the archives: -2 becomes -3, -1 becomes -2, the current one becomes -1.
     *
     * It is traversed from HIGHEST to LOWEST. The other way round, the first
     * rename would crush the next one before it had been shifted.
     */
    private void rotate(File current) throws IOException {
        // The oldest one is lost
        File oldest = archive(MAX_ARCHIVES);
        if (oldest.exists() && !oldest.delete()) {
            throw new IOException("Could not delete " + oldest.getName());
        }

        for (int i = MAX_ARCHIVES - 1; i >= 1; i--) {
            File source = archive(i);
            File target = archive(i + 1);
            if (source.exists() && !source.renameTo(target)) {
                throw new IOException("Could not rotate " + source.getName());
            }
        }

        if (!current.renameTo(archive(1))) {
            throw new IOException("Could not rotate " + current.getName());
        }

        rotations++;
        LOG.fine(() -> "Audit rotated (rotation number " + rotations + ")");
    }

    private File archive(int n) {
        return new File(directory, baseName + "-" + n + ".txt");
    }

    public int getOperationsRecorded() { return operationsRecorded; }
    public int getRotations()          { return rotations; }
    public int getFailures()           { return failures; }

    // ------------------------- DEMONSTRATION -------------------------

    public static void main(String[] args) {
        BiblioTechAudit audit = new BiblioTechAudit("data/audit");

        String[] operations = { "LOAN", "RETURN", "ADD", "REMOVE" };
        String[] employees  = { "EMP-001", "EMP-002", "EMP-003" };

        for (int i = 1; i <= 200; i++) {
            audit.record(
                    operations[i % operations.length],
                    String.format("LN-%04d", i),
                    employees[i % employees.length]);
        }

        System.out.println("=== AUDIT ===");
        System.out.printf("  Operations recorded: %d%n",
                audit.getOperationsRecorded());
        System.out.printf("  Rotations performed: %d%n", audit.getRotations());
        System.out.printf("  Failures           : %d%n", audit.getFailures());

        System.out.println("  --- Files ---");
        File dir = new File("data/audit");
        File[] files = dir.listFiles();         // it may be null: it is checked
        if (files != null) {
            java.util.Arrays.sort(files);       // 05-09
            for (File f : files) {
                System.out.printf("    %-20s %6d bytes%n", f.getName(), f.length());
            }
        }
    }
}

Output:

=== AUDIT ===
  Operations recorded: 200
  Rotations performed: 4
  Failures           : 0
  --- Files ---
    audit-1.txt            1044 bytes
    audit-2.txt            1044 bytes
    audit-3.txt            1044 bytes
    audit.txt               174 bytes

The four teaching points:

  1. The rotation loop goes from highest to lowest. It is the detail people get wrong most often. If you rotated from 1 to 3, the first renameTo would crush audit-2.txt before it had been shifted to -3, and you would lose the whole history except the last one. Draw the three arrows in your head before writing it.
  2. Rotation happens before writing, not afterwards. That way the file never appreciably exceeds the limit, and you do not have to worry about the size of the line about to go in.
  3. The failure does not propagate and is counted. The failures counter lets you detect in the report that the audit is degraded, even though the application keeps working. Degrading without leaving a trace would be worse than failing.
  4. listFiles() can return null. The check is not paranoia: it returns null if the directory does not exist or if access fails. It is the mute boolean of section 6 of 07-01 in another form, and in 07-06 Files.list() solves it.

And notice that this mechanism is exactly what the FileHandler of java.util.logging you configured in 06-07 does internally, with its bibliotech-%g.log, its 5 files and its 1 MB limit. Now you know how it is implemented.

Conclusion

BiblioTech now saves.

You know FileWriter and its behaviour on opening: it creates the file if it does not exist and —this is what to remember above everything else— it truncates it to zero bytes if it does exist, at the instant of construction, before writing anything. You have mastered the append parameter and you know that forgetting it turns a three-hundred-thousand-line history into a one-line file, with no exception, no warning and in a perfectly stable way. You have the three defences: naming the boolean with a constant, using the explicit NIO.2 options you will see in 07-06, and always writing to a temporary file when you regenerate a whole file.

You know how to write with write in its five overloads, with the asymmetry of write(int) which writes a character and not a number, and you know PrintWriter as a wrapper contributing println, printf and format, reusing the formatting of module 1 —with Locale.ROOT when a program is going to read the file and the system Locale when a person is. And you know its greatest trap: PrintWriter does not throw IOException, it stores it in a flag, so a full disk goes unnoticed if you do not call checkError().

You understand the buffer and the complete chain of flushes: write reaches the application buffer, flush reaches the operating system, close does a flush and releases, and only sync reaches the disk platter. You know why a just-written file has 0 bytes, why tail -f shows nothing for a while, and exactly what happens if the program ends without closing: the file is left empty, because the garbage collector closes nothing and finalize() has been removed. That is why try-with-resources matters more when writing than when reading: there, not closing is a leak; here it is data loss.

You know that an explicit charset is even more critical when writing, because the mistake is recorded and only appears when another tool reads the file; and that writing an unrepresentable character —an é in US-ASCII— replaces it with ? without saying anything. You know System.lineSeparator(), the three end-of-line conventions, and the criterion for choosing: the system one for reports, a fixed \n for data files that are compared or versioned. And you know that the charset and the separator must be declared only once in a constant shared by the reader and the writer.

And you have the pattern that separates amateur code from professional code: atomic writing. Write to a temporary file, verify, and rename only at the end. You know why it works —the rename is atomic for whoever reads, so a half-finished file is never seen—, you know that the finally compensates by deleting the temporary file exactly as you learned in 06-05, and you know that the target needs no compensation because it was never opened. And you know when to apply it: when the file is regenerated; never when the file grows, which is the append mode case. The whole lesson fits into that question: is this file regenerated or does it grow?

You also know lock files with createNewFile() as an atomic check-and-create operation, their honest limitations —orphaned locks, the FileLock alternative—, and the rotating backups that 07-06 will do properly with NIO.2.

BiblioTech, at the close of this lesson, loads its catalogue on start-up and saves it on exit. CatalogExporter really writes, atomically, shares the FileFormat constants with CatalogLoader so that they cannot disagree, and has stopped being Closeable because it no longer owns any live resource —an object with nothing to close should not promise that it closes. Its audit log is opened in append mode because it grows, and its failures do not bring down the business operation but degrade with a warning in the log. And a shutdown hook guarantees the save on normal exit, with the explicit warning that a kill -9 skips it and that the serious strategy is to save during execution too.

There remains an underlying question the two lessons have dodged. You have used FileReader, FileWriter, Scanner and PrintWriter as if they were loose pieces, and they are not: they are part of a design with a very deliberate structure. Why does PrintWriter wrap FileWriter instead of replacing it? Why are there two different families of classes, some ending in Reader/Writer and others in InputStream/OutputStream? And how do you read an image, which is not text?

In lesson 07-03, File Streams, that is answered. You will see the conceptual model of the stream with its vocabulary of source, destination and direction; the two hierarchies —bytes and characters— and why there had to be two; the distinction between node classes and filter classes, which is the Decorator pattern in its purest form and explains once and for all those new BufferedInputStream(new FileInputStream(...)) that appear everywhere; the bridges InputStreamReader and OutputStreamWriter, which are exactly the point where the encoding you have been specifying for two lessons is decided; reading and writing binary data by bytes and by blocks, with the copying of a book's cover image; and transferTo, the modern way of copying a whole stream in one line. By the end of it, you will stop using these classes from memory and start composing them knowing exactly what each layer does.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved