Choosing a Store: Relational vs Cache vs Document
Our vehicle-valuation service writes to three different databases on purpose — this lesson is about the decision, not the tour.
Up to this point in the course, `VehicleValuationService` has been a story about coroutines, timeouts, retries and circuit breakers — all the machinery that keeps three unreliable third-party providers from taking down our request thread. Starting with this module, we zoom out to a different question: where does the data live once we have it? The honest answer, for this service, is 'in three different places,' and that is not an accident of history or a symptom of a team that could not agree on a database. It is a deliberate response to three access patterns that behave nothing alike.
Pattern one: the pricing and guardrail rules — the tables that say 'a 2021 sedan with over 80,000 miles gets a 12% deduction' or 'never offer above book value on a branded-title vehicle.' These rules are read on every single valuation request, they reference vehicle categories and provider weightings that live in other tables, and when an analyst updates a rule set, several rows need to change together or not at all. That is a relational access pattern: structured, foreign-keyed, transactional.
Pattern two: the valuation lookup by VIN. Black Book, Carfax and S&P VIS calls are slow and, in some pricing tiers, metered per call. If two dealers ask about the same VIN within the same hour, calling all three providers again for the second dealer is pure waste — the answer has not changed, and it does not need to be perfectly fresh. That is a cache access pattern: hot, disposable, keyed by a single field, and tolerant of staleness in exchange for speed.
Pattern three: the audit trail of every valuation ever computed — which providers responded, what each one said, what the rules engine decided, and why. This data is written far more often than it is read, its shape changes as we add fields (a new provider, a new rule flag, a fraud score nobody had six months ago), and when it is read, it is usually read by ID or by a handful of predictable filters, not joined against six other tables. That is a document access pattern: high write volume, flexible schema, append-only.
The tempting move — and the one most teams make by default — is to put all three patterns into the one Postgres instance the team already knows how to operate. It works, for a while. Then the rule tables, which need fast transactional writes, start competing for I/O with an audit log table that is growing by a million rows a day. Schema migrations on the audit table become terrifying because the table is enormous and every provider adds a slightly different set of fields, so half the columns are nullable and nobody remembers why. And the VIN lookups either get their own cache layer bolted on top anyway — because no team tolerates the latency of a cold relational read repeated for every dealer — or they get denormalized into the same rows the transactional rules depend on, coupling two things that have no business being coupled.
So the decision this module walks through is not 'which database is best' in the abstract. It is 'which access pattern does this specific slice of data have, and which storage model was built for that pattern.' Postgres, Redis and MongoDB are not competitors here — they are three tools doing three different jobs inside the same service, and `VehicleValuationService` calls into all three without any of its callers needing to know that. The controller asks for a valuation; it has no idea that answering it means a transactional read from Postgres, a cache check against Redis and a fire on write into MongoDB.
The next three lessons take each store in turn — why Postgres owns the rules, why Redis sits in front of the valuation lookup, and why the audit log left a Snowflake batch pipeline for MongoDB — and the lesson after that turns the three into a checklist you can point at any new table your own service needs and ask, honestly, which store it belongs in.
class VehicleValuationService(private val ruleRepository: PricingRuleRepository, // Postgres: transactional, structuredprivate val valuationCache: ReactiveRedisTemplate<String, ValuationResult>, // Redis: hot, disposableprivate val eventLog: ValuationEventRepository, // MongoDB: append-only, schema-flexibleprivate val providerClients: List<ValuationProviderClient>,) {suspend fun valuate(vin: String): ValuationResult {// 1. Cache check (Redis) — is this VIN's valuation still fresh enough to reuse?valuationCache.opsForValue().get("valuation:$vin").awaitSingleOrNull()?.let { return it }// 2. Structured rules (Postgres) — guardrails and boosts for this vehicle's categoryval rules = ruleRepository.findActiveRulesForCategory(categoryOf(vin))// 3. Call providers, apply rules — the coroutine fan-out from Module 2val result = computeValuation(vin, rules, providerClients)// 4. Populate cache for the next dealer who asks about this VINvaluationCache.opsForValue().set("valuation:$vin", result, Duration.ofMinutes(30)).awaitSingle()// 5. Append-only audit write (MongoDB) — never blocks the response on a slow analytics storeeventLog.save(ValuationEvent.from(vin, rules, result))return result}}
This is illustrative only (runnable: false) — it sketches the three calls VehicleValuationService actually makes per request, one to each store, to make the access-pattern split concrete before the next three lessons dig into each one.
// Access pattern | Example in this service | Storage model that fits// --------------------------|------------------------------------|------------------------// Structured + transactional| pricing_rule, vehicle_category | Relational (Postgres)// Hot + disposable | valuation lookup by VIN | Cache (Redis)// High-volume + flexible | valuation_event audit log | Document (MongoDB)
This is illustrative only (runnable: false) — it names the three access patterns side by side, the way you'd sketch them on a whiteboard before picking a store, rather than a snippet meant to compile or run.
🧠 Check your understanding
0/1 · 0/1 answered1. A teammate proposes storing the valuation cache as rows in the same Postgres database that holds pricing_rule, arguing 'one database is simpler to operate.' What is the strongest technical objection?