> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use WatchService in Java to Watch a Directory
- URL: https://www.ggorantala.dev/how-to-use-watchservice-java/
- Published: 2026-09-15T05:27:00.000Z
- Updated: 2026-10-31T06:30:57.000Z
- Description: Build a folder watcher in Java 21 with WatchService. It copes with half-written files, duplicate events and overflow, and ships with 23 passing checks.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-io, java-tutorials

## Here's the inbox folder doing its job

A library's returns desk has a scanner that drops one small file per returned book into a folder. Nobody presses a button. A Java process notices each file, reads it, updates the catalogue and files the slip away. Here's the console from the finished program, run against five files dropped in one second:

```log
watching /home/claude/returns/work/inbox
[watcher] processed slip-001.slip
[watcher] processed slip-002.slip
[watcher] processed slip-003.slip
[watcher] rejected slip-004.slip: isbn must be 13 digits: 123
catalogue: returned={9780134685991=2, 9780596009205=1}, damaged=1
inbox now:     [notes.txt]
processed now: [slip-001.slip, slip-002.slip, slip-003.slip]
rejected now:  [slip-004.slip, slip-004.slip.reason]

```

That's how to use WatchService in Java for a job people actually have: react to files appearing in a folder instead of polling it every few seconds. You'll build it with the JDK alone, about 350 lines including a small catalogue. The part most tutorials leave out is what the watcher does with a file that's still being written, with a burst of 300 files, and with events it can't trust. Those are the parts that break on a real folder, so they're in here.

`slip-003.slip` in that output was written slowly, in three chunks, straight to its final name. The watcher got an event for it before the member line existed. Step 4 shows why it still came out right.

```mermaid
flowchart LR
    S[Scanner writes<br/>x.slip.tmp] -->|rename| I[inbox/<br/>x.slip]
    I -->|ENTRY_CREATE<br/>ENTRY_MODIFY| W[InboxWatcher thread]
    W -->|wait until size is stable| P[parse the slip]
    P -->|ok| D[processed/]
    P -->|bad| R[rejected/ + .reason]
    P -->|ok| C[Catalogue]

```

## Versions I used

| Tool                     | Version I used                          | Check it with |
| ------------------------ | --------------------------------------- | ------------- |
| JDK                      | OpenJDK 21.0.12.1 (Ubuntu build)        | java -version |
| Operating system         | Linux, 6.18 kernel (inotify underneath) | uname -r      |
| IntelliJ IDEA or Eclipse | optional, any current release           | Help → About  |
| Build tool               | none; plain javac                       |               |

Java 16 or newer compiles all of it (the slip is a `record`); nothing here needs 21 specifically. No Maven, no Gradle, no containers. Everything is in `java.nio.file`.

I ran every command and every output below on Linux. I did not run any of it on macOS or Windows, and that matters for this particular API, because the JDK picks a different `WatchService` implementation per operating system. The "How it works" section says what I do know about that.

