<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: lukman lukman</title>
    <description>The latest articles on DEV Community by lukman lukman (@lukman-ss).</description>
    <link>https://dev.to/lukman-ss</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3474743%2Fcbcf077b-0089-4c5f-8236-7f53d2acd53c.jpg</url>
      <title>DEV Community: lukman lukman</title>
      <link>https://dev.to/lukman-ss</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lukman-ss"/>
    <language>en</language>
    <item>
      <title>Load Testing: Read the Queue Behind Successful Bookings</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:36:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/load-testing-read-the-queue-behind-successful-bookings-j3e</link>
      <guid>https://dev.to/lukman-ss/load-testing-read-the-queue-behind-successful-bookings-j3e</guid>
      <description>&lt;p&gt;A booking endpoint can keep returning success while concurrent users wait much longer. Lab 15 makes that behavior visible with a local HTTP server, a constrained work pool, and a small Go load generator.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Load testing reveals how latency changes when demand competes for limited resources.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Fast bookings can queue under load
&lt;/h2&gt;

&lt;p&gt;One successful booking does not explain concurrent behavior. Lab 15 sends POST /booking to a local HTTP server, comparing low and high concurrency to expose queueing and latency distributions. It models bounded work slots, not a production database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two scenarios share one server
&lt;/h2&gt;

&lt;p&gt;The demo uses five simulated database slots and a 20 ms base query. Smoke runs two VUs; stress runs 50. Both run for two seconds, sequentially, with the same endpoint and booking body. These are demonstration settings, not production capacity targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Each VU waits before sending again
&lt;/h2&gt;

&lt;p&gt;Each VU has one goroutine and sends its next request after the previous attempt finishes. There is no think time, ramp-up, or arrival-rate scheduler. This is a closed loop: slower responses reduce each VU's request rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  A buffered channel bounds simulated work
&lt;/h2&gt;

&lt;p&gt;A buffered channel acts as a semaphore. Handlers acquire a token before simulated query work and release it with defer. Full capacity makes additional handlers wait; there is no explicit bounded waiting queue or overload rejection policy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;semaphore&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}{}&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;semaphore&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The database pool is simulated
&lt;/h2&gt;

&lt;p&gt;There is no SQL driver, real query, booking table, or database transaction. A timer simulates work. ActiveConnections returns len(semaphore): acquired slots, not waiting handlers, TCP connections, or real database telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Random slow work amplifies queueing
&lt;/h2&gt;

&lt;p&gt;activeReq includes waiting and working POST handlers. After admission, when activeReq exceeds capacity, a 10% random branch multiplies query duration by 25. The demo's 20 ms becomes 500 ms. Stress results therefore combine queueing with deliberately injected slow work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;atomic&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadInt64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activeReq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxDBConnections&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Float32&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0.10&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;dur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DBQueryDuration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Context bounds the test lifetime
&lt;/h2&gt;

&lt;p&gt;Run uses context.WithTimeout for the configured duration and passes that context to each HTTP request. A five-second client timeout also applies. The server checks cancellation while acquiring a slot and waiting for simulated work; it does not return a dedicated overload response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canceled attempts are not all counted
&lt;/h2&gt;

&lt;p&gt;Request-construction and client.Do errors are counted only while ctx.Err() is nil. End-of-test canceled attempts can be omitted. TotalRequests is recorded success samples plus counted errors, not every attempt started.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Success follows an HTTP status rule
&lt;/h2&gt;

&lt;p&gt;The runner drains and closes response bodies. Status &amp;gt;=400 increments errors; status &amp;lt;400 records success latency, which is broader than a 2xx-only rule. JSON business fields are not validated, and body-read errors are ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency includes reading the body
&lt;/h2&gt;

&lt;p&gt;Timing starts before client.Do and ends after the successful response body is drained and closed. It is not query-only time. DNS, dialing, TLS, first-byte wait, pool wait, and body transfer are not separately measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Percentiles use successful samples only
&lt;/h2&gt;

&lt;p&gt;Failed responses and transport errors have no latency samples. Percentiles, average, min, and max use successful samples only. With no successes, latency fields remain zero; that does not mean the service responded instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  RPS includes counted errors
&lt;/h2&gt;

&lt;p&gt;Total is len(latencies)+errors. RPS divides that total by Run's actual elapsed duration after goroutines finish, so it is not success-only throughput. With no total requests, an empty Result is returned, including zero Duration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latencies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;
&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;
    &lt;span class="n"&gt;totalDuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Percentiles select a floored index
&lt;/h2&gt;

&lt;p&gt;The calculator copies and sorts samples, then selects int((n-1)*p/100) without interpolation. For the test fixture 1–100 ms, P50=50 ms, P95=95 ms, and P99=99 ms. Other tools may use different percentile conventions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A fixture makes the calculation explicit
&lt;/h2&gt;

&lt;p&gt;The 1–100 ms fixture has min 1 ms, max 100 ms, and average 50.5 ms. time.Duration retains nanosecond units, so division does not force whole-millisecond rounding. These are calculated fixture values, not HTTP performance measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small samples make the tail coarse
&lt;/h2&gt;

&lt;p&gt;With one 42 ms sample, every percentile is 42 ms. Small datasets can map several tail percentiles to the same sample and may exclude the maximum under this formula. Sample count and method matter when interpreting precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Average and tail answer different questions
&lt;/h2&gt;

&lt;p&gt;Average summarizes all successful samples; P95 selects a high-ranked sample. Unit tests verify ordered percentile invariants. The integration test separately checks stress P95 exceeds stress average; that relationship is not guaranteed for every possible distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo numbers are recorded examples
&lt;/h2&gt;

&lt;p&gt;The repository records a sample smoke P95 of about 21.37 ms and stress P95 of about 1.357 s, with zero counted errors. These are historical example results, not a fresh measurement. Random spikes and scheduling vary; omitted canceled attempts also qualify the zero-error interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transport settings are not a full generator audit
&lt;/h2&gt;

&lt;p&gt;MaxIdleConns and MaxIdleConnsPerHost are both 1000; these are idle-connection retention settings, not proof that every client bottleneck is eliminated. All successful latencies are retained per VU, merged, copied, and sorted, so memory grows with collected samples.&lt;/p&gt;

&lt;h2&gt;
  
  
  A booking response is not a real transaction
&lt;/h2&gt;

&lt;p&gt;Non-POST methods receive 405. Completed POST requests return 201, confirmed, and the fixed ID BK-1001. Request JSON is not decoded or validated, and there is no persistence, authentication, idempotency, or unique booking creation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests establish specific behaviors
&lt;/h2&gt;

&lt;p&gt;Five metric tests cover fixtures, empty input, one sample, RPS, and ordering. Seven integration tests cover load comparison, HTTP 500, method rejection, dial errors, cancellation, slot limits, and total-count invariants. They do not prove queue fairness or maximum capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab bottleneck is known by design
&lt;/h2&gt;

&lt;p&gt;The slot bottleneck is deliberately constructed. In production, rising P95 alone does not isolate application queues, database work, network, or external dependencies. The runtime has no exporter, tracing, or automated SLA threshold; the demo prints summaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rerun and interpret in context
&lt;/h2&gt;

&lt;p&gt;The module declares Go 1.22 and uses the standard library. Go is unavailable in this authoring environment, so source and assertions were inspected without rerunning tests. Recorded repository results are prior-run evidence. Load testing reveals how latency changes when demand competes for limited resources.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;labs/15-load-testing
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-race&lt;/span&gt; ./...
go run ./cmd/demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/15-load-testing" rel="noopener noreferrer"&gt;Software Engineering Lab 15&lt;/a&gt;, snapshot commit &lt;code&gt;82af988b89439fa92b1200406c5afbd59ce72a56&lt;/code&gt;. Source: README, runner, metric calculator and tests, server, integration tests, demo, execution record, and go.mod. Code excerpts belong in their original package context.&lt;/p&gt;

</description>
      <category>go</category>
      <category>testing</category>
      <category>performance</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Circuit Breakers Protect Callers from Failing Dependencies</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Wed, 14 Oct 2026 11:29:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/circuit-breakers-protect-callers-from-failing-dependencies-2d3d</link>
      <guid>https://dev.to/lukman-ss/circuit-breakers-protect-callers-from-failing-dependencies-2d3d</guid>
      <description>&lt;p&gt;A payment service slows down. Checkout continues sending requests and waiting for timeouts. Lab 14 places a circuit breaker before the payment client so repeated failures can stop subsequent attempts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A circuit breaker protects the caller; it does not repair the dependency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The lab uses real local HTTP through an &lt;code&gt;httptest&lt;/code&gt; fake server. It does not perform real payments, balance mutations, or production gateway integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implemented path
&lt;/h2&gt;

&lt;p&gt;Checkout calls &lt;code&gt;Breaker.Execute&lt;/code&gt;, whose callback calls &lt;code&gt;payment.Client.ProcessPayment&lt;/code&gt;. The client sends &lt;code&gt;POST /pay&lt;/code&gt;. The fake server supports HEALTHY (200), DOWN (500), and SLOW (sleep, then 200).&lt;/p&gt;

&lt;p&gt;The README motivates the pattern with cascading resource exhaustion. The tests demonstrate narrower behavior: state transitions, callback rejection, HTTP request counts, timeout errors, and recovery probes. They do not measure thread exhaustion, memory pressure, or a platform-wide outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLOSED counts consecutive failures
&lt;/h2&gt;

&lt;p&gt;The breaker starts CLOSED and admits callbacks. A successful completion resets its failure count. A nonnil error increments it; reaching the threshold opens the circuit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// circuit_breaker.go: onFailureLocked excerpt&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FailureThreshold&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Open&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;openedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generation&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a consecutive-error counter, not a sliding-window failure rate. With concurrent calls, valid completion order determines the counter updates.&lt;/p&gt;

&lt;p&gt;Defaults are three failures, a 300 ms cooldown, and one HALF_OPEN call. &lt;code&gt;New&lt;/code&gt; substitutes defaults for nonpositive values. These are lab settings, not universal production recommendations.&lt;/p&gt;

&lt;p&gt;Callbacks execute outside the mutex. CLOSED has no general in-flight limit, so a concurrent burst can dispatch more requests than the threshold before the first failures complete. A circuit breaker is not automatically a bulkhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  OPEN rejects before invoking the callback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// circuit_breaker.go: Execute state branch&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrCircuitOpen&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During OPEN before cooldown expires, no callback runs. When the callback wraps the payment client, that rejected call produces no new network request. &lt;code&gt;TestOpenDoesNotCallDownstream&lt;/code&gt; checks one initial failed callback followed by five rejections, with the callback count remaining one.&lt;/p&gt;

&lt;p&gt;Already admitted calls are not canceled. “No downstream calls while rejecting” is different from claiming no older request is still running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cooldown advancement is lazy
&lt;/h2&gt;

&lt;p&gt;There is no background timer that sleeps and sends a probe. Both &lt;code&gt;State()&lt;/code&gt; and &lt;code&gt;Execute()&lt;/code&gt; call &lt;code&gt;advanceLocked&lt;/code&gt;. Once enough time has elapsed since &lt;code&gt;openedAt&lt;/code&gt;, the state becomes HALF_OPEN, the probe counter resets, and the generation increases.&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;State()&lt;/code&gt; can therefore mutate state. An expired cooldown does not itself dispatch a recovery request; a later admitted &lt;code&gt;Execute&lt;/code&gt; does that.&lt;/p&gt;

&lt;h2&gt;
  
  
  HALF_OPEN limits admission, not the number of successes required
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// circuit_breaker.go: HALF_OPEN branch&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;HalfOpen&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;halfOpenIn&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HalfOpenMaxCalls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrCircuitOpen&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;halfOpenIn&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extra calls receive the same &lt;code&gt;ErrCircuitOpen&lt;/code&gt; used for OPEN. The error alone does not distinguish an open circuit from a full probe allowance.&lt;/p&gt;

&lt;p&gt;One successful probe closes the breaker. One failed probe reopens it and restarts cooldown. When the configured allowance exceeds one, the implementation does not wait for every admitted probe to succeed: the first state-changing result advances the generation, making remaining old results stale.&lt;/p&gt;

&lt;p&gt;That is a specific recovery policy, not a requirement for N consecutive successful probes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generation guards separate old work from new state
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Execute&lt;/code&gt; captures a generation before releasing the mutex to run the callback. Result bookkeeping begins with this guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// circuit_breaker.go: result bookkeeping guard&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A trailing request from an earlier generation cannot reset or reopen a newer state. The trailing-in-flight test starts a slow CLOSED request, opens the circuit using another request, advances to HALF_OPEN, and checks that the old failure does not corrupt HALF_OPEN.&lt;/p&gt;

&lt;p&gt;The old callback still runs and its error still returns to its caller. Generation filtering changes its authority to update breaker state; it is not cancellation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Panic cleanup does not swallow panic
&lt;/h2&gt;

&lt;p&gt;Deferred bookkeeping treats a callback panic as a failure, including reopening HALF_OPEN. The panic continues to the caller rather than becoming an ordinary returned error. The test recovers outside the breaker and checks that a later recovery probe can run.&lt;/p&gt;

&lt;p&gt;This prevents a probe panic from leaving the breaker permanently stuck with its allowance consumed, while preserving the panic's normal propagation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeouts remain necessary
&lt;/h2&gt;

&lt;p&gt;The HTTP client has a configured timeout. &lt;code&gt;Checkout(ctx)&lt;/code&gt; passes the caller's context into the payment request; the two demo helpers use &lt;code&gt;context.Background()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Breaker.Execute&lt;/code&gt; itself has no context argument or execution deadline. An admitted callback can still wait indefinitely without its own timeout, including a HALF_OPEN probe occupying a slot.&lt;/p&gt;

&lt;p&gt;Retry and exponential backoff are discussed in the README but not implemented in this client or checkout service. The breaker does not schedule retries or automatically control an external retry loop. It only admits or rejects each callback evaluation.&lt;/p&gt;

&lt;p&gt;The fake SLOW handler sleeps without checking request cancellation. A client timeout is therefore not proof that the server has stopped its work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure classification is intentionally broad
&lt;/h2&gt;

&lt;p&gt;The client accepts only HTTP 200. Every other status becomes &lt;code&gt;ErrPaymentFailed&lt;/code&gt;; transport errors, timeouts, and cancellation also return errors. The breaker counts all nonnil errors without classification, so a 4xx or canceled request can affect the threshold if it reaches this path.&lt;/p&gt;

&lt;p&gt;A 200 response is not decoded to validate payment business semantics. A successful probe means this client returned nil, not that a real transaction was completed correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the demo as four scenarios, not one benchmark
&lt;/h2&gt;

&lt;p&gt;The first scenario uses SLOW with a 200 ms server delay and a 100 ms client timeout, making three sequential calls without a breaker. The second uses DOWN responses, a threshold of three, and six sequential calls with a breaker.&lt;/p&gt;

&lt;p&gt;Those are different failure modes. They should not be presented as a controlled latency benchmark of an identical workload. The N × timeout approximation describes sequential waiting, not general concurrent request latency.&lt;/p&gt;

&lt;p&gt;The third scenario makes the server HEALTHY before probing and demonstrates HALF_OPEN → CLOSED. The fourth keeps it DOWN and demonstrates HALF_OPEN → OPEN.&lt;/p&gt;

&lt;p&gt;Nanosecond or microsecond durations shown in the README are example output, not a latency guarantee or a newly measured result here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallback must preserve business meaning
&lt;/h2&gt;

&lt;p&gt;The implemented checkout returns an error on payment failure or rejection. It has no cached success, queue, or automatic alternative payment flow.&lt;/p&gt;

&lt;p&gt;The README's CMMS and PPOB examples discuss critical versus noncritical dependencies. Invoice creation, WhatsApp delivery, provider fulfillment, queue retention, and debit protection are architectural discussion, not implemented workflows in this folder.&lt;/p&gt;

&lt;p&gt;A fast payment rejection still means checkout has failed. The breaker reduces futile attempts; it does not manufacture a successful payment during an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests and instrumentation establish
&lt;/h2&gt;

&lt;p&gt;Unit tests cover thresholds, success resets, rejected callbacks, cooldown, both probe outcomes, probe limits, panic cleanup, stale generations, and concurrent access. Integration tests use the HTTP fake server to check 500 responses, recovery, request counts, and slow-call timeouts.&lt;/p&gt;

&lt;p&gt;The general concurrency test checks a valid final state. The interleaved test waits for goroutines without additional semantic assertions. A race-detector run can provide evidence about executed paths, but does not independently prove every interleaving or policy.&lt;/p&gt;

&lt;p&gt;Production gauges, open/rejected counters, and dependency latency percentiles are recommendations in the README. This implementation exposes state and the fake server's request counter, not a production metrics exporter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;labs/14-circuit-breaker
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-race&lt;/span&gt; ./...
go run ./cmd/demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module declares Go 1.22. Go is unavailable in this authoring environment, so I inspected code and assertions rather than rerunning the tests. No new passing-test result or benchmark is claimed.&lt;/p&gt;

&lt;p&gt;The reusable lesson is to bound permitted calls and interpret recovery precisely: timeout limits waiting, the breaker controls subsequent admission, and probes decide whether that path should reopen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/14-circuit-breaker" rel="noopener noreferrer"&gt;Software Engineering Lab 14&lt;/a&gt;, snapshot commit &lt;code&gt;82af988b89439fa92b1200406c5afbd59ce72a56&lt;/code&gt;: README, circuit breaker implementation/tests, payment client/fake server, checkout service, integration tests, demo, and go.mod. Excerpts belong in their existing package context.&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Backward Compatibility Must Survive the Transition</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Tue, 13 Oct 2026 10:14:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/backward-compatibility-must-survive-the-transition-3c5i</link>
      <guid>https://dev.to/lukman-ss/backward-compatibility-must-survive-the-transition-3c5i</guid>
      <description>&lt;p&gt;The old system stores one phone number in &lt;code&gt;users.phone&lt;/code&gt;. The new requirement is a list of numbers per user. Replacing a JSON string with an array is easy to code, but an old consumer still expects that string.&lt;/p&gt;

