<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Architecture on 0AndWild_log</title><link>https://0andwild.com/en/categories/architecture/</link><description>Recent content in Architecture on 0AndWild_log</description><generator>Hugo -- gohugo.io</generator><language>en-US</language><lastBuildDate>Fri, 17 Jul 2026 16:20:00 +0900</lastBuildDate><atom:link href="https://0andwild.com/en/categories/architecture/index.xml" rel="self" type="application/rss+xml"/><item><title>Designing a Product Ranking System</title><link>https://0andwild.com/en/posts/260717_ranking_system_design/</link><pubDate>Fri, 17 Jul 2026 16:20:00 +0900</pubDate><guid>https://0andwild.com/en/posts/260717_ranking_system_design/</guid><description>&lt;img src="https://0andwild.com/" alt="Featured image of post Designing a Product Ranking System" /&gt;&lt;h2 id="tldr"&gt;&lt;a href="#tldr" class="header-anchor"&gt;&lt;/a&gt;TL;DR&#10;&lt;/h2&gt;&lt;p&gt;I designed a daily product ranking system based on user behavior events.&lt;/p&gt;&#10;&lt;p&gt;My first approach published product views, likes, and successful payments to Kafka. &lt;code&gt;commerce-streamer&lt;/code&gt; consumed the events and updated ranking scores in a Redis Sorted Set in real time. Redis was a good fit as a serving store because it supports fast Top N queries and rank lookups for individual products.&lt;/p&gt;&#10;&lt;p&gt;As I worked through the design, though, I began to question whether Redis should be the ranking system&amp;rsquo;s only Source of Truth. Expired or lost data is difficult to recover, and changing weights requires historical metrics if we want to recalculate past scores. Supporting hourly, weekly, and monthly rankings makes retaining those source metrics even more important.&lt;/p&gt;&#10;&lt;p&gt;This post starts with the Redis design, then explores an alternative that stores source metrics in an RDB and uses Redis to serve only the Top N results needed for queries.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="my-first-reading-of-the-requirements"&gt;&lt;a href="#my-first-reading-of-the-requirements" class="header-anchor"&gt;&lt;/a&gt;My first reading of the requirements&#10;&lt;/h2&gt;&lt;p&gt;Initially, ranking sounded like a simple matter of calculating a score for each product and sorting the results.&lt;/p&gt;&#10;&lt;p&gt;On closer inspection, the more important question was how to interpret different user actions.&lt;/p&gt;&#10;&lt;p&gt;A product detail view signals interest, but less strongly than a purchase. A like is stronger than a view, but doesn&amp;rsquo;t necessarily lead to revenue. A successful payment is the strongest signal, yet using the sales amount directly could let expensive products dominate the ranking.&lt;/p&gt;&#10;&lt;p&gt;I used the following signals:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Event&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Meaning&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Ranking effect&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Product detail view&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Mild interest&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Increase view score&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Like&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Explicit interest&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Increase like score&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Unlike&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Withdrawn interest&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Decrease like score&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Successful payment&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Purchase conversion&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Increase sales score&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Views, likes, and sales use different units, so I kept their metrics separate and applied weights when calculating the final score.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;score = carry&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; + viewCount * viewWeight&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; + likeCount * likeWeight&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; + ln(1 + salesAmount) * salesWeight&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The sales contribution is based on &lt;code&gt;price * quantity&lt;/code&gt;, with a logarithm applied. Using the raw amount could give expensive or already popular products an excessive, persistent advantage once they reach the top.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="first-design-real-time-redis-rankings"&gt;&lt;a href="#first-design-real-time-redis-rankings" class="header-anchor"&gt;&lt;/a&gt;First design: real-time Redis rankings&#10;&lt;/h2&gt;&lt;p&gt;My first design used Redis as the real-time ranking store.&lt;/p&gt;&#10;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260717_ranking_system_design/current-ranking-architecture.png"&#10;&#9;&#9;&#9;alt="Initial design for daily product rankings using Redis" width="1100"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;The flow is roughly:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;A user views a product, likes it, or pays for an order.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;commerce-api&lt;/code&gt; records a domain event.&lt;/li&gt;&#10;&lt;li&gt;The event is stored in a transaction outbox.&lt;/li&gt;&#10;&lt;li&gt;An outbox relay publishes it to a Kafka topic.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;commerce-streamer&lt;/code&gt; consumes the event.&lt;/li&gt;&#10;&lt;li&gt;It updates the metrics and final score in the daily Redis ranking keys according to the event type.&lt;/li&gt;&#10;&lt;li&gt;The ranking API reads ranks and scores from Redis, adds product information from MySQL, and returns the response.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The outbox addresses the gap between the API transaction and Kafka publication. If an order or like change commits but its event fails to publish, ranking data can drift from the actual state. Recording the outbox row in the same transaction as the domain change lets a relay publish it separately.&lt;/p&gt;&#10;&lt;h2 id="why-use-date-specific-redis-keys"&gt;&lt;a href="#why-use-date-specific-redis-keys" class="header-anchor"&gt;&lt;/a&gt;Why use date-specific Redis keys?&#10;&lt;/h2&gt;&lt;p&gt;These are daily rankings, so the keys include a date.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:metric:view:{yyyyMMdd}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:metric:like:{yyyyMMdd}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:metric:sales:{yyyyMMdd}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:metric:raw-sales-amount:{yyyyMMdd}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:metric:carry:{yyyyMMdd}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:all:{yyyyMMdd}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:processed:{yyyyMMdd}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I separated the metric keys because weights can change.&lt;/p&gt;&#10;&lt;p&gt;If we store only the final score, changing &lt;code&gt;viewWeight&lt;/code&gt; or &lt;code&gt;likeWeight&lt;/code&gt; leaves us without enough information to reinterpret the old value. Separate view, like, and sales metrics let us recalculate &lt;code&gt;ranking:all:{date}&lt;/code&gt; using the current weights.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;ranking:processed:{date}&lt;/code&gt; tracks duplicate events. Kafka consumers may process messages at least once, which means the same event can arrive again. If an &lt;code&gt;eventId&lt;/code&gt; has already been processed, it must not increase the score a second time.&lt;/p&gt;&#10;&lt;h2 id="what-worked-well-about-redis"&gt;&lt;a href="#what-worked-well-about-redis" class="header-anchor"&gt;&lt;/a&gt;What worked well about Redis&#10;&lt;/h2&gt;&lt;p&gt;A Redis Sorted Set felt like a natural match for ranking: it stores members with scores and supports fast retrieval in score order.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ZINCRBY ranking:metric:view:20260717 1 productId&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ZREVRANGE ranking:all:20260717 0 19 WITHSCORES&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ZREVRANK ranking:all:20260717 productId&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Redis commands cover both Top N queries and rank lookups for a specific product.&lt;/p&gt;&#10;&lt;p&gt;The API doesn&amp;rsquo;t need to run expensive aggregations. It reads ranks and scores from Redis, then retrieves display information such as product and brand names from MySQL.&lt;/p&gt;&#10;&lt;p&gt;Another benefit is freshness. Scores change as soon as events are consumed, so user actions are reflected quickly. A batch-only design introduces at least the scheduling delay; here, Kafka consumer lag accounts for most of the delay.&lt;/p&gt;&#10;&lt;h2 id="end-of-day-carry-over"&gt;&lt;a href="#end-of-day-carry-over" class="header-anchor"&gt;&lt;/a&gt;End-of-day carry-over&#10;&lt;/h2&gt;&lt;p&gt;Starting every product at zero each day would feel abrupt. Products that were popular yesterday shouldn&amp;rsquo;t necessarily disappear at midnight.&lt;/p&gt;&#10;&lt;p&gt;I planned a carry-over at 23:50 each day, using that day&amp;rsquo;s Top 100 products to seed the next day&amp;rsquo;s scores.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Read the Top 100 from ranking:all:{D}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Multiply each final score by 0.1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Apply to ranking:metric:carry:{D+1}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Also apply to ranking:all:{D+1}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Top 100 limit controls Redis memory usage. Carrying every product forward would become expensive as product counts and daily keys accumulate.&lt;/p&gt;&#10;&lt;p&gt;Keys also have a TTL. Together, expiry and the carry-over limit keep Redis from becoming an indefinite historical store.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="limitations-of-the-first-design"&gt;&lt;a href="#limitations-of-the-first-design" class="header-anchor"&gt;&lt;/a&gt;Limitations of the first design&#10;&lt;/h2&gt;&lt;p&gt;The Redis design is simple and fast for daily real-time rankings. Looking at it from an operational perspective reveals several limitations.&lt;/p&gt;&#10;&lt;h2 id="1-can-redis-be-the-source-of-truth"&gt;&lt;a href="#1-can-redis-be-the-source-of-truth" class="header-anchor"&gt;&lt;/a&gt;1. Can Redis be the Source of Truth?&#10;&lt;/h2&gt;&lt;p&gt;This was my biggest concern.&lt;/p&gt;&#10;&lt;p&gt;Redis works well for serving ranking data, but this design needs more support for preserving the source information. TTL expiry removes data. After an outage or operational mistake, we need enough evidence to reconstruct rankings, and a Redis-centered design alone doesn&amp;rsquo;t provide that.&lt;/p&gt;&#10;&lt;p&gt;Retaining Kafka topics and replaying events is an option. But replaying all events just to recalculate one historical period can be costly. If the event schema or consumer logic has changed, reproducing the original result may also be difficult.&lt;/p&gt;&#10;&lt;h2 id="2-changing-weights-and-recalculating-scores"&gt;&lt;a href="#2-changing-weights-and-recalculating-scores" class="header-anchor"&gt;&lt;/a&gt;2. Changing weights and recalculating scores&#10;&lt;/h2&gt;&lt;p&gt;The first design&amp;rsquo;s separate metric ZSETs allow recalculation while the daily data remains in Redis.&lt;/p&gt;&#10;&lt;p&gt;After the TTL expires, that option disappears. A request a month later to recalculate last week&amp;rsquo;s rankings with today&amp;rsquo;s weights cannot rely on the remaining Redis data alone.&lt;/p&gt;&#10;&lt;p&gt;Runtime weight changes make the distinction between metrics and scores important. A score is the result of a policy; the metrics describe what happened.&lt;/p&gt;&#10;&lt;h2 id="3-rankings-across-different-periods"&gt;&lt;a href="#3-rankings-across-different-periods" class="header-anchor"&gt;&lt;/a&gt;3. Rankings across different periods&#10;&lt;/h2&gt;&lt;p&gt;Date-based keys look sufficient for daily rankings. Hourly, weekly, monthly, and yearly rankings make the key strategy more complicated.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:all:daily:20260717&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:all:hourly:2026071713&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:all:weekly:2026W29&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:all:monthly:202607&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;We could update every period&amp;rsquo;s keys when consuming each event. But then a single event modifies multiple keys, and weight changes require recalculating more sets of scores.&lt;/p&gt;&#10;&lt;p&gt;At that point, retaining source metrics elsewhere and publishing each period&amp;rsquo;s Top N to Redis becomes a more natural design.&lt;/p&gt;&#10;&lt;h2 id="4-should-every-product-live-in-redis"&gt;&lt;a href="#4-should-every-product-live-in-redis" class="header-anchor"&gt;&lt;/a&gt;4. Should every product live in Redis?&#10;&lt;/h2&gt;&lt;p&gt;The memory question also becomes more important as the catalog grows.&lt;/p&gt;&#10;&lt;p&gt;Storing 100,000 products is something we can test readily. At one million or ten million products, multiplied across daily keys, Redis memory cost becomes harder to ignore.&lt;/p&gt;&#10;&lt;p&gt;Most ranking API requests need only the Top N. Keeping those results in Redis and the complete metrics elsewhere gives each store a clearer role.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="revised-design-rdb-metric-sot--redis-top-n"&gt;&lt;a href="#revised-design-rdb-metric-sot--redis-top-n" class="header-anchor"&gt;&lt;/a&gt;Revised design: RDB Metric SOT + Redis Top N&#10;&lt;/h2&gt;&lt;p&gt;To address those limits, I explored storing source metrics in an RDB and serving only query-ready Top N results from Redis.&lt;/p&gt;&#10;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260717_ranking_system_design/rdb-metric-sot-architecture.png"&#10;&#9;&#9;&#9;alt="RDB Metric SOT and Redis Top N ranking architecture" width="1100"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;Redis changes roles in this design. In the first version, it was effectively the Source of Truth for real-time rankings. Here, it is a read model for fast queries.&lt;/p&gt;&#10;&lt;p&gt;Source metrics live in an RDB such as MySQL.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking_daily_metric&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- metric_date&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- product_id&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- view_count&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- like_count&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- sales_amount&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Weights are deliberately left out of those records. Views, likes, and sales amounts describe recorded activity; weights are policy, and policy can change. Storing metrics before weights are applied makes recalculation easier.&lt;/p&gt;&#10;&lt;h2 id="event-consumption"&gt;&lt;a href="#event-consumption" class="header-anchor"&gt;&lt;/a&gt;Event consumption&#10;&lt;/h2&gt;&lt;p&gt;The event pipeline stays largely the same. User actions reach Kafka, and &lt;code&gt;RankingMetricConsumer&lt;/code&gt; consumes them. Instead of incrementing Redis scores directly, it upserts metric rows in the RDB.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PRODUCT_VIEWED -&amp;gt; view_count + 1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PRODUCT_LIKED -&amp;gt; like_count + 1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PRODUCT_UNLIKED -&amp;gt; like_count - 1&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PAYMENT_SUCCEEDED -&amp;gt; sales_amount + price * quantity&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Rankings can now be rebuilt from RDB metrics even if Redis is empty.&lt;/p&gt;&#10;&lt;p&gt;Weekly rankings can be computed by summing daily metrics over the required period, and monthly rankings work the same way. Neither requires replaying every original event.&lt;/p&gt;&#10;&lt;h2 id="score-calculation"&gt;&lt;a href="#score-calculation" class="header-anchor"&gt;&lt;/a&gt;Score calculation&#10;&lt;/h2&gt;&lt;p&gt;A separate batch or scheduler calculates scores. For example, it can read recent metrics every five minutes and apply the current weights.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;score = carry&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; + view_count * currentViewWeight&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; + like_count * currentLikeWeight&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; + ln(1 + sales_amount) * currentSalesWeight&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It then loads only the Top N products into a Redis ZSET.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ranking:top:daily:20260717&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Redis contains the results the API needs rather than the entire catalog. It serves as the fast serving layer, while the source data remains elsewhere.&lt;/p&gt;&#10;&lt;h2 id="what-this-design-improves"&gt;&lt;a href="#what-this-design-improves" class="header-anchor"&gt;&lt;/a&gt;What this design improves&#10;&lt;/h2&gt;&lt;p&gt;The biggest improvement is recoverability. If Redis data is lost, the Top N can be rebuilt from RDB metrics. If incorrect weights are deployed, the same metrics can be used to calculate corrected scores.&lt;/p&gt;&#10;&lt;p&gt;The second improvement is support for different ranking periods. With daily metrics, weekly, monthly, and yearly rankings become period aggregation problems. At larger scale, an RDB alone may not be enough; an OLAP store or separate aggregate tables could be needed. Retaining the source metrics leaves those options open.&lt;/p&gt;&#10;&lt;p&gt;Third, Redis memory usage becomes easier to control. Only the Top N needed for queries lives there. Full product metrics remain in the RDB.&lt;/p&gt;&#10;&lt;h2 id="what-it-costs"&gt;&lt;a href="#what-it-costs" class="header-anchor"&gt;&lt;/a&gt;What it costs&#10;&lt;/h2&gt;&lt;p&gt;This design isn&amp;rsquo;t automatically better in every respect.&lt;/p&gt;&#10;&lt;p&gt;Rankings become less immediate. A five-minute calculation schedule can introduce roughly five minutes of update delay.&lt;/p&gt;&#10;&lt;p&gt;The system also becomes more involved. It needs metric tables, upsert logic, score calculation batches, batch failure recovery, and Redis loading logic. There is more to manage than a direct &lt;code&gt;ZINCRBY&lt;/code&gt; on a ZSET.&lt;/p&gt;&#10;&lt;p&gt;RDB write load matters, too. Every user action can lead to a metric upsert. At higher traffic volumes, buffering, batch inserts, time-bucket aggregation, Kafka Streams, or an OLAP store may become worth considering.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="comparing-the-designs"&gt;&lt;a href="#comparing-the-designs" class="header-anchor"&gt;&lt;/a&gt;Comparing the designs&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Perspective&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Real-time Redis rankings&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;RDB Metric SOT + Redis Top N&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Priority&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Immediate updates&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Recovery and recalculation&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Redis role&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Real-time ranking store&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Query read model&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Source metrics&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Stored in Redis keys&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Persisted in the RDB&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Weight changes&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Recalculate only periods still in Redis&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Recalculate from retained RDB metrics&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Multiple periods&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;More keys add complexity&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Extend through metric aggregation&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Memory usage&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Grows when all products are loaded&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Controlled by loading only Top N&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Implementation complexity&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Relatively simple&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Requires batch and recovery policies&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Update delay&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Low&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Depends on the batch interval&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The first design is a good starting point for fast daily rankings. User actions are reflected almost immediately, and the API read path is simple.&lt;/p&gt;&#10;&lt;p&gt;As recovery, recalculation, and rankings across longer periods become operational requirements, the RDB Metric SOT design becomes more compelling.&lt;/p&gt;&#10;&lt;h2 id="how-should-we-choose"&gt;&lt;a href="#how-should-we-choose" class="header-anchor"&gt;&lt;/a&gt;How should we choose?&#10;&lt;/h2&gt;&lt;p&gt;This exercise made me realize that the first question shouldn&amp;rsquo;t be which database to use. More useful questions are:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;How quickly must this ranking reflect new activity?&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Can we tolerate losing the ranking data?&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do historical rankings need to be recalculated?&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Could the weight policy change frequently?&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do all products need to remain in the ranking dataset?&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Will rankings stay daily, or expand to weekly and monthly periods?&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If the answers prioritize freshness, a Redis-centered design is simple and fast. If recovery, auditing, recalculation, and broader time periods matter, the system needs to retain source metrics.&lt;/p&gt;&#10;&lt;h2 id="evolving-the-design"&gt;&lt;a href="#evolving-the-design" class="header-anchor"&gt;&lt;/a&gt;Evolving the design&#10;&lt;/h2&gt;&lt;p&gt;The real-time Redis design initially seemed sufficient. Daily rankings needed to respond quickly to user behavior, and Sorted Sets supported both Top N queries and individual product ranks well.&lt;/p&gt;&#10;&lt;p&gt;I placed several limits on that design:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Use date-specific keys.&lt;/li&gt;&#10;&lt;li&gt;Give keys a TTL.&lt;/li&gt;&#10;&lt;li&gt;Carry over only the Top 100 products.&lt;/li&gt;&#10;&lt;li&gt;Keep metric ZSETs separate so weights can be recalculated for retained daily data.&lt;/li&gt;&#10;&lt;li&gt;Track processed events to avoid duplicate updates.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;These choices make it a starting point that favors freshness and simplicity. Once historical recovery or weekly and monthly rankings enter the requirements, evolving toward RDB Metric SOT + Redis Top N makes more sense.&lt;/p&gt;&#10;&lt;h2 id="closing-thoughts"&gt;&lt;a href="#closing-thoughts" class="header-anchor"&gt;&lt;/a&gt;Closing thoughts&#10;&lt;/h2&gt;&lt;p&gt;Designing rankings kept bringing me back to questions beyond sorting: which actions count as strong signals, how long to retain them, whether scores must be recalculated when policy changes, and whether an empty ranking after Redis failure is acceptable.&lt;/p&gt;&#10;&lt;p&gt;I began by focusing on how quickly Redis Sorted Sets could provide rankings. As the design grew, deciding what to preserve as source data became more important than deciding what to put in Redis.&lt;/p&gt;&#10;&lt;p&gt;The first design is a starting point for real-time daily rankings. RDB Metric SOT + Redis Top N is a possible next step as operational requirements grow.&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t think a good design has to implement every future requirement from the start. But I do want to be able to explain the limits of today&amp;rsquo;s choice and how it could evolve when those limits matter.&lt;/p&gt;&#10;</description></item><item><title>Handling Order Surges: Designing a Redis Waiting Queue</title><link>https://0andwild.com/en/posts/260710_waiting_queue_system_design/</link><pubDate>Fri, 10 Jul 2026 16:59:39 +0900</pubDate><guid>https://0andwild.com/en/posts/260710_waiting_queue_system_design/</guid><description>&lt;img src="https://0andwild.com/" alt="Featured image of post Handling Order Surges: Designing a Redis Waiting Queue" /&gt;&lt;h2 id="tldr"&gt;&lt;a href="#tldr" class="header-anchor"&gt;&lt;/a&gt;TL;DR&#10;&lt;/h2&gt;&lt;p&gt;Instead of sending every request straight to the order API, I first placed users in a Redis Sorted Set.&lt;/p&gt;&#10;&lt;p&gt;A scheduler removes up to 50 users from the queue every second and issues entry tokens valid for five minutes. Users poll their position until they receive a token, and the order API accepts only requests with a valid &lt;code&gt;X-Entry-Token&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;After an order commits successfully, a local event listener receives &lt;code&gt;OrderEvent.Created&lt;/code&gt; and deletes the token. If the order fails, the token remains so the user can try again.&lt;/p&gt;&#10;&lt;p&gt;This is an initial design for controlling admission rate, rather than a complete production waiting queue. Atomic queue entry, recovery during token issuance, global throughput across instances, and polling load still need more work.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="overview"&gt;&lt;a href="#overview" class="header-anchor"&gt;&lt;/a&gt;Overview&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Goals:&#10;&lt;ul&gt;&#10;&lt;li&gt;Keep a sudden burst of order requests from reaching the order database all at once.&lt;/li&gt;&#10;&lt;li&gt;Let users check their queue position and estimated wait time.&lt;/li&gt;&#10;&lt;li&gt;Issue entry tokens to a limited number of users who can then call the order API.&lt;/li&gt;&#10;&lt;li&gt;Remove entry tokens only after a successful order transaction.&lt;/li&gt;&#10;&lt;li&gt;Keep the waiting queue and order domains independent of Redis implementation details.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Settings used in this implementation:&#10;&lt;ul&gt;&#10;&lt;li&gt;Scheduler fixed delay: 1 second&lt;/li&gt;&#10;&lt;li&gt;Scheduler batch size: 50 users&lt;/li&gt;&#10;&lt;li&gt;Entry token TTL: 300 seconds&lt;/li&gt;&#10;&lt;li&gt;Rank: zero-based, using the Redis &lt;code&gt;ZRANK&lt;/code&gt; result directly&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="why-put-a-queue-in-front-of-the-order-api"&gt;&lt;a href="#why-put-a-queue-in-front-of-the-order-api" class="header-anchor"&gt;&lt;/a&gt;Why put a queue in front of the order API?&#10;&lt;/h2&gt;&lt;p&gt;A modest rise in order traffic may be handled by scaling out application servers or tuning the database connection pool.&lt;/p&gt;&#10;&lt;p&gt;A sharp burst at the start of an event is different. Even with more application instances, requests converge on the same database to lock inventory and coupon rows and save orders. Accepting more requests at the application tier can make database connection demand and lock waits grow even faster.&lt;/p&gt;&#10;&lt;p&gt;The queue&amp;rsquo;s purpose is to admit requests at a rate the downstream system can handle, while other users wait outside that processing path.&lt;/p&gt;&#10;&lt;p&gt;I chose not to store order request payloads in the server-side queue. The queue contains only &lt;code&gt;memberId&lt;/code&gt;; once admitted, the user sends the original order request again.&lt;/p&gt;&#10;&lt;p&gt;This avoids managing the state and retry policy of queued order payloads on the server. The client instead needs to poll its position and call the order API after reaching &lt;code&gt;READY&lt;/code&gt;.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="overall-architecture"&gt;&lt;a href="#overall-architecture" class="header-anchor"&gt;&lt;/a&gt;Overall architecture&#10;&lt;/h2&gt;&lt;figure class="mx-auto"&gt;&lt;a href="waiting-queue-architecture.png"&gt;&lt;img src="https://0andwild.com/posts/260710_waiting_queue_system_design/waiting-queue-architecture.png"&#10;&#9;&#9;&#9;alt="Overall architecture of the Redis waiting queue system" width="1400"&gt;&lt;/a&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;The flow has three parts:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Register users in a Redis ZSET and expose their current status.&lt;/li&gt;&#10;&lt;li&gt;Let the scheduler remove users from the front and issue entry tokens.&lt;/li&gt;&#10;&lt;li&gt;Validate tokens at the order API and delete them through a local event after the order commits.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The layers have distinct responsibilities:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Layer&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Responsibility&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Interface&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Waiting queue and order APIs, plus the order completion event listener&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Application&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;User authentication, queue status assembly, token issuance, and order admission validation&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Domain / Port&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The &lt;code&gt;WaitingQueuePosition&lt;/code&gt; state model and Repository interfaces that abstract Redis&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Infrastructure&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Repository port implementations using Spring Data Redis&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Redis&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A ZSET for waiting order and a String entry token for each admitted user&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="1-representing-queue-order-with-a-redis-sorted-set"&gt;&lt;a href="#1-representing-queue-order-with-a-redis-sorted-set" class="header-anchor"&gt;&lt;/a&gt;1. Representing queue order with a Redis Sorted Set&#10;&lt;/h2&gt;&lt;p&gt;The queue needs to answer three main questions:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Is the user in the queue?&lt;/li&gt;&#10;&lt;li&gt;What is their current position?&lt;/li&gt;&#10;&lt;li&gt;How many users are waiting in total?&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;A Redis Sorted Set can represent all three.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;key : queue:waiting&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;member : memberId&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;score : enteredAt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Because &lt;code&gt;memberId&lt;/code&gt; is unique within the ZSET, adding the same user repeatedly doesn&amp;rsquo;t create duplicate members. Using &lt;code&gt;enteredAt&lt;/code&gt; as the score places earlier users ahead of later ones. &lt;code&gt;ZRANK&lt;/code&gt; returns their position, and &lt;code&gt;ZPOPMIN&lt;/code&gt; removes users from the front.&lt;/p&gt;&#10;&lt;p&gt;I use the &lt;code&gt;ZRANK&lt;/code&gt; value directly, so the first user&amp;rsquo;s rank is 0. Keeping both the internal model and API response zero-based avoids &lt;code&gt;+1&lt;/code&gt; and &lt;code&gt;-1&lt;/code&gt; conversions at every boundary.&lt;/p&gt;&#10;&lt;h3 id="preserve-the-position-on-repeated-entry"&gt;&lt;a href="#preserve-the-position-on-repeated-entry" class="header-anchor"&gt;&lt;/a&gt;Preserve the position on repeated entry&#10;&lt;/h3&gt;&lt;p&gt;A waiting user may refresh the page or call the entry API again. Overwriting the score with the current time would send them to the back.&lt;/p&gt;&#10;&lt;p&gt;The current implementation checks for an existing score and adds only users who aren&amp;rsquo;t already registered.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;enterIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;member&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;opsForZSet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WAITING_QUEUE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;opsForZSet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WAITING_QUEUE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This preserves the score and rank across sequential repeated calls.&lt;/p&gt;&#10;&lt;p&gt;There is an important limitation: &lt;code&gt;ZSCORE&lt;/code&gt; and &lt;code&gt;ZADD&lt;/code&gt; are separate commands. Two simultaneous first-entry requests for the same user can both see no score and then write different scores. Strict idempotency requires a single atomic Redis operation such as &lt;code&gt;ZADD NX&lt;/code&gt; or &lt;code&gt;addIfAbsent&lt;/code&gt;.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="2-one-response-model-for-waiting-status"&gt;&lt;a href="#2-one-response-model-for-waiting-status" class="header-anchor"&gt;&lt;/a&gt;2. One response model for waiting status&#10;&lt;/h2&gt;&lt;p&gt;After entering, clients poll the same position API. It returns one of three states:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;WAITING&lt;/code&gt;: the user is in the ZSET and has no entry token yet.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;READY&lt;/code&gt;: the user has an entry token and can call the order API.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;NOT_ENTERED&lt;/code&gt;: the user is neither in the queue nor holding a valid entry token.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Status checks look for the entry token first. The scheduler removes users from the ZSET before issuing tokens, so a &lt;code&gt;READY&lt;/code&gt; user is no longer in the ZSET. Checking rank first could incorrectly classify an admitted user as &lt;code&gt;NOT_ENTERED&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="mermaid-box" style="max-width: 720px; margin: 1.5rem auto; overflow-x: auto;"&gt;&#10; &lt;pre class="mermaid" style="visibility:hidden"&gt;&#10;stateDiagram-v2&#10; [*] --&gt; NOT_ENTERED&#10; NOT_ENTERED --&gt; WAITING: enter() / register in queue&#10; WAITING --&gt; WAITING: position polling&#10; WAITING --&gt; READY: scheduler / issue token&#10; READY --&gt; READY: order rollback / retain token&#10; READY --&gt; NOT_ENTERED: order commit / delete token&#10; READY --&gt; NOT_ENTERED: TTL expires&#10;&lt;/pre&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;&lt;code&gt;WaitingQueuePosition&lt;/code&gt; uses the retrieved rank and total waiting count to calculate these response values:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;status&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;rank&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;currentTotalWaitingCount&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;estimatedWaitSeconds&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;pollingIntervalSeconds&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;entryToken&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The estimated wait is the current rank divided by an assumed throughput of 50 users per second. The polling interval is one second with fewer than 100 users ahead, three seconds with fewer than 1,000, and five seconds otherwise.&lt;/p&gt;&#10;&lt;p&gt;Users near admission receive faster updates, while users further back make fewer Redis queries. Since the throughput assumption is fixed, this is only a rough estimate; it doesn&amp;rsquo;t reflect actual order processing speed.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="3-controlling-admission-rate-with-a-scheduler"&gt;&lt;a href="#3-controlling-admission-rate-with-a-scheduler" class="header-anchor"&gt;&lt;/a&gt;3. Controlling admission rate with a scheduler&#10;&lt;/h2&gt;&lt;p&gt;Sending all queued users to the order API at once would defeat the purpose of the queue.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;WaitingQueueScheduler&lt;/code&gt; calls &lt;code&gt;issueNextEntries()&lt;/code&gt; every second. The service uses &lt;code&gt;ZPOPMIN&lt;/code&gt; to remove up to 50 users with the lowest scores and issues a token to each.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;issueNextEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;waitingQueueRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;popNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batchSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mapNotNull&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;memberId&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generateToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;entryTokenRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entryTokenTtl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;entryTokenRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each token is a URL-safe Base64 encoding of 32 bytes generated by &lt;code&gt;SecureRandom&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;key : queue:entry-token:{memberId}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;value : generated token&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;TTL : 300 seconds&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The application doesn&amp;rsquo;t return the generated token directly. It stores it in Redis and reads it back, so a token that wasn&amp;rsquo;t stored isn&amp;rsquo;t treated as a valid admission credential.&lt;/p&gt;&#10;&lt;p&gt;The TTL serves two purposes. It prevents a token from granting indefinite access to the order API, and it eventually cleans up tokens even if deletion after order completion fails.&lt;/p&gt;&#10;&lt;p&gt;A very short TTL could expire while a user is preparing an order. A very long TTL leaves users eligible to enter long after they have stopped trying. The current 300-second value is the assignment&amp;rsquo;s default policy; a production value should be based on measured user behavior and target throughput.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="4-requiring-an-entry-token-at-the-order-api"&gt;&lt;a href="#4-requiring-an-entry-token-at-the-order-api" class="header-anchor"&gt;&lt;/a&gt;4. Requiring an entry token at the order API&#10;&lt;/h2&gt;&lt;p&gt;Order requests include an &lt;code&gt;X-Entry-Token&lt;/code&gt; header.&lt;/p&gt;&#10;&lt;p&gt;After authentication, &lt;code&gt;OrderFacade.placeOrder()&lt;/code&gt; compares the request token with the one stored in Redis. A missing, mismatched, or expired token results in &lt;code&gt;401 Unauthorized&lt;/code&gt; before order processing begins.&lt;/p&gt;&#10;&lt;p&gt;I put this validation in the application layer because eligibility to place an order is a precondition of the use case, beyond simply validating an HTTP header&amp;rsquo;s format.&lt;/p&gt;&#10;&lt;p&gt;This is a fail-closed design: when Redis doesn&amp;rsquo;t respond, orders are blocked. I chose to stop accepting orders instead of letting traffic bypass the queue and overwhelm downstream systems. That protects the server, but makes a Redis outage an order outage as well.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="5-deleting-tokens-outside-the-order-transaction"&gt;&lt;a href="#5-deleting-tokens-outside-the-order-transaction" class="header-anchor"&gt;&lt;/a&gt;5. Deleting tokens outside the order transaction&#10;&lt;/h2&gt;&lt;p&gt;I initially considered deleting the token directly inside the order creation method.&lt;/p&gt;&#10;&lt;p&gt;That would mix an external Redis operation into the database transaction flow. Deleting the token before commit is also problematic: if the order rolls back, the user loses admission even though the order failed.&lt;/p&gt;&#10;&lt;p&gt;The order flow already publishes &lt;code&gt;OrderEvent.Created&lt;/code&gt; on success, so I moved token deletion into a listener that receives this local event at &lt;code&gt;AFTER_COMMIT&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionPhase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;waitingQueueService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleteEntryToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This gives us two useful properties:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A rolled-back order leaves the token available for another attempt.&lt;/li&gt;&#10;&lt;li&gt;A token cleanup failure cannot roll back an already committed order.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;A local event is not a durable message, however. If the process exits just after commit, or Redis deletion fails, the listener has no automatic recovery. The TTL provides eventual cleanup for now. Immediate cleanup would require a retryable event record or a separate cleanup job.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;AFTER_COMMIT&lt;/code&gt; also does not automatically mean asynchronous execution or exception isolation. The current listener runs in the same call flow as the order request. If a Redis deletion exception propagates, the HTTP response can fail even though the order has committed. Production handling needs an explicit policy for logging and alerting on those exceptions, alongside retryable cleanup work.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="technical-decisions"&gt;&lt;a href="#technical-decisions" class="header-anchor"&gt;&lt;/a&gt;Technical decisions&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Design area&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Choice&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Reason&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Waiting order&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Redis Sorted Set&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Unique members, score ordering, rank, and total count in one structure&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ZSET member&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;memberId&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;One waiting entry per user&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;ZSET score&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Entry timestamp in milliseconds&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Give earlier arrivals priority&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Admission control&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Fixed-delay scheduler + batch size&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Limit the rate at which users reach downstream processing&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Admission credential&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Per-user Redis String token + TTL&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Separate waiting from readiness and expire old credentials automatically&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Status updates&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Client polling&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Expose positions through a simple HTTP API without a separate push channel&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Order validation&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Start of &lt;code&gt;OrderFacade&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Check admission before expensive order processing&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Token cleanup&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;AFTER_COMMIT&lt;/code&gt; listener for &lt;code&gt;OrderEvent.Created&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clean up only successful orders and separate deletion from order rollback&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="trade-offs"&gt;&lt;a href="#trade-offs" class="header-anchor"&gt;&lt;/a&gt;Trade-offs&#10;&lt;/h2&gt;&lt;h3 id="admission-rate-is-controlled-but-concurrent-orders-are-not-strictly-bounded"&gt;&lt;a href="#admission-rate-is-controlled-but-concurrent-orders-are-not-strictly-bounded" class="header-anchor"&gt;&lt;/a&gt;Admission rate is controlled, but concurrent orders are not strictly bounded&#10;&lt;/h3&gt;&lt;p&gt;Issuing 50 tokens per second doesn&amp;rsquo;t guarantee that no more than 50 orders run at once.&lt;/p&gt;&#10;&lt;p&gt;Tokens remain valid for 300 seconds. Users admitted across several seconds can wait and then submit orders at the same moment. This design controls the token issuance rate, not actual in-flight order concurrency.&lt;/p&gt;&#10;&lt;p&gt;A strict downstream concurrency limit would need active slots, token claims, a semaphore, or another control. Another option is to admit users based on actual order completion rate.&lt;/p&gt;&#10;&lt;h3 id="polling-is-simple-but-creates-read-traffic"&gt;&lt;a href="#polling-is-simple-but-creates-read-traffic" class="header-anchor"&gt;&lt;/a&gt;Polling is simple, but creates read traffic&#10;&lt;/h3&gt;&lt;p&gt;HTTP polling is easy to implement on both sides and doesn&amp;rsquo;t require long-lived connections. But more waiting users mean more repeated &lt;code&gt;GET token&lt;/code&gt;, &lt;code&gt;ZRANK&lt;/code&gt;, and &lt;code&gt;ZCARD&lt;/code&gt; calls. Varying the polling interval by rank reduces that load.&lt;/p&gt;&#10;&lt;p&gt;At larger scale, I would consider client-side jitter to spread requests out, longer polling intervals, Redis read replicas rather than CDN caching for these reads, or SSE-based push.&lt;/p&gt;&#10;&lt;h3 id="separate-cleanup-does-not-guarantee-immediate-cleanup"&gt;&lt;a href="#separate-cleanup-does-not-guarantee-immediate-cleanup" class="header-anchor"&gt;&lt;/a&gt;Separate cleanup does not guarantee immediate cleanup&#10;&lt;/h3&gt;&lt;p&gt;An &lt;code&gt;AFTER_COMMIT&lt;/code&gt; listener reduces the responsibilities inside the order transaction. Its failures happen after the order succeeds, though, so the order cannot be rolled back. A local event also leaves no retry record.&lt;/p&gt;&#10;&lt;p&gt;The TTL eventually resolves the leftover-token state, but the token can remain valid until it expires.&lt;/p&gt;&#10;&lt;h3 id="a-redis-outage-stops-orders"&gt;&lt;a href="#a-redis-outage-stops-orders" class="header-anchor"&gt;&lt;/a&gt;A Redis outage stops orders&#10;&lt;/h3&gt;&lt;p&gt;Fail-closed behavior protects the database from requests bypassing the queue, while making Redis a required dependency of the entire order flow. Production design needs replication, Sentinel or Cluster, persistence, and failover policies as well.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="remaining-limitations-and-work-beyond-the-assignment"&gt;&lt;a href="#remaining-limitations-and-work-beyond-the-assignment" class="header-anchor"&gt;&lt;/a&gt;Remaining limitations and work beyond the assignment&#10;&lt;/h2&gt;&lt;h3 id="1-concurrent-entry-by-the-same-user-must-be-atomic"&gt;&lt;a href="#1-concurrent-entry-by-the-same-user-must-be-atomic" class="header-anchor"&gt;&lt;/a&gt;1. Concurrent entry by the same user must be atomic&#10;&lt;/h3&gt;&lt;p&gt;&lt;code&gt;ZSCORE&lt;/code&gt; followed by &lt;code&gt;ZADD&lt;/code&gt; is a check-then-act sequence. I verified concurrent entry by eight distinct users and sequential repeated entry by the same user. That does not establish atomicity for simultaneous first-entry requests from one user.&lt;/p&gt;&#10;&lt;p&gt;This should use the single &lt;code&gt;ZADD NX&lt;/code&gt; command.&lt;/p&gt;&#10;&lt;h3 id="2-users-can-be-lost-between-zpopmin-and-token-storage"&gt;&lt;a href="#2-users-can-be-lost-between-zpopmin-and-token-storage" class="header-anchor"&gt;&lt;/a&gt;2. Users can be lost between ZPOPMIN and token storage&#10;&lt;/h3&gt;&lt;p&gt;The scheduler removes a user from the ZSET before saving the token. If the process exits or the Redis write fails between those operations, the user has neither a queue entry nor a token and becomes &lt;code&gt;NOT_ENTERED&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Reading the token back after storage cannot recover a user who has already been popped.&lt;/p&gt;&#10;&lt;p&gt;A production design could atomically move users into a processing ZSET, then acknowledge them after token issuance succeeds. A recovery job could return users without an acknowledgment to the waiting ZSET after a timeout.&lt;/p&gt;&#10;&lt;h3 id="3-batch-size-is-not-a-global-limit-across-application-instances"&gt;&lt;a href="#3-batch-size-is-not-a-global-limit-across-application-instances" class="header-anchor"&gt;&lt;/a&gt;3. Batch size is not a global limit across application instances&#10;&lt;/h3&gt;&lt;p&gt;If every instance runs the scheduler, each can pop 50 users per second. &lt;code&gt;ZPOPMIN&lt;/code&gt; prevents duplicate removal of the same user, but four instances can issue up to 200 tokens per second in total.&lt;/p&gt;&#10;&lt;p&gt;Using batch size as a global throughput limit requires scheduler leader election, a distributed lock, a dedicated worker, or a Redis-based global rate limiter.&lt;/p&gt;&#10;&lt;h3 id="4-the-token-grants-temporary-admission-not-exactly-one-order"&gt;&lt;a href="#4-the-token-grants-temporary-admission-not-exactly-one-order" class="header-anchor"&gt;&lt;/a&gt;4. The token grants temporary admission, not exactly one order&#10;&lt;/h3&gt;&lt;p&gt;Token deletion happens after order commit. Two concurrent requests from the same user with the same token can both pass validation.&lt;/p&gt;&#10;&lt;p&gt;If one token must authorize only one order, validation and claiming it must be atomic. A simple &lt;code&gt;GETDEL&lt;/code&gt; at order start also removes the token when the order later rolls back. A more complete design needs states such as &lt;code&gt;READY → CLAIMED → CONSUMED&lt;/code&gt;, plus a release policy on rollback. Order-level idempotency keys deserve separate consideration, too.&lt;/p&gt;&#10;&lt;h3 id="5-estimated-wait-time-doesnt-reflect-real-throughput"&gt;&lt;a href="#5-estimated-wait-time-doesnt-reflect-real-throughput" class="header-anchor"&gt;&lt;/a&gt;5. Estimated wait time doesn&amp;rsquo;t reflect real throughput&#10;&lt;/h3&gt;&lt;p&gt;The current formula is &lt;code&gt;rank / 50&lt;/code&gt;. It matches the scheduler setting but ignores database latency, order success rate, users who receive tokens without ordering, and outages.&lt;/p&gt;&#10;&lt;p&gt;A moving average of recent token issuance and order completion rates, together with batch-size adjustments based on operational metrics, would provide a more realistic estimate.&lt;/p&gt;&#10;&lt;h3 id="6-arrivals-within-the-same-millisecond-are-not-strictly-fifo"&gt;&lt;a href="#6-arrivals-within-the-same-millisecond-are-not-strictly-fifo" class="header-anchor"&gt;&lt;/a&gt;6. Arrivals within the same millisecond are not strictly FIFO&#10;&lt;/h3&gt;&lt;p&gt;Scores use application timestamps in milliseconds. Multiple users can arrive in the same millisecond, and clock differences across application servers can change score order relative to actual arrival order.&lt;/p&gt;&#10;&lt;p&gt;For equal scores, Redis ZSET orders members lexicographically, which can also differ from arrival order. Strict FIFO would require a design using Redis &lt;code&gt;TIME&lt;/code&gt; with a sequence, or a separate incrementing value in the score.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="what-i-verified"&gt;&lt;a href="#what-i-verified" class="header-anchor"&gt;&lt;/a&gt;What I verified&#10;&lt;/h2&gt;&lt;p&gt;Using Testcontainers Redis and API E2E tests, I checked:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;First entry returns rank 0 and the total waiting count.&lt;/li&gt;&#10;&lt;li&gt;Sequential repeated entry preserves the user&amp;rsquo;s rank.&lt;/li&gt;&#10;&lt;li&gt;Concurrent entry by distinct users assigns unique ranks.&lt;/li&gt;&#10;&lt;li&gt;Responses represent &lt;code&gt;WAITING&lt;/code&gt;, &lt;code&gt;READY&lt;/code&gt;, and &lt;code&gt;NOT_ENTERED&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Tokens are unavailable after their TTL expires.&lt;/li&gt;&#10;&lt;li&gt;One scheduler run issues no more than the batch size, even with more users waiting.&lt;/li&gt;&#10;&lt;li&gt;Orders without an entry token are rejected.&lt;/li&gt;&#10;&lt;li&gt;A successful order commit deletes the entry token.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;These tests verify functional contracts. They don&amp;rsquo;t establish maximum capacity or the right batch size. To choose production settings, I would use a tool such as k6 to generate entry bursts and position polling together, while observing application latency, Redis command throughput, CPU, memory, and connection counts.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="alternatives-considered"&gt;&lt;a href="#alternatives-considered" class="header-anchor"&gt;&lt;/a&gt;Alternatives considered&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Option&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Benefits&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Costs&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Delete the token inside the order transaction&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;The full flow is visible in the order code&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Redis cleanup becomes part of the transaction&amp;rsquo;s responsibilities, and rollback handling becomes more complicated&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;Selected: delete through an &lt;code&gt;AFTER_COMMIT&lt;/code&gt; local event&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clean up successful orders and separate post-processing from the transaction&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Listener failures aren&amp;rsquo;t automatically recovered; tokens can remain until TTL expiry&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The central decision was that token deletion should not determine whether an order succeeds.&lt;/p&gt;&#10;&lt;p&gt;Once an order has committed, preserving that success and cleaning up the token separately makes more sense than presenting a failure because cleanup failed.&lt;/p&gt;&#10;&lt;p&gt;The local event alone doesn&amp;rsquo;t provide that reliability, though. It separates responsibilities, while failure recovery still relies on the TTL. A cleaner structure and a more reliable production system are separate outcomes.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="closing-thoughts"&gt;&lt;a href="#closing-thoughts" class="header-anchor"&gt;&lt;/a&gt;Closing thoughts&#10;&lt;/h2&gt;&lt;p&gt;At first, I thought adding users to a Redis ZSET and returning their rank would complete the queue implementation.&lt;/p&gt;&#10;&lt;p&gt;Most of the difficult questions turned out to concern when a user becomes eligible to order, when that eligibility ends, and which state they should return to after a failure.&lt;/p&gt;&#10;&lt;p&gt;In this design, the ZSET represents waiting and the TTL token represents readiness. The order API checks the token, and an event after order commit triggers cleanup. This made the boundary between queue responsibilities and the order transaction clearer.&lt;/p&gt;&#10;&lt;p&gt;It also exposed gaps: atomic registration, recovery after pop, global throughput across instances, one-time token use, and wait estimates based on actual throughput. The assignment didn&amp;rsquo;t force all of these issues into view, but production use would require addressing them.&lt;/p&gt;&#10;&lt;p&gt;A waiting queue is an admission control system. It needs to move users at a pace the system can handle and preserve their state transitions even when something fails.&lt;/p&gt;&#10;</description></item><item><title>Why I Chose Pessimistic Locking for Order Consistency</title><link>https://0andwild.com/en/posts/260612_order_consistency_lock_design/</link><pubDate>Fri, 12 Jun 2026 05:50:00 +0900</pubDate><guid>https://0andwild.com/en/posts/260612_order_consistency_lock_design/</guid><description>&lt;img src="https://0andwild.com/" alt="Featured image of post Why I Chose Pessimistic Locking for Order Consistency" /&gt;&lt;h2 id="tldr"&gt;&lt;a href="#tldr" class="header-anchor"&gt;&lt;/a&gt;TL;DR&#10;&lt;/h2&gt;&lt;p&gt;There were two failures I wanted the order flow to prevent: two successful orders using the same coupon, and more successful orders than available stock.&lt;/p&gt;&#10;&lt;p&gt;For this flow, I chose to acquire locks up front and serialize conflicting work with pessimistic locking, rather than detect conflicts through optimistic locking.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="overview"&gt;&lt;a href="#overview" class="header-anchor"&gt;&lt;/a&gt;Overview&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Goals:&#10;&lt;ul&gt;&#10;&lt;li&gt;Save the order, deduct inventory, and consume the coupon in one transaction.&lt;/li&gt;&#10;&lt;li&gt;Allow an issued coupon to be used only once, even under concurrent requests.&lt;/li&gt;&#10;&lt;li&gt;Allow concurrent orders for a product to succeed only up to the available stock quantity.&lt;/li&gt;&#10;&lt;li&gt;Avoid partial changes to orders, inventory, or coupons when a request fails.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="the-problem"&gt;&lt;a href="#the-problem" class="header-anchor"&gt;&lt;/a&gt;The problem&#10;&lt;/h2&gt;&lt;p&gt;A transaction guarantees atomicity within a request. It doesn&amp;rsquo;t automatically solve every problem caused by multiple requests reading and modifying the same data concurrently.&lt;/p&gt;&#10;&lt;p&gt;Two orders using the same coupon can both read &lt;code&gt;AVAILABLE&lt;/code&gt;. If ten users order a product with five units left, more than five orders could succeed.&lt;/p&gt;&#10;&lt;p&gt;I wanted to prevent these outcomes when the order was created rather than detect and correct them afterward.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="order-processing-flow"&gt;&lt;a href="#order-processing-flow" class="header-anchor"&gt;&lt;/a&gt;Order processing flow&#10;&lt;/h2&gt;&lt;p&gt;Order creation follows this flow:&lt;/p&gt;&#10;&lt;div class="mermaid-box" style="max-width: 600px; margin: 1.5rem auto; overflow-x: auto;"&gt;&#10; &lt;pre class="mermaid" style="visibility:hidden"&gt;&#10;flowchart TD&#10; A["Order request"] --&gt; B["Authenticate user"]&#10; B --&gt; C["Sort requested product IDs"]&#10; C --&gt; D["Read coupon issue row with pessimistic lock"]&#10; C --&gt; E["Read inventory rows with pessimistic locks"]&#10; D --&gt; F["Load products / brands"]&#10; E --&gt; F&#10; F --&gt; G["Validate domain rules"]&#10; G --&gt; H{"Validation passed?"}&#10; H -- "No" --&gt; R["Roll back transaction"]&#10; H -- "Yes" --&gt; I["Deduct inventory"]&#10; I --&gt; J["Mark coupon USED"]&#10; J --&gt; K["Save order"]&#10; K --&gt; L["Commit transaction"]&#10;&lt;/pre&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The key is to lock the coupon and inventory first, then complete validation and updates within the same transaction. If validation fails, no order is saved and no coupon or inventory changes are committed.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="technical-decisions"&gt;&lt;a href="#technical-decisions" class="header-anchor"&gt;&lt;/a&gt;Technical decisions&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Design area&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Choice&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Rationale&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Order transaction boundary&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;OrderFacade.placeOrder&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Keep authentication, inventory, coupon use, and order persistence within one atomic use case&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Coupon concurrency control&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Pessimistic lock on the &lt;code&gt;CouponIssue&lt;/code&gt; row&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Serialize validation and the transition to &lt;code&gt;USED&lt;/code&gt; so an issued coupon can be used only once&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Inventory concurrency control&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Pessimistic lock on the &lt;code&gt;Inventory&lt;/code&gt; row&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Prevent overselling and make deduction results predictable&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Orders with multiple products&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sort by &lt;code&gt;productId&lt;/code&gt; before acquiring locks&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Reduce deadlock risk by using a consistent lock acquisition order&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Like count&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Pessimistic lock on the &lt;code&gt;ProductStat&lt;/code&gt; row&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Prevent a Lost Update to &lt;code&gt;likeCount&lt;/code&gt; during concurrent like and unlike requests&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Coupon terms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Snapshot stored in &lt;code&gt;CouponIssue&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Preserve the discount terms promised at issuance when the coupon is used later&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="why-pessimistic-locking"&gt;&lt;a href="#why-pessimistic-locking" class="header-anchor"&gt;&lt;/a&gt;Why pessimistic locking?&#10;&lt;/h2&gt;&lt;p&gt;Conflicts involving coupons and inventory have a high cost. Duplicate coupon use directly affects money, and overselling produces successful orders for stock that no longer exists.&lt;/p&gt;&#10;&lt;p&gt;Optimistic locking detects a conflict after concurrent work has taken place. That can be the right choice, but here it would also require decisions about retries, failures, and the response presented to the user.&lt;/p&gt;&#10;&lt;p&gt;Pessimistic locking serializes work on the same &lt;code&gt;CouponIssue&lt;/code&gt; or &lt;code&gt;Inventory&lt;/code&gt; row from the start. Requests may wait, but only one request at a time validates and changes the locked data, making the outcome easier to reason about.&lt;/p&gt;&#10;&lt;p&gt;For this implementation, preserving states that must never be violated took priority over throughput.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="detailed-design"&gt;&lt;a href="#detailed-design" class="header-anchor"&gt;&lt;/a&gt;Detailed design&#10;&lt;/h2&gt;&lt;h3 id="1-lock-couponissue-for-coupon-use"&gt;&lt;a href="#1-lock-couponissue-for-coupon-use" class="header-anchor"&gt;&lt;/a&gt;1. Lock CouponIssue for coupon use&#10;&lt;/h3&gt;&lt;p&gt;The one-time resource is the issued coupon, not the coupon template.&lt;/p&gt;&#10;&lt;p&gt;When an order request contains &lt;code&gt;couponId&lt;/code&gt;, I load the &lt;code&gt;CouponIssue&lt;/code&gt; row with &lt;code&gt;PESSIMISTIC_WRITE&lt;/code&gt;. The domain then validates ownership, usage status, expiration, and the minimum order amount before changing the status to &lt;code&gt;USED&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="mermaid-box" style="max-width: 400px; margin: 1.5rem auto; overflow-x: auto;"&gt;&#10; &lt;pre class="mermaid" style="visibility:hidden"&gt;&#10;flowchart LR&#10; A["CouponIssueEntity&lt;br/&gt;SELECT FOR UPDATE"] --&gt; B["Convert to CouponIssue domain model"]&#10; B --&gt; C["Domain.use()&lt;br/&gt;Validate + mark USED"]&#10; C --&gt; D["Reload entity"]&#10; D --&gt; E["entity.update(domain)"]&#10; E --&gt; F["Flush at commit"]&#10;&#10; subgraph TX["Same database transaction"]&#10; A&#10; B&#10; C&#10; D&#10; E&#10; F&#10; end&#10;&lt;/pre&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Pessimistic locking still works when the domain model and JPA entity are separate. The lock is associated with the database row and transaction, not the domain object.&lt;/p&gt;&#10;&lt;p&gt;Changing the domain object doesn&amp;rsquo;t trigger dirty checking, however. Its values must be copied back to the entity before persistence.&lt;/p&gt;&#10;&lt;h3 id="2-sort-productid-before-locking-inventory"&gt;&lt;a href="#2-sort-productid-before-locking-inventory" class="header-anchor"&gt;&lt;/a&gt;2. Sort productId before locking inventory&#10;&lt;/h3&gt;&lt;p&gt;An order can contain multiple products, so it may need locks on several &lt;code&gt;Inventory&lt;/code&gt; rows.&lt;/p&gt;&#10;&lt;p&gt;Different lock acquisition orders increase the risk of deadlock. If request A locks product 1 and then product 2 while request B locks product 2 and then product 1, each can end up waiting for the other&amp;rsquo;s lock.&lt;/p&gt;&#10;&lt;p&gt;I therefore remove duplicate product IDs and sort them before loading the rows. This doesn&amp;rsquo;t eliminate every possible deadlock, but it provides a basic safeguard.&lt;/p&gt;&#10;&lt;div class="mermaid-box" style="max-width: 680px; margin: 1.5rem auto; overflow-x: auto;"&gt;&#10; &lt;pre class="mermaid" style="visibility:hidden"&gt;&#10;flowchart TD&#10; A["Order product IDs&lt;br/&gt;3, 1, 2, 1"] --&gt; B["Remove duplicates&lt;br/&gt;3, 1, 2"]&#10; B --&gt; C["Sort&lt;br/&gt;1, 2, 3"]&#10; C --&gt; D["Lock Inventory rows&lt;br/&gt;in order: 1 -&gt; 2 -&gt; 3"]&#10; D --&gt; E["Validate inventory"]&#10; E --&gt; F["Deduct inventory"]&#10;&lt;/pre&gt;&#10;&lt;/div&gt;&#10;&lt;h3 id="3-snapshot-coupon-terms-at-issuance"&gt;&lt;a href="#3-snapshot-coupon-terms-at-issuance" class="header-anchor"&gt;&lt;/a&gt;3. Snapshot coupon terms at issuance&#10;&lt;/h3&gt;&lt;p&gt;If &lt;code&gt;CouponIssue&lt;/code&gt; only references &lt;code&gt;Coupon&lt;/code&gt; and doesn&amp;rsquo;t hold its own discount terms, those terms can change between issuance and use.&lt;/p&gt;&#10;&lt;p&gt;A user might receive a 10% coupon, only to get 5% off after someone edits the template.&lt;/p&gt;&#10;&lt;p&gt;I store &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;discountValue&lt;/code&gt;, &lt;code&gt;minOrderAmount&lt;/code&gt;, and &lt;code&gt;expiredAt&lt;/code&gt; as a snapshot in &lt;code&gt;CouponIssue&lt;/code&gt;. The extra columns are a cost I accepted to preserve what was promised to the user.&lt;/p&gt;&#10;&lt;div class="mermaid-box" style="max-width: 820px; margin: 1.5rem auto; overflow-x: auto;"&gt;&#10; &lt;pre class="mermaid" style="visibility:hidden"&gt;&#10;flowchart LR&#10; A["Coupon template&lt;br/&gt;10% discount"] --&gt; B["Issue CouponIssue"]&#10; B --&gt; C["Snapshot&lt;br/&gt;type / discountValue / minOrderAmount / expiredAt"]&#10; A --&gt; D["Template later edited or deleted"]&#10; C --&gt; E["Calculate order discount&lt;br/&gt;from the CouponIssue snapshot"]&#10;&lt;/pre&gt;&#10;&lt;/div&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="alternatives-considered"&gt;&lt;a href="#alternatives-considered" class="header-anchor"&gt;&lt;/a&gt;Alternatives considered&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Option&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Pros&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Cons&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A. Optimistic locking&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No lock waiting when conflicts are rare&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Requires a retry/failure policy after conflicts and complicates order responses&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;B. Pessimistic locking&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Serializes validation and updates, making duplicate use and excessive deductions easier to reason about&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Busy rows cause longer waits; deadlocks need consideration&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;C. Queue-based serial processing&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Straightforward sequential processing for hot keys&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Requires redesigning order states and responses around an asynchronous flow&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;Selected: B&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clearest consistency checks and failure rules for the current requirements&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Requires care with lock duration and query order&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;&lt;strong&gt;Why I chose B:&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;A successful order appears to the user as an immediate, confirmed outcome. If stock is insufficient or a coupon has already been used, I would rather reject the request up front than show success and cancel it later.&lt;/p&gt;&#10;&lt;p&gt;That led me to lock the contested resources before validation. Optimistic locking has clear benefits, but conflict handling would become a central source of complexity in this flow. Pessimistic locking trades some performance for simpler success and failure rules.&lt;/p&gt;&#10;&lt;h2 id="open-questions-and-trade-offs"&gt;&lt;a href="#open-questions-and-trade-offs" class="header-anchor"&gt;&lt;/a&gt;Open questions and trade-offs&#10;&lt;/h2&gt;&lt;p&gt;Experimenting with optimistic locking also made the cost of separating domain models from JPA entities more apparent.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;@Version&lt;/code&gt; and dirty checking fit most naturally when working directly with entities. Exposing entities to the application layer makes JPA features convenient to use, but weakens the layer boundaries. Keeping entities inside infrastructure makes the Mapper and Repository implementations more complicated.&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t take this to mean that pessimistic locking is always the right answer. Preventing duplicate coupon use and excessive inventory deductions was the priority for this order flow, and pessimistic locking addressed that priority directly.&lt;/p&gt;&#10;&lt;p&gt;Deadlocks remain a concern. Sorting by &lt;code&gt;productId&lt;/code&gt; reduces the risk by aligning lock acquisition order, but it isn&amp;rsquo;t a complete solution. Still, a consistent order is a basic precaution whenever a flow locks multiple rows.&lt;/p&gt;&#10;</description></item><item><title>Separating Domain Models from JPA Entities</title><link>https://0andwild.com/en/posts/260529_domain_vs_entity/</link><pubDate>Fri, 29 May 2026 12:43:19 +0900</pubDate><guid>https://0andwild.com/en/posts/260529_domain_vs_entity/</guid><description>&lt;h2 id="tldr"&gt;&lt;a href="#tldr" class="header-anchor"&gt;&lt;/a&gt;TL;DR&#10;&lt;/h2&gt;&lt;p&gt;I separated domain models from database entities to keep business rules in the domain and isolate JPA mappings and persistence concerns in the infrastructure layer.&lt;/p&gt;&#10;&lt;p&gt;Core models such as &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, and &lt;code&gt;Order&lt;/code&gt; can now express domain rules without JPA annotations. Repository implementations use Mappers to convert between those models and database entities.&lt;/p&gt;&#10;&lt;p&gt;The separation also raised questions: where DTO-like objects should live, what Mappers should own, how to reload entities for updates, and where read query projections belong.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="overview"&gt;&lt;a href="#overview" class="header-anchor"&gt;&lt;/a&gt;Overview&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Goals:&#10;&lt;ul&gt;&#10;&lt;li&gt;Separate the domain rules and persistence mappings previously handled by a single JPA entity.&lt;/li&gt;&#10;&lt;li&gt;Let domain models express business rules and database entities handle table mappings and persistence.&lt;/li&gt;&#10;&lt;li&gt;Let the Application Layer coordinate domain objects while the Infrastructure Layer stores and retrieves data through JPA.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Main design priorities:&#10;&lt;ul&gt;&#10;&lt;li&gt;Remove direct JPA dependencies from the Domain Layer.&lt;/li&gt;&#10;&lt;li&gt;Keep Repository interfaces in the Domain Layer and implementations in the Infrastructure Layer.&lt;/li&gt;&#10;&lt;li&gt;Use Mappers to convert between database entities and domain models.&lt;/li&gt;&#10;&lt;li&gt;Express domain rules through methods and constructor validation rather than entity annotations.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="why-separate-them"&gt;&lt;a href="#why-separate-them" class="header-anchor"&gt;&lt;/a&gt;Why separate them?&#10;&lt;/h2&gt;&lt;p&gt;The original Member implementation had a service layer that called repositories directly and also handled business logic. That is quick to build for small features, but several problems emerge as the domain grows.&lt;/p&gt;&#10;&lt;p&gt;First, the JPA entity takes on too many responsibilities. It already uses annotations such as &lt;code&gt;Column&lt;/code&gt;, &lt;code&gt;Entity&lt;/code&gt;, &lt;code&gt;Table&lt;/code&gt;, and &lt;code&gt;OneToMany&lt;/code&gt; to describe table mappings. Adding business rules means the same object must understand both the database structure and the domain.&lt;/p&gt;&#10;&lt;p&gt;Second, testing becomes harder. When domain logic is built around JPA entities, even simple rule checks can make us think about the persistence context and JPA behavior. A plain Kotlin domain object lets us test rules through its constructor and methods without a repository.&lt;/p&gt;&#10;&lt;p&gt;Third, names and responsibilities become mixed. This project called the user domain &lt;code&gt;User&lt;/code&gt;, but used a database table named &lt;code&gt;member&lt;/code&gt;. Calling the JPA class &lt;code&gt;UserEntity&lt;/code&gt; made imports and concepts feel inconsistent. I kept &lt;code&gt;User&lt;/code&gt; in the domain and used database-oriented names such as &lt;code&gt;MemberEntity&lt;/code&gt;, &lt;code&gt;MemberMapper&lt;/code&gt;, and &lt;code&gt;MemberRepositoryImpl&lt;/code&gt; in infrastructure.&lt;/p&gt;&#10;&lt;h2 id="technical-decisions"&gt;&lt;a href="#technical-decisions" class="header-anchor"&gt;&lt;/a&gt;Technical decisions&#10;&lt;/h2&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Design area&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Choice&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Rationale&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Domain models and database entities&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Separate them&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Separate domain rules from JPA mapping responsibilities&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Repository placement&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Interface in Domain, implementation in Infrastructure&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Avoid coupling the application to a concrete JPA implementation&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Conversion&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Separate Mapper&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Keep both conversion directions together instead of adding methods such as &lt;code&gt;toDomain&lt;/code&gt; to entities&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Entity names&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;MemberEntity&lt;/code&gt;, &lt;code&gt;ProductEntity&lt;/code&gt;, &lt;code&gt;OrderEntity&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Make their role as table-mapped persistence models explicit&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Domain names&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;User&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;Order&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Express the business concepts directly&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Query optimization&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Projections for some list queries&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Converting every result to a domain object can hurt sorting and pagination performance&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;h2 id="how-i-applied-it"&gt;&lt;a href="#how-i-applied-it" class="header-anchor"&gt;&lt;/a&gt;How I applied it&#10;&lt;/h2&gt;&lt;h2 id="1-separating-user-from-memberentity"&gt;&lt;a href="#1-separating-user-from-memberentity" class="header-anchor"&gt;&lt;/a&gt;1. Separating User from MemberEntity&#10;&lt;/h2&gt;&lt;p&gt;The domain model has no JPA annotations. &lt;code&gt;User&lt;/code&gt; expresses the business rules for a member.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;loginId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;birthDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;loginId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loginId&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;birthDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocalDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;birthDate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;loginId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOGIN_ID_REGEX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;LoginId must contain only letters and numbers.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NAME_REGEX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Name cannot contain special characters or numbers.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EMAIL_REGEX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Email cannot contain special characters or numbers.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;birthDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Birthdate must be before now.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;updatePassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encodedPassword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encodedPassword&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The database entity lives in infrastructure and maps to the &lt;code&gt;member&lt;/code&gt; table.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;member&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MemberEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;loginId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;birthDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocalDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BaseEntity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;loginId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loginId&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;birthDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;birthDate&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Although &lt;code&gt;User&lt;/code&gt; and &lt;code&gt;MemberEntity&lt;/code&gt; hold the same data, they have different responsibilities. &lt;code&gt;User&lt;/code&gt; represents the business concept of a member. &lt;code&gt;MemberEntity&lt;/code&gt; is the persistence model used to store that information in the &lt;code&gt;member&lt;/code&gt; table.&lt;/p&gt;&#10;&lt;h2 id="2-moving-conversion-into-a-mapper"&gt;&lt;a href="#2-moving-conversion-into-a-mapper" class="header-anchor"&gt;&lt;/a&gt;2. Moving conversion into a Mapper&#10;&lt;/h2&gt;&lt;p&gt;Separate models need conversion code. I introduced a dedicated Mapper instead of putting &lt;code&gt;toDomain&lt;/code&gt; inside the entity.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;MemberMapper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MemberEntity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;loginId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loginId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;birthDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;birthDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;toEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;MemberEntity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;MemberEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;loginId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loginId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;birthDate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;birthDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The goal was to reduce how much the entity needed to know about the domain.&lt;/p&gt;&#10;&lt;p&gt;Some dependencies remain. For example, &lt;code&gt;MemberEntity.update(user: User)&lt;/code&gt; still accepts a domain object. During an update, I load a JPA managed entity and copy the domain values onto it.&lt;/p&gt;&#10;&lt;p&gt;A stricter separation could make &lt;code&gt;update&lt;/code&gt; accept primitive values or a command instead. But that can add duplication as the number of fields grows. I chose a practical separation rather than complete isolation.&lt;/p&gt;&#10;&lt;h2 id="3-separating-product-from-productentity"&gt;&lt;a href="#3-separating-product-from-productentity" class="header-anchor"&gt;&lt;/a&gt;3. Separating Product from ProductEntity&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Product&lt;/code&gt; owns product rules. A product name cannot be empty, its price cannot be negative, and deletion is expressed as a change to its state.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;brandId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;isDeleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;isDeleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isDeleted&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;brandId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;brandId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;brandId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;brandId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imageUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;isDeleted&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;ProductEntity&lt;/code&gt; maps to the &lt;code&gt;product&lt;/code&gt; table and contains the JPA annotations and database constraints.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@SQLRestriction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;is_deleted = false&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;product&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;uniqueConstraints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;uk_product_brand_id_name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;columnNames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;brand_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;brand_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;brandId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;isDeleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BaseEntity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;brandId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brandId&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;imageUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;imageUrl&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;isDeleted&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDeleted&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This separation lets infrastructure handle the soft delete query policy while &lt;code&gt;Product&lt;/code&gt; expresses the domain action of deleting a product.&lt;/p&gt;&#10;&lt;p&gt;There is a trade-off, though. &lt;code&gt;SQLRestriction&lt;/code&gt; automatically excludes deleted data from ordinary queries, so the application layer doesn&amp;rsquo;t need to check &lt;code&gt;isDeleted&lt;/code&gt; every time. Features such as administrative auditing or restoration need a separate query path that can include deleted records.&lt;/p&gt;&#10;&lt;h2 id="4-separating-the-repository-interface-from-its-implementation"&gt;&lt;a href="#4-separating-the-repository-interface-from-its-implementation" class="header-anchor"&gt;&lt;/a&gt;4. Separating the Repository interface from its implementation&#10;&lt;/h2&gt;&lt;p&gt;The Domain Layer contains only the Repository interface.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findAllByIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;):&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Infrastructure Layer contains the implementation that uses the JPA repository.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductRepositoryImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;productJpaRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ProductJpaRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ProductRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;productJpaRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findByIdOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProductMapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;productJpaRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProductMapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProductMapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;entity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;productJpaRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findByIdOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;also&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Product not found.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;productJpaRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProductMapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Application Service depends only on &lt;code&gt;ProductRepository&lt;/code&gt;. Tests can supply a fake repository to check domain behavior, while production uses the JPA implementation.&lt;/p&gt;&#10;&lt;h2 id="troubleshooting"&gt;&lt;a href="#troubleshooting" class="header-anchor"&gt;&lt;/a&gt;Troubleshooting&#10;&lt;/h2&gt;&lt;h3 id="where-the-complexity-appeared"&gt;&lt;a href="#where-the-complexity-appeared" class="header-anchor"&gt;&lt;/a&gt;Where the complexity appeared&#10;&lt;/h3&gt;&lt;p&gt;There were moments when separating the models actually made the code more complicated. I spent time thinking about:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Naming a &lt;code&gt;User&lt;/code&gt; domain backed by a &lt;code&gt;member&lt;/code&gt; table.&lt;/li&gt;&#10;&lt;li&gt;The apparent duplication of fields between domain objects and entities.&lt;/li&gt;&#10;&lt;li&gt;Where Mappers should live.&lt;/li&gt;&#10;&lt;li&gt;Whether an update should reload the JPA entity.&lt;/li&gt;&#10;&lt;li&gt;Whether list queries should always return domain models.&lt;/li&gt;&#10;&lt;li&gt;Whether DTO-like objects such as &lt;code&gt;ProductSummary&lt;/code&gt; belong in the domain.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="why-these-questions-arose"&gt;&lt;a href="#why-these-questions-arose" class="header-anchor"&gt;&lt;/a&gt;Why these questions arose&#10;&lt;/h3&gt;&lt;p&gt;One source of confusion is that “Entity” has two meanings. In DDD, an Entity is a domain object with an identity. A JPA entity is a persistence object mapped to a database row. Because both are called entities, it is easy to assume they must be the same object.&lt;/p&gt;&#10;&lt;p&gt;For this project, I saw different reasons for them to change:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;A domain model changes when business rules change.&lt;/li&gt;&#10;&lt;li&gt;A database entity changes when the table structure or JPA mapping changes.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Another source of complexity is the difference between queries and commands. For flows such as creating an order or updating a product, converting to a domain model makes sense because domain rules matter. For read queries where sorting, pagination, and projections matter most, that conversion can be unnecessary overhead.&lt;/p&gt;&#10;&lt;p&gt;The product list needed data from &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;Brand&lt;/code&gt;, and &lt;code&gt;ProductStat&lt;/code&gt;, along with &lt;code&gt;likes_desc&lt;/code&gt; sorting. Fetching each domain separately and merging the results in memory could break pagination accuracy. I used a QueryDSL projection for that query.&lt;/p&gt;&#10;&lt;h3 id="what-i-changed"&gt;&lt;a href="#what-i-changed" class="header-anchor"&gt;&lt;/a&gt;What I changed&#10;&lt;/h3&gt;&lt;h4 id="1-make-responsibilities-visible-in-names"&gt;&lt;a href="#1-make-responsibilities-visible-in-names" class="header-anchor"&gt;&lt;/a&gt;1. Make responsibilities visible in names&#10;&lt;/h4&gt;&lt;p&gt;I kept business names such as &lt;code&gt;User&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, and &lt;code&gt;Order&lt;/code&gt; in the domain. Infrastructure uses &lt;code&gt;MemberEntity&lt;/code&gt;, &lt;code&gt;ProductEntity&lt;/code&gt;, and &lt;code&gt;OrderEntity&lt;/code&gt; to make the persistence role explicit.&lt;/p&gt;&#10;&lt;p&gt;For &lt;code&gt;User&lt;/code&gt;, the table name &lt;code&gt;member&lt;/code&gt; informed the infrastructure name &lt;code&gt;Member&lt;/code&gt;. This made the imports easier to understand:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;domain.user.User&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;infrastructure.member.MemberEntity&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;infrastructure.member.MemberMapper&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;infrastructure.member.MemberRepositoryImpl&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h4 id="2-keep-mappers-in-infrastructure"&gt;&lt;a href="#2-keep-mappers-in-infrastructure" class="header-anchor"&gt;&lt;/a&gt;2. Keep Mappers in Infrastructure&#10;&lt;/h4&gt;&lt;p&gt;A Mapper needs to know about the database entity, so it belongs in infrastructure. Making the Domain Layer aware of JPA entities would weaken the separation.&lt;/p&gt;&#10;&lt;p&gt;The structure currently looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;domain/user/User&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;domain/user/UserRepository&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;infrastructure/member/MemberEntity&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;infrastructure/member/MemberMapper&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;infrastructure/member/MemberRepositoryImpl&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h4 id="3-load-a-managed-entity-before-applying-updates"&gt;&lt;a href="#3-load-a-managed-entity-before-applying-updates" class="header-anchor"&gt;&lt;/a&gt;3. Load a managed entity before applying updates&#10;&lt;/h4&gt;&lt;p&gt;Creating a new JPA entity from a modified domain object and calling &lt;code&gt;save&lt;/code&gt; can lead to an insert instead of an update, or make ID handling awkward.&lt;/p&gt;&#10;&lt;p&gt;For updates, I load the existing entity and apply the domain values to it.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;entity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;productJpaRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findByIdOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;also&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Product not found.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;productJpaRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ProductMapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Initially, I wondered why &lt;code&gt;update&lt;/code&gt; called &lt;code&gt;findById&lt;/code&gt; when I had already loaded the &lt;code&gt;Product&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;With separate models, however, the &lt;code&gt;Product&lt;/code&gt; held by the application layer is not a managed entity. To use JPA dirty checking, infrastructure needs to obtain the managed entity and apply the changes there.&lt;/p&gt;&#10;&lt;p&gt;This introduces another lookup, but preserves both the JPA persistence context behavior and the separation of the domain model.&lt;/p&gt;&#10;&lt;h4 id="4-dont-force-every-query-through-a-domain-model"&gt;&lt;a href="#4-dont-force-every-query-through-a-domain-model" class="header-anchor"&gt;&lt;/a&gt;4. Don&amp;rsquo;t force every query through a domain model&#10;&lt;/h4&gt;&lt;p&gt;I decided that separating the models didn&amp;rsquo;t mean every query had to return a domain object.&lt;/p&gt;&#10;&lt;p&gt;Sorting and pagination were central to the product list. Once like counts were moved into &lt;code&gt;ProductStat&lt;/code&gt;, &lt;code&gt;likes_desc&lt;/code&gt; also needed to sort using that data. &lt;code&gt;ProductQueryRepository&lt;/code&gt; therefore returns &lt;code&gt;ProductSummary&lt;/code&gt; through a QueryDSL projection.&lt;/p&gt;&#10;&lt;p&gt;The placement of &lt;code&gt;ProductSummary&lt;/code&gt; is still an open question. It currently lives under a domain DTO package, but an application read model or infrastructure projection may be a better home.&lt;/p&gt;&#10;&lt;h2 id="what-i-would-question-about-the-earlier-design"&gt;&lt;a href="#what-i-would-question-about-the-earlier-design" class="header-anchor"&gt;&lt;/a&gt;What I would question about the earlier design&#10;&lt;/h2&gt;&lt;p&gt;Three issues stood out during this work.&lt;/p&gt;&#10;&lt;p&gt;First, services were doing too much. When repository calls, business validation, and coordination across domains all live together, the service grows with every feature. This time, I separated the Facade, Application Service, and Domain Service responsibilities.&lt;/p&gt;&#10;&lt;p&gt;Second, entity names mixed domain and database concepts awkwardly. A class named &lt;code&gt;UserEntity&lt;/code&gt; mapped to a table named &lt;code&gt;member&lt;/code&gt;. Now the domain uses &lt;code&gt;User&lt;/code&gt; and infrastructure uses &lt;code&gt;MemberEntity&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Third, separation itself could become the goal. Separate models mean more files and conversion code; they don&amp;rsquo;t automatically improve every feature. A small CRUD project may be simpler if its JPA entities also serve as domain models.&lt;/p&gt;&#10;&lt;p&gt;In this project, product, like, order, and inventory rules were likely to grow, so the separation felt worthwhile.&lt;/p&gt;&#10;&lt;h2 id="retrospective"&gt;&lt;a href="#retrospective" class="header-anchor"&gt;&lt;/a&gt;Retrospective&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;Keep:&#10;&lt;ul&gt;&#10;&lt;li&gt;Express business rules in domain models.&lt;/li&gt;&#10;&lt;li&gt;Keep Repository interfaces in the domain and implementations in infrastructure; this helped testability and dependency direction.&lt;/li&gt;&#10;&lt;li&gt;Use clear persistence model names such as &lt;code&gt;MemberEntity&lt;/code&gt; to make imports and responsibilities easier to follow.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Problem:&#10;&lt;ul&gt;&#10;&lt;li&gt;Mapper code is repetitive.&lt;/li&gt;&#10;&lt;li&gt;Reloading a managed entity during updates made the flow initially unintuitive.&lt;/li&gt;&#10;&lt;li&gt;DTO-like objects such as &lt;code&gt;ProductSummary&lt;/code&gt; and &lt;code&gt;ProductCatalog&lt;/code&gt; still live under the domain, leaving the boundary between domain models and read models unclear.&lt;/li&gt;&#10;&lt;li&gt;Methods such as &lt;code&gt;Entity.update(domain)&lt;/code&gt; mean the separation isn&amp;rsquo;t completely clean.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Try:&#10;&lt;ul&gt;&#10;&lt;li&gt;Consider moving query-only models into application read models or infrastructure projections.&lt;/li&gt;&#10;&lt;li&gt;Check for unnecessary update lookups, and revisit command-based update queries or the dirty checking strategy if performance becomes a problem.&lt;/li&gt;&#10;&lt;li&gt;Add Mapper tests if entity-to-domain conversion rules become more complex.&lt;/li&gt;&#10;&lt;li&gt;Start with domains that have business rules rather than separating every simple CRUD model by default.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="deep-dive"&gt;&lt;a href="#deep-dive" class="header-anchor"&gt;&lt;/a&gt;Deep dive&#10;&lt;/h2&gt;&lt;h2 id="the-underlying-idea"&gt;&lt;a href="#the-underlying-idea" class="header-anchor"&gt;&lt;/a&gt;The underlying idea&#10;&lt;/h2&gt;&lt;p&gt;Creating two copies of an object isn&amp;rsquo;t the point. The point is separating reasons for change.&lt;/p&gt;&#10;&lt;p&gt;A domain model expresses business rules. For example, &lt;code&gt;Inventory.quantity&lt;/code&gt; must not be negative. A database check constraint can enforce that too, but the domain should enforce it as well. That allows order creation tests to verify insufficient inventory without a database.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Inventory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Inventory quantity must not be negative.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;deduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Deduct quantity must be positive.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Inventory quantity is insufficient.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A database entity expresses the storage structure. For example, &lt;code&gt;OrderEntity&lt;/code&gt; describes the relationship between the &lt;code&gt;orders&lt;/code&gt; and &lt;code&gt;order_item&lt;/code&gt; tables.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;orders&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;order_number&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;orderNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;member_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Enumerated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnumType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;total_amount&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;totalAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ordered_at&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="py"&gt;orderedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ZonedDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BaseEntity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@OneToMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mappedBy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;order&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cascade&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;CascadeType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;orphanRemoval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@BatchSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MutableList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItemEntity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutableListOf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderItemEntity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;Order&lt;/code&gt; domain model expresses the business result of an order.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;totalAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ZonedDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Order items must not be empty.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalAmount&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sumOf&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totalAmount&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Order total amount is invalid.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The objects look similar, but their responsibilities differ. &lt;code&gt;OrderEntity&lt;/code&gt; knows about JPA cascade, batch fetching, and table mappings. &lt;code&gt;Order&lt;/code&gt; knows that an order must contain items and that &lt;code&gt;totalAmount&lt;/code&gt; must equal the sum of the item amounts.&lt;/p&gt;&#10;&lt;h2 id="how-this-worked-in-my-project"&gt;&lt;a href="#how-this-worked-in-my-project" class="header-anchor"&gt;&lt;/a&gt;How this worked in my project&#10;&lt;/h2&gt;&lt;p&gt;Creating an order required &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;Brand&lt;/code&gt;, &lt;code&gt;Inventory&lt;/code&gt;, and &lt;code&gt;Order&lt;/code&gt; to cooperate.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;OrderFacade&lt;/code&gt; in the Application Layer retrieves the required data and establishes the transaction boundary. The Domain Service, &lt;code&gt;OrderPlacementService&lt;/code&gt;, checks that products, brands, and inventory exist, deducts stock, and creates order snapshots.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderPlacementService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderPlacementItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;brands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Brand&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;inventories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Inventory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;OrderPlacementResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;productById&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;associateBy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;brandById&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;brands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;associateBy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;inventoryByProductId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inventories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;associateBy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderItems&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;product&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;productById&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Product not found.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;brand&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;brandById&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brandId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Brand not found.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;inventory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inventoryByProductId&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;CoreException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Inventory not found.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;productName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;brandName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unitPrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;OrderPlacementResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memberId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;memberId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderItems&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;inventories&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inventories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;OrderPlacementService&lt;/code&gt; knows nothing about JPA or &lt;code&gt;ProductEntity&lt;/code&gt;, &lt;code&gt;InventoryEntity&lt;/code&gt;, and &lt;code&gt;OrderEntity&lt;/code&gt;. It accepts domain objects and applies the rules.&lt;/p&gt;&#10;&lt;p&gt;That was the biggest benefit I gained from the separation.&lt;/p&gt;&#10;&lt;h2 id="when-might-separation-be-unnecessary"&gt;&lt;a href="#when-might-separation-be-unnecessary" class="header-anchor"&gt;&lt;/a&gt;When might separation be unnecessary?&#10;&lt;/h2&gt;&lt;p&gt;Separating domain models from database entities isn&amp;rsquo;t always the best choice. Using JPA entities as domain models may be reasonable when:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The project mostly consists of simple CRUD.&lt;/li&gt;&#10;&lt;li&gt;There are few business rules.&lt;/li&gt;&#10;&lt;li&gt;The table structure closely matches the API requirements.&lt;/li&gt;&#10;&lt;li&gt;Database dependencies aren&amp;rsquo;t a significant problem in tests.&lt;/li&gt;&#10;&lt;li&gt;The team is unfamiliar with separate models and productivity matters more.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Separation is worth considering when:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Domain rules are growing.&lt;/li&gt;&#10;&lt;li&gt;Multiple Aggregates or domains cooperate.&lt;/li&gt;&#10;&lt;li&gt;Database structures and business models may change independently.&lt;/li&gt;&#10;&lt;li&gt;JPA annotations or associations intrude on domain code.&lt;/li&gt;&#10;&lt;li&gt;Unit tests need to verify rules without a database.&lt;/li&gt;&#10;&lt;li&gt;Read queries and command models have different requirements.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Here, products, brands, likes, inventory, and orders worked together, with core rules such as deducting inventory during order creation. That made separation a better fit.&lt;/p&gt;&#10;&lt;hr&gt;&#10;</description></item><item><title>You Can Do DDD, Too!</title><link>https://0andwild.com/en/posts/260521_ddd/</link><pubDate>Fri, 22 May 2026 17:00:00 +0900</pubDate><guid>https://0andwild.com/en/posts/260521_ddd/</guid><description>&lt;img src="https://0andwild.com/" alt="Featured image of post You Can Do DDD, Too!" /&gt;&lt;p&gt;I suspect plenty of developers are like me: we&amp;rsquo;ve heard of DDD, but haven&amp;rsquo;t really put it into practice.&lt;/p&gt;&#10;&lt;p&gt;At its core, the idea is fairly straightforward:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;Understand the business, then design around that understanding.&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;Everyone involved in solving the problem develops a shared understanding of the domain and uses it to guide the design, rather than starting with technology.&lt;/code&gt;&lt;/p&gt;&#10;&lt;p&gt;DDD is more concerned with “What problem are we actually solving?” than “Which framework should we use?”&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="why-did-ddd-emerge"&gt;&lt;a href="#why-did-ddd-emerge" class="header-anchor"&gt;&lt;/a&gt;Why did DDD emerge?&#10;&lt;/h2&gt;&lt;p&gt;Software gets more complicated over time.&lt;/p&gt;&#10;&lt;p&gt;At first, things are simple: receive a request in a controller, process it in a service, and save the result to a database.&lt;/p&gt;&#10;&lt;p&gt;As features accumulate, though, things start to look like this:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The order service decides which coupon policies apply.&lt;/li&gt;&#10;&lt;li&gt;The payment service checks membership grades.&lt;/li&gt;&#10;&lt;li&gt;Shipping logic directly changes order status.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;User&lt;/code&gt;, &lt;code&gt;Member&lt;/code&gt;, and &lt;code&gt;Customer&lt;/code&gt; appear throughout the code with similar meanings.&lt;/li&gt;&#10;&lt;li&gt;Someone asks, “Why is this condition here?” and everyone suddenly looks away.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The problem is bigger than long source files. Business knowledge has become scattered across the system.&lt;/p&gt;&#10;&lt;p&gt;Eric Evans introduced DDD in his 2003 book, &lt;code&gt;Domain-Driven Design: Tackling Complexity in the Heart of Software&lt;/code&gt;, as an approach to managing this complexity. Its focus is the &lt;strong&gt;domain&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;A domain is the business problem area the software addresses. In commerce, examples include orders, payments, inventory, delivery, and settlement.&lt;/p&gt;&#10;&lt;p&gt;DDD aims to develop a deep understanding of those areas and reflect that understanding in the names, structure, and responsibilities of the code.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="three-parts-of-ddd"&gt;&lt;a href="#three-parts-of-ddd" class="header-anchor"&gt;&lt;/a&gt;Three parts of DDD&#10;&lt;/h2&gt;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260521_ddd/img1.jpg" width="500"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;The terminology can feel overwhelming when you first study DDD. I find it easier to organize it into three broad parts.&lt;/p&gt;&#10;&lt;h3 id="1-domain-exploration"&gt;&lt;a href="#1-domain-exploration" class="header-anchor"&gt;&lt;/a&gt;1. Domain exploration&#10;&lt;/h3&gt;&lt;p&gt;This is where we get to know the domain.&lt;/p&gt;&#10;&lt;p&gt;Domain experts and developers examine business workflows together: what happens, which policies apply, and where problems occur.&lt;/p&gt;&#10;&lt;p&gt;Methods such as &lt;code&gt;EventStorming&lt;/code&gt; can help. For example, we can lay out events such as “Order Created,” “Payment Approved,” “Inventory Deducted,” and “Delivery Started” to see the workflow as a whole.&lt;/p&gt;&#10;&lt;h3 id="2-strategic-design"&gt;&lt;a href="#2-strategic-design" class="header-anchor"&gt;&lt;/a&gt;2. Strategic Design&#10;&lt;/h3&gt;&lt;p&gt;Strategic Design defines the larger boundaries.&lt;/p&gt;&#10;&lt;p&gt;Instead of building one enormous model for the entire system, we divide it into areas where meanings remain consistent. &lt;code&gt;Bounded Context&lt;/code&gt; is a key concept here.&lt;/p&gt;&#10;&lt;p&gt;For a commerce system, one possible division is:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Order: order creation, order status, and cancellation&lt;/li&gt;&#10;&lt;li&gt;Payment: payment approval, cancellation, and payment gateway integration&lt;/li&gt;&#10;&lt;li&gt;Inventory: stock reservation, deduction, and restoration&lt;/li&gt;&#10;&lt;li&gt;Delivery: shipment requests, tracking numbers, and delivery status&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;These boundaries shouldn&amp;rsquo;t simply follow folders or tables. Language, rules, responsibilities, and reasons for change are more useful criteria.&lt;/p&gt;&#10;&lt;h3 id="3-tactical-design"&gt;&lt;a href="#3-tactical-design" class="header-anchor"&gt;&lt;/a&gt;3. Tactical Design&#10;&lt;/h3&gt;&lt;p&gt;Tactical Design is about expressing the inside of a Bounded Context in code.&lt;/p&gt;&#10;&lt;p&gt;This is where patterns such as &lt;code&gt;Entity&lt;/code&gt;, &lt;code&gt;Value Object&lt;/code&gt;, &lt;code&gt;Aggregate&lt;/code&gt;, &lt;code&gt;Repository&lt;/code&gt;, &lt;code&gt;Domain Service&lt;/code&gt;, &lt;code&gt;Domain Event&lt;/code&gt;, and &lt;code&gt;Factory&lt;/code&gt; come in.&lt;/p&gt;&#10;&lt;p&gt;Using those patterns doesn&amp;rsquo;t automatically make a design DDD, though.&lt;/p&gt;&#10;&lt;p&gt;If all business logic still lives in a single &lt;code&gt;OrderService&lt;/code&gt; and the domain objects are empty shells with getters and setters, naming a package &lt;code&gt;domain&lt;/code&gt; doesn&amp;rsquo;t accomplish much.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="still-unsure-what-a-bounded-context-is"&gt;&lt;a href="#still-unsure-what-a-bounded-context-is" class="header-anchor"&gt;&lt;/a&gt;Still unsure what a Bounded Context is?&#10;&lt;/h2&gt;&lt;p&gt;If I had to pick the most important concept in DDD, it would be &lt;code&gt;Bounded Context&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;A Bounded Context defines the boundary within which a particular model and its terminology have consistent meanings.&lt;/p&gt;&#10;&lt;p&gt;Take the word “product.”&lt;/p&gt;&#10;&lt;p&gt;The inventory team cares about incoming stock and quantities available to ship. The settlement team cares about selling prices, supply costs, and commission rates.&lt;/p&gt;&#10;&lt;p&gt;They&amp;rsquo;re talking about the same product from different perspectives.&lt;/p&gt;&#10;&lt;p&gt;What happens if we put everything into one &lt;code&gt;Product&lt;/code&gt; model?&lt;/p&gt;&#10;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260521_ddd/img2.jpg" width="500"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;At first, it feels convenient to have everything in one place. Eventually, it becomes a huge object nobody wants to touch.&lt;/p&gt;&#10;&lt;p&gt;The lesson is: &lt;code&gt;when the same word has different meanings, consider separate boundaries.&lt;/code&gt;&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="how-do-we-identify-domain-boundaries"&gt;&lt;a href="#how-do-we-identify-domain-boundaries" class="header-anchor"&gt;&lt;/a&gt;How do we identify domain boundaries?&#10;&lt;/h2&gt;&lt;p&gt;Finding useful criteria for splitting domains was one of the harder parts of learning DDD. These four questions helped me.&lt;/p&gt;&#10;&lt;h3 id="does-the-same-word-mean-different-things"&gt;&lt;a href="#does-the-same-word-mean-different-things" class="header-anchor"&gt;&lt;/a&gt;Does the same word mean different things?&#10;&lt;/h3&gt;&lt;p&gt;If teams use words such as “member,” “product,” “order,” or “settlement” differently, that&amp;rsquo;s a potential boundary.&lt;/p&gt;&#10;&lt;p&gt;A member might be an authenticated identity in an authentication context, buyer information in an order context, and a campaign recipient in a marketing context.&lt;/p&gt;&#10;&lt;p&gt;A shared word doesn&amp;rsquo;t necessarily call for a shared model.&lt;/p&gt;&#10;&lt;h3 id="who-owns-this-rule"&gt;&lt;a href="#who-owns-this-rule" class="header-anchor"&gt;&lt;/a&gt;Who owns this rule?&#10;&lt;/h3&gt;&lt;p&gt;Payment approval rules belong to the payment domain. Stock deduction rules belong to inventory. Coupon eligibility belongs to the coupon domain.&lt;/p&gt;&#10;&lt;p&gt;When one domain starts applying another domain&amp;rsquo;s rules directly, validation can be skipped, history can be lost, and unintended side effects can appear.&lt;/p&gt;&#10;&lt;p&gt;This matters in practical DDD implementations, too. A change to a limit balance, for example, should go through the domain responsible for that limit. That keeps validation, history, and policy enforcement in one place.&lt;/p&gt;&#10;&lt;h3 id="must-these-changes-happen-in-one-transaction"&gt;&lt;a href="#must-these-changes-happen-in-one-transaction" class="header-anchor"&gt;&lt;/a&gt;Must these changes happen in one transaction?&#10;&lt;/h3&gt;&lt;p&gt;An &lt;code&gt;Aggregate&lt;/code&gt; is more than a container for objects that look related. It is closer to &lt;strong&gt;the smallest unit whose consistency must be maintained immediately&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;An order total may need to match the sum of its items immediately. Sending a notification or updating statistics after the order completes may be allowed to happen later.&lt;/p&gt;&#10;&lt;p&gt;Putting everything in one transaction makes the model too large. Routing everything through events can make the flow hard to follow.&lt;/p&gt;&#10;&lt;p&gt;The useful distinction is: &lt;code&gt;what must be consistent immediately, and what can become consistent later?&lt;/code&gt;&lt;/p&gt;&#10;&lt;h3 id="do-these-things-change-together-or-independently"&gt;&lt;a href="#do-these-things-change-together-or-independently" class="header-anchor"&gt;&lt;/a&gt;Do these things change together or independently?&#10;&lt;/h3&gt;&lt;p&gt;Things that frequently change together are likely to belong within the same boundary. Things that change for different reasons are candidates for separation.&lt;/p&gt;&#10;&lt;p&gt;Promotion policies change with marketing campaigns. Delivery integration changes with carrier APIs or logistics policies. Settlement changes with accounting, contracts, and commission policies.&lt;/p&gt;&#10;&lt;p&gt;When unrelated reasons for change share one model, they keep getting in each other&amp;rsquo;s way.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="a-practical-view-of-the-tactical-patterns"&gt;&lt;a href="#a-practical-view-of-the-tactical-patterns" class="header-anchor"&gt;&lt;/a&gt;A practical view of the tactical patterns&#10;&lt;/h2&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;Entity&lt;/code&gt;: an object whose identity matters, such as an order or a member.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Value Object&lt;/code&gt;: an object whose value matters, such as money, an address, or a period of time.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Aggregate&lt;/code&gt;: a unit of change within which consistency must be maintained.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Repository&lt;/code&gt;: an interface for storing and retrieving objects.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Domain Service&lt;/code&gt;: domain rules that don&amp;rsquo;t fit naturally in a single object.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Domain Event&lt;/code&gt;: a meaningful occurrence in the domain.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;Factory&lt;/code&gt;: an object responsible for complex object creation.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The pattern names matter less than whether the business rules are visible in the code.&lt;/p&gt;&#10;&lt;p&gt;For example, we could pass a price around as a &lt;code&gt;Long&lt;/code&gt;. But if an amount cannot be negative, needs a currency, or follows calculation rules, a &lt;code&gt;Price&lt;/code&gt; Value Object may express that more clearly.&lt;/p&gt;&#10;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260521_ddd/img3.png" width="500"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;Computers aren&amp;rsquo;t the only things that read code. Future me reads it, too. And future me is already annoyed. Let&amp;rsquo;s give that person a break.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="is-ddd-the-same-as-microservices"&gt;&lt;a href="#is-ddd-the-same-as-microservices" class="header-anchor"&gt;&lt;/a&gt;Is DDD the same as microservices?&#10;&lt;/h2&gt;&lt;p&gt;No. A Bounded Context is a design boundary; it doesn&amp;rsquo;t have to be a deployment boundary.&lt;/p&gt;&#10;&lt;p&gt;Adopting DDD doesn&amp;rsquo;t mean splitting everything into separate services from day one. A modular monolith can often be a good starting point.&lt;/p&gt;&#10;&lt;p&gt;We can first enforce boundaries through packages, modules, and dependency rules within one application. If independent deployment becomes necessary later, we can split out a service then.&lt;/p&gt;&#10;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260521_ddd/img4.jpg" width="500"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;DDD doesn&amp;rsquo;t require the full collection of Kafka, Event Sourcing, CQRS, and MSA. Sometimes that&amp;rsquo;s just an excuse to collect more tools.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="a-quick-look-at-the-anti-corruption-layer"&gt;&lt;a href="#a-quick-look-at-the-anti-corruption-layer" class="header-anchor"&gt;&lt;/a&gt;A quick look at the Anti-Corruption Layer&#10;&lt;/h2&gt;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260521_ddd/img5.png" width="800"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;External and legacy systems inevitably have models that differ from ours.&lt;/p&gt;&#10;&lt;p&gt;If we bring those models directly into our domain, our internal model gradually takes on their assumptions. For example, if a payment provider&amp;rsquo;s status values spread throughout our payment domain, changing providers can affect code across the system.&lt;/p&gt;&#10;&lt;p&gt;An &lt;code&gt;Anti-Corruption Layer&lt;/code&gt; provides a translation layer between them. It protects the internal domain by translating the external system&amp;rsquo;s language into our own.&lt;/p&gt;&#10;&lt;p&gt;A simple analogy is using a USB-C hub to connect HDMI to a MacBook Air: MacBook ↔ USB-C hub ↔ HDMI.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ACL example&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LegacyOrderTranslator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;legacyStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;legacyStatus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;A&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PAID&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;B&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SHIPPED&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Unknown status: &lt;/span&gt;&lt;span class="si"&gt;$legacyStatus&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;hr&gt;&#10;&lt;h2 id="what-ive-taken-away"&gt;&lt;a href="#what-ive-taken-away" class="header-anchor"&gt;&lt;/a&gt;What I&amp;rsquo;ve taken away&#10;&lt;/h2&gt;&lt;p&gt;DDD has clear benefits when used well.&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Business rules become easier to find in code. We can see where policies live and better predict the impact of a change.&lt;/li&gt;&#10;&lt;li&gt;Team conversations become closer to the code. When product planners and domain experts use terms that also appear in the implementation, there is less room for misunderstanding requirements.&lt;/li&gt;&#10;&lt;li&gt;We don&amp;rsquo;t have to understand a complex system all at once. Bounded Contexts let us reason about each area separately instead of memorizing the entire system.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Still, I don&amp;rsquo;t think DDD belongs everywhere. Applying it heavily to simple CRUD or features with very few policies can introduce unnecessary complexity.&lt;/p&gt;&#10;&lt;p&gt;It seems most useful when the domain itself is complex: many rules, many exceptions, frequent changes, and multiple teams using the same words differently.&lt;/p&gt;&#10;&lt;p&gt;The idea I keep coming back to is:&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;Bring the business and the code closer together.&lt;/code&gt;&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260521_ddd/featured.png" width="500"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;See? You can do DDD, too.&lt;/p&gt;&#10;</description></item></channel></rss>