ALBERT ORTELLS // TECH TESTS

CASE · CAPITOLE

The right price, on the right date — in O(log n)

A product can have many prices over time, but never two at once. This is the story of how that rule got modeled, how looking it up became nearly instant, and the concrete bugs that showed up — and got fixed — along the way.

Java 21 Javalin 6.7.0 Virtual threads No database 71 tests 1 CPU / 1 GB SOLVED

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

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
$99.99 · Jan 1 – Jun 30
attempt: $199.99 · Jun 30 – Dec 31
Jun 30, shared day
Jan Mar May Jul Sep Nov

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
$99.99 · Jan 1 – Jun 30
$199.99 · Jul 1 – Dec 31
Jun 30
Jan Mar May Jul Sep Nov

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.

Jul 25, 2026Phase 0 · Plan

Analysis and planning

Hexagonal architecture, the overlap algorithm and the concurrency strategy decided before any code was written.

Caught and fixed

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.

Jul 25, 2026Phase 1 · Foundations

build.gradle and the package skeleton

Shadow plugin for the fat jar, dependencies pinned, the domain / application / infrastructure structure created empty.

Jul 25, 2026Phase 2 · Domain

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.

Caught and fixed

The shared-boundary bug — see the diagram above. Fixed and locked in with a regression test.

Jul 25, 2026Phase 3 · Persistence

In-memory repository and IdGenerator

A ConcurrentHashMap behind the ProductRepository port. Test for 1,000 concurrent product creations with no lost ids.

Jul 25, 2026Phase 4 · Use cases

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.

Jul 25, 2026Phase 5 · HTTP API

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...

Caught and fixed

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.

Jul 25, 2026Phase 6 · Tests

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.

Jul 25, 2026Phase 7–8 · Packaging and Docker

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.

Caught and fixed

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.

Jul 25, 2026Phase 9 · Benchmark

36,000 requests, under 1 CPU

Full results and reading in the next section.

Jul 26, 2026Follow-up fix

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.

Caught and fixed

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

71 / 0 tests run / failures
O(log n) overlap check and price lookup
~225 ms cold start
36,000 / 0 concurrent requests / errors
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

Multi-currency

Adding currency would change the exact shape {"value": X} the brief fixes for the price-in-effect response — the one the automated tests use.

Update / delete prices

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.

Authentication

Would break the token-less calls benchmark.sh itself makes, if made mandatory.

Swagger/OpenAPI at runtime

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.