&lt;p&gt;Lab 13 uses &lt;strong&gt;Expand → Migrate → Contract&lt;/strong&gt; to let old and new representations coexist. The important question is not only whether the final design works. It is whether readers and writers can survive every intermediate state.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Compatibility must survive the transition, not just the final state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The implementation uses an in-memory Go store. It teaches a migration sequence; it does not demonstrate a live database migration or establish zero downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand the contract before retiring the old representation
&lt;/h2&gt;

&lt;p&gt;The enriched response preserves &lt;code&gt;phone&lt;/code&gt; as a string and adds a separate &lt;code&gt;phones&lt;/code&gt; array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// model.go: complete response type&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;UserResponse&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;     &lt;span class="kt"&gt;int&lt;/span&gt;          &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;       &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Phone&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;       &lt;span class="s"&gt;`json:"phone,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Phones&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;PhoneEntry&lt;/span&gt; &lt;span class="s"&gt;`json:"phones"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PhoneEntry&lt;/code&gt; contains an ID, user ID, number, and primary marker. &lt;code&gt;LegacyConsumerDTO&lt;/code&gt; expects the scalar field; &lt;code&gt;ModernConsumerDTO&lt;/code&gt; expects the array.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestSerializationBackwardCompatibility&lt;/code&gt; creates a dual-written user, marshals one enriched payload, and unmarshals it into both DTOs. Its assertions check the legacy number, two modern entries, and the primary number. That is useful evidence for these two Go consumers and this fixture, not a universal claim about all strict decoders or generated clients.&lt;/p&gt;

&lt;p&gt;Empty representations also matter. The scalar has &lt;code&gt;omitempty&lt;/code&gt;, so an empty string may disappear from the enriched payload. &lt;code&gt;CreateUser&lt;/code&gt; in legacy-only mode leaves &lt;code&gt;Phones&lt;/code&gt; nil, which can serialize as &lt;code&gt;null&lt;/code&gt;; the store's empty read slice can serialize as &lt;code&gt;[]&lt;/code&gt;. Those are different payload shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  API version and storage mode are separate choices
&lt;/h2&gt;

&lt;p&gt;The V1 handler returns &lt;code&gt;LegacyConsumerDTO&lt;/code&gt;; the V2 handler returns &lt;code&gt;ModernConsumerDTO&lt;/code&gt;. They do not both return the same enriched payload used by the serialization test.&lt;/p&gt;

&lt;p&gt;The service also has independent write and read modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WriteLegacyOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Store the scalar; ignore extra phone arguments.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WriteDual&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Store scalar and modern primary, then add extras to modern storage.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WriteNewOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Store modern entries with a nil legacy phone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ReadLegacyOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Derive the response's scalar from legacy storage.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ReadFallback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prefer modern primary/first entry, otherwise use legacy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ReadNewOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Derive the scalar from modern entries.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is a subtle boundary here: &lt;code&gt;GetUser&lt;/code&gt; always fetches modern entries before selecting the scalar's source. &lt;code&gt;ReadLegacyOnly&lt;/code&gt; does not hide the enriched response's &lt;code&gt;Phones&lt;/code&gt; array. &lt;code&gt;GetModernUser&lt;/code&gt; returns that array.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dual-write protects what the old reader still needs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MemoryStore.CreateDual&lt;/code&gt; creates the user and modern primary phone under one mutex. The old scalar stays populated, allowing a legacy read of records created during the dual-write phase.&lt;/p&gt;

&lt;p&gt;Additional numbers exist only in the modern representation. Backward compatibility preserves the old view; it does not give an old client all new capabilities.&lt;/p&gt;

&lt;p&gt;The atomicity claim needs a precise scope. &lt;code&gt;Service.CreateUser&lt;/code&gt; saves &lt;code&gt;extraPhones&lt;/code&gt; through separate &lt;code&gt;SavePhoneEntry&lt;/code&gt; calls after &lt;code&gt;CreateDual&lt;/code&gt;, ignoring errors from those extra calls. The complete multi-phone operation is not one database transaction. The store is a collection of maps, while &lt;code&gt;schema.sql&lt;/code&gt; is a schema illustration rather than an executed storage adapter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backfill historical data in bounded batches
&lt;/h2&gt;

&lt;p&gt;Dual-write covers creates passing through that mode. It does not migrate historical users by itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BackfillWorker&lt;/code&gt; scans IDs after &lt;code&gt;LastProcessedID&lt;/code&gt;. It copies a nonempty legacy phone only when modern entries are absent. Existing modern entries are skipped, so backfill is not a repair mechanism for every mismatch.&lt;/p&gt;

&lt;p&gt;The storage helper prevents duplicate numbers for the same user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// store.go: SavePhoneEntry excerpt&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userPhones&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning an existing entry does not update its primary marker. The behavior is deduplication by user and number, not a general merge policy.&lt;/p&gt;

&lt;p&gt;The worker keeps &lt;code&gt;LastProcessedID&lt;/code&gt;, &lt;code&gt;TotalMigrated&lt;/code&gt;, and &lt;code&gt;IsComplete&lt;/code&gt; in memory. Its test processes ten users as batches of three, three, then four remaining records, and expects zero from rerunning the completed worker.&lt;/p&gt;

&lt;p&gt;“Resumable” therefore means continuing the same worker object. There is no durable checkpoint or restart-recovery demonstration. Once &lt;code&gt;IsComplete&lt;/code&gt; is set, that worker stops scanning; newly created legacy-only records are not automatically revisited. A completed scan does not establish that all writers have switched.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fallback read can repair storage without repairing this response
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ReadFallback&lt;/code&gt; prefers modern data. If the modern list is empty and the legacy pointer exists, it sets the scalar from legacy and performs a lazy &lt;code&gt;SavePhoneEntry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, the local &lt;code&gt;phones&lt;/code&gt; slice was loaded before that write and is not reloaded afterward. The first fallback response can contain the correct scalar and an empty array, even though storage contains the new entry by the time the method returns. &lt;code&gt;GetModernUser&lt;/code&gt; can therefore receive an empty array on that first read.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestFallbackRead&lt;/code&gt; checks the scalar and inspects storage after the call. It does not assert the first response's array. That difference is exactly why reading assertions matters more than inferring guarantees from a test name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconciliation detects drift; it does not fix it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ReconcileData&lt;/code&gt; compares a populated legacy phone with the modern primary number. Missing entries and mismatched primary numbers count as drift. It skips users whose legacy pointer is nil and returns immediately after contract has been applied.&lt;/p&gt;

&lt;p&gt;The function returns the number of findings in that scan. &lt;code&gt;DriftDetected&lt;/code&gt;, by contrast, is cumulative: rescanning the same mismatch increments it again. A clean current scan and a nonzero historical counter can both be correct.&lt;/p&gt;

&lt;p&gt;There is no automatic repair or periodic scheduler. The demo and tests invoke reconciliation explicitly. Zero drift within this comparison is not proof that every aspect of modern data is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback safety depends on write history
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TestRollbackScenarios&lt;/code&gt; contrasts two states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Record creation mode&lt;/th&gt;
&lt;th&gt;Result after returning to legacy reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dual-write&lt;/td&gt;
&lt;td&gt;The primary phone remains visible.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New-only&lt;/td&gt;
&lt;td&gt;The scalar is empty, without a read error.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The modern number may still exist. What fails is visibility through the old representation. Restoring an old application mode does not populate data that the old reader needs.&lt;/p&gt;

&lt;p&gt;This rollback boundary appears before the column is dropped: merely retaining a legacy column is insufficient if new records have stopped writing it. The demo changes flags in one process; it does not deploy and roll back separate binaries against a shared database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract needs evidence that the current guard does not gather
&lt;/h2&gt;

&lt;p&gt;The initial guard in &lt;code&gt;ApplyContract&lt;/code&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// service.go: ApplyContract excerpt&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LegacyReadHits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%w: recorded %d legacy reads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ErrContractViolation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LegacyReadHits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LegacyReadHits&lt;/code&gt; is a cumulative counter, not a traffic rate or observation window. The guard has no managed reset or time window. &lt;code&gt;GetLegacyUser&lt;/code&gt; increments it before checking errors, so failed attempts count too.&lt;/p&gt;

&lt;p&gt;The demo calls &lt;code&gt;ApplyContract(true)&lt;/code&gt;, bypassing this guard. That does not prove a zero-traffic period. Even without force, a zero counter can allow contract before historical data has been backfilled: the method does not check worker completion, drift, or old writers.&lt;/p&gt;

&lt;p&gt;It then changes write mode, changes read mode, marks contract applied, and clears legacy pointers in storage. Those steps do not form one atomic service-wide transition.&lt;/p&gt;

&lt;p&gt;After contract, legacy service reads return an error and the V1 handler responds with &lt;code&gt;410 Gone&lt;/code&gt;. But the enriched &lt;code&gt;UserResponse&lt;/code&gt; type still has &lt;code&gt;Phone&lt;/code&gt;, and new-only reads can populate it from modern entries. This is retirement of the legacy route and storage value, not removal of the enriched field from source code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not promote illustrative SQL or headers into production guarantees
&lt;/h2&gt;

&lt;p&gt;The example SQL keeps &lt;code&gt;users.phone TEXT NOT NULL&lt;/code&gt; during expansion. The in-memory &lt;code&gt;CreateModern&lt;/code&gt; creates users with a nil legacy phone. The SQL file does not implement the complete sequencing needed to reconcile new-only inserts with the still-present database constraint.&lt;/p&gt;

&lt;p&gt;No test applies the DDL to a database or measures database locks. The example is not evidence of an online, zero-downtime schema migration.&lt;/p&gt;

&lt;p&gt;The V1 handler emits hardcoded &lt;code&gt;Deprecation&lt;/code&gt; and &lt;code&gt;Sunset&lt;/code&gt; values. Its test checks &lt;code&gt;Deprecation == "true"&lt;/code&gt; and a nonempty &lt;code&gt;Sunset&lt;/code&gt;, not standards compliance or time-triggered retirement. &lt;code&gt;410&lt;/code&gt; follows &lt;code&gt;ApplyContract&lt;/code&gt;, not a sunset scheduler. This article therefore makes no RFC-compliance claim for those values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read concurrency coverage narrowly
&lt;/h2&gt;

&lt;p&gt;The concurrency test overlaps creates, legacy/modern reads, backfill, and reconciliation. Many returned errors are ignored, and its final explicit assertion checks only &lt;code&gt;DualWriteErrors&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That does not establish final consistency of every record or safe concurrent contract transitions. Running a race detector is valuable evidence for executed paths, but is not a substitute for semantic assertions about the migration.&lt;/p&gt;

&lt;p&gt;To reproduce the lab from its own module directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;labs/13-backward-compatibility
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-race&lt;/span&gt; ./...
go run ./cmd/demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module declares Go 1.26.7. Go is unavailable in this authoring environment, so I inspected the source and tests rather than executing them. Execution reports committed in the repository are historical author reports, not a fresh run performed for this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the transition, not just the destination
&lt;/h2&gt;

&lt;p&gt;Expand keeps both representations available. Migrate moves writes, historical data, and consumers with explicit checks. Contract retires the old path after its dependencies are actually gone.&lt;/p&gt;

&lt;p&gt;The pattern supplies an order of work, not an automatic safety guarantee. Compatibility still depends on the real reader behavior, write history, checkpoint durability, and retirement gates implemented along that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/13-backward-compatibility" rel="noopener noreferrer"&gt;Software Engineering Lab 13&lt;/a&gt;, commit &lt;code&gt;82af988b89439fa92b1200406c5afbd59ce72a56&lt;/code&gt;: &lt;code&gt;internal/compat/*.go&lt;/code&gt;, &lt;code&gt;tests/*.go&lt;/code&gt;, &lt;code&gt;cmd/demo/main.go&lt;/code&gt;, &lt;code&gt;README.md&lt;/code&gt;, &lt;code&gt;schema.sql&lt;/code&gt;, and &lt;code&gt;go.mod&lt;/code&gt;. Code excerpts belong in their existing package context.&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Feature Flags: Deploy Code, Control the Release</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:27:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/feature-flags-deploy-code-control-the-release-4jl</link>
      <guid>https://dev.to/lukman-ss/feature-flags-deploy-code-control-the-release-4jl</guid>
      <description>&lt;p&gt;The new online-booking flow is ready to deploy. Does that mean every workshop customer must use it immediately?&lt;/p&gt;

&lt;p&gt;Lab 12 separates those decisions. Deployment puts code in place; release exposes its behavior to users. The &lt;code&gt;online_booking&lt;/code&gt; flag controls that exposure through internal targeting, percentage rollout, and a global OFF switch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Deploy does not have to mean release.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The lab is a small Go simulation, not a production flag platform. Reading its boundaries is part of understanding what a feature flag actually guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the decision at a clear boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FeatureService&lt;/code&gt; owns flag configuration and evaluation. &lt;code&gt;BookingService.CreateBooking&lt;/code&gt; makes one routing decision rather than scattering targeting rules throughout business logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// booking_service.go: complete method&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;BookingService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CreateBooking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;BookingRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;BookingResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;featureService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"online_booking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;newBookingFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;legacyBookingFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both flows return simulated responses. The new flow succeeds unless &lt;code&gt;simulateFail&lt;/code&gt; is true; the legacy flow always succeeds. There is no database write, mechanic reservation, or real vendor call. &lt;code&gt;Vendor Error (Simulated)&lt;/code&gt; is deliberately injected behavior.&lt;/p&gt;

&lt;p&gt;This distinction matters: the lab demonstrates choosing a path, not implementing the booking transaction itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluation order is part of the contract
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IsEnabled&lt;/code&gt; applies rules in this order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Order&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Unknown flag returns false.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Enabled == false&lt;/code&gt; returns false for everyone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;A matching ID in &lt;code&gt;InternalUsers&lt;/code&gt; returns true.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Percentage &amp;lt;= 0 returns false for ordinary users; &amp;gt;= 100 returns true.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;For percentages 1–99, compare the user's bucket with the threshold.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Global OFF wins over internal targeting. Conversely, &lt;code&gt;Enabled: true&lt;/code&gt; with a zero percentage can release to internal users while leaving ordinary users on legacy.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;User&lt;/code&gt; struct has an &lt;code&gt;IsInternal&lt;/code&gt; field, but evaluation does not use it. Internal access comes from matching &lt;code&gt;userID&lt;/code&gt; against the configured list. Do not infer targeting behavior from a field that is never read.&lt;/p&gt;

&lt;p&gt;The percentage comment says 0–100, but there is no rejecting validation. Negative values behave like zero; values above 100 behave like 100 for ordinary users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic cohorts, not random requests
&lt;/h2&gt;

&lt;p&gt;Randomizing every request could send the same user through different flows on consecutive calls. The implemented calculation is deterministic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// feature_service.go: bucketing excerpt&lt;/span&gt;
&lt;span class="n"&gt;hashInput&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s:%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;featureKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum256&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashInput&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;bucketVal&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;binary&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BigEndian&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Uint64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucketVal&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RolloutPercentage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact input includes a colon separator. SHA-256 produces the digest; the first eight bytes are interpreted as a big-endian uint64; modulo 100 gives a bucket from 0 to 99.&lt;/p&gt;

&lt;p&gt;At 10%, buckets 0–9 qualify. At 50%, buckets 0–49 qualify. With the same IDs, feature key, algorithm, and targeting rules, the original 10% cohort remains included as the threshold increases.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;online_booking&lt;/code&gt;, recalculating the source algorithm puts &lt;code&gt;user-3&lt;/code&gt; in bucket 25 and &lt;code&gt;user-4&lt;/code&gt; in bucket 83. The former enters at 50%; the latter remains excluded until a higher threshold or the 100% shortcut. These are derived examples, not recorded Go execution output.&lt;/p&gt;

&lt;p&gt;Consistency is conditional. Changing configuration is supposed to change eligibility. Changing the user ID or feature key can also change the bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 10% threshold does not promise exactly ten requests
&lt;/h2&gt;

&lt;p&gt;The CLI generates 100 users: three internal IDs, then &lt;code&gt;user-3&lt;/code&gt; through &lt;code&gt;user-99&lt;/code&gt;. It issues one request per user in each phase. Internal users remain allowlisted through the percentage phases.&lt;/p&gt;

&lt;p&gt;Re-evaluating this exact fixture with the source hash algorithm gives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;New flow&lt;/th&gt;
&lt;th&gt;Legacy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OFF&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal only&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10% plus internal allowlist&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50% plus internal allowlist&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I calculated these counts in Python from the repository's fixture and algorithm; I did not run the Go demo. At 10%, 12 of the 97 ordinary users qualify, plus all three internal users. At 50%, 45 ordinary users qualify, plus the three internal users.&lt;/p&gt;

&lt;p&gt;Two effects explain the difference from the threshold label: a finite sample need not contain exactly 10% of users in buckets 0–9, and internal targeting overrides the percentage gate.&lt;/p&gt;

&lt;p&gt;User exposure is also not necessarily traffic exposure. Users can send unequal request volumes. The demo makes those quantities align by sending exactly one request per user per phase. A 10% cohort is therefore not a hard ceiling on every possible production impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observe before increasing exposure
&lt;/h2&gt;

&lt;p&gt;The README recommends OFF, internal, 10%, 50%, and 100%, with observation between increases. The CLI demonstrates that sequence, but its transitions are hardcoded. It does not wait for an observation window or inspect a threshold before advancing.&lt;/p&gt;

