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.
interface 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
findByIdreturnsOptional<T>, because absence is a normal result that becomes a 404.findAllreturns a list in a stable order.ConcurrentHashMapiteration order is not defined, so sort by id or use aConcurrentSkipListMap. Unstable ordering breaks pagination.updateanddeletereport 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
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.
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.
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: trueOptimistic 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.
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