An In-Memory Repository

Store resources behind a repository interface with a thread-safe map and atomic ids, so handlers stay simple and a database can replace it later.

Syntaxinterface BookRepository { Book create(String title, String author); Optional<Book> findById(long id); List<Book> findAll(); Optional<Book> update(long id, String title, String author); boolean delete(long id); }

Handlers should not know where data lives. A repository is the one class that does: it creates, finds, updates and deletes one kind of resource and hides the storage behind a small interface. Start with an in-memory version and you can build and test the whole API before a database exists; swap in a JDBC version later without touching a handler.

Thread safety is not optional

An HTTP server handles requests on several threads as soon as you give it an executor, and every production server does. A HashMap with a long nextId++ counter then loses updates, hands out duplicate ids and can corrupt its internal structure. Use ConcurrentHashMap for the rows and AtomicLong for the ids. Single calls such as put, remove, putIfAbsent, computeIfPresent and replace(key, old, new) are atomic; a get followed by a put is not, so build check-then-act logic from those atomic calls.

Store immutable values

Records are ideal: a caller cannot change a stored row by mutating the object it got back, which is the in-memory equivalent of editing the database without an UPDATE. If you must store mutable objects, copy them on the way in and out.

Shape of the interface

  • findById returns Optional<T>, because absence is a normal result that becomes a 404.
  • findAll returns a list in a stable order. ConcurrentHashMap iteration order is not defined, so sort by id or use a ConcurrentSkipListMap. Unstable ordering breaks pagination.
  • update and delete report whether the row existed, so the handler can choose between 200/204 and 404.
  • Uniqueness rules (one account per email) belong here too, enforced atomically, the way a database unique index would.

Limits

Data disappears on restart and is not shared between instances, so two pods behind a load balancer each see different data. That is fine for tests, prototypes and caches of reference data, and wrong for anything users expect to keep.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Build and test a complete tasks API before the database schema has been agreed.
  • Back fast handler and service tests with an in-memory implementation of the same repository interface the JDBC version implements.
  • Reject a second account with the same email with 409 even when two sign-up requests arrive at the same moment.

More examples

A unique email rule enforced atomically

putIfAbsent checks and inserts in one atomic step, so two simultaneous sign-ups cannot both win. The rejected attempt still used id 2, just as a database sequence leaves gaps after a failed insert.

Example · java
record User(long id, String email) {}

class UserRepository {
    private final ConcurrentMap<Long, User> byId = new ConcurrentHashMap<>();
    private final ConcurrentMap<String, Long> idByEmail = new ConcurrentHashMap<>();
    private final AtomicLong ids = new AtomicLong();

    // Empty means the email is taken; the handler turns that into 409 Conflict.
    Optional<User> create(String email) {
        String key = email.strip().toLowerCase(Locale.ROOT);
        long id = ids.incrementAndGet();
        if (idByEmail.putIfAbsent(key, id) != null) {
            return Optional.empty();
        }
        User user = new User(id, key);
        byId.put(id, user);
        return Optional.of(user);
    }
}

void main() {
    UserRepository users = new UserRepository();
    for (String email : List.of("[email protected]", "  [email protected] ", "[email protected]")) {
        String result = users.create(email).map(u -> "201 " + u).orElse("409 email already registered");
        IO.println("'" + email + "' -> " + result);
    }
}

// Output:
// '[email protected]' -> 201 User[id=1, [email protected]]
// '  [email protected] ' -> 409 email already registered
// '[email protected]' -> 201 User[id=3, [email protected]]

Eight threads, 4000 inserts, no lost ids

This is the load a handler sees when requests arrive in parallel. With a HashMap and a plain long counter the same program can report fewer rows or duplicate ids, and the result changes from run to run.

Example · java
void main() throws Exception {
    ConcurrentMap<Long, String> rows = new ConcurrentHashMap<>();
    AtomicLong ids = new AtomicLong();

    // Close waits for every submitted task to finish.
    try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
        for (int t = 0; t < 8; t++) {
            int worker = t;
            pool.submit(() -> {
                for (int i = 0; i < 500; i++) {
                    long id = ids.incrementAndGet();
                    rows.put(id, "worker " + worker + " row " + i);
                }
            });
        }
    }

    IO.println("rows stored: " + rows.size());
    IO.println("highest id:  " + ids.get());
    IO.println("ids 1..4000 all present: " + LongStream.rangeClosed(1, 4000).allMatch(rows::containsKey));
}

// Output:
// rows stored: 4000
// highest id:  4000
// ids 1..4000 all present: true

Optimistic locking with a version field

replace compares the stored value with equals, and records compare by value, so this is a compare-and-set on the whole row. A JDBC repository does the same with UPDATE ... WHERE id = ? AND version = ? and checks the row count.

Example · java
record Note(long id, String text, int version) {}

ConcurrentMap<Long, Note> notes = new ConcurrentHashMap<>();

String update(long id, int expectedVersion, String text) {
    Note current = notes.get(id);
    if (current == null) return "404 not found";
    if (current.version() != expectedVersion) {
        return "409 note is at version " + current.version() + ", you sent " + expectedVersion;
    }
    Note next = new Note(id, text, current.version() + 1);
    // replace(key, old, new) succeeds only if nobody changed the note since we read it.
    return notes.replace(id, current, next) ? "200 " + next : "409 changed concurrently";
}

void main() {
    notes.put(1L, new Note(1, "draft", 1));
    IO.println(update(1, 1, "first edit"));
    IO.println(update(1, 1, "edit from a stale browser tab"));
    IO.println(update(1, 2, "second edit"));
    IO.println(update(2, 1, "anything"));
}

// Output:
// 200 Note[id=1, text=first edit, version=2]
// 409 note is at version 2, you sent 1
// 200 Note[id=1, text=second edit, version=3]
// 404 not found

Discussion

  • Be the first to comment on this lesson.