&lt;p&gt;That makes it an illustration of rollout stages, not an automatic rollout controller.&lt;/p&gt;

&lt;p&gt;The runner's &lt;code&gt;phase&lt;/code&gt; function records metrics after calling &lt;code&gt;CreateBooking&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total requests.&lt;/li&gt;
&lt;li&gt;New-flow and legacy-flow request counts.&lt;/li&gt;
&lt;li&gt;Aggregate booking successes and failures.&lt;/li&gt;
&lt;li&gt;Response-time samples and their mean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Although &lt;code&gt;BookingService&lt;/code&gt; accepts a metrics pointer, &lt;code&gt;CreateBooking&lt;/code&gt; does not update those counters. Calling the service directly does not provide the CLI's instrumentation automatically.&lt;/p&gt;

&lt;p&gt;The error-rate formula in &lt;code&gt;Metrics.Snapshot&lt;/code&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// metrics.go: struct-literal field excerpt&lt;/span&gt;
&lt;span class="n"&gt;ErrorRate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BookingFailed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;
    &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalRequests&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The denominator is all requests, not just requests using the new flow. During a partial rollout, aggregate error rate can dilute a severe failure rate inside the new cohort. The current metrics split request volume by flow but combine success/failure outcomes. Per-flow error rates would be an extension, not an existing feature.&lt;/p&gt;

&lt;p&gt;Metrics reset at each phase. &lt;code&gt;AvgResponseTime&lt;/code&gt; is a mean, not a percentile. It measures a local simulated method with no database or network work, so its latency should not be presented as a production booking benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  A kill switch changes subsequent decisions
&lt;/h2&gt;

&lt;p&gt;After reaching 100%, the CLI sets &lt;code&gt;simulateFail&lt;/code&gt; to true. Every request in phase 6a selects the new flow and fails. Then it applies this configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// cmd/simulate/main.go: kill-switch configuration&lt;/span&gt;
&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;featureflags&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flag&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;"online_booking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Enabled&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requests in phase 6b select legacy and succeed, even though the failure simulation remains enabled. Turning off the feature avoids the failing path on subsequent evaluations that read the updated configuration.&lt;/p&gt;

&lt;p&gt;It does not retry an already failed request through legacy. It does not cancel an evaluation that already read the old configuration, undo completed side effects, or remove deployed code. The distinction between rerouting future work and repairing past work is essential to the kill-switch claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime configuration has an operational boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FeatureService&lt;/code&gt; stores flags in an in-memory map protected by an &lt;code&gt;RWMutex&lt;/code&gt;. &lt;code&gt;SetFlag&lt;/code&gt; replaces the entire &lt;code&gt;Flag&lt;/code&gt; value. It is not a partial-field update: leaving out &lt;code&gt;RolloutPercentage&lt;/code&gt; and &lt;code&gt;InternalUsers&lt;/code&gt; resets them to their zero values.&lt;/p&gt;

&lt;p&gt;The CLI sends complete targeting configuration during rollout. A caller that wants to preserve targeting must do the same rather than assume omitted fields remain unchanged.&lt;/p&gt;

&lt;p&gt;There is no persistent configuration store, cross-replica distribution, operator dashboard, or administration endpoint. “Without redeploy” means updating the object in this running process. The map's mutex also does not establish concurrency safety for every other field: &lt;code&gt;simulateFail&lt;/code&gt; is a plain bool, and toggling it concurrently with booking reads is not synchronized by this implementation. The demo itself runs sequentially.&lt;/p&gt;

&lt;h2&gt;
  
  
  A flag is not authorization
&lt;/h2&gt;

&lt;p&gt;Authorization asks whether the user is allowed to create a booking. The feature flag asks which booking behavior is selected for that user.&lt;/p&gt;

&lt;p&gt;The README explicitly distinguishes these questions. This implementation answers the second one; it does not implement an authorization check. An internal allowlist for release targeting should not be described as a permission system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the paths, then remove the temporary branch
&lt;/h2&gt;

&lt;p&gt;The test file covers global OFF, zero percentage, internal inclusion, examples on both sides of a 10% gate, repeated evaluation, increased rollout, kill switch, and unknown flags. Booking tests check ON/OFF responses and a simulated failure followed by an OFF update and a successful legacy response.&lt;/p&gt;

&lt;p&gt;The percentage test searches for one ON user and one OFF user. It does not prove an exact 10% distribution. There are no metric assertions or concurrent execution tests in this file. Test names should not be read as broader guarantees than their assertions provide.&lt;/p&gt;

&lt;p&gt;Use the repository's Makefile targets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From the repository root&lt;/span&gt;
make lab-12-demo
make lab-12-test

&lt;span class="c"&gt;# Or enter the nested lab module&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;labs/12-feature-flags
go run ./cmd/simulate
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 ./...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lab's own &lt;code&gt;go.mod&lt;/code&gt; declares Go 1.26.7. Entering that module matches the Makefile; the README's root-relative &lt;code&gt;go run&lt;/code&gt; alternative needs to account for the nested-module boundary. Go is unavailable in this authoring environment, so I inspected the tests and demo rather than executing them. No passing test result is claimed.&lt;/p&gt;

&lt;p&gt;Finally, the README's lifecycle continues beyond 100%: observe stability, remove the flag, then remove unused legacy code. That cleanup is a recommended future change, not a refactor already performed in this lab.&lt;/p&gt;

&lt;p&gt;A release flag is useful because it separates code delivery from user exposure. Its value depends on consistent targeting, meaningful evidence, tested fallback behavior, and a plan to retire the extra branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/12-feature-flags" rel="noopener noreferrer"&gt;Software Engineering Lab 12&lt;/a&gt;, inspected at commit &lt;code&gt;82af988b89439fa92b1200406c5afbd59ce72a56&lt;/code&gt;: the README, feature service, booking service, metrics, tests, CLI simulation, lab &lt;code&gt;go.mod&lt;/code&gt;, and Lab 12 targets in the root Makefile. Code excerpts belong in their existing package context.&lt;/p&gt;

</description>
      <category>go</category>
      <category>featureflags</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Production Debugging: Test Hypotheses, Not Guesses</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Sun, 11 Oct 2026 10:14:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/production-debugging-test-hypotheses-not-guesses-269i</link>
      <guid>https://dev.to/lukman-ss/production-debugging-test-hypotheses-not-guesses-269i</guid>
      <description>&lt;p&gt;A purchase-order request for a one-year range is slow. The README frames the incident as a &lt;code&gt;504 Gateway Timeout&lt;/code&gt;. Is the handler serializing too much JSON? Is the service doing expensive work? Is the repository slow?&lt;/p&gt;

&lt;p&gt;Lab 11 turns those guesses into an investigation. Its most useful lesson is not simply “measure every layer.” It is &lt;strong&gt;understand what each measurement includes before using it as evidence&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Debugging is testing hypotheses, not guessing causes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The scenario and the implementation are different things
&lt;/h2&gt;

&lt;p&gt;The lab contains &lt;code&gt;README.md&lt;/code&gt;, &lt;code&gt;main.go&lt;/code&gt;, and &lt;code&gt;main_test.go&lt;/code&gt;. The implementation is an in-process Go simulation: a handler calls a service, which calls a repository. Tests invoke &lt;code&gt;HandleGetOrders&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;There is no HTTP server, load balancer, database query, or JSON serialization in these files. The &lt;code&gt;504&lt;/code&gt; is the motivating scenario, not an HTTP response generated by the tests. Missing indexes and inefficient queries are analogies in the README, not demonstrated database findings.&lt;/p&gt;

&lt;p&gt;Here is the complete slow repository method from &lt;code&gt;main.go&lt;/code&gt;, formatted for readability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;SlowRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FetchOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rangeType&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;PurchaseOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rangeType&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"1y"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;PurchaseOrder&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"po-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one-year input selects a four-second sleep. Every other input selects a ten-millisecond sleep. Both paths return one order, not different-sized datasets. There is no date filtering or range validation. This matters: the experiment models latency, not the cost of retrieving a larger result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce one specific condition
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TestReproduceBug&lt;/code&gt; wires &lt;code&gt;SlowRepository&lt;/code&gt; into the service and calls the handler with &lt;code&gt;"1y"&lt;/code&gt;. It fails when &lt;code&gt;TotalDuration &amp;lt; 4*time.Second&lt;/code&gt;. Precisely, the assertion accepts a duration of at least four seconds; the failure message's &lt;code&gt;&amp;gt; 4s&lt;/code&gt; is slightly looser wording.&lt;/p&gt;

&lt;p&gt;From the repository root, the README's workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;labs/11-debugging-production
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-run&lt;/span&gt; TestReproduceBug
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-run&lt;/span&gt; TestCollectEvidence
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-run&lt;/span&gt; TestVerifyFix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root &lt;code&gt;go.mod&lt;/code&gt; specifies Go 1.25.0. Go is unavailable in the environment used to prepare this article, so I inspected these tests rather than running them. The timing arithmetic below is derived from source, not benchmark output or a claim that tests passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give each hypothesis a falsifiable question
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;README hypothesis&lt;/th&gt;
&lt;th&gt;What the evidence must distinguish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;H1: handler / JSON work&lt;/td&gt;
&lt;td&gt;Handler-local work versus time spent downstream; JSON is not implemented here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H2: service business logic&lt;/td&gt;
&lt;td&gt;Service-local work versus waiting inside the repository call.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H3: repository&lt;/td&gt;
&lt;td&gt;Whether changing the repository while holding the other parts fixed removes the modeled delay.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A slow service measurement does not automatically support H2. A caller's timer can include its dependency. Similarly, a small preparation timer cannot establish that JSON serialization is fast when serialization never occurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service time is inclusive
&lt;/h2&gt;

&lt;p&gt;This is the complete &lt;code&gt;Service.GetOrders&lt;/code&gt; method, with its signature wrapped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rangeType&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;PurchaseOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FetchOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rangeType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dur&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timer starts before the service's simulated work and stops after &lt;code&gt;FetchOrders&lt;/code&gt; returns. &lt;code&gt;ServiceDuration&lt;/code&gt; therefore includes repository time. With the slow repository, the configured sleep contribution is 10 ms + 4000 ms, not 10 ms alone.&lt;/p&gt;

&lt;p&gt;The handler then derives repository time using this excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;totalDur&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;repoDur&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;svcDur&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a subtraction, not an independently measured repository span. Any excess time in service scheduling or other overhead can remain in the derived value. Calling the field &lt;code&gt;RepositoryDuration&lt;/code&gt; does not change its measurement boundary.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Actual meaning in the code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HandlerDuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Handler preparation only, including a 5 ms sleep.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ServiceDuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inclusive service work plus the repository call.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RepositoryDuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Service duration minus a fixed 10 ms.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TotalDuration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Elapsed time across the successful handler path.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Do not add handler, service, and repository durations together. Service already includes repository time, so that would double-count the repository. The general habit is to ask where every timer starts and stops before comparing its value with another timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The example log is not output from this implementation
&lt;/h2&gt;

&lt;p&gt;The README shows &lt;code&gt;service_duration=12ms&lt;/code&gt; alongside &lt;code&gt;repository_duration=4200ms&lt;/code&gt;. That pairing is inconsistent with the current calculation: repository duration equals service duration minus 10 ms. It cannot be treated as actual output from this code.&lt;/p&gt;

&lt;p&gt;The configured sleep budgets are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Sum of configured sleeps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slow repository, &lt;code&gt;1y&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 + 10 + 4000 = 4015 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow repository, other input&lt;/td&gt;
&lt;td&gt;5 + 10 + 10 = 25 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast repository, &lt;code&gt;1y&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 + 10 + 10 = 25 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are source-derived sums, not measured runtimes. Scheduling and overhead affect real elapsed time. The README's claim that &lt;code&gt;1m&lt;/code&gt; takes less than 20 ms also conflicts with the 25 ms of configured sleeps in the current code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestCollectEvidence&lt;/code&gt; prints the duration fields but does not assert a bottleneck relationship. Its &lt;code&gt;request_id=abc123&lt;/code&gt; line is hardcoded; it is not a unique-ID generator or a tracing implementation. Printing evidence and automatically verifying an interpretation are different operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change one variable
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TestVerifyFix&lt;/code&gt; replaces &lt;code&gt;SlowRepository&lt;/code&gt; with &lt;code&gt;FastRepository&lt;/code&gt; while retaining the same handler, service, and &lt;code&gt;"1y"&lt;/code&gt; input. The fast repository always sleeps for 10 ms and returns the same one-order payload.&lt;/p&gt;

&lt;p&gt;This controlled replacement isolates the artificial repository delay. It does not demonstrate a SQL optimization or a production deployment. Both implementations visibly return the same data, but the tests do not assert payload correctness.&lt;/p&gt;

&lt;p&gt;The verification assertion from &lt;code&gt;main_test.go&lt;/code&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalDuration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected request to be fast (&amp;lt; 100ms), but took %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalDuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The condition accepts a total duration of at most 100 ms, including exactly 100 ms. That is a lab test threshold, not a production SLO or latency guarantee. Because these are wall-clock tests, execution conditions can influence the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the conclusion inside the evidence
&lt;/h2&gt;

&lt;p&gt;All three tests use &lt;code&gt;"1y"&lt;/code&gt; and &lt;code&gt;context.Background()&lt;/code&gt;. They do not test the &lt;code&gt;1m&lt;/code&gt; path, returned order contents, HTTP status codes, concurrent load, or cancellation.&lt;/p&gt;

&lt;p&gt;Although context is passed through the call chain, neither repository checks it, and the sleeps are not cancellation-aware. A context parameter alone does not establish timeout behavior.&lt;/p&gt;

&lt;p&gt;The error path also deserves careful reading. The service returns the repository error, but the handler returns an empty &lt;code&gt;RequestResult&lt;/code&gt; on error. Timing is therefore not returned on that path. Both provided repositories always return a nil error, and no test injects a failing repository. These are possible follow-up checks, not existing capabilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reusable debugging loop
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Define the exact condition you can reproduce.&lt;/li&gt;
&lt;li&gt;Separate implementation facts, documentation examples, and assumptions.&lt;/li&gt;
&lt;li&gt;Write hypotheses with evidence that could refute them.&lt;/li&gt;
&lt;li&gt;Check the boundaries and overlap of your measurements.&lt;/li&gt;
&lt;li&gt;Change one variable and verify against an explicit criterion.&lt;/li&gt;
&lt;li&gt;State what the experiment did not establish.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For this lab, the source identifies a four-second artificial repository delay. That is a useful, bounded conclusion. Claiming a missing production index would go beyond the evidence.&lt;/p&gt;

&lt;p&gt;The point is not to sound certain sooner. It is to make each conclusion traceable to the code, measurement, or controlled change that supports it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/11-debugging-production" rel="noopener noreferrer"&gt;Software Engineering Lab 11&lt;/a&gt;: &lt;code&gt;README.md&lt;/code&gt;, &lt;code&gt;main.go&lt;/code&gt;, and &lt;code&gt;main_test.go&lt;/code&gt;, inspected at commit &lt;code&gt;82af988b89439fa92b1200406c5afbd59ce72a56&lt;/code&gt;. The repository root supplies &lt;code&gt;go.mod&lt;/code&gt;. Code excerpts belong in the existing lab package; they are not standalone programs.&lt;/p&gt;

</description>
      <category>go</category>
      <category>debugging</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Project Estimation: Eight Screens Are Not Eight Days of Work</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Fri, 09 Oct 2026 10:36:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/project-estimation-eight-screens-are-not-eight-days-of-work-3oo9</link>
      <guid>https://dev.to/lukman-ss/project-estimation-eight-screens-are-not-eight-days-of-work-3oo9</guid>
      <description>&lt;p&gt;An application has eight features. Assigning one day to each produces an eight-day estimate, but says nothing about webhook behavior, sandbox access, booking validation, or the engineer's actual availability.&lt;/p&gt;

&lt;p&gt;Lab 10 replaces that shortcut with a small Go estimation model. Its case study is a vehicle-service booking application: login, online booking, branch selection, mechanic selection, payment, WhatsApp notifications, an admin dashboard, and Excel reports. The repository implements estimation arithmetic and validation—not those application features or vendor integrations.&lt;/p&gt;

&lt;p&gt;The important result is not a more convincing single number. It is an explanation of scope, effort, uncertainty, dependencies, assumptions, and the capacity needed to turn effort into elapsed working time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page count hides the work
&lt;/h2&gt;

&lt;p&gt;The naive estimator deliberately returns one day per page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// estimation.go; complete function&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;EstimateByPageCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageCount&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pageCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pageCount&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TestNaiveEstimator&lt;/code&gt; expects ten days for ten pages. That assertion confirms the function's behavior; it does not establish that a ten-page project can be delivered in ten days.&lt;/p&gt;

&lt;p&gt;The README's payment breakdown explains what page count misses: documentation discovery, authentication, transaction creation, callback verification, error paths, retry/idempotency, and sandbox testing. A screen is an interface boundary, not a unit of implementation effort.&lt;/p&gt;

&lt;p&gt;Similarly, branch selection involves master data and a selector, mechanic selection needs relationships and availability checks, and Excel reporting includes filtered data and file generation. These are planning examples in the README, not implemented booking endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the estimation unit explicit
&lt;/h2&gt;

&lt;p&gt;Each executable planning task carries a name, a three-point effort range, a risk label, spike effort, and assumptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// estimation.go; complete type definitions&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EstimateRange&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Min&lt;/span&gt;        &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;MostLikely&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;Max&lt;/span&gt;        &lt;span class="kt"&gt;float64&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Estimate&lt;/span&gt;    &lt;span class="n"&gt;EstimateRange&lt;/span&gt;
    &lt;span class="n"&gt;Risk&lt;/span&gt;        &lt;span class="n"&gt;RiskLevel&lt;/span&gt;
    &lt;span class="n"&gt;SpikeDays&lt;/span&gt;   &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;Assumptions&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Project.Estimate&lt;/code&gt; combines the tasks directly attached to the project with tasks inside each feature. Feature names organize the input; the calculation flattens their tasks into one collection. &lt;code&gt;TestFeatureBreakdownAffectsTotal&lt;/code&gt; checks that three feature tasks contribute six minimum implementation days and a task count of three.&lt;/p&gt;

