Four endpoints, one non-negotiable
The challenge asks for a system to manage products and their price history: create a product, add a price while validating it doesn't overlap with existing ones, look up the price in effect on a given date, and return the full history. Framework, architecture and database are all open choices.
"One of the most important requirements of this test is that your solution has the best possible performance, both in response time and efficient resource usage." — original brief, Objective section
- POST /products — create a product (name, description)
- POST /products/{id}/prices — add a price, no date overlap allowed,
endDateoptional - GET /products/{id}/prices?date= — price in effect on a given date
- GET /products/{id}/prices — full history, ordered
- App container capped at 1 CPU and 1 GB of memory, no exceptions
Choosing tools with performance as the tie-breaker
With performance called out as an explicit priority and a hard 1 CPU / 1 GB ceiling, the first decision — the one that shapes everything after it — is what to leave out.
| Option | Startup | Base memory | Verdict |
|---|---|---|---|
| Spring Boot | ~1.5–3 s | High (classpath scanning, full DI container) | Ruled out — the opposite of the stated priority |
Raw com.sun.net.httpserver |
fastest | Minimal | Ruled out — hand-rolling routing and error handling, no real gain |
| Javalin (chosen) | hundreds of ms | Low (thin layer over Jetty, no DI or scanning) | Chosen — best balance of performance / readability / testability |
No database
No requirement to persist across restarts: a DB would only add I/O and startup latency for nothing. Concurrent in-memory structures instead.
Virtual threads
With 1 CPU they don't parallelize more compute. Their value is memory: they stop thousands of concurrent connections from exhausting the 1 GB via ~1 MB platform-thread stacks.
BigDecimal, never double
A price's value is money. Binary floating point introduces rounding errors that aren't acceptable here.
Never two prices at once — and knowing it without scanning anything
A product's prices live in a ConcurrentSkipListMap keyed by start
date. Adding a new price checks exactly two things — the immediate left
neighbor (floorEntry) and the date window the new range covers
(subMap) — in O(log n), without scanning the full
list. The same idea, a single floorEntry, answers "what price is
in effect on date X".
The date range is closed on both ends: if a price ends on June 30th, that day belongs to it. That has a consequence the first draft of the algorithm didn't handle correctly.
Before the fix — exclusive upper bound
409 · OVERLAPS
The original plan checked the right side with
subMap(initDate, true, endDate, false) — an exclusive upper bound.
With the insertion order reversed from the case the plan anticipated, that
exclusive bound let through exactly the case where a new price starts on the
same day an existing one ends.
After the fix — both bounds inclusive
201 · CREATED
Fixed to subMap(initDate, true, endDate, true). One day of
difference on the start date and the price is accepted. The case was locked in
as a regression test:
rejectsAPriceSharingExactlyOneBoundaryDayWithAnExistingOne.
How it got built, phase by phase
Development was logged phase by phase in HISTORIAL.md as it
happened: what changed, what got tested, and what went wrong. These are the
points where reality corrected the plan.
Analysis and planning
Hexagonal architecture, the overlap algorithm and the concurrency strategy decided before any code was written.
The first draft plan cited library versions that don't exist (mockito-core:5.23.0, shadow:8.3.11...). Verified one by one against Maven Central before writing build.gradle.
build.gradle and the package skeleton
Shadow plugin for the fat jar, dependencies pinned, the domain / application / infrastructure structure created empty.
DateRange, Price, Product
Validation in static factories, a per-product ReentrantLock guarding writes only, a concurrency test with 50 virtual threads racing to insert overlapping prices at once.
The shared-boundary bug — see the diagram above. Fixed and locked in with a regression test.
In-memory repository and IdGenerator
A ConcurrentHashMap behind the ProductRepository port. Test for 1,000 concurrent product creations with no lost ids.
ProductService
The plan called for a separate getHistory method; implementing it, it turned out to be a plain alias for getProduct().history() — dropped as indirection with no payoff.
Controllers, DTOs and Javalin
Startup measured at 225–245 ms. Twelve cases verified by hand with curl: creation, overlap, invalid date, non-numeric id, malformed JSON...
config.concurrency.useVirtualThreads didn't compile. The documentation consulted described a different Javalin version; the real field, confirmed by inspecting the class with javap, is config.useVirtualThreads.
HTTP integration and mapper tests
The twelve cases hand-checked in the previous phase get formalized as JUnit tests against a real server on a random port. Total: 71 tests, all green.
A single app.jar, and a container that actually starts
Dockerfile and docker-compose.yml shipped with pre-existing bugs: a command pointing at a class that doesn't exist, a wildcard COPY that would break with two jars present.
This environment had no docker installed. Validated anyway with Podman, reproducing the same limits by hand (--cpus=1.0 --memory=1g) and running the real benchmark.sh against the real image.
36,000 requests, under 1 CPU
Full results and reading in the next section.
The README replaced the brief instead of adding to it
While documenting the solution, README.md got fully overwritten, losing the original brief. The brief itself asked the README to include certain things — never to replace what was already there.
Rebuilt by recovering the original brief from the repository's first commit and appending the solution write-up after it, under a new heading.
The domain doesn't know Javalin exists
Three packages, dependencies always pointing inward. Swapping the in-memory
store for a real database would mean implementing ProductRepository
again — without touching a single line of the domain.
infrastructure
the only layer that knows the concrete libraries
- JavalinApp, controllers
- DTOs + mappers
- InMemoryProductRepository
- GlobalExceptionHandler
application
use cases, knows only the domain + its ports
- ProductService
- createProduct
- addPrice
- getPriceAt
domain
business rules, zero external dependencies
- Product · Price · DateRange
- ProductRepository (port)
- IdGenerator (port)
- DomainException and subtypes
infrastructure → application → domain · dependency arrows only ever point inward
What the benchmark says
| Block | Requests | Duration |
|---|---|---|
| Concurrent product creation | 1,000 | 17.25 s |
| Concurrent price-in-effect lookups | 20,000 | 380.68 s |
| Concurrent full-history lookups | 15,000 | 304.60 s |
Run with Podman under the same limits docker-compose.yml sets
(--cpus=1.0 --memory=1g). Zero refused connections across 36,000
requests — the measured bottleneck is time, not stability. Worth noting:
benchmark.sh fires each request as an independent curl
process, with no keep-alive, from a client container that's also capped at 1 CPU —
a meaningful share of the total time is the load-generation mechanism itself, not
only the server.
What got dropped, and why
Adding currency would change the exact shape {"value": X} the brief fixes for the price-in-effect response — the one the automated tests use.
Complicates the no-overlap invariant (editing would require revalidating against the rest) without adding value to the main focus: performance and correctness of the existing model.
Would break the token-less calls benchmark.sh itself makes, if made mandatory.
Directly fights the fast-startup, low-memory goal. The contract is already documented in the README.
No polish added after the fact: this is what actually happened, including what went wrong the first time.