You should be comfortable with `Path` and `Files` (see [how to read and write files with NIO.2](https://ggorantala.dev/how-to-read-write-files-nio-java/?ref=ggorantala.dev)) and with records (see [how to use Java records](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev)).

## Set up the library-returns project

I used the terminal as the primary path this time, because the watcher has a working-directory quirk (the demo makes a `work/` folder relative to where you start it) that's easiest to see on the command line.

### From the command line

```bash
mkdir -p returns/src/main/java/com/example/returns returns/src/test/java/com/example/returns
cd returns

```

The project tree once all the files exist:

```log
returns/
├── src/
│   ├── main/java/com/example/returns/
│   │   ├── Catalogue.java
│   │   ├── Demo.java
│   │   ├── InboxWatcher.java
│   │   ├── PrintEvents.java
│   │   └── ReturnSlip.java
│   └── test/java/com/example/returns/
│       └── Checks.java
└── work/            (created by the programs when they run)
    ├── inbox/
    ├── processed/
    └── rejected/

```

You'll compile with `javac --release 21 -d out src/main/java/com/example/returns/*.java` from the `returns` folder. There's nothing to download.

### In IntelliJ IDEA

Verified from the documentation, not executed: **File → New → Project**, choose **Java**, set the build system to **IntelliJ**, pick a JDK 21 (the dialog can download one), and name it `returns`. Create the package `com.example.returns` under `src`. Put `Checks.java` in a second source folder: right-click the project, **New → Directory**, name it `test`, then right-click it and choose **Mark Directory as → Test Sources Root**. For `Demo` and `PrintEvents`, open **Run → Edit Configurations**, and set **Working directory** to the project root so `work/` lands where you expect.

### In Eclipse

Verified from the documentation, not executed: **File → New → Java Project**, uncheck **Use default location** only if you want a folder of your own, and choose a JavaSE-21 execution environment. Create the package with **File → New → Package**. For `Checks`, add a second source folder with **right-click project → New → Source Folder**. In **Run → Run Configurations**, the **Arguments** tab has a **Working directory** field; leave it on the project.

### With Docker or Podman

This project has no container. One caution I haven't tested: if you later run the watcher in a container and the inbox is a folder mounted from your computer, check that events arrive at all. The Javadoc says that changes on non-local storage may not be detected.

## Watch a folder with WatchService in Java, six steps

### Step 1: Model one slip

**Goal:** a type for what's inside a `.slip` file, and a parser that explains what's wrong with a bad one.

`src/main/java/com/example/returns/ReturnSlip.java`

```java
package com.example.returns;

import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public record ReturnSlip(String isbn, String member, Condition condition) {

    public enum Condition { GOOD, DAMAGED }

    /** Parses the lines of a .slip file. Throws IllegalArgumentException with a readable reason. */
    public static ReturnSlip parse(List<String> lines) {
        Map<String, String> values = new HashMap<>();
        for (String line : lines) {
            String text = line.strip();
            if (text.isEmpty() || text.startsWith("#")) {
                continue;
            }
            int eq = text.indexOf('=');
            if (eq < 1) {
                throw new IllegalArgumentException("not a key=value line: " + text);
            }
            values.put(text.substring(0, eq).strip().toLowerCase(Locale.ROOT),
                       text.substring(eq + 1).strip());
        }
        String isbn = require(values, "isbn");
        if (!isbn.matches("\\d{13}")) {
            throw new IllegalArgumentException("isbn must be 13 digits: " + isbn);
        }
        String member = require(values, "member");
        String raw = values.getOrDefault("condition", "good").toUpperCase(Locale.ROOT);
        Condition condition;
        try {
            condition = Condition.valueOf(raw);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("condition must be good or damaged: " + raw);
        }
        return new ReturnSlip(isbn, member, condition);
    }

    private static String require(Map<String, String> values, String key) {
        String value = values.get(key);
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("missing " + key);
        }
        return value;
    }
}

```

A slip is three `key=value` lines: `isbn`, `member`, and an optional `condition`. The parser throws `IllegalArgumentException` with a message a librarian could read, because that message ends up in the `.reason` file next to a rejected slip. Keys are case-insensitive, blank lines and `#` comments are skipped.

**Checkpoint:** nothing to run yet. You'll compile everything at Step 5.

### Step 2: Give the slips somewhere to land

**Goal:** a catalogue that counts returns, safe to call from the watcher thread while another thread reads it.

`src/main/java/com/example/returns/Catalogue.java`

```java
package com.example.returns;

import java.util.Map;
import java.util.TreeMap;

/** The part of the library system that cares about returned books. In memory, thread-safe. */
public final class Catalogue {

    private final Map<String, Integer> returned = new TreeMap<>();
    private int damaged;

    public synchronized void recordReturn(ReturnSlip slip) {
        returned.merge(slip.isbn(), 1, Integer::sum);
        if (slip.condition() == ReturnSlip.Condition.DAMAGED) {
            damaged++;
        }
    }

    public synchronized int returnedCount(String isbn) {
        return returned.getOrDefault(isbn, 0);
    }

    public synchronized int totalReturned() {
        return returned.values().stream().mapToInt(Integer::intValue).sum();
    }

    public synchronized int damagedCount() {
        return damaged;
    }

    @Override
    public synchronized String toString() {
        return "returned=" + returned + ", damaged=" + damaged;
    }
}

```

Every method is `synchronized` because the watcher thread writes and your main thread will read. For this size, that's the simplest correct thing.

### Step 3: See the raw events first

**Goal:** watch the folder with the bare API, so you know what the operating system actually sends before you build on it.

`src/main/java/com/example/returns/PrintEvents.java`

```java
package com.example.returns;

import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;

import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;

public class PrintEvents {

    public static void main(String[] args) throws Exception {
        Path inbox = Files.createDirectories(Path.of("work", "inbox"));
        try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
            inbox.register(watcher, ENTRY_CREATE, ENTRY_MODIFY);
            System.out.println("watching " + inbox.toAbsolutePath() + " (Ctrl+C to stop)");
            while (true) {
                WatchKey key = watcher.take();
                for (WatchEvent<?> event : key.pollEvents()) {
                    System.out.println(event.kind() + " " + event.context() + " (count " + event.count() + ")");
                }
                if (!key.reset()) {
                    break;
                }
            }
        }
    }
}

```

Five calls matter: `newWatchService()`, `register(...)` on a directory `Path`, `take()` (blocks), `pollEvents()`, and `reset()`. Compile and run it:

```bash
mkdir -p out
javac --release 21 -d out src/main/java/com/example/returns/PrintEvents.java
java -cp out com.example.returns.PrintEvents

```

In a second terminal, from the `returns` folder, create a slip, append to it, and drop a text file:

```bash
cd work/inbox
printf 'isbn=9780134685991\nmember=M-1\n' > one.slip
echo "more" >> one.slip
touch ignore.txt

```

**Checkpoint:** the first terminal prints this (timing can change how events are grouped, but the kinds are these):

```log
watching /home/claude/returns/work/inbox (Ctrl+C to stop)
ENTRY_CREATE one.slip (count 1)
ENTRY_MODIFY one.slip (count 1)
ENTRY_MODIFY one.slip (count 1)
ENTRY_CREATE ignore.txt (count 1)
ENTRY_MODIFY ignore.txt (count 1)

```

Look at that carefully. One `printf` produced a create and a modify. `touch` on a new file produced a create and a modify too, for a file with nothing in it. The `echo` produced one more modify. An event says "something happened to this name", never "this file is finished". Everything in Step 4 follows from that.

Stop it with Ctrl+C.

### Step 4: Write the watcher that survives real files

**Goal:** turn raw events into "process each finished slip exactly once".

`src/main/java/com/example/returns/InboxWatcher.java`

```java
package com.example.returns;

import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import static java.nio.file.StandardWatchEventKinds.OVERFLOW;

import java.io.IOException;
import java.nio.charset.CharacterCodingException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;

public final class InboxWatcher implements AutoCloseable {

    private final Path inbox;
    private final Path processed;
    private final Path rejected;
    private final Consumer<ReturnSlip> sink;
    private final Consumer<String> log;
    private final Duration settle;
    private final Duration maxWait;
    private final WatchService watcher;
    private final Thread thread;

    public InboxWatcher(Path inbox, Path processed, Path rejected,
                        Duration settle, Consumer<ReturnSlip> sink, Consumer<String> log)
            throws IOException {
        this.inbox = inbox;
        this.processed = processed;
        this.rejected = rejected;
        this.settle = settle;
        this.maxWait = Duration.ofSeconds(10);
        this.sink = sink;
        this.log = log;
        Files.createDirectories(processed);
        Files.createDirectories(rejected);
        this.watcher = inbox.getFileSystem().newWatchService();
        inbox.register(watcher, ENTRY_CREATE, ENTRY_MODIFY);   // throws if inbox is not a directory
        this.thread = new Thread(this::run, "inbox-watcher");
    }

    public void start() {
        thread.start();
    }

    private void run() {
        try {
            scanExisting();                       // files that arrived before we registered
            while (true) {
                WatchKey key = watcher.take();    // blocks until something happens
                Set<Path> pending = new LinkedHashSet<>();
                boolean overflow = false;
                for (WatchEvent<?> event : key.pollEvents()) {
                    if (event.kind() == OVERFLOW) {
                        overflow = true;
                        continue;
                    }
                    Path name = (Path) event.context();       // relative: just the file name
                    if (isSlip(name)) {
                        pending.add(inbox.resolve(name));      // a Set folds CREATE + MODIFY into one
                    }
                }
                boolean stillValid = key.reset();              // without this, no more events. Ever.
                if (overflow) {
                    log.accept("overflow: events were lost, rescanning " + inbox);
                    scanExisting();
                }
                for (Path file : pending) {
                    handle(file);
                }
                if (!stillValid) {
                    log.accept("inbox is no longer accessible, stopping");
                    return;
                }
            }
        } catch (ClosedWatchServiceException e) {
            // close() was called: the normal way out
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (IOException e) {
            log.accept("watcher failed: " + e);
        }
    }

    private void scanExisting() throws IOException {
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(inbox, "*.slip")) {
            List<Path> files = new java.util.ArrayList<>();
            stream.forEach(files::add);
            files.sort(null);
            for (Path file : files) {
                handle(file);
            }
        }
    }

    private static boolean isSlip(Path name) {
        return name.toString().endsWith(".slip");
    }

    private void handle(Path file) {
        if (!Files.isRegularFile(file)) {
            return;                               // an earlier event already moved it
        }
        try {
            awaitStable(file);
            ReturnSlip slip = ReturnSlip.parse(Files.readAllLines(file));
            move(file, processed);                // move first, so a failed move never double-counts
            sink.accept(slip);
            log.accept("processed " + file.getFileName());
        } catch (NoSuchFileException e) {
            // moved or deleted while we waited
        } catch (IllegalArgumentException | CharacterCodingException e) {
            reject(file, e.getMessage());
        } catch (IOException e) {
            log.accept("could not handle " + file.getFileName() + ": " + e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /** Waits until the file size stops changing. A heuristic. The reliable fix is for writers to rename into place. */
    private void awaitStable(Path file) throws IOException, InterruptedException {
        long deadline = System.nanoTime() + maxWait.toNanos();
        long last = -1;
        while (System.nanoTime() < deadline) {
            long size = Files.size(file);
            if (size == last) {
                return;
            }
            last = size;
            Thread.sleep(settle.toMillis());
        }
        throw new IOException("still changing after " + maxWait);
    }

    private void reject(Path file, String reason) {
        try {
            Files.writeString(rejected.resolve(file.getFileName() + ".reason"), reason + System.lineSeparator());
            move(file, rejected);
            log.accept("rejected " + file.getFileName() + ": " + reason);
        } catch (IOException e) {
            log.accept("could not reject " + file.getFileName() + ": " + e);
        }
    }

    private static void move(Path file, Path targetDir) throws IOException {
        Files.move(file, targetDir.resolve(file.getFileName()), REPLACE_EXISTING);
    }

    @Override
    public void close() throws IOException {
        watcher.close();                           // wakes take() with ClosedWatchServiceException
        try {
            thread.join(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

```

Read it in this order, because the decisions are small but each one is there for a reason.

**Register in the constructor, scan in `run()`.** `inbox.register(...)` happens first, so any file arriving afterwards produces an event. `scanExisting()` then picks up whatever was already sitting there. A file that lands in the gap between the two gets handled twice; that's fine, because the second `handle` finds the file already moved and returns.

**A `Set` per batch.** A single file produced `ENTRY_CREATE` plus one or more `ENTRY_MODIFY`. Collecting paths in a `LinkedHashSet` turns that into one unit of work per file per batch.

**`key.reset()` before the slow work.** I call it right after reading the events and before parsing anything. That puts the key back in the queue, so events that arrive while I'm busy aren't lost. If you forget `reset()` altogether, the watcher goes deaf after the first batch (I forced that in the "When it broke" section).

**`OVERFLOW` means rescan.** If the operating system's event buffer fills, the JDK delivers one `OVERFLOW` event and the individual events are gone. The only safe move is to look at the directory again, which `scanExisting()` does.

**`awaitStable` is the answer to the slow scanner.** It reads the file size, sleeps for the `settle` time, and reads it again. When two reads match, the file has stopped growing. That handled `slip-003.slip` in the demo. It's a heuristic, and I'll say exactly where it fails in the troubleshooting section.

**Move before you count.** In `handle`, the order is parse, then move to `processed/`, then call the sink. If the move fails, nothing was counted and the next event retries. The other order double-counts on a failed move.

**Moving files out of the inbox is the bookkeeping.** The inbox only ever holds slips that haven't been dealt with. The watcher registers `ENTRY_CREATE` and `ENTRY_MODIFY` only, so the moves out don't generate events for it.

**Checkpoint:** compile the whole main source set:

```bash
javac --release 21 -d out src/main/java/com/example/returns/*.java

```

No output means it compiled.

### Step 5: Run it against a messy morning

**Goal:** one program that behaves like a scanner: clean drops, a slow write, a bad slip and a stray file.

`src/main/java/com/example/returns/Demo.java`

```java
package com.example.returns;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Comparator;
import java.util.stream.Stream;

public class Demo {

    public static void main(String[] args) throws Exception {
        Path root = Path.of("work");
        if (Files.exists(root)) {
            try (Stream<Path> walk = Files.walk(root)) {
                walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
            }
        }
        Path inbox = Files.createDirectories(root.resolve("inbox"));
        Catalogue catalogue = new Catalogue();

        try (InboxWatcher watcher = new InboxWatcher(inbox, root.resolve("processed"), root.resolve("rejected"),
                Duration.ofMillis(250), catalogue::recordReturn, msg -> System.out.println("[watcher] " + msg))) {
            watcher.start();
            System.out.println("watching " + inbox.toAbsolutePath());

            // 1. The safe way: write to a temp name, then rename into place.
            dropAtomically(inbox, "slip-001.slip", "isbn=9780134685991\nmember=M-1042\ncondition=good\n");
            dropAtomically(inbox, "slip-002.slip", "isbn=9780596009205\nmember=M-0007\ncondition=damaged\n");

            // 2. A scanner that writes straight to the final name, slowly, in three chunks.
            Path slow = inbox.resolve("slip-003.slip");
            try (var out = Files.newBufferedWriter(slow)) {
                out.write("isbn=9780134685991\n");
                out.flush();
                Thread.sleep(100);
                out.write("member=M-2210\n");
                out.flush();
                Thread.sleep(100);
                out.write("condition=good\n");
            }

            // 3. A bad slip and a file that isn't a slip at all.
            dropAtomically(inbox, "slip-004.slip", "isbn=123\nmember=M-9\n");
            Files.writeString(inbox.resolve("notes.txt"), "front desk reminders\n");

            Thread.sleep(1500);
            System.out.println("catalogue: " + catalogue);
        }
        System.out.println("inbox now:     " + names(inbox));
        System.out.println("processed now: " + names(root.resolve("processed")));
        System.out.println("rejected now:  " + names(root.resolve("rejected")));
    }

    static void dropAtomically(Path dir, String name, String content) throws IOException {
        Path tmp = dir.resolve(name + ".tmp");
        Files.writeString(tmp, content);
        Files.move(tmp, dir.resolve(name), StandardCopyOption.ATOMIC_MOVE);
    }

    static java.util.List<String> names(Path dir) throws IOException {
        try (Stream<Path> s = Files.list(dir)) {
            return s.map(p -> p.getFileName().toString()).sorted().toList();
        }
    }
}

```

`dropAtomically` is the writer-side half of the contract: write to `name.tmp`, then rename to `name`. The watcher ignores anything that doesn't end in `.slip`, so it never sees the half-written file. `ATOMIC_MOVE` means the final name appears complete or not at all.

## Drop files in and watch it react

```bash
java -cp out com.example.returns.Demo

```

```log
watching /home/claude/returns/work/inbox
[watcher] processed slip-001.slip
[watcher] processed slip-002.slip
[watcher] processed slip-003.slip
[watcher] rejected slip-004.slip: isbn must be 13 digits: 123
catalogue: returned={9780134685991=2, 9780596009205=1}, damaged=1
inbox now:     [notes.txt]
processed now: [slip-001.slip, slip-002.slip, slip-003.slip]
rejected now:  [slip-004.slip, slip-004.slip.reason]

```

The whole run took 1.8 seconds, nearly all of it the program's own `Thread.sleep(1500)`. Two copies of `9780134685991` came in (slips 001 and 003), one damaged copy of `9780596009205`, `notes.txt` was ignored, and the bad slip sits in `rejected/` with its reason beside it. If you see that, the hard part's done.

If the `watching` line shows a different folder than you expect, you started the program from a different working directory. `work/` is relative.

## Twenty-three checks, including a 300-file burst

I couldn't download JUnit in my environment, so the checks are a plain `main` with a `check(name, boolean)` helper. Moving to JUnit 5 later is mechanical: each `check(...)` becomes one `@Test` with an `assertThat`, and `waitFor` becomes Awaitility's `await().atMost(...)`.

`src/test/java/com/example/returns/Checks.java`

```java
package com.example.returns;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.BooleanSupplier;
import java.util.stream.Stream;

public class Checks {

    static int passed;
    static int failed;

    public static void main(String[] args) throws Exception {
        slipParsing();
        goodSlipIsProcessed();
        badSlipIsRejectedWithReason();
        tmpFilesAndOtherFilesAreIgnored();
        existingFilesAreHandledAtStartup();
        slowWriterIsNotParsedHalfWritten();
        burstOfSlipsAllArrive();
        sameSlipTwiceCountsTwice();
        closeStopsTheThread();
        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }

    // ---- ReturnSlip.parse ----
    static void slipParsing() {
        ReturnSlip s = ReturnSlip.parse(List.of("# comment", "ISBN = 9780134685991", "member=M-1", ""));
        check("parse: keys are case-insensitive, comments skipped, condition defaults to good",
              s.equals(new ReturnSlip("9780134685991", "M-1", ReturnSlip.Condition.GOOD)));
        check("parse: short isbn rejected", fails(List.of("isbn=123", "member=M-1"), "13 digits"));
        check("parse: missing member rejected", fails(List.of("isbn=9780134685991"), "missing member"));
        check("parse: bad condition rejected", fails(List.of("isbn=9780134685991", "member=M-1", "condition=torn"), "good or damaged"));
        check("parse: line without = rejected", fails(List.of("isbn 9780134685991"), "key=value"));
    }

    static boolean fails(List<String> lines, String messagePart) {
        try {
            ReturnSlip.parse(lines);
            return false;
        } catch (IllegalArgumentException e) {
            return e.getMessage().contains(messagePart);
        }
    }

    // ---- watcher behaviour ----
    static void goodSlipIsProcessed() throws Exception {
        try (Fixture f = new Fixture(20)) {
            f.dropAtomically("a.slip", "isbn=9780134685991\nmember=M-1\n");
            check("good slip: reaches the catalogue", waitFor(() -> f.catalogue.totalReturned() == 1));
            check("good slip: moved to processed", waitFor(() -> f.exists(f.processed, "a.slip")));
            check("good slip: gone from inbox", !f.exists(f.inbox, "a.slip"));
        }
    }

    static void badSlipIsRejectedWithReason() throws Exception {
        try (Fixture f = new Fixture(20)) {
            f.dropAtomically("bad.slip", "isbn=abc\nmember=M-1\n");
            check("bad slip: moved to rejected", waitFor(() -> f.exists(f.rejected, "bad.slip")));
            check("bad slip: reason file written", waitFor(() -> f.exists(f.rejected, "bad.slip.reason")));
            check("bad slip: reason names the problem", Files.readString(f.rejected.resolve("bad.slip.reason")).contains("13 digits"));
            check("bad slip: catalogue untouched", f.catalogue.totalReturned() == 0);
        }
    }

    static void tmpFilesAndOtherFilesAreIgnored() throws Exception {
        try (Fixture f = new Fixture(20)) {
            Files.writeString(f.inbox.resolve("x.slip.tmp"), "isbn=9780134685991\nmember=M-1\n");
            Files.writeString(f.inbox.resolve("notes.txt"), "hello");
            Thread.sleep(300);
            check("ignored: .tmp and .txt stay put, nothing processed",
                  f.exists(f.inbox, "x.slip.tmp") && f.exists(f.inbox, "notes.txt") && f.catalogue.totalReturned() == 0);
            Files.move(f.inbox.resolve("x.slip.tmp"), f.inbox.resolve("x.slip"), StandardCopyOption.ATOMIC_MOVE);
            check("ignored: renaming .tmp to .slip makes it count", waitFor(() -> f.catalogue.totalReturned() == 1));
        }
    }

    static void existingFilesAreHandledAtStartup() throws Exception {
        Path root = Files.createTempDirectory("returns-checks");
        Path inbox = Files.createDirectories(root.resolve("inbox"));
        Files.writeString(inbox.resolve("early1.slip"), "isbn=9780134685991\nmember=M-1\n");
        Files.writeString(inbox.resolve("early2.slip"), "isbn=9780134685991\nmember=M-2\n");
        Catalogue c = new Catalogue();
        try (InboxWatcher w = new InboxWatcher(inbox, root.resolve("p"), root.resolve("r"),
                Duration.ofMillis(20), c::recordReturn, m -> { })) {
            w.start();
            check("startup: slips already in the inbox are processed", waitFor(() -> c.totalReturned() == 2));
        }
        deleteTree(root);
    }

    static void slowWriterIsNotParsedHalfWritten() throws Exception {
        // settle = 250 ms, writer pauses 100 ms between chunks: the watcher should wait for the end.
        try (Fixture f = new Fixture(250)) {
            Path slow = f.inbox.resolve("slow.slip");
            try (var out = Files.newBufferedWriter(slow)) {
                out.write("isbn=9780134685991\n"); out.flush(); Thread.sleep(100);
                out.write("member=M-77\n"); out.flush(); Thread.sleep(100);
                out.write("condition=damaged\n");
            }
            check("slow writer: processed whole, member intact", waitFor(() -> f.catalogue.damagedCount() == 1));
            check("slow writer: nothing rejected", f.count(f.rejected) == 0);
        }
    }

    static void burstOfSlipsAllArrive() throws Exception {
        try (Fixture f = new Fixture(5)) {
            for (int i = 0; i < 300; i++) {
                f.dropAtomically("burst-" + i + ".slip", "isbn=9780134685991\nmember=M-" + i + "\n");
            }
            check("burst: all 300 slips counted exactly once", waitFor(() -> f.catalogue.totalReturned() == 300, 20_000));
            Thread.sleep(300);
            check("burst: still exactly 300 after things settle", f.catalogue.totalReturned() == 300);
            check("burst: inbox empty, 300 files in processed", f.count(f.inbox) == 0 && f.count(f.processed) == 300);
        }
    }

    static void sameSlipTwiceCountsTwice() throws Exception {
        try (Fixture f = new Fixture(20)) {
            f.dropAtomically("one.slip", "isbn=9780134685991\nmember=M-1\n");
            check("repeat: first copy counted", waitFor(() -> f.catalogue.totalReturned() == 1));
            f.dropAtomically("one.slip", "isbn=9780134685991\nmember=M-1\n");
            check("repeat: same file name dropped again is a new return", waitFor(() -> f.catalogue.totalReturned() == 2));
        }
    }

    static void closeStopsTheThread() throws Exception {
        Fixture f = new Fixture(20);
        long start = System.nanoTime();
        f.close();
        long ms = (System.nanoTime() - start) / 1_000_000;
        boolean alive = Thread.getAllStackTraces().keySet().stream().anyMatch(t -> t.getName().equals("inbox-watcher") && t.isAlive());
        check("close: watcher thread ends (" + ms + " ms)", !alive && ms < 1000);
    }

    // ---- helpers ----
    static final class Fixture implements AutoCloseable {
        final Path root;
        final Path inbox, processed, rejected;
        final Catalogue catalogue = new Catalogue();
        final List<String> log = new CopyOnWriteArrayList<>();
        final InboxWatcher watcher;

        Fixture(long settleMillis) throws IOException {
            root = Files.createTempDirectory("returns-checks");
            inbox = Files.createDirectories(root.resolve("inbox"));
            processed = root.resolve("processed");
            rejected = root.resolve("rejected");
            watcher = new InboxWatcher(inbox, processed, rejected, Duration.ofMillis(settleMillis),
                    catalogue::recordReturn, log::add);
            watcher.start();
        }

        void dropAtomically(String name, String content) throws IOException {
            Path tmp = inbox.resolve(name + ".tmp");
            Files.writeString(tmp, content);
            Files.move(tmp, inbox.resolve(name), StandardCopyOption.ATOMIC_MOVE);
        }

        boolean exists(Path dir, String name) { return Files.exists(dir.resolve(name)); }

        long count(Path dir) throws IOException {
            if (!Files.exists(dir)) return 0;
            try (Stream<Path> s = Files.list(dir)) { return s.count(); }
        }

        @Override
        public void close() throws IOException {
            watcher.close();
            deleteTree(root);
        }
    }

    static void deleteTree(Path root) throws IOException {
        try (Stream<Path> walk = Files.walk(root)) {
            walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
        }
    }

    static boolean waitFor(BooleanSupplier condition) throws InterruptedException {
        return waitFor(condition, 5000);
    }

    static boolean waitFor(BooleanSupplier condition, long millis) throws InterruptedException {
        long deadline = System.currentTimeMillis() + millis;
        while (System.currentTimeMillis() < deadline) {
            if (condition.getAsBoolean()) return true;
            Thread.sleep(10);
        }
        return condition.getAsBoolean();
    }

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else { failed++; System.out.println("FAIL  " + name); }
    }
}

```

Each watcher check builds its own temporary folder, starts a real `InboxWatcher` on it, and waits (with a deadline) for the outcome. That's the honest way to test this class. A mock `WatchService` would only prove the loop calls the methods you told it to.

```bash
mkdir -p out-test
javac --release 21 -cp out -d out-test src/test/java/com/example/returns/Checks.java
java -cp out:out-test com.example.returns.Checks

```

On Windows, separate the class path entries with `;` instead of `:`.

```log
PASS  parse: keys are case-insensitive, comments skipped, condition defaults to good
PASS  parse: short isbn rejected
PASS  parse: missing member rejected
PASS  parse: bad condition rejected
PASS  parse: line without = rejected
PASS  good slip: reaches the catalogue
PASS  good slip: moved to processed
PASS  good slip: gone from inbox
PASS  bad slip: moved to rejected
PASS  bad slip: reason file written
PASS  bad slip: reason names the problem
PASS  bad slip: catalogue untouched
PASS  ignored: .tmp and .txt stay put, nothing processed
PASS  ignored: renaming .tmp to .slip makes it count
PASS  startup: slips already in the inbox are processed
PASS  slow writer: processed whole, member intact
PASS  slow writer: nothing rejected
PASS  burst: all 300 slips counted exactly once
PASS  burst: still exactly 300 after things settle
PASS  burst: inbox empty, 300 files in processed
PASS  repeat: first copy counted
PASS  repeat: same file name dropped again is a new return
PASS  close: watcher thread ends (1 ms)

23 passed, 0 failed

```

The full run takes about 3 seconds. Two of these checks are the ones I'd keep even if I deleted the rest: the burst (it proves exactly-once under load) and the `.tmp` rename (it proves the contract with the writer).

## What the operating system tells you, and what it doesn't

A `WatchService` is a queue of signalled keys. You register a directory and get back a `WatchKey`. When something changes in that directory, the key is signalled and put on the service's queue. `take()` hands you a signalled key, `pollEvents()` drains its events, and `reset()` says "ready for more". Between `take()` and `reset()` the key is yours, and the service won't deliver it again.

On Linux, the JDK's implementation (`sun.nio.fs.LinuxWatchService`, which is what my machine reported) sits on inotify, so events come from the kernel and arrive quickly. That isn't promised everywhere. The Javadoc says the API uses the system's native notification facility where there is one and falls back to something like polling where there isn't, and that timing and ordering depend on the implementation. I haven't tested macOS or Windows. If you ship to either, run the checks above on that machine, not just on yours.

What an event tells you is narrow: a name in this directory was created, modified or (if you ask for `ENTRY_DELETE`) removed. It doesn't tell you the write is finished, and it doesn't say who wrote it. The `count` on an event is a hint that the same event repeated; my first experiment showed one `ENTRY_MODIFY` with `count=7` for a single 100 KB `writeString`.

Registration is per directory and not recursive. I created a subfolder and a file inside it while the parent was registered, and no event arrived. To watch a tree you register each directory yourself, including new ones as their `ENTRY_CREATE` arrives.

Where to go deeper on the related pieces: the file operations here (`Files.move`, `ATOMIC_MOVE`, reading with `readAllLines`) are covered in [how to read and write files with NIO.2](https://ggorantala.dev/how-to-read-write-files-nio-java/?ref=ggorantala.dev), and the "register a listener, get told what happened" shape is the one in [how to implement the observer pattern in Java](https://ggorantala.dev/how-to-implement-observer-pattern-java/?ref=ggorantala.dev).

## Where it broke

I wrote the experiments in a single throwaway file, `Experiments.java`, that uses the raw API and prints what happens. You don't need it to follow along; each entry below is something you can reproduce with the classes above. One bug in it was mine: I polled a key, forgot to `reset()` it, and my next three "experiments" returned nothing. That was the missing `reset()` problem happening to me, which is the first entry below. Everything here is output from my runs.

**The watcher goes quiet after the first batch.** Cause: `reset()` wasn't called. The key stays in a signalled state and the service never queues it again. In my run, a second file created while the key was un-reset produced `null` from `poll`, and calling `reset()` released three events:

```log
2. first batch: 1 events, NOT calling reset()
   second batch after no reset: null
   after reset(): 3 events delivered

```

The same code printed `2 events` for the first batch on an earlier run. The grouping depends on timing, so never write code that expects a particular number of events.

`**java.nio.file.NoSuchFileException: /tmp/exp.../nope**` from `register`. The directory doesn't exist. The service won't create it. Create it first with `Files.createDirectories`.

`**java.nio.file.NotDirectoryException: /tmp/exp.../c.slip**` from `register`. You can only register directories. To watch one file, register its parent and filter by name.

`**java.nio.file.ClosedWatchServiceException**` from `take()`, or from `register`. The service was closed. In `InboxWatcher` that's the intended exit: `close()` closes the service, `take()` throws, `run()` catches it and returns. In my check, the thread was gone 1 ms after `close()`.

**`error: incompatible types: CAP#1 cannot be converted to Path`.** Cause: `event.context()` on a `WatchEvent<?>` returns `capture of ?`, not `Path`. Cast it: `(Path) event.context()`. The `WatchEvent<Path>` generic is only available if you cast the event first.

**`error: unreported exception IOException; must be caught or declared to be thrown`.** `newWatchService()` throws `IOException`. That's why the constructor in `InboxWatcher` declares it.

**A slip rejected for `missing member`, though the file is fine on disk.** This is the one that bites in production. I set `settle` to 30 ms in a separate run and made the scanner pause 200 ms between chunks. The size looked stable during a pause, so the watcher parsed half a file:

```log
[watcher] rejected slow.slip: missing member
catalogue: returned={}, damaged=0
rejected: missing member

```

The file that ended up in `rejected/` was whole by then. The fix isn't a bigger `settle`; any number is a guess about someone else's program. The fix is the rename contract from Step 5: ask the writers to write `name.tmp` and rename to `name.slip`. Keep `awaitStable` as a seatbelt for writers you don't control.

**Files in a subfolder are ignored.** Registration isn't recursive (see above). No exception, no event, no log line, which is why I'm listing it here.

## What changes on a real server

Make the writer contract explicit. Tell the system that produces files to write `*.tmp` and rename. If you can't, treat `awaitStable` as best effort and consider a "quiet period" of several seconds on slow network scanners.

Don't trust the watcher as the only source of truth. Events can be lost (`OVERFLOW`), a process can be down when a file lands, and network folders may not report changes at all. That's why `run()` scans the folder on startup. For a real service I'd also rescan every few minutes as a safety net; it's five lines and it turns "lost event" from an incident into a delay.

Think about what "exactly once" means for your side effects. In this code, a crash between the move and the sink call loses a count; a crash before the move re-reads the file. For a catalogue update that must never be lost, make the sink idempotent (keyed by file name plus a hash) and move *after* it succeeds.

Handle the watcher's own failures. `run()` logs and exits if `IOException` escapes. A real service should restart it, or fail its health check so the orchestrator does.

Version notes: nothing here changed between Java 11 and 21 except that you'd write the `record` as a small class on 8–15\. `Files.readString`/`writeString` need Java 11\. If you need to react to thousands of files per second, this is the wrong tool; look at a queue or a message broker, and see [how to use BlockingQueue for producer-consumer pipelines](https://ggorantala.dev/how-to-use-blockingqueue-producer-consumer-java/?ref=ggorantala.dev) for handing events to worker threads.

When you want proper tests with JUnit 5 and Awaitility instead of my `waitFor`, the next read is [how to write unit tests with JUnit 5 in Java](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev).

The watcher and the demo ran correctly on the first compile. What cost me time was the throwaway experiments file, where my own missing `reset()` made three results look like API behaviour, and the settle-time trap, which no happy-path check would have caught. Build the slow-writer check early.

## Keep these four rules

- Register directories, not files, and call `reset()` on every key you take.
- An event means "this name changed", never "this file is complete". Make writers rename into place and still check size stability.
- Treat `OVERFLOW` and startup as rescans, and make processing safe to run twice.
- Run your checks on the operating system you deploy to; the JDK picks the implementation for you.

What you did: built a thread-owned `WatchService` loop, handled the create/modify duplicates, covered lost events, proved it with 23 checks, and forced seven failures to see their messages.

## WatchService questions, answered

**How do I watch a directory for changes in Java?** Call `FileSystems.getDefault().newWatchService()`, `register` the directory `Path` with the event kinds you want, then loop on `take()`, `pollEvents()` and `reset()`. Step 3 has the 33-line version that prints events.

**Why do I get two `ENTRY_MODIFY` events for one file?** Writing a file is usually several operations, and each can be reported. On my Linux machine a single `writeString` produced a create plus a modify (with a count of 7 in one run), and `touch` produced a create and a modify. Collect paths into a `Set` per batch, as `InboxWatcher` does.

**Does WatchService watch subdirectories?** No. I created a file in a subfolder of a registered directory and got no event. Register each subdirectory yourself and add new ones as they appear.

**Is WatchService reliable on macOS?** I haven't tested macOS. The Javadoc says implementations may fall back to polling where there's no native facility, which affects how soon events arrive and whether very short-lived files are seen. Test on the platform you ship to.

## References

- [WatchService (Java SE 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/WatchService.html?ref=ggorantala.dev), read for the platform-dependence, `OVERFLOW`, `reset()` and `ENTRY_MODIFY` notes
- [StandardWatchEventKinds (Java SE 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/StandardWatchEventKinds.html?ref=ggorantala.dev)
- [Files.move and StandardCopyOption.ATOMIC\_MOVE (Java SE 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/Files.html?ref=ggorantala.dev)
- [Watching a directory for changes (dev.java)](https://dev.java/learn/java-io/file-system/watching-dir-changes?ref=ggorantala.dev) and the [Oracle tutorial on change notification](https://docs.oracle.com/javase/tutorial/essential/io/notification.html?ref=ggorantala.dev).