&lt;p&gt;There is no task identity or deduplication. Listing the same work in both collections counts it twice. Nor does the model store task-to-task dependencies or create a schedule from feature order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown work needs a bounded investigation
&lt;/h2&gt;

&lt;p&gt;The README distinguishes familiar work from work whose authentication, webhook behavior, or vendor environment is not understood. Unknown does not mean zero effort. It also should not become an unsupported implementation guess.&lt;/p&gt;

&lt;p&gt;A spike is timeboxed discovery with a specific output: enough evidence to identify and estimate the implementation tasks. The payment example investigates authentication, transaction creation, signature verification, sandbox behavior, and failure/retry handling. It is not a production integration delivered inside the discovery timebox.&lt;/p&gt;

&lt;p&gt;The actual validation rule is strict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// estimation.go; excerpt from Task.Validate&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Risk&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;RiskUnknown&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SpikeDays&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task %q: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&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;ErrUnknownRiskNoSpike&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;RiskUnknown&lt;/code&gt; task must have positive &lt;code&gt;SpikeDays&lt;/code&gt;, even when its implementation range is nonzero. &lt;code&gt;TestUnknownRiskWithoutSpikeRejected&lt;/code&gt; supplies a 1/2/3 estimate with no spike and expects rejection. &lt;code&gt;TestUnknownRiskWithSpikeAndExplicitEstimateAccepted&lt;/code&gt; supplies a positive spike and expects acceptance.&lt;/p&gt;

&lt;p&gt;This contradicts the README's failure-scenario row claiming that an unknown task with zero spike and a nonzero estimate is allowed after discovery. The executable model has no completed-spike flag. Once discovery is complete, keeping &lt;code&gt;RiskUnknown&lt;/code&gt; while setting &lt;code&gt;SpikeDays&lt;/code&gt; to zero still fails validation; a re-estimated task needs an appropriate supported risk classification.&lt;/p&gt;

&lt;p&gt;Also, &lt;code&gt;RequiredSpikes&lt;/code&gt; includes any task with positive spike days, not just unknown-risk tasks. Spike time is counted as fixed effort in all three range positions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A weighted estimate is not a deadline probability
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// estimation.go; complete function&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;EstimateRange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Expected&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Min&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;4.0&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MostLikely&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;6.0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle input gets four times the weight of each endpoint. The model validates nonnegative values and &lt;code&gt;Min &amp;lt;= MostLikely &amp;lt;= Max&lt;/code&gt;, then sums minimum, weighted expected, and maximum values across tasks.&lt;/p&gt;

&lt;p&gt;The word &lt;code&gt;Expected&lt;/code&gt; is an output name for this weighted calculation. There is no sampled distribution, Monte Carlo simulation, percentile, or historical calibration. A maximum estimate is not a promised latest delivery date, and the output range is not a statistical confidence interval.&lt;/p&gt;

&lt;p&gt;For the arithmetic below, the source snapshot is commit &lt;code&gt;82af988b89439fa92b1200406c5afbd59ce72a56&lt;/code&gt;. Go is unavailable in this environment, so the tests were inspected but not executed. The numbers are independently checked arithmetic derived from the fixture and formulas, not captured Go test output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Work through the booking fixture
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TestBookingServiceCaseStudy&lt;/code&gt; has six direct tasks and two tasks inside an admin feature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Most likely&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Spike days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Booking Online&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pilih Cabang&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pilih Mekanik&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment Gateway&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Unknown&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp Notification&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Unknown&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard Admin&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laporan Excel&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Task estimates use engineer-days. These are illustrative fixture inputs, not measured productivity or a quotation for a real booking project.&lt;/p&gt;

&lt;p&gt;The minimum values sum to 17, the most-likely inputs sum to 29, and the maximum values sum to 49. The sum of weighted expectations is &lt;code&gt;(17 + 4 × 29 + 49) / 6 = 30.333…&lt;/code&gt;, rounded by the implementation to 30.33.&lt;/p&gt;

&lt;p&gt;Adding the two fixed spikes gives 2.5 engineer-days and a base range of 19.50 / 32.83 / 51.50. This is one complete fixture calculation; it should not be combined with the separate reference example in the README, which uses different task estimates and spike lengths.&lt;/p&gt;

&lt;p&gt;The fixture does not contain separate testing/UAT or deployment tasks. Some README breakdowns include testing inside a task, but the fixture does not specify how much of those activities its ranges cover. The model cannot detect omitted work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Risk controls a heuristic, not external reality
&lt;/h2&gt;

&lt;p&gt;Project risk is derived from task counts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any unknown task makes overall risk High.&lt;/li&gt;
&lt;li&gt;Otherwise, at least 30% High tasks makes overall risk High.&lt;/li&gt;
&lt;li&gt;Otherwise, any High or Medium task makes overall risk Medium.&lt;/li&gt;
&lt;li&gt;Otherwise, it is Low.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not effort-weighted: a short unknown investigation can elevate the whole project. The booking fixture is High because it contains unknown tasks.&lt;/p&gt;

&lt;p&gt;Automatic contingency applies only when &lt;code&gt;AutoContingency&lt;/code&gt; is true and the supplied rate is zero. It selects 25% for High, 15% for Medium, and 10% for Low. A positive explicit rate takes precedence, even if automatic selection is enabled. A zero rate with automatic selection disabled means no contingency.&lt;/p&gt;

&lt;p&gt;The defaults are teaching choices, not universal planning rules. The README's broader percentage ranges should not be presented as additional executable settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contingency is applied to every endpoint
&lt;/h3&gt;

&lt;p&gt;The model computes &lt;code&gt;ContingencyEffort&lt;/code&gt; as base expected effort times the rate. It scales minimum and maximum by the same percentage, rather than adding that single expected contingency amount to both endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// estimation.go; excerpt from Project.Estimate&lt;/span&gt;
&lt;span class="n"&gt;contingencyDays&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;baseRange&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpectedDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;contingencyRate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this fixture, 25% produces expected contingency of 8.2075 engineer-days and a rounded final range of &lt;strong&gt;24.38 / 41.04 / 64.38 engineer-days&lt;/strong&gt;. &lt;code&gt;ContingencyEffort&lt;/code&gt; itself is not rounded in the stored result; formatted output may display fewer decimals.&lt;/p&gt;

&lt;p&gt;This is an explicit assumption about risk buffering. It does not discover vendor delays, validate assumptions, or guarantee that the chosen buffer is sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effort becomes duration through capacity
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// estimation.go; excerpt from Project.Estimate&lt;/span&gt;
&lt;span class="n"&gt;effectiveDailyCapacity&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EngineerCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Availability&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each final effort endpoint is divided by this capacity and rounded to one decimal. One engineer at 70% availability gives 0.7 engineer-days of modeled daily capacity. For the fixture, that produces &lt;strong&gt;34.8 / 58.6 / 92.0 working days&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DurationRange.Weeks&lt;/code&gt; divides each duration by five. The endpoint range is therefore approximately &lt;strong&gt;7.0–18.4 five-working-day weeks&lt;/strong&gt;, not literal calendar days and not a date-aware schedule. Holidays, vendor waiting, dependency ordering, and the critical path are not represented.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestMultipleEngineersReducesCalendar&lt;/code&gt; checks that the formula gives a smaller duration with two engineers. It does not prove that every real task can be split evenly or that a project finishes twice as fast. The linear capacity formula assumes that the modeled effort can use that capacity.&lt;/p&gt;

&lt;p&gt;The README's separate reference example says 15–23 engineer-days with one engineer at 70%, yet mentions three to four weeks and converts only the lower endpoint. Dividing both endpoints yields approximately 21.4–32.9 working days, or 4.3–6.6 five-working-day weeks. The executable fixture above uses different inputs; neither example should be quoted as a verified delivery commitment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confidence is a communication label
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;calculateConfidence&lt;/code&gt; returns Low for any unknown task or High overall risk. Medium risk returns Medium. Low risk with at least one assumption returns High; without assumptions it returns Low.&lt;/p&gt;

&lt;p&gt;The function does not inspect whether assumption text is true, specific, or meaningful. &lt;code&gt;contingencyRate&lt;/code&gt; is passed into it but is unused. Adding buffer therefore does not increase confidence under this implementation. The booking fixture remains Low confidence despite its assumptions and contingency.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestAssumptionsIncreaseConfidence&lt;/code&gt; and &lt;code&gt;TestMissingAssumptionsReduceConfidence&lt;/code&gt; exercise the low-risk label behavior. They do not establish a statistical probability of meeting a deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation is useful, but bounded
&lt;/h2&gt;

&lt;p&gt;Tests cover invalid range order, negative effort/spike/contingency, unsupported risk labels, empty projects, empty task names, invalid availability, and nonpositive engineer counts. Other tests check feature aggregation, range ordering, risk labels, spike accounting, and the distinction between effort and duration.&lt;/p&gt;

&lt;p&gt;Several validations only assert that an error exists. &lt;code&gt;TestBookingServiceCaseStudy&lt;/code&gt; asserts High risk and at least two spikes, then logs calculated values; it does not pin every numeric endpoint to an expected constant.&lt;/p&gt;

&lt;p&gt;The implementation checks an empty task name with &lt;code&gt;Name == ""&lt;/code&gt;, so whitespace-only names pass. Floating-point validation does not explicitly reject &lt;code&gt;NaN&lt;/code&gt; or infinity. It has no dependency graph, waiting-time field, task deduplication, missing-scope detection, or automatic re-estimation after a spike. These are limits of the teaching model, not features silently provided by its result type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Communicate the conditions with the number
&lt;/h2&gt;

&lt;p&gt;For the fixture, an honest summary is: eight planning tasks, 2.5 engineer-days of spikes, 24.38–64.38 final engineer-days under the model's 25% contingency, and 34.8–92.0 modeled working days with one engineer at 70% availability. Overall risk is High and confidence is Low. Assumptions include final UI design, available vendor sandbox/documentation, accessible provider API, and no major scope changes.&lt;/p&gt;

&lt;p&gt;Vendor credentials are dependencies. Unexpected webhook behavior and changing booking allocation rules are risks. A capacity formula does not automatically add time spent waiting for either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An estimate is a range with assumptions, not a promise without conditions.&lt;/strong&gt; The model is valuable when its arithmetic and limitations make those conditions visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/10-project-estimation" rel="noopener noreferrer"&gt;Lab 10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/10-project-estimation/README.md" rel="noopener noreferrer"&gt;README.md&lt;/a&gt;: scope, breakdown, risks, and communication examples.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/10-project-estimation/estimation.go" rel="noopener noreferrer"&gt;estimation.go&lt;/a&gt;: types, validation, arithmetic, and heuristics.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/10-project-estimation/estimation_test.go" rel="noopener noreferrer"&gt;estimation_test.go&lt;/a&gt;: fixtures and assertions.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/10-project-estimation/go.mod" rel="noopener noreferrer"&gt;go.mod&lt;/a&gt;: module and Go 1.25 requirement.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>backend</category>
      <category>softwareengineering</category>
      <category>projectmanagement</category>
      <category>go</category>
    </item>
    <item>
      <title>Code Review: When Checkout Success Still Breaks Inventory</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Wed, 07 Oct 2026 23:31:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/code-review-when-checkout-success-still-breaks-inventory-3ok3</link>
      <guid>https://dev.to/lukman-ss/code-review-when-checkout-success-still-breaks-inventory-3ok3</guid>
      <description>&lt;p&gt;A checkout can return success after reducing stock without creating an order. It can also accept two purchases when only one unit exists. Lab 09 makes those risks concrete by comparing &lt;code&gt;CheckoutNaive&lt;/code&gt; with &lt;code&gt;CheckoutImproved&lt;/code&gt; and inspecting the assertions around both implementations.&lt;/p&gt;

&lt;p&gt;The useful review unit is a change in behavior: which requirement it serves, what can fail, who is affected, and what evidence protects the fix. A transaction or an idempotency interface is meaningful only when its actual boundary addresses that failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with requirements and observable behavior
&lt;/h2&gt;

&lt;p&gt;For this checkout, the important requirements are that stock stays nonnegative, stock reservation and order creation succeed together, a retry does not create another order, and a principal can only check out their own cart.&lt;/p&gt;

&lt;p&gt;Review in this order: requirement → changed behavior → failure mode → impact → simplest sufficient fix → test evidence. Naming and extraction suggestions can wait when inventory integrity or authorization is at risk.&lt;/p&gt;

&lt;p&gt;All technical observations below come from &lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/09-code-review" rel="noopener noreferrer"&gt;Lab 09&lt;/a&gt;, inspected at commit &lt;code&gt;82af988b89439fa92b1200406c5afbd59ce72a56&lt;/code&gt;. Go is unavailable in this content-generation environment; the tests were read, not executed. Described outcomes are assertions, not recorded run results. Go excerpts are copied from the source; surrounding code is omitted where indicated. The README's PHP example is illustrative pseudocode, not the executable implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: success hides a partial checkout
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Code
&lt;/h3&gt;

&lt;p&gt;The naive service updates stock inside its product loop and then ignores the result of order creation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// checkout_naive.go; excerpt, surrounding code omitted&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notify&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SendOrderConfirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;CheckoutResponse&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Failure mode and impact
&lt;/h3&gt;

&lt;p&gt;If order creation fails, previously applied stock deductions remain. The caller receives success and a notification is attempted for an order that may not exist. This is a correctness issue, not merely an error-message problem.&lt;/p&gt;

&lt;p&gt;The actual naive code does propagate &lt;code&gt;GetCart&lt;/code&gt; failure. It also skips an item when &lt;code&gt;GetProduct&lt;/code&gt; fails; it does not dereference a nil product on that branch. Yet &lt;code&gt;toOrderItems&lt;/code&gt; still includes all cart items, while the total only includes items that passed product lookup. That creates another inconsistency the abbreviated README example does not explain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix and test
&lt;/h3&gt;

&lt;p&gt;The improved version reserves every item and creates the order inside one callback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// checkout_improved.go; excerpt&lt;/span&gt;
 &lt;span class="n"&gt;txErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;txManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithinTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="n"&gt;CheckoutTx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&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;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;cartItems&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reserveErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReserveStock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;.&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;item&lt;/span&gt;&lt;span class="o"&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;reserveErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reserveErr&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;createErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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="n"&gt;createErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;createErr&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TestImprovedCheckout_IdempotencyFailureCanRetry&lt;/code&gt; asserts that an injected order-creation failure leaves stock unchanged, creates no order, sends no notification, and permits a later retry. &lt;code&gt;TestImprovedCheckout_MultiProductFailureRollsBackEverything&lt;/code&gt; checks that both product stocks retain their original values when order creation fails.&lt;/p&gt;

&lt;p&gt;Those assertions describe the mock transaction contract. They do not validate a database adapter, because the lab contains no production database transaction implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: synchronized memory can still oversell
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// checkout_naive.go; excerpt&lt;/span&gt;
&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stock&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdateStock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;.&lt;/span&gt;&lt;span class="n"&gt;ProductID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;MockProductRepository&lt;/code&gt;, &lt;code&gt;GetProduct&lt;/code&gt; returns a new &lt;code&gt;Product&lt;/code&gt;. Changing its &lt;code&gt;Stock&lt;/code&gt; field does not persist that value. &lt;code&gt;UpdateStock&lt;/code&gt; applies a delta under a mutex, but it has no condition preventing a negative result. Its error is ignored by the naive service.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode and impact
&lt;/h3&gt;

&lt;p&gt;Two requests can each deduct one unit from an initial stock of one, leaving negative inventory. The business failure is overselling. This does not require an unsynchronized memory access: locks can protect each map access while still allowing an invalid business operation.&lt;/p&gt;

&lt;p&gt;The barrier in &lt;code&gt;TestNaiveCheckout_DeterministicallyDemonstratesOverselling&lt;/code&gt; makes both product reads occur before the requests proceed. Its assertions expect two logical successes and negative final stock. The naive path would also accept a sequential deduction larger than available stock; concurrency is not its only problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix and test
&lt;/h3&gt;

&lt;p&gt;The improved service calls &lt;code&gt;CheckoutTx.ReserveStock&lt;/code&gt;. The actual mock transaction stages an accumulated deduction per product, rejects insufficient stock, and applies deductions on commit. Repeated entries for the same product share that accumulated reservation.&lt;/p&gt;

&lt;p&gt;The README supplies this production-oriented SQL illustration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its explanation requires checking affected rows. This SQL is documentation, not a database adapter implemented or exercised by the lab. &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt; alone do not express the stock condition.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestImprovedCheckout_ConcurrentStockReservationPreservesInvariant&lt;/code&gt; asserts one success, one failure, one order, and final stock zero for two buyers competing for one unit. &lt;code&gt;TestImprovedCheckout_DuplicateProductInCartAccumulatesReservation&lt;/code&gt; checks both sufficient and insufficient accumulated reservations.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the mock actually proves
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;MockTransactionManager&lt;/code&gt; holds one mutex per manager for the complete callback and commit. For requests sharing that manager, transactions are serialized. This is the global serialization boundary of the demonstration, not production database concurrency control.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;go test -race ./...&lt;/code&gt; is relevant to unsafe concurrent memory access. It cannot establish the stock invariant by itself. Also, despite its name and comment, &lt;code&gt;TestImprovedCheckout_DeterministicNoRace&lt;/code&gt; does not use the naive read hook on the improved path: improved calls &lt;code&gt;GetProducts&lt;/code&gt;, which does not invoke that hook. Its outcome assertions remain useful, but it does not force the same read interleaving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: retries need a claim, not just a key
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// checkout_improved.go; excerpt&lt;/span&gt;
&lt;span class="n"&gt;scopedKey&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"checkout:%s:%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hashRequest&lt;/code&gt; marshals &lt;code&gt;CheckoutCommand&lt;/code&gt; and hashes it with SHA-256. That command contains only &lt;code&gt;CartOwnerID&lt;/code&gt; and &lt;code&gt;IdempotencyKey&lt;/code&gt;; it does not contain quantities, product IDs, prices, or a cart version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode and impact
&lt;/h3&gt;

&lt;p&gt;Without retry protection, repeating a checkout can deduct stock and create an order again. Hashing mutable cart contents would also make a completed retry depend on the cart's later state.&lt;/p&gt;

&lt;p&gt;The improved service first checks for a completed record before loading the cart. If it finds a stored response, it returns that response immediately. Otherwise it loads and validates the cart, calculates the command hash, and calls &lt;code&gt;Claim&lt;/code&gt;. Contrary to the README's broad wording, the atomic claim and hash comparison happen after cart loading; only the completed-record lookup happens before it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix and test
&lt;/h3&gt;

&lt;p&gt;The mock repository protects &lt;code&gt;Claim&lt;/code&gt; with a mutex. A new key becomes &lt;code&gt;PROCESSING&lt;/code&gt;. The same hash while processing returns &lt;code&gt;ErrDuplicateRequest&lt;/code&gt;; a different hash returns &lt;code&gt;ErrIdempotencyConflict&lt;/code&gt;; a completed matching claim returns the stored response. &lt;code&gt;TestIdempotencyRepository_StateMachineDirect&lt;/code&gt; asserts those repository behaviors with explicit test hashes.&lt;/p&gt;

&lt;p&gt;At the service level, &lt;code&gt;TestImprovedCheckout_DuplicateRequestReturnsSameResult&lt;/code&gt; expects the same order ID and one order after two requests. &lt;code&gt;TestImprovedCheckout_RetryAfterCartMutationReturnsOriginalResponse&lt;/code&gt; empties the cart between attempts and asserts replay without another stock deduction. The same-key concurrency test asserts one order, one stock deduction, and one notification; it does not require both callers to receive success.&lt;/p&gt;

&lt;h3&gt;
  
  
  The conflict boundary is narrower than it sounds
&lt;/h3&gt;

&lt;p&gt;The early &lt;code&gt;Get&lt;/code&gt; replay does not compare &lt;code&gt;RequestHash&lt;/code&gt;. With the current command shape and ownership check, the same scoped key normally implies the same authorized command fields. Still, the service does not implement hash validation on every completed replay, and the hash cannot distinguish changed cart contents. If the command grows, the fast path needs review. The direct repository conflict test is not evidence that an expanded service payload would be checked correctly.&lt;/p&gt;

&lt;p&gt;Failures before business commit generally release the claim so another attempt can run. Release can fail too. The transaction-error path joins that error with &lt;code&gt;errors.Join&lt;/code&gt;; the missing-product-in-batch branch logs a release failure but returns only &lt;code&gt;ErrProductNotFound&lt;/code&gt;. Error preservation is therefore not uniform across all branches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: authentication context must constrain cart access
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// checkout_improved.go; excerpt&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CartOwnerID&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"authorization failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"userID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cartOwnerID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CartOwnerID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrForbidden&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Failure mode, impact, fix, and test
&lt;/h3&gt;

&lt;p&gt;Accepting a caller-selected cart owner without comparing it with the principal could permit checkout of another user's cart. Improved checks ownership before cart loading or replay. &lt;code&gt;TestImprovedCheckout_CannotCheckoutAnotherUsersCart&lt;/code&gt; asserts forbidden access, unchanged stock, and no order.&lt;/p&gt;

&lt;p&gt;Per-user key scoping solves a separate problem: two users submitting the same key string should not share an operation. &lt;code&gt;TestImprovedCheckout_IdempotencyKeyIsScopedPerUser&lt;/code&gt; asserts two orders and the combined stock deduction.&lt;/p&gt;

&lt;p&gt;The naive service only accepts a &lt;code&gt;userID&lt;/code&gt;; it has no independent principal/owner comparison. There is no HTTP authentication middleware in this lab, so it does not establish how that identity is authenticated upstream. The equality check also does not validate that a principal ID is nonempty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: repeated lookup is a measurable call pattern
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Code and failure mode
&lt;/h3&gt;

&lt;p&gt;Naive calls &lt;code&gt;GetProduct&lt;/code&gt; for each cart item. Improved collects unique product IDs and calls &lt;code&gt;GetProducts&lt;/code&gt; once. Repeated product lookup is the N+1-style issue here; multiple stock updates are a separate write pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Impact, fix, and test
&lt;/h3&gt;

&lt;p&gt;Batch loading reduces repeated repository lookup work. &lt;code&gt;TestNaiveCheckout_NPlusOneProductLookups&lt;/code&gt; expects three single-product calls for three items. &lt;code&gt;TestImprovedCheckout_BatchLoadsProducts&lt;/code&gt; expects one batch call and zero single calls. &lt;code&gt;TestImprovedCheckout_BatchLoadUsesUniqueProductIDs&lt;/code&gt; checks that repeated product entries produce two unique requested IDs in its fixture.&lt;/p&gt;

&lt;p&gt;These counters measure repository calls, not database round trips or query latency. There is no benchmark result to report.&lt;/p&gt;

&lt;p&gt;The improved total uses &lt;code&gt;product.UnitPrice&lt;/code&gt;, whereas naive uses the cart's price. This changes the price source, but quantity positivity is the explicit validation; negative prices, multiplication overflow, and accumulated-total overflow are not guarded. The mock fixes product prices at 1000, and the batch-loading test does not assert pricing behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 6: commit is not the end of every operation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// checkout_improved.go; excerpt&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;markErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idempotency&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarkCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scopedKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;markErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"failed to finalize idempotency record; business transaction was committed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"userID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"idempotencyKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scopedKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%w: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrIdempotencyFinalize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Failure mode and impact
&lt;/h3&gt;

&lt;p&gt;Business commit occurs before &lt;code&gt;MarkCompleted&lt;/code&gt;. Finalization failure can therefore return an error after stock has been deducted and the order exists. The claim remains processing in the mock, and notification is not reached. This is an atomicity gap between two state transitions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ErrIdempotencyFinalize&lt;/code&gt; is wrapped for &lt;code&gt;errors.Is&lt;/code&gt;; the underlying &lt;code&gt;markErr&lt;/code&gt; is formatted with &lt;code&gt;%v&lt;/code&gt;, not wrapped as a second cause. This error must not be read as proof that checkout rolled back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix boundary and test
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;TestImprovedCheckout_IdempotencyFinalizeFailureAfterCommit&lt;/code&gt; injects a finalization failure and asserts the sentinel error, stock 8 from an initial 10, one order, no notification, and no completed record. The README discusses a shared transactional boundary, reconciliation, and a unique business constraint as production options. Those options are not implemented here, nor is an Outbox.&lt;/p&gt;

&lt;p&gt;After successful finalization, notification runs synchronously outside the transaction. Its error is logged without changing checkout success. A crash before delivery can lose the message, and completed replay does not resend it. The supplied notification mock always succeeds, so the test suite does not exercise a notification-send failure.&lt;/p&gt;

&lt;p&gt;Contextual logger calls include user, key, order, and error information where applicable. &lt;code&gt;MockLogger&lt;/code&gt; only stores messages, however, so these tests do not verify preservation of the structured fields. &lt;code&gt;TestImprovedCheckout_PreservesRepositoryErrors&lt;/code&gt; checks that a batch infrastructure error is not mislabeled as &lt;code&gt;ErrProductNotFound&lt;/code&gt;; it does not assert &lt;code&gt;errors.Is(err, infraErr)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rank the risk before polishing the code
&lt;/h2&gt;

&lt;p&gt;Inventory corruption and cross-user access deserve attention before naming. Partial state, swallowed errors, duplicate execution, and post-commit ambiguity change what a client can safely assume. Batch loading matters too, but its urgency depends on workload and impact; a call count does not establish severity on its own.&lt;/p&gt;

&lt;p&gt;The README's general findings include hardcoded environment configuration and duplicate validation code. Those are discussion topics, not verified concrete defects in the executable checkout. Several README test names are illustrative rather than the names present in &lt;code&gt;checkout_test.go&lt;/code&gt;; the references above use the actual test functions.&lt;/p&gt;

&lt;p&gt;The improved service is a teaching implementation with in-memory repositories. Its &lt;code&gt;atomic.Int64&lt;/code&gt; counter belongs to each service instance, so IDs are only unique within that instance's running counter; they can collide across instances or replicas and after restart. There is no implemented TTL/recovery mechanism for stale processing claims, database uniqueness constraint, durable notification delivery, or general exactly-once guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code review reduces risk by protecting behavior and business invariants.&lt;/strong&gt; A review is stronger when it connects a concrete branch to a failure, a bounded fix, and an assertion that would expose regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source files
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/09-code-review/README.md" rel="noopener noreferrer"&gt;README.md&lt;/a&gt;: review framework and documented SQL illustration.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/09-code-review/checkout_naive.go" rel="noopener noreferrer"&gt;checkout_naive.go&lt;/a&gt;: intentionally broken checkout.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/09-code-review/checkout_improved.go" rel="noopener noreferrer"&gt;checkout_improved.go&lt;/a&gt;: service orchestration and remaining gaps.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/09-code-review/domain.go" rel="noopener noreferrer"&gt;domain.go&lt;/a&gt;: command, interfaces, and errors.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/09-code-review/mock.go" rel="noopener noreferrer"&gt;mock.go&lt;/a&gt;: in-memory transaction and idempotency semantics.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/09-code-review/checkout_test.go" rel="noopener noreferrer"&gt;checkout_test.go&lt;/a&gt;: behavior assertions.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/09-code-review/go.mod" rel="noopener noreferrer"&gt;go.mod&lt;/a&gt;: module and Go 1.25 requirement.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>backend</category>
      <category>testing</category>
      <category>codereview</category>
    </item>
    <item>
      <title>Database Isolation: Why Two Committed Transfers Can Create Money</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Fri, 02 Oct 2026 07:16:37 +0000</pubDate>
      <link>https://dev.to/lukman-ss/database-isolation-why-two-committed-transfers-can-create-money-49f9</link>
      <guid>https://dev.to/lukman-ss/database-isolation-why-two-committed-transfers-can-create-money-49f9</guid>
      <description>&lt;p&gt;Two wallet transfers can both commit successfully while creating money. In Lab 08, the problem is a specific query pattern: read a balance, validate it in Go, calculate a replacement value, and write that value later. A transaction boundary does not make the original read remain current.&lt;/p&gt;

&lt;p&gt;The useful question is whether overlapping transactions preserve the business invariant. This lab approaches that question through a three-account wallet, PostgreSQL isolation experiments, and concurrent tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with what must remain true
&lt;/h2&gt;

&lt;p&gt;Alice, Bob, and Charlie each begin with 1,000,000. A transfer should change the distribution of those balances while keeping their sum at 3,000,000. Every balance must also remain non-negative.&lt;/p&gt;

&lt;p&gt;The schema implements the row-level invariant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That constraint does not enforce conservation of money across accounts. A broken transfer can leave every individual balance positive while increasing their sum. Correctness therefore needs both checks.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TransferNaive&lt;/code&gt; uses &lt;code&gt;sql.LevelReadCommitted&lt;/code&gt;. It begins a transaction, reads the sender, checks sufficient funds, reads the receiver, updates both accounts, inserts an audit record, and commits. These operations share an atomic boundary. Their decisions can still depend on stale application values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow the overlap instead of the happy path
&lt;/h2&gt;

&lt;p&gt;Transfer A sends 800,000 from Alice to Bob. Transfer B sends 800,000 from Alice to Charlie. Both transactions read Alice at 1,000,000 before either writes. Each concludes that the transfer is allowed.&lt;/p&gt;

&lt;p&gt;Each then calculates Alice's replacement balance as 200,000. The naive sender update uses that calculated value rather than deriving the new balance from the current row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// 4. Overwrite sender balance with calculated value (Lost Update vulnerability)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UPDATE isolation_accounts SET balance = $1 WHERE id = $2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromBalance&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"update from balance: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Excerpt from &lt;code&gt;transfer.go&lt;/code&gt;; surrounding validation, receiver update, audit, and commit are omitted.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second write can wait for the first writer and still overwrite Alice with the same 200,000. Bob and Charlie receive separate credits of 800,000. The expected final state in &lt;code&gt;TestNaiveTransfer_LostUpdate&lt;/code&gt; is Alice 200,000, Bob 1,800,000, Charlie 1,800,000: total 3,800,000.&lt;/p&gt;

&lt;p&gt;The test orchestrates this overlap with channels. Both transactions signal that their reads have completed; the coordinator then releases their writes. It reproduces the read-calculate-write pattern explicitly, rather than calling &lt;code&gt;TransferNaive&lt;/code&gt; directly. That distinction matters when describing what the test covers.&lt;/p&gt;

&lt;p&gt;These numbers are assertions in the repository, not measurements from a test run for this article. No lab tests were executed during preparation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the constraint does not reject it
&lt;/h3&gt;

&lt;p&gt;Alice still has 200,000. There is no negative balance to reject. The missing debit is a lost update, and the violated invariant is the cross-account sum. A successful commit and a satisfied CHECK constraint are insufficient evidence that the transfer was correct.&lt;/p&gt;

&lt;p&gt;This does not mean every READ COMMITTED update loses data. The vulnerable implementation reads, calculates in application memory, and later writes an absolute value. Its query pattern is part of the explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the SQL standard from PostgreSQL behavior
&lt;/h2&gt;

&lt;p&gt;The README describes the standard anomaly ladder: READ UNCOMMITTED permits dirty reads; READ COMMITTED prevents dirty reads but permits non-repeatable and phantom reads; standard REPEATABLE READ prevents dirty and non-repeatable reads but may permit phantoms; SERIALIZABLE prevents those anomalies.&lt;/p&gt;

&lt;p&gt;PostgreSQL provides stronger behavior in two important places. READ UNCOMMITTED behaves as READ COMMITTED and does not permit dirty reads. PostgreSQL REPEATABLE READ uses a stable snapshot and prevents the classic phantom read demonstrated by the lab.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PostgreSQL level&lt;/th&gt;
&lt;th&gt;Visibility in this lab&lt;/th&gt;
&lt;th&gt;Remaining concern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;READ UNCOMMITTED&lt;/td&gt;
&lt;td&gt;Same behavior as READ COMMITTED&lt;/td&gt;
&lt;td&gt;Does not grant a stable transaction snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;READ COMMITTED&lt;/td&gt;
&lt;td&gt;New snapshot per statement&lt;/td&gt;
&lt;td&gt;Repeated reads may change; naive writes may lose updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REPEATABLE READ&lt;/td&gt;
&lt;td&gt;Snapshot acquired at the first read&lt;/td&gt;
&lt;td&gt;Concurrent same-row update can fail with 40001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SERIALIZABLE&lt;/td&gt;
&lt;td&gt;SSI with serial execution equivalence&lt;/td&gt;
&lt;td&gt;Conflicts may abort a transaction; retry must be handled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The table describes the engine used by the repository. It should not be treated as a portable implementation table for other databases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read Committed: two statements, two views
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TestReadCommitted_NonRepeatableRead&lt;/code&gt; coordinates a reader and a writer. The reader first sees Alice at 1,000,000. The writer adds 500,000 and commits. The reader's second SELECT, still inside the same transaction, is expected to see 1,500,000.&lt;/p&gt;

&lt;p&gt;The change is committed, so this is not a dirty read. It is a non-repeatable read caused by statement snapshots.&lt;/p&gt;

&lt;p&gt;The phantom experiment uses a predicate instead of a single account. Two invoices initially have status PAID. A writer inserts a third PAID invoice and commits between the reader's queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;isolation_invoices&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PAID'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under READ COMMITTED, the test expects counts of two and three. Under PostgreSQL REPEATABLE READ, it expects two and two. The predicate is unchanged; the visible set of matching rows differs only in the first strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A stable snapshot is not exclusive access
&lt;/h2&gt;

&lt;p&gt;PostgreSQL MVCC lets ordinary readers observe versions without taking a FOR UPDATE row lock. &lt;code&gt;TestRepeatableRead_SnapshotIsolation&lt;/code&gt; expects both reads to return the original 1,000,000 even after another transaction commits an increase.&lt;/p&gt;

&lt;p&gt;The concurrent-update experiment is different. Two REPEATABLE READ transactions read the same account, then try to deduct 800,000. Its assertions require exactly one success and one serialization failure, SQLSTATE 40001.&lt;/p&gt;

&lt;p&gt;The application must therefore handle an aborted transaction. Choosing REPEATABLE READ does not make the second transfer silently succeed correctly. It changes the failure behavior.&lt;/p&gt;

&lt;p&gt;The README also discusses write skew as a possible serialization anomaly under snapshot isolation. The supplied experiments demonstrate same-row update conflicts; they do not reproduce a separate write-skew scenario. Keep the conceptual warning separate from the experiment's actual evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Row locking moves validation behind coordination
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TransferWithLock&lt;/code&gt; stays at READ COMMITTED but obtains both account locks before validating the sender. Here is the locking excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;firstID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secondID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;DeterministicLockOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fromID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Lock both accounts deterministically&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b2&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRowContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT balance FROM isolation_accounts WHERE id = $1 FOR UPDATE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;b1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lock account %d: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firstID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRowContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT balance FROM isolation_accounts WHERE id = $1 FOR UPDATE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secondID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;b2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lock account %d: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secondID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Excerpt from &lt;code&gt;transfer.go&lt;/code&gt;; the rest of the transfer is omitted.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A competing FOR UPDATE or UPDATE on those rows waits until the holder ends its transaction. An ordinary SELECT continues to use MVCC. The lock is coordination for the write path, not a promise that every reader is blocked.&lt;/p&gt;

&lt;p&gt;After locking, the implementation identifies which locked balance belongs to the sender and checks sufficient funds. Debit and credit use arithmetic SQL updates, and the audit record is written before commit. The second competing transfer validates after obtaining access to the row, rather than trusting an earlier unlocked balance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Acquire locks in a shared order
&lt;/h3&gt;

&lt;p&gt;If A-to-B locks A first while B-to-A locks B first, the two transactions can create circular wait. The lab uses a helper independent of transfer direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;DeterministicLockOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id2&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secondID&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;id1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;id2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id2&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both directions acquire the smaller account ID, then the larger. The bidirectional test runs two workers with 50 transfers each. It checks errors and total balance. This order addresses the two-account lock pattern used here; it does not guarantee that arbitrary additional locking elsewhere can never deadlock.&lt;/p&gt;

&lt;p&gt;Lock waits are a real cost. The README's phrase about deterministic latency should not be read as a guarantee from this implementation. Contention and transaction duration still affect waiting, and the repository provides no latency benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serializable means serial equivalence, not a global queue
&lt;/h2&gt;

&lt;p&gt;PostgreSQL SERIALIZABLE uses Serializable Snapshot Isolation. Concurrent transactions can run together; committed results must be equivalent to an allowed serial execution. Conflicts can require an abort.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestSerializable_ConcurrentUpdate_SerializationFailure&lt;/code&gt; coordinates two same-row updates and expects one success plus one 40001. It is a conflict experiment, not a throughput comparison or proof of every possible invariant.&lt;/p&gt;

&lt;p&gt;The recovery path needs a new transaction with fresh reads and validation. Retrying only the failed statement would not repeat the decision that caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry the whole operation, with a limit
&lt;/h2&gt;

&lt;p&gt;The wrapper passes a complete transfer as the operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;PostgresWalletRepo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;TransferSerializableWithRetryPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toID&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;RetryPolicy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;RetryTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TransferSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Complete wrapper from &lt;code&gt;transfer.go&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each attempt calls &lt;code&gt;TransferSerializable&lt;/code&gt; again, including BeginTx, read, validation, debit, credit, audit, and commit. Failed attempts defer rollback. The retry helper accepts only serialization failure 40001 or deadlock 40P01, through the PostgreSQL error or corresponding sentinel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;IsRetryableTxError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrSerializationFailure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrDeadlockDetected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pqErr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pqErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pqErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"40001"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;pqErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"40P01"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Complete filter from &lt;code&gt;transfer.go&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WrapTxError&lt;/code&gt; preserves both sentinel matching with &lt;code&gt;errors.Is&lt;/code&gt; and access to &lt;code&gt;*pq.Error&lt;/code&gt; with &lt;code&gt;errors.As&lt;/code&gt;. Constraint violations such as 23505 and 23503, generic errors, and insufficient funds are not automatically retried.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;maxAttempts&lt;/code&gt; includes the initial attempt. Non-positive values are rejected. The default base delay is 10 milliseconds and the maximum is one second. Before another attempt, the helper computes an exponential upper bound, caps it, and multiplies it by a random value for full jitter.&lt;/p&gt;

&lt;p&gt;Context cancellation is checked before attempts and can interrupt the default sleep. When retryable errors exhaust the limit, &lt;code&gt;ErrMaxRetryExceeded&lt;/code&gt; wraps the last error. &lt;code&gt;retry_test.go&lt;/code&gt; covers filtering, error preservation, success after a conflict, attempt exhaustion, cancellation, invalid limits, and delay behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the invariant under contention
&lt;/h2&gt;

&lt;p&gt;The stress fixture differs from the introductory wallet: three accounts begin at 50,000 each, giving total 150,000. One hundred goroutines attempt transfers of 1,000 using &lt;code&gt;TransferWithLock&lt;/code&gt; after a shared start gate is released.&lt;/p&gt;

&lt;p&gt;The test checks transfer errors, non-negative final balances, and the conserved sum of 150,000. A start gate creates competing work; it does not force every goroutine to reach every query at an identical moment. The naive test uses a more specific barrier after reads to reproduce its anomaly.&lt;/p&gt;

&lt;p&gt;To run the supplied experiments, start the repository's PostgreSQL infrastructure and execute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;labs/08-database-isolation-level
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database helper skips integration tests when PostgreSQL is unavailable unless &lt;code&gt;REQUIRE_POSTGRES=1&lt;/code&gt; makes absence a failure. A test command that reports success alongside skipped experiments is not evidence that database behavior was verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose from the invariant outward
&lt;/h2&gt;

&lt;p&gt;For this wallet's known rows, READ COMMITTED with FOR UPDATE and a shared lock order coordinates the critical read-check-write path. REPEATABLE READ serves a stable multi-query view but requires conflict handling for updates. SERIALIZABLE addresses serial equivalence at the cost of aborts, retry work, and dependency tracking.&lt;/p&gt;

&lt;p&gt;The README mentions an atomic conditional update as an alternative, but the lab does not implement that strategy. It also offers no measured throughput or latency ranking. The practical conclusion is to specify the invariant, identify the relevant reads and writes, choose coordination or conflict detection, and verify the resulting state under overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository and source map
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/08-database-isolation-level" rel="noopener noreferrer"&gt;Lab 08&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/README.md" rel="noopener noreferrer"&gt;README.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/schema.sql" rel="noopener noreferrer"&gt;schema.sql&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/isolation.go" rel="noopener noreferrer"&gt;isolation.go&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/transfer.go" rel="noopener noreferrer"&gt;transfer.go&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/read_committed_test.go" rel="noopener noreferrer"&gt;read_committed_test.go&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/repeatable_read_test.go" rel="noopener noreferrer"&gt;repeatable_read_test.go&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/phantom_read_test.go" rel="noopener noreferrer"&gt;phantom_read_test.go&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/transfer_test.go" rel="noopener noreferrer"&gt;transfer_test.go&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/serializable_test.go" rel="noopener noreferrer"&gt;serializable_test.go&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/blob/main/labs/08-database-isolation-level/retry_test.go" rel="noopener noreferrer"&gt;retry_test.go&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>postgres</category>
      <category>database</category>
      <category>backend</category>
    </item>
    <item>
      <title>Observability: Making an Invoice Request Explain Itself</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:55:00 +0000</pubDate>
      <link>https://dev.to/lukman-ss/observability-making-an-invoice-request-explain-itself-1o4i</link>
      <guid>https://dev.to/lukman-ss/observability-making-an-invoice-request-explain-itself-1o4i</guid>
      <description>&lt;p&gt;A successful response says the invoice workflow finished. It does not explain where the request spent its time. Lab 07 explores that gap through a Go service that loads an invoice, reserves inventory, calculates commission, generates a PDF, and sends a notification.&lt;/p&gt;

&lt;p&gt;The useful question is not just “Is the endpoint working?” It is “What happened inside this request, and which dependency explains its latency or failure?”&lt;/p&gt;

&lt;h2&gt;
  
  
  One workflow, two levels of visibility
&lt;/h2&gt;

&lt;p&gt;The unsafe service executes the same five dependency operations but exposes only a coarse completion duration or a generic failure log. Its HTTP response can still contain a request ID. What is missing is the service-level breakdown needed to investigate the request.&lt;/p&gt;

&lt;p&gt;The safe service adds structured logs, Prometheus metrics, and OpenTelemetry spans around the workflow. This changes the evidence available during investigation; it does not make the business operations themselves faster.&lt;/p&gt;

&lt;p&gt;The demo dependencies simulate work using configurable delays and errors. A delay waits on a timer while also listening for context cancellation. These are controlled diagnostic scenarios, not measurements of a real database, inventory service, or PDF engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs, metrics, and traces answer different questions
&lt;/h2&gt;

&lt;p&gt;Logs describe discrete events. The safe service emits a structured event when a dependency completes, with its component, operation, duration, and outcome. On failure, the event also carries an error.&lt;/p&gt;

&lt;p&gt;Metrics summarize many requests. Request counters describe traffic, duration histograms describe latency, error counters describe failures, and an in-flight gauge describes active requests. The gauge is a concurrency signal; it is not a complete measurement of CPU capacity or resource saturation.&lt;/p&gt;

&lt;p&gt;Traces explain a particular execution. A server span contains an invoice-processing span, which contains one span for each dependency operation. That hierarchy connects the HTTP request to the work performed inside it.&lt;/p&gt;

&lt;p&gt;A latency histogram can reveal a shift across requests. A trace can identify the slow operation in one request. A structured log can provide the event details at that operation. None replaces the other two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the slow step visible
&lt;/h2&gt;

&lt;p&gt;The demo's normal configuration assigns 20 ms to invoice loading, 15 ms to inventory, 10 ms to commission, 30 ms to PDF generation, and 20 ms to notification. The configured delays sum to approximately 95 ms before runtime and instrumentation overhead.&lt;/p&gt;

&lt;p&gt;In the slow-PDF scenario, PDF generation receives a 4,800 ms delay. Keeping the other fake dependencies unchanged gives a configured total of approximately 4,865 ms. This is scenario arithmetic, not a benchmark result. An HTTP notification client also changes the execution path when enabled.&lt;/p&gt;

&lt;p&gt;The point of the scenario is the contrast between “the request took several seconds” and “pdf.generate consumed most of the request.” A total-duration log provides the first statement. Dependency spans provide the second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /invoices/{id}/process
  invoice.process
    database.load_invoice
    inventory.reserve
    commission.calculate
    pdf.generate
    notification.send
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the hierarchy for the five-dependency workflow. A real HTTP notification call adds transport and downstream spans when those boundaries are instrumented.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carry context through every dependency
&lt;/h2&gt;

&lt;p&gt;The safe service starts a span for each dependency and passes that span's context into the dependency call. The context carries the causal relationship and cancellation signal together.&lt;/p&gt;

&lt;p&gt;On a dependency failure, the implementation records the error on the dependency span and the invoice-processing span, marks them as failed, observes an error outcome, and returns a wrapped error identifying the component and operation. Processing stops at that failed step.&lt;/p&gt;

&lt;p&gt;The HTTP handler then maps a processing error to a response: a deadline becomes 504, cancellation becomes 499, and other processing failures become 502. The response uses the generic message “invoice processing failed” and includes the request ID. Detailed failure information stays in telemetry.&lt;/p&gt;

&lt;p&gt;These are mappings implemented by this lab, rather than a universal status-code policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlate events without turning IDs into metric labels
&lt;/h2&gt;

&lt;p&gt;ContextLogger attaches request_id and, when an active span is valid, trace_id and span_id. The request ID helps connect the HTTP response to logs. The trace ID groups spans in one execution. The span ID identifies the active operation within that trace.&lt;/p&gt;

&lt;p&gt;The correlation fields are useful in logs, but they are deliberately absent from metric labels. Request metrics use method, a route template, and status class. Dependency metrics use component, operation, and outcome.&lt;/p&gt;

&lt;p&gt;The route label is &lt;code&gt;/invoices/{id}/process&lt;/code&gt;, rather than a separate label value for each invoice URL. The registry tests also check that request IDs and invoice IDs do not leak into label values.&lt;/p&gt;

&lt;p&gt;That distinction preserves aggregate metrics while leaving request-level investigation to logs and traces. The fixture tests check selected sensitive keys in emitted logs; they do not implement a general-purpose redaction system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the metric names as an investigation map
&lt;/h2&gt;

&lt;p&gt;The collector exposes six metric families:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lab07_http_requests_total&lt;/td&gt;
&lt;td&gt;Requests by method, route, and status class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lab07_http_request_duration_seconds&lt;/td&gt;
&lt;td&gt;Request duration observations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lab07_http_request_errors_total&lt;/td&gt;
&lt;td&gt;Processing errors at the HTTP boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lab07_http_in_flight_requests&lt;/td&gt;
&lt;td&gt;Currently active requests for the route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lab07_dependency_duration_seconds&lt;/td&gt;
&lt;td&gt;Dependency duration by component, operation, and outcome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lab07_dependency_errors_total&lt;/td&gt;
&lt;td&gt;Dependency failures&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Histograms store duration observations in seconds. Structured dependency logs report duration_ms. Keep those units explicit when comparing the two.&lt;/p&gt;

&lt;p&gt;The missing-ID branch records a 4xx request but does not increment the processing-error counter. The name “request errors” therefore needs to be read alongside the implementation's accounting rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross an HTTP boundary without losing the trace
&lt;/h2&gt;

&lt;p&gt;The handler extracts incoming W3C trace context before starting its server span. The demo configures TraceContext and Baggage propagation globally.&lt;/p&gt;

&lt;p&gt;For outgoing notifications, HTTPNotificationClient creates a request with the current context and uses an OpenTelemetry HTTP transport. The transport propagates trace context, while the client explicitly copies X-Request-ID from the context into the downstream request.&lt;/p&gt;

&lt;p&gt;The default notification client has a five-second timeout. A supplied client is copied before its transport is wrapped, and an already instrumented transport is not wrapped again. Request cancellation still reaches the outgoing HTTP call through its context.&lt;/p&gt;

&lt;p&gt;The local &lt;code&gt;/notifications&lt;/code&gt; endpoint lets the demo exercise this HTTP boundary. It runs in the same demo process; this is not evidence of a deployed multi-service production system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exercise specific failure modes
&lt;/h2&gt;

&lt;p&gt;The demo supports normal, slow-pdf, slow-database, slow-external, and pdf-error scenarios. They isolate different dependency delays or a PDF failure so the resulting telemetry can be inspected.&lt;/p&gt;

&lt;p&gt;For example, with the demo and its configured telemetry services running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST   &lt;span class="s1"&gt;'http://localhost:8087/invoices/INV-007/process?scenario=slow-pdf'&lt;/span&gt;   &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-Request-ID: lab07-slow-pdf'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The corresponding unsafe route is &lt;code&gt;/unsafe/invoices/INV-007/process&lt;/code&gt;. Comparing the routes makes the difference in diagnostic detail visible.&lt;/p&gt;

&lt;p&gt;Use a stable request ID to find the relevant logs, inspect the request's dependency spans, and compare request-level duration observations with dependency duration observations. The useful outcome is an explanation of the configured scenario, not merely confirmation that an endpoint returned a response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat telemetry behavior as a contract
&lt;/h2&gt;

&lt;p&gt;The tests inspect span hierarchy, shared trace IDs, incoming trace-context extraction, outgoing propagation, request-ID preservation, structured log fields, and metric accounting.&lt;/p&gt;

&lt;p&gt;The fake-dependency hierarchy test expects seven ended spans: one HTTP server span, one invoice-processing span, and five dependency spans. The HTTP notification path has additional instrumentation and should not be forced into that seven-span expectation.&lt;/p&gt;

&lt;p&gt;The slow-PDF test uses a shorter, 10 ms delay and checks the recorded PDF span. Concurrent-request tests check mixed success and error responses, matching counters, and an in-flight gauge that returns to zero.&lt;/p&gt;

&lt;p&gt;There is also a test for response-encoding failures. Such failures are logged with correlation fields, but the implementation has already recorded success accounting before encoding the successful response. That limitation matters when interpreting metrics.&lt;/p&gt;

&lt;p&gt;These observations come from reading the source and its assertions. The Go tests were not executed in this content-generation environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Observability is useful when the evidence explains a request: what happened, where its time went, and which operation failed. This lab makes that idea concrete by connecting dependency events, aggregate measurements, and a causal trace through one invoice workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A system should explain what happened, where time was spent, and why it was slow or failed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/07-observability" rel="noopener noreferrer"&gt;https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/07-observability&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>observability</category>
      <category>backend</category>
      <category>opentelemetry</category>
    </item>
    <item>
      <title>API Versioning with Separate Invoice Contracts</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Wed, 30 Sep 2026 07:23:01 +0000</pubDate>
      <link>https://dev.to/lukman-ss/api-versioning-with-separate-invoice-contracts-42cg</link>
      <guid>https://dev.to/lukman-ss/api-versioning-with-separate-invoice-contracts-42cg</guid>
      <description>&lt;p&gt;An invoice endpoint returns customer information as a string. A new representation replaces that string with an object containing the customer's ID, name, and phone number. The response remains valid JSON and the server still returns HTTP 200. The legacy consumer expects a string and cannot decode the new object.&lt;/p&gt;

&lt;p&gt;Lab 06 of Software Engineering Lab demonstrates this change using Go HTTP handlers and an explicit consumer model. It then keeps the old representation on V1 and introduces the nested representation on V2. A third example adds a field without changing the fields that the legacy model already understands.&lt;/p&gt;

&lt;p&gt;The tests described here are source-defined scenarios and assertions. They were inspected, not executed for this article. The legacy consumer is simulated by a Go struct; this is not an Android device test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The original invoice contract
&lt;/h2&gt;

&lt;p&gt;The documented invoice response contains four fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Budi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PAID"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;domain.go&lt;/code&gt; makes the consumer's expectation concrete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;LegacyInvoice&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;       &lt;span class="kt"&gt;int&lt;/span&gt;    &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"customer"`&lt;/span&gt;
    &lt;span class="n"&gt;Total&lt;/span&gt;    &lt;span class="kt"&gt;int64&lt;/span&gt;  &lt;span class="s"&gt;`json:"total"`&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"status"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The helper &lt;code&gt;ParseLegacyInvoice&lt;/code&gt; calls &lt;code&gt;json.Unmarshal&lt;/code&gt; into that struct. This turns compatibility into an operation that the tests can exercise. The question is whether the existing model can still consume the body and obtain the expected values.&lt;/p&gt;

&lt;p&gt;The fixture uses invoice ID &lt;code&gt;1001&lt;/code&gt;, customer name &lt;code&gt;Budi&lt;/code&gt;, total &lt;code&gt;500000&lt;/code&gt;, and status &lt;code&gt;PAID&lt;/code&gt;. These are demonstration values, not a production invoice dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unsafe handler changes an existing field's type
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;UnsafeHandler&lt;/code&gt; returns the following customer representation while retaining the invoice's other fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Budi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"08123"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PAID"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new information is useful, but it occupies a field whose published representation was a string. &lt;code&gt;LegacyInvoice.Customer&lt;/code&gt; cannot receive this object through the supplied decoder.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestBreakingChange_LegacyClientFails&lt;/code&gt; first requires HTTP 200, then calls &lt;code&gt;ParseLegacyInvoice&lt;/code&gt; on the response body. It expects decoding to fail. A passing test in this example would confirm the deliberate incompatibility; it would not certify the handler as backward compatible.&lt;/p&gt;

&lt;p&gt;The unsafe endpoint is synthetic. It creates its payload directly after validating the path, rather than retrieving the invoice through the repository used by V1 and V2. It demonstrates the shape change, not a complete persistence-backed invoice service.&lt;/p&gt;

&lt;h2&gt;
  
  
  One domain model, two response models
&lt;/h2&gt;

&lt;p&gt;The internal &lt;code&gt;Invoice&lt;/code&gt; model already contains a nested &lt;code&gt;Customer&lt;/code&gt;. The public V1 response does not have to copy that shape.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;safe_server.go&lt;/code&gt;, the response types establish separate contracts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;InvoiceV1Response&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;       &lt;span class="kt"&gt;int&lt;/span&gt;    &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"customer"`&lt;/span&gt;
    &lt;span class="n"&gt;Total&lt;/span&gt;    &lt;span class="kt"&gt;int64&lt;/span&gt;  &lt;span class="s"&gt;`json:"total"`&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"status"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CustomerV2Response&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;    &lt;span class="kt"&gt;int&lt;/span&gt;    &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Phone&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"phone"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;InvoiceV2Response&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;       &lt;span class="kt"&gt;int&lt;/span&gt;                &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="n"&gt;CustomerV2Response&lt;/span&gt; &lt;span class="s"&gt;`json:"customer"`&lt;/span&gt;
    &lt;span class="n"&gt;Total&lt;/span&gt;    &lt;span class="kt"&gt;int64&lt;/span&gt;              &lt;span class="s"&gt;`json:"total"`&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;             &lt;span class="s"&gt;`json:"status"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mapper for V1 selects only the customer name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;mapToV1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;InvoiceV1Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;InvoiceV1Response&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="o"&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;Total&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mapToV2&lt;/code&gt; maps the same domain invoice into the nested customer DTO, preserving ID, name, and phone. Both handlers use the same repository interface. The repository fixture supplies the same invoice to each mapper.&lt;/p&gt;

&lt;p&gt;This is the boundary the lab implements: the domain model holds the invoice information, while a version-specific mapper decides what the consumer receives. There is no database schema or database migration in this lab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route each contract explicitly
&lt;/h2&gt;

&lt;p&gt;The supported endpoints are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/v1/invoices/1001
GET /api/v2/invoices/1001
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;V1Handler&lt;/code&gt; maps its result through &lt;code&gt;mapToV1&lt;/code&gt;; &lt;code&gt;V2Handler&lt;/code&gt; maps through &lt;code&gt;mapToV2&lt;/code&gt;. Registering V2 does not replace V1's handler or DTO.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TestVersionedRoutes_CanRunSideBySide&lt;/code&gt; registers both handlers on the same mux and requests both paths. It decodes each response into its version's type and checks the customer values.&lt;/p&gt;

&lt;p&gt;Another regression test registers V2 and then checks V1's wire contract again. That test targets a specific risk: a new route exists, but the old response must still retain the fields the legacy consumer understands.&lt;/p&gt;

&lt;p&gt;The lab uses URL versioning. Header-based selection appears in the README as a design alternative; there is no header-version negotiation implementation here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the consumer and inspect the response
&lt;/h2&gt;

&lt;p&gt;The safe tests use two complementary views of the body. One decodes V1 into &lt;code&gt;LegacyInvoice&lt;/code&gt; and checks its invoice fields. Another reads the response into a map of &lt;code&gt;json.RawMessage&lt;/code&gt; and checks field presence and values.&lt;/p&gt;

&lt;p&gt;For the central breaking change, the important assertion is explicit: V1's &lt;code&gt;customer&lt;/code&gt; is decoded into a string and compared with &lt;code&gt;Budi&lt;/code&gt;. V2's &lt;code&gt;customer&lt;/code&gt; is decoded into an object, then its &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, and &lt;code&gt;phone&lt;/code&gt; are checked separately.&lt;/p&gt;

&lt;p&gt;Typed response tests also check the invoice ID, total, and status. This gives the tests a concrete fixture-based contract rather than merely requiring that the body be JSON.&lt;/p&gt;

&lt;p&gt;These assertions cover the fields and requests they exercise. They are not an exhaustive schema validator for all payloads, nullability combinations, or consumer implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a field is a different change
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AdditiveHandler&lt;/code&gt; keeps &lt;code&gt;customer&lt;/code&gt; as a string and adds currency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Budi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PAID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IDR"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TestAdditiveField_LegacyClientStillWorks&lt;/code&gt; verifies that the body contains &lt;code&gt;currency&lt;/code&gt; with value &lt;code&gt;IDR&lt;/code&gt;. It then decodes the response into the old &lt;code&gt;LegacyInvoice&lt;/code&gt;, which has no currency field, and checks the customer and total.&lt;/p&gt;

&lt;p&gt;The supplied decoder tolerates the extra field. The README qualifies the broader design rule: additive response fields are usually compatible when consumers tolerate unknown fields. Consumers using strict validation may behave differently.&lt;/p&gt;

&lt;p&gt;The demonstrated outcome belongs to this Go decoding path. The lab does not include a strict-schema consumer test, so it does not prove compatibility with every possible reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility includes request requirements
&lt;/h2&gt;

&lt;p&gt;The source also protects an expectation outside the JSON body. &lt;code&gt;TestV1Contract_DoesNotIntroduceRequiredTenantHeader&lt;/code&gt; sends a V1 request without &lt;code&gt;X-Tenant-ID&lt;/code&gt; and expects HTTP 200.&lt;/p&gt;

&lt;p&gt;That test records that this V1 handler does not introduce the header as a requirement. It does not establish a broader authentication policy; the lab contains no tenant authentication middleware.&lt;/p&gt;

&lt;p&gt;The README lists other contract changes, such as renamed fields, required inputs, date formats, and changed error representations. The implemented string-to-object case is the main demonstration. The listed possibilities should not be mistaken for separately implemented experiments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing behavior is tested separately
&lt;/h2&gt;

&lt;p&gt;The helper &lt;code&gt;parseInvoiceID&lt;/code&gt; validates the prefix, requires an ID, rejects extra path segments, parses a numeric value, and requires it to be positive. The versioned handlers then look up that ID.&lt;/p&gt;

&lt;p&gt;For the versioned fixture, the expected cases include:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Expected response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/invoices/1001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, invoice JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/invoices/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400, missing ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/invoices/abc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400, invalid numeric ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/invoices/0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400, nonpositive ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/invoices/1001/extra&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400, extra segment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/invoices/9999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;404, invoice not found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/invoices/1001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;405, &lt;code&gt;Allow: GET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/v3/invoices/1001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;404 from the router&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fixture repository contains only invoice &lt;code&gt;1001&lt;/code&gt;. Thus &lt;code&gt;9999&lt;/code&gt; is a valid ID format but has no corresponding invoice in the fixture.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;newVersionedMux&lt;/code&gt; registers both the exact collection path and the subtree path for each version. The request without a trailing slash therefore reaches the handler and produces the lab's 400 response, rather than relying on an implicit redirect.&lt;/p&gt;

&lt;p&gt;Handler-generated responses use &lt;code&gt;application/json&lt;/code&gt;, including method errors. An unmatched V3 path is handled by the mux's default 404 behavior; the README documents that response as plain text. The common JSON writer does not wrap every router response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration remains a documented policy
&lt;/h2&gt;

&lt;p&gt;The README describes releasing V2, retaining V1 for older consumers, monitoring adoption, communicating deprecation, and removing V1 after sunset criteria are met.&lt;/p&gt;

&lt;p&gt;It includes example timeframes and traffic thresholds. They are illustrative policy values, not measured adoption data or an implemented shutdown schedule. This directory contains no traffic-monitoring pipeline or automated sunset mechanism.&lt;/p&gt;

&lt;p&gt;The code demonstrates the prerequisite: both representations can be served through separate routes, with tests that continue checking V1. Deciding when V1 can be removed requires the consumer information described in the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the lab from its module
&lt;/h2&gt;

&lt;p&gt;The directory contains its own &lt;code&gt;go.mod&lt;/code&gt;, declaring Go &lt;code&gt;1.25.0&lt;/code&gt;. Its README lists these test commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;labs/06-api-versioning
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-race&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests use &lt;code&gt;httptest&lt;/code&gt; and a mock repository rather than an external database. No test execution results are claimed here.&lt;/p&gt;

&lt;p&gt;The lab's mental model is precise: HTTP 200 describes a server response, while compatibility must be evaluated against the consumer's contract. Separate DTOs and regression tests keep that contract visible when a new representation is introduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;Based solely on the README, Go handlers, models, and tests in Software Engineering Lab, Lab 06. Test outcomes are described from source assertions, not a new run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/06-api-versioning" rel="noopener noreferrer"&gt;https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/06-api-versioning&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>api</category>
      <category>testing</category>
      <category>backend</category>
    </item>
    <item>
      <title>Race Condition on Concurrent Stock Updates</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Tue, 29 Sep 2026 05:58:05 +0000</pubDate>
      <link>https://dev.to/lukman-ss/race-condition-on-concurrent-stock-updates-398h</link>
      <guid>https://dev.to/lukman-ss/race-condition-on-concurrent-stock-updates-398h</guid>
      <description>&lt;p&gt;A stock update can be implemented with a sequence that looks reasonable when only one request is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read stock
check availability
decrease stock
save the new value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lab 05 focuses on what happens when multiple operations execute that sequence against the same state.&lt;/p&gt;

&lt;p&gt;The important part of the experiment is not whether each request contains valid business logic in isolation. The test is whether the invariant still holds when those requests overlap.&lt;/p&gt;

&lt;p&gt;The lab covers this from several angles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an unsafe read/check/write flow;&lt;/li&gt;
&lt;li&gt;deterministic lost-update tests;&lt;/li&gt;
&lt;li&gt;an atomic conditional SQL update;&lt;/li&gt;
&lt;li&gt;PostgreSQL row locking;&lt;/li&gt;
&lt;li&gt;concurrent inventory attempts;&lt;/li&gt;
&lt;li&gt;concurrent booking attempts protected by a database uniqueness constraint;&lt;/li&gt;
&lt;li&gt;a separate Go memory data-race example.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main experiment is the application/database race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Smallest Inventory Case
&lt;/h2&gt;

&lt;p&gt;Start with one unit of stock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stock = 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two operations attempt to consume it.&lt;/p&gt;

&lt;p&gt;The expected business result is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;successful operations = 1
remaining stock       = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unsafe implementation separates the decision into multiple steps.&lt;/p&gt;

&lt;p&gt;Conceptually, the execution is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READ
  ↓
CHECK
  ↓
CALCULATE
  ↓
WRITE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With one caller, the sequence behaves as expected.&lt;/p&gt;

&lt;p&gt;With two callers, those steps can interleave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Operations Read the Same State
&lt;/h2&gt;

&lt;p&gt;Consider this execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request A                    Request B
---------                    ---------
READ stock = 1

                             READ stock = 1

CHECK stock &amp;gt; 0              CHECK stock &amp;gt; 0

newStock = 0                 newStock = 0

WRITE stock = 0

                             WRITE stock = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both operations observed the same original value.&lt;/p&gt;

&lt;p&gt;Both checks passed.&lt;/p&gt;

&lt;p&gt;Both calculated the same replacement value.&lt;/p&gt;

&lt;p&gt;The final database value is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stock = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looking only at the final stock value does not expose the full problem.&lt;/p&gt;

&lt;p&gt;Both operations may have been treated as successful even though only one unit existed.&lt;/p&gt;

&lt;p&gt;The broken invariant is therefore not simply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stock must never be negative
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test also needs to account for the number of successful decrements.&lt;/p&gt;

&lt;p&gt;For one available item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;successful decrements &amp;lt;= 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Lost Update
&lt;/h2&gt;

&lt;p&gt;The unsafe flow calculates a new value outside the final write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read stock
calculate stock - 1
write calculated value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That creates a window where another operation can read the same state.&lt;/p&gt;

&lt;p&gt;The important interleaving is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A reads 1
B reads 1

A calculates 0
B calculates 0

A writes 0
B writes 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second write is not based on the state produced by the first write.&lt;/p&gt;

&lt;p&gt;It is based on the earlier value both callers observed.&lt;/p&gt;

&lt;p&gt;That is the lost-update behavior exercised by the lab.&lt;/p&gt;

&lt;p&gt;The repository uses deterministic concurrency coordination for these tests rather than depending only on timing or arbitrary sleeps. Barriers/start gates are used to force the relevant operations to overlap at the point required by the experiment.&lt;/p&gt;

&lt;p&gt;That matters because a concurrency test that passes or fails only depending on scheduler luck is a weak proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving the Condition Into the Update
&lt;/h2&gt;

&lt;p&gt;One implementation in the lab removes the separate read/check/write sequence.&lt;/p&gt;

&lt;p&gt;The SQL statement is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;inventory_products&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two important details in this statement.&lt;/p&gt;

&lt;p&gt;First:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application does not read a value, calculate a replacement, then send that replacement back.&lt;/p&gt;

&lt;p&gt;Second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The availability condition is part of the statement that mutates the row.&lt;/p&gt;

&lt;p&gt;The operation becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt decrement
only when stock &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read
decide in application
write later
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes the application-level race window demonstrated by the unsafe implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Invariant Is Checked at the Write Boundary
&lt;/h2&gt;

&lt;p&gt;The atomic version changes where the decision happens.&lt;/p&gt;

&lt;p&gt;Unsafe model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application:
    read stock
    check stock
    calculate new stock

Database:
    write supplied value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database:
    decrement stock
    only if stock &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is visible under contention.&lt;/p&gt;

&lt;p&gt;The repository includes a concurrent inventory scenario with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;initial stock = 100
concurrent attempts = 500
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful assertion is not merely that all concurrent calls return.&lt;/p&gt;

&lt;p&gt;The final state must still be consistent with the initial inventory.&lt;/p&gt;

&lt;p&gt;The number of successful decrements cannot exceed the amount that was available.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL Row Locking
&lt;/h2&gt;

&lt;p&gt;The lab also contains a PostgreSQL row-lock implementation and tests.&lt;/p&gt;

&lt;p&gt;The relevant read uses &lt;code&gt;FOR UPDATE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;inventory_products&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row lock changes the execution shape.&lt;/p&gt;

&lt;p&gt;Instead of two transactions independently reading the same stock and later trying to update it, the critical read/check/update flow is coordinated around the same row.&lt;/p&gt;

&lt;p&gt;A simplified execution is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction A
-------------
BEGIN

SELECT ... FOR UPDATE

check stock

update stock

COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A concurrent transaction targeting the same locked row does not proceed through the same critical section independently while the first transaction still owns the lock.&lt;/p&gt;

&lt;p&gt;The repository demonstrates this as another way to protect the same business invariant.&lt;/p&gt;

&lt;p&gt;The lab does not use that result to claim that every concurrency problem should be solved with row locking. It is one implementation demonstrated against the stock-update problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atomic Update and Row Lock Solve the Problem Differently
&lt;/h2&gt;

&lt;p&gt;Both implementations target the same unsafe boundary, but the shape is different.&lt;/p&gt;

&lt;p&gt;With the atomic update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the condition and mutation fit into one statement.&lt;/p&gt;

&lt;p&gt;With row locking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lock row
↓
read state
↓
make decision
↓
update
↓
commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the operation retains a multi-step decision while coordinating access to the row.&lt;/p&gt;

&lt;p&gt;The lab therefore gives two concrete implementations rather than reducing the topic to "put everything in a transaction."&lt;/p&gt;

&lt;p&gt;A transaction establishes a transaction boundary.&lt;/p&gt;

&lt;p&gt;The concurrency behavior still depends on how the shared row is accessed inside that boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Booking Uses a Different Invariant
&lt;/h2&gt;

&lt;p&gt;The repository also applies the concurrency problem to booking.&lt;/p&gt;

&lt;p&gt;The invariant is no longer a numeric stock value.&lt;/p&gt;

&lt;p&gt;A booking is identified by a combination including:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;branch_id
service_date
slot_time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema protects that combination with a unique constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UNIQUE(branch_id, service_date, slot_time)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important concurrency case is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;many requests
↓
same branch
same date
same slot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An application can check whether the slot exists before inserting.&lt;/p&gt;

&lt;p&gt;That check is useful for normal control flow, but it is not enough to enforce the invariant under concurrency.&lt;/p&gt;

&lt;p&gt;Two requests can both observe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slot is available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;before either insert is visible to the other operation.&lt;/p&gt;

&lt;p&gt;The database constraint is the final storage boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrent Booking Test
&lt;/h2&gt;

&lt;p&gt;The booking test exercises a single slot with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;500 concurrent attempts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the valid database state should remain.&lt;/p&gt;

&lt;p&gt;PostgreSQL uniqueness conflicts are mapped through SQLSTATE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;23505
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conflict is treated as an expected concurrency outcome rather than as proof that the database is broken.&lt;/p&gt;

&lt;p&gt;The database is enforcing the invariant that the application needs.&lt;/p&gt;

&lt;p&gt;This is the same design principle as the inventory test, applied to a different state shape.&lt;/p&gt;

&lt;p&gt;Inventory invariant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;successful decrements &amp;lt;= available stock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Booking invariant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one row for one unique branch/date/slot combination
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism is chosen around the invariant being protected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Application Pre-Checks Do Not Replace Constraints
&lt;/h2&gt;

&lt;p&gt;Consider the booking sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT booking
WHERE branch/date/slot = requested slot

if not found:
    INSERT booking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under concurrent execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request A                       Request B
---------                       ---------
SELECT → not found

                                SELECT → not found

INSERT

                                INSERT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without storage enforcement, both requests reached the same application decision.&lt;/p&gt;

&lt;p&gt;The unique constraint prevents the database from accepting both final rows for the same slot.&lt;/p&gt;

&lt;p&gt;This is another example of why code that is correct sequentially can still be incomplete as concurrent business logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory Data Race vs Business Race
&lt;/h2&gt;

&lt;p&gt;Lab 05 also keeps a Go memory data-race example.&lt;/p&gt;

&lt;p&gt;That experiment is useful, but it is not the same failure as the inventory and booking cases.&lt;/p&gt;

&lt;p&gt;A memory data race is concerned with unsynchronized memory access between goroutines.&lt;/p&gt;

&lt;p&gt;The inventory and booking experiments focus on business invariants over shared application/database state.&lt;/p&gt;

&lt;p&gt;Those two categories can overlap in some systems, but they should not be treated as synonyms.&lt;/p&gt;

&lt;p&gt;The repository keeps the intentional memory race separated so the application/database concurrency tests can be reasoned about independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Tests Need to Prove
&lt;/h2&gt;

&lt;p&gt;Concurrency tests need stronger assertions than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no panic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;all goroutines completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For inventory, useful assertions concern the invariant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;successful operations
remaining stock
initial stock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For booking, the relevant assertion concerns how many rows survive for the unique slot.&lt;/p&gt;

&lt;p&gt;The final repository revision also made the PostgreSQL lost-update reproduction deterministic rather than relying on an arbitrary timing window.&lt;/p&gt;

&lt;p&gt;That is an important testing property.&lt;/p&gt;

&lt;p&gt;The test should deliberately create the interleaving being investigated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commands
&lt;/h2&gt;

&lt;p&gt;Lab 05 is a nested Go module.&lt;/p&gt;

&lt;p&gt;The repository exposes dedicated Make targets including:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make lab-05-test
make lab-05-test-race
make lab-05-vet
make lab-05-fmt
make lab-05-integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nested module boundary matters when running tests from the repository root: a plain root-level &lt;code&gt;go test ./...&lt;/code&gt; does not implicitly replace the dedicated Lab 05 targets.&lt;/p&gt;

&lt;p&gt;The README was updated to reflect that layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Lab Demonstrates
&lt;/h2&gt;

&lt;p&gt;The unsafe inventory example starts from code that is locally understandable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read
check
modify
write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Concurrency changes the correctness requirement.&lt;/p&gt;

&lt;p&gt;Another operation can run between those steps.&lt;/p&gt;

&lt;p&gt;The fixes in the repository move the invariant to a boundary that can protect it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;atomic database update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;row lock around the critical database flow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For booking, the invariant is enforced through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database uniqueness
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broader result from the experiment is specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequential correctness
does not prove
concurrent correctness
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The invariant has to survive the interleavings that the system actually permits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;This article is based on an implementation from my Software Engineering Lab:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/05-race-condition" rel="noopener noreferrer"&gt;https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/05-race-condition&lt;/a&gt;&lt;/p&gt;

</description>
      <category>concurrency</category>
      <category>database</category>
      <category>backend</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Caching: Why Faster Reads Create Consistency Problems</title>
      <dc:creator>lukman lukman</dc:creator>
      <pubDate>Tue, 22 Sep 2026 07:48:22 +0000</pubDate>
      <link>https://dev.to/lukman-ss/caching-why-faster-reads-create-consistency-problems-kp9</link>
      <guid>https://dev.to/lukman-ss/caching-why-faster-reads-create-consistency-problems-kp9</guid>
      <description>&lt;p&gt;A dashboard request can look harmless.&lt;/p&gt;

&lt;p&gt;The problem appears when hundreds of users ask for the same expensive data at the same time.&lt;/p&gt;

&lt;p&gt;In Software Engineering Lab 04, the scenario is a workshop dashboard. Without caching, every request runs six database queries with joins and aggregations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;500 concurrent users
        ↓
Dashboard request
        ↓
6 queries + join/aggregation per request
        ↓
3000 total DB queries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first instinct is easy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;put Redis in front of the database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reduces repeated work.&lt;/p&gt;

&lt;p&gt;But it also creates a new set of problems.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;How stale may the cached value be?
When should it be invalidated?
What happens when the key expires under load?
What happens if Redis is unavailable?
Can the cache return data from the wrong tenant?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the part I wanted to explore in Lab 04.&lt;/p&gt;

&lt;p&gt;The main mental model is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slow query → add cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reduce repeated work
        ↓
accept a consistency boundary
        ↓
design expiration, invalidation, failure, and concurrency behavior
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PostgreSQL is still the source of truth
&lt;/h2&gt;

&lt;p&gt;The lab uses two storage layers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Durable, persistent, authoritative storage that can rebuild the cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache&lt;/td&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;Derived data, TTL-bound, rebuilt on demand&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important decision is ownership of correctness.&lt;/p&gt;

&lt;p&gt;In this lab, PostgreSQL remains authoritative for business data. Redis stores derived data that can disappear, expire, or be rebuilt.&lt;/p&gt;

&lt;p&gt;That means cache correctness has to work even when the cache is empty.&lt;/p&gt;

&lt;p&gt;The cache is an optimization layer, not the only copy of the business state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read path: Cache Aside
&lt;/h2&gt;

&lt;p&gt;Lab 04 uses Cache Aside for reads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET cache
   ↓
hit? ───── yes ───→ return cached value
   ↓ no
query PostgreSQL
   ↓
populate Redis
   ↓
return value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cache miss is not an application failure.&lt;/p&gt;

&lt;p&gt;It means the application has to rebuild the value from the authoritative source.&lt;/p&gt;

&lt;p&gt;The application explicitly knows about both storage layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Redis
PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That control is useful when Redis is unavailable because the application can still attempt to read from PostgreSQL, as long as the database and fallback capacity can handle the traffic.&lt;/p&gt;

&lt;p&gt;But Cache Aside also means the application now owns cache freshness.&lt;/p&gt;

&lt;p&gt;That is where the interesting failures start.&lt;/p&gt;

&lt;h2&gt;
  
  
  TTL is really a staleness decision
&lt;/h2&gt;

&lt;p&gt;The useful question is not simply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this data change?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The lab asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How long can stale data be accepted?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Examples recorded in the lab:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Max staleness&lt;/th&gt;
&lt;th&gt;Reasonable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard statistics&lt;/td&gt;
&lt;td&gt;30s–2min&lt;/td&gt;
&lt;td&gt;Yes, operational metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stock display&lt;/td&gt;
&lt;td&gt;1–5s&lt;/td&gt;
&lt;td&gt;Yes, UI/UX only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wallet balance&lt;/td&gt;
&lt;td&gt;0s&lt;/td&gt;
&lt;td&gt;No, audit risk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The difference matters.&lt;/p&gt;

&lt;p&gt;A dashboard metric can tolerate a freshness window that would be unacceptable for a transactional balance.&lt;/p&gt;

&lt;p&gt;So TTL is not just an expiry configuration. In this implementation, it represents an accepted freshness window and also acts as a recovery path when stale cache survives longer than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invalidating before commit can reintroduce stale data
&lt;/h2&gt;

&lt;p&gt;Consider this write flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DELETE cache
↓
update database
↓
COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks reasonable. Remove the old cache first, then write the new database value.&lt;/p&gt;

&lt;p&gt;The race appears when a reader enters between those operations.&lt;/p&gt;

&lt;p&gt;Lab 04 describes this timeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T1 Writer: DELETE cache
T2 Reader: cache MISS → reads old DB value
T3 Reader: SET old value into cache
T4 Writer: DB COMMIT new value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Final state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database = new value
Cache    = old value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is stale cache, not data loss. The authoritative business data in PostgreSQL is still correct.&lt;/p&gt;

&lt;p&gt;A safer order is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB COMMIT
↓
DELETE cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a reader that misses after the delete can rebuild from the committed database value.&lt;/p&gt;

&lt;p&gt;But even this is not strong consistency.&lt;/p&gt;

&lt;p&gt;Another interleaving still exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T1 Reader: cache MISS
T2 Reader: reads old DB value
T3 Writer: DB COMMIT new value
T4 Writer: DELETE cache
T5 Reader: SET old DB result into cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Final state again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database = new value
Cache    = old value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the conclusion is narrower:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;COMMIT → DELETE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is safer than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DELETE → COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but Cache Aside is still an eventually consistent optimization in this lab.&lt;/p&gt;

&lt;p&gt;TTL remains useful as a safety net for the residual stale window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating Redis after a database write is not atomic either
&lt;/h2&gt;

&lt;p&gt;The lab also uses an application-managed update-on-write flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB update
↓
DB COMMIT succeeds
↓
best-effort Redis SET
↓
return success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database returns the authoritative value, then the application tries to update the cache.&lt;/p&gt;

&lt;p&gt;The problem is the boundary between PostgreSQL and Redis.&lt;/p&gt;

&lt;p&gt;These are separate systems, so this sequence is not atomic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB COMMIT
↓
Redis SET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A process can fail between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB COMMIT succeeds
↓
process crashes
↓
Redis SET never happens
↓
old cache remains
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Concurrent writers create another race:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Writer A commits value A
Writer B commits value B
Writer B SET cache = B
Writer A performs a late SET cache = A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Final state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database = B
Cache    = A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was an important distinction for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cache Aside
→ read strategy

Invalidate-on-write / update-on-write
→ write strategy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They solve different parts of the cache lifecycle.&lt;/p&gt;

&lt;p&gt;Neither turns PostgreSQL and Redis into one atomic system.&lt;/p&gt;

&lt;h2&gt;
  
  
  One miss is normal. One thousand simultaneous misses are not
&lt;/h2&gt;

&lt;p&gt;Now consider a popular key reaching expiration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache expires
      ↓
1000 concurrent requests arrive
      ↓
1000 cache misses
      ↓
1000 parallel DB queries
      ↓
database overload / crash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the cache stampede scenario used in Lab 04.&lt;/p&gt;

&lt;p&gt;The cache successfully removes repeated database work while the entry exists, but expiration can suddenly send that work back to the database at the same time.&lt;/p&gt;

&lt;p&gt;For duplicate rebuilds inside one process, the lab uses &lt;code&gt;golang.org/x/sync/singleflight&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The flow includes a second cache check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;initial cache GET
      ↓
miss
      ↓
singleflight.Do
      ↓
check cache again
      ↓
query DB
      ↓
populate cache
      ↓
share result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second check matters because another caller may already have populated the cache between the first miss and the point where this caller becomes the rebuild leader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Singleflight stops at the process boundary
&lt;/h2&gt;

&lt;p&gt;Singleflight coordinates callers inside one process.&lt;/p&gt;

&lt;p&gt;A multi-instance deployment needs a different coordination boundary.&lt;/p&gt;

&lt;p&gt;Lab 04 implements a distributed-lock primitive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WithLock() = try-once lock primitive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conceptual cache regeneration flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache GET
   ↓ miss
acquire distributed lock
   ↓
check cache again
   ↓
query DB
   ↓
populate cache
   ↓
safe release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lock requirements in the lab are explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unique token or owner;&lt;/li&gt;
&lt;li&gt;TTL to avoid a permanent deadlock;&lt;/li&gt;
&lt;li&gt;atomic compare-and-delete when releasing;&lt;/li&gt;
&lt;li&gt;one holder must not remove another holder's lock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But even this has a boundary.&lt;/p&gt;

&lt;p&gt;If regeneration takes longer than the lock TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instance A acquires lock
↓
lock expires
↓
Instance B acquires a new lock
↓
Instance A is still rebuilding
↓
duplicate rebuild can run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lock in Lab 04 reduces duplicate cache regeneration. It is not used as a correctness primitive for business transactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expiration can also be spread out
&lt;/h2&gt;

&lt;p&gt;The lab adds TTL jitter so many keys do not expire at nearly the same moment.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;60s + random 0–15s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation produces values in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[base, base + maxJitter)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TTL never goes below &lt;code&gt;base&lt;/code&gt;, and the upper bound is exclusive.&lt;/p&gt;

&lt;p&gt;The lab also discusses background refresh: refresh the cached value before expiry while clients continue receiving the existing cache value.&lt;/p&gt;

&lt;p&gt;Both techniques target expiration behavior rather than changing the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache keys are part of the data boundary
&lt;/h2&gt;

&lt;p&gt;The canonical key format in the lab is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{app}:{tenant}:{branch}:{resource}:{dimension}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cmms:tenant:42:branch:7:dashboard:2026-09-01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every input that changes the result belongs in the cache key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For the dashboard example, that includes tenant, branch, and business date.&lt;/p&gt;

&lt;p&gt;This is also a security boundary in a multi-tenant system.&lt;/p&gt;

&lt;p&gt;A sensitive cached value must include tenant scope. Missing isolation can expose data under the wrong tenant context.&lt;/p&gt;

&lt;p&gt;Key design also affects reuse.&lt;/p&gt;

&lt;p&gt;If 10,000 users read the same branch dashboard, this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache:tenant:42:user:{user_id}:dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;creates high-cardinality entries for data that is actually shared.&lt;/p&gt;

&lt;p&gt;The lab contrasts it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache:tenant:42:branch:{branch_id}:dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One branch-scoped value can be reused by those readers.&lt;/p&gt;

&lt;p&gt;The trade-off is not "specific keys are bad." The point is that key dimensions should match the result boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redis failure changes where the traffic goes
&lt;/h2&gt;

&lt;p&gt;Cache Aside allows database fallback when Redis fails, but only while the authoritative dependency and fallback capacity remain available.&lt;/p&gt;

&lt;p&gt;The traffic pattern becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traffic previously absorbed by Redis
      ↓
directly reaches PostgreSQL
      ↓
load spike / cache-failure amplification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So graceful degradation does not mean the outage has no effect.&lt;/p&gt;

&lt;p&gt;It means the main function may continue with degraded performance while PostgreSQL can still handle the fallback traffic.&lt;/p&gt;

&lt;p&gt;The lab also separates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache_miss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache_error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because an expected miss and an unavailable cache backend are different operational events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hit ratio alone does not tell me whether the cache is worth it
&lt;/h2&gt;

&lt;p&gt;Lab 04 evaluates cache value together with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avoided query cost
cache latency
memory cost
invalidation complexity
failure amplification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The README gives this comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30% hit ratio for a 100ms operation
may still be valuable

99% hit ratio for a 0.1ms operation
may not justify the complexity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful signal is not the percentage alone.&lt;/p&gt;

&lt;p&gt;The cost of the work being avoided matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache comes after understanding the database work
&lt;/h2&gt;

&lt;p&gt;The diagnostic order in the lab is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;measure endpoint
↓
check N+1 queries
↓
inspect execution plan
↓
add / optimize indexes
↓
reduce selected columns
↓
optimize joins / subqueries
↓
evaluate caching if the workload needs it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents cache from becoming a way to hide an unexplained query problem.&lt;/p&gt;

&lt;p&gt;For a very cheap query and some workloads, the extra cache hop and operational complexity may not provide meaningful end-to-end benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model I take from this lab
&lt;/h2&gt;

&lt;p&gt;The naive model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query is expensive
→ add Redis
→ problem solved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lab 04 forces a longer chain of questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What is the source of truth?
↓
How stale may this value be?
↓
What dimensions belong in the key?
↓
How is the value invalidated after writes?
↓
What happens during concurrent misses?
↓
What happens when Redis fails?
↓
Is the avoided work worth the added complexity?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the main lesson from the experiment.&lt;/p&gt;

&lt;p&gt;Caching can reduce repeated work in a read-heavy workload.&lt;/p&gt;

&lt;p&gt;But the moment a second storage layer is introduced, freshness, invalidation, concurrency, failure behavior, and isolation become part of the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the lab
&lt;/h2&gt;

&lt;p&gt;From the repository root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; redis

make lab-04-test
make lab-04-test-race
make lab-04-vet
make lab-04-demo
make lab-04-integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo scenarios can also be run directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;labs/04-caching

go run ./cmd/demo &lt;span class="nt"&gt;-scenario&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;without-cache
go run ./cmd/demo &lt;span class="nt"&gt;-scenario&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache-aside
go run ./cmd/demo &lt;span class="nt"&gt;-scenario&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stampede-unprotected
go run ./cmd/demo &lt;span class="nt"&gt;-scenario&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stampede-protected
go run ./cmd/demo &lt;span class="nt"&gt;-scenario&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;write-through
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;Software Engineering Lab 04 — Caching&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/04-caching" rel="noopener noreferrer"&gt;https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/04-caching&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Repository:&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/lukman-ss/software-engineering-lab" rel="noopener noreferrer"&gt;https://github.com/lukman-ss/software-engineering-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Author: Lukman (&lt;code&gt;lukman-ss&lt;/code&gt;)&lt;/p&gt;

</description>
      <category>redis</category>
      <category>backend</category>
      <category>go</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
