<?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: Prashant Singh </title>
    <description>The latest articles on DEV Community by Prashant Singh  (@leanbow).</description>
    <link>https://dev.to/leanbow</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%2F4117280%2F940d5440-cf71-4034-bb78-ce58971e990b.png</url>
      <title>DEV Community: Prashant Singh </title>
      <link>https://dev.to/leanbow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leanbow"/>
    <language>en</language>
    <item>
      <title>Why my habit tracker gives you a 1.5 multiplier for showing up 11 days straight</title>
      <dc:creator>Prashant Singh </dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:57:24 +0000</pubDate>
      <link>https://dev.to/leanbow/why-my-habit-tracker-gives-you-a-15-multiplier-for-showing-up-11-days-straight-4i6</link>
      <guid>https://dev.to/leanbow/why-my-habit-tracker-gives-you-a-15-multiplier-for-showing-up-11-days-straight-4i6</guid>
      <description>&lt;p&gt;Most habit trackers give you a checkbox and a streak counter. I wanted the thing that actually makes&lt;br&gt;
games sticky: XP that compounds, levels that get meaningfully harder as you climb, and a percentile&lt;br&gt;
rank that puts you against everyone else on the board. So logging a 45-minute study session in&lt;br&gt;
&lt;a href="https://github.com/prashant-singh-2001/gamified_tracker" rel="noopener noreferrer"&gt;Gamified Tracker&lt;/a&gt; doesn't just tick a box —&lt;br&gt;
it computes &lt;code&gt;durationMinutes × activityMultiplier × bonusRoll × streakMultiplier&lt;/code&gt;, banks XP against&lt;br&gt;
that specific activity, maybe crosses a level threshold, and nudges your global rank from FOOTHILL&lt;br&gt;
toward SUMMIT.&lt;/p&gt;

&lt;p&gt;The formula isn't the hard part. The hard part is that &lt;strong&gt;the XP award must never be able to break the&lt;br&gt;
thing it's rewarding&lt;/strong&gt;. If the gamification layer falls over at 2am, your log still has to save. I&lt;br&gt;
learned that the ugly way — my first version called the gamification service synchronously &lt;em&gt;before&lt;/em&gt;&lt;br&gt;
it saved the log, so a gamification outage didn't just skip your XP, it silently threw away the&lt;br&gt;
activity you logged. That one bug is responsible for most of the architecture below.&lt;/p&gt;
&lt;h2&gt;
  
  
  The system, in one picture
&lt;/h2&gt;


&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    C[Client] --&amp;gt; GW["API Gateway :8080&amp;lt;br/&amp;gt;JWT · rate limit · routing"]
    GW --&amp;gt;|lb://activity-service| ACT["Activity Service :8081&amp;lt;br/&amp;gt;logs, streaks, XP calc"]
    GW --&amp;gt;|lb://gamification-service| GAM["Gamification Service :8082&amp;lt;br/&amp;gt;levels, ranks, achievements"]

    ACT --&amp;gt;|outbox row, same tx| PG[("PostgreSQL :5433&amp;lt;br/&amp;gt;schemas: gateway · activity · gamification")]
    ACT -.-&amp;gt;|OutboxRelay polls every 2s| MQ{{"RabbitMQ&amp;lt;br/&amp;gt;activity.events"}}
    MQ -.-&amp;gt;|ActivityLoggedEvent| GAM
    GAM --&amp;gt; PG
    GW --&amp;gt; PG
    GW --&amp;gt; RD[("Redis&amp;lt;br/&amp;gt;rate-limit buckets")]

    ACT -.-&amp;gt;|register| EU["Eureka :8761"]
    GAM -.-&amp;gt;|register| EU
    GW -.-&amp;gt;|discover| EU

    ACT &amp;amp; GAM &amp;amp; GW -.-&amp;gt;|metrics + traces| OBS["Prometheus · Grafana · Zipkin"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The dotted lines are the ones that matter. Everything solid is a request that can fail in front of a&lt;br&gt;
user; everything dotted can be down for ten minutes and nobody notices except me.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Java 17 + Spring Boot 3.5&lt;/strong&gt; — versatile, boring in the good way, and 20+ years of community
answers for every stack trace I'd hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Cloud Gateway (MVC)&lt;/strong&gt; — single front door: auth, rate limiting, routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netflix Eureka&lt;/strong&gt; — service registry, so nothing hardcodes a host:port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Security OAuth2 Resource Server + JWT&lt;/strong&gt; — token issue at the gateway, validation at the gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RabbitMQ (Spring AMQP)&lt;/strong&gt; — async XP awards, with a dead-letter queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 15 + Flyway&lt;/strong&gt; — one instance, one schema per service, versioned migrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt; — the whole thing, one command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus · Grafana · Zipkin · Micrometer&lt;/strong&gt; — metrics, dashboards, distributed traces.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Why microservices and not a monolith
&lt;/h2&gt;

&lt;p&gt;I planned this for a wide audience — students through early-career professionals — and the split I&lt;br&gt;
cared about was blast radius. In a monolith, the gamification layer chewing through a leaderboard&lt;br&gt;
recompute degrades the endpoint that logs your activity, because they share a thread pool and a&lt;br&gt;
heap. Splitting them means a gamification stall is a &lt;em&gt;delay in XP appearing&lt;/em&gt;, not a &lt;em&gt;failure to&lt;br&gt;
record what you did&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The honest counterpoint: for my current traffic, a monolith would be entirely fine and about a&lt;br&gt;
quarter of the operational work. I took the complexity deliberately, because the decoupling I wanted&lt;br&gt;
between "record the fact" and "reward the fact" is the kind you can't fake with a well-named package.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pieces
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Service discovery — Eureka
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;eureka-server&lt;/code&gt; is a bare &lt;code&gt;@EnableEurekaServer&lt;/code&gt; app, no custom code at all. The value is on the&lt;br&gt;
client side: the gateway routes to &lt;code&gt;lb://activity-service&lt;/code&gt;, a &lt;em&gt;name&lt;/em&gt;, not &lt;code&gt;http://activity:8081&lt;/code&gt;.&lt;br&gt;
When I scale a service to two containers, the gateway load-balances across both without a config&lt;br&gt;
change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;eureka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;service-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;defaultZone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://eureka-server:8761/eureka&lt;/span&gt;
  &lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;prefer-ip-address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;lease-renewal-interval-in-seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;   &lt;span class="c1"&gt;# default is 30&lt;/span&gt;
    &lt;span class="na"&gt;lease-expiration-duration-in-seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="c1"&gt;# default is 90&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tightened the lease timings well below Eureka's defaults. Out of the box, a dead instance can stay&lt;br&gt;
in the registry for 90 seconds, which in a demo environment means a minute and a half of requests&lt;br&gt;
being load-balanced into a corpse.&lt;/p&gt;
&lt;h3&gt;
  
  
  API Gateway — one place to say "no"
&lt;/h3&gt;

&lt;p&gt;Routes are Java config rather than YAML, because each one composes a rewrite, a discovery-backed&lt;br&gt;
load balancer, and a rate limiter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;RouterFunction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;activityRoute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RateLimitProperties&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&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;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;activity&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"activity"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/activity/**"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;or&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/activitylog/**"&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;or&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/insights/**"&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;before&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rewritePath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"^/api/(.*)$"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/$1"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lb&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"activity-service"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rateLimit&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapacity&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="na"&gt;capacity&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPeriod&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&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="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setKeyResolver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RateLimitKeyResolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byUserIdOrIp&lt;/span&gt;&lt;span class="o"&gt;())))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Centralizing here instead of per-service was the call I'd defend hardest. Both downstream services&lt;br&gt;
run with &lt;strong&gt;no Spring Security of their own&lt;/strong&gt;. Every &lt;code&gt;hasRole("ADMIN")&lt;/code&gt; rule in the system lives in&lt;br&gt;
one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/api/activity"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/api/activity/"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;hasRole&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/activitylog/review/**"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;hasRole&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/api/level"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/api/level/"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;hasRole&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One file to audit beats three files to keep in sync. The cost is real, though: anything that reaches&lt;br&gt;
a service without going through the gateway is completely unauthenticated. That's acceptable behind&lt;br&gt;
a Docker network and unacceptable the moment those ports are public — which is a note-to-self, not a&lt;br&gt;
recommendation.&lt;/p&gt;
&lt;h3&gt;
  
  
  Auth — the half nobody blogs about
&lt;/h3&gt;

&lt;p&gt;Issuing and validating a JWT is the easy half, and Spring Security does it for me. The gateway signs&lt;br&gt;
HS256 tokens carrying &lt;code&gt;role&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; the numeric &lt;code&gt;userId&lt;/code&gt;, and &lt;code&gt;NimbusJwtDecoder&lt;/code&gt; verifies them on the&lt;br&gt;
way back in. Deleting my hand-rolled &lt;code&gt;JwtFilter&lt;/code&gt; in favor of the OAuth2 resource server removed about&lt;br&gt;
130 lines of token handling for identical guarantees.&lt;/p&gt;

&lt;p&gt;The hard half is getting the &lt;em&gt;verified&lt;/em&gt; identity to services that trust nothing. The gateway&lt;br&gt;
overwrites a &lt;code&gt;userId&lt;/code&gt; header from the token claim, so a downstream service can read it as gospel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;rawUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwtAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getClaim&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userId"&lt;/span&gt;&lt;span class="o"&gt;);&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;rawUserId&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SC_UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;trustedUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawUserId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;HttpServletRequestWrapper&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpServletRequestWrapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;USER_ID_HEADER&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equalsIgnoreCase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;trustedUserId&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Enumeration&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getHeaders&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* same override */&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Enumeration&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getHeaderNames&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Collections&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;list&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHeaderNames&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;removeIf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;USER_ID_HEADER&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equalsIgnoreCase&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;USER_ID_HEADER&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Collections&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enumeration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those last two overrides are the entire security fix, and I got them wrong first. Overriding only&lt;br&gt;
&lt;code&gt;getHeader()&lt;/code&gt; looks correct and tests green — but Spring Cloud Gateway builds the forwarded request&lt;br&gt;
by iterating &lt;code&gt;getHeaderNames()&lt;/code&gt; and calling &lt;code&gt;getHeaders(name)&lt;/code&gt;. It never calls &lt;code&gt;getHeader()&lt;/code&gt;. So a&lt;br&gt;
client sending its own &lt;code&gt;userId: 1&lt;/code&gt; header had that value forwarded verbatim, straight past a filter I&lt;br&gt;
believed was sanitizing it. A textbook IDOR, hiding behind a method I hadn't overridden.&lt;/p&gt;
&lt;h3&gt;
  
  
  Data — one Postgres, three schemas
&lt;/h3&gt;

&lt;p&gt;Every service points at the same instance and the same &lt;code&gt;tracker_db&lt;/code&gt;, but owns a distinct Postgres&lt;br&gt;
schema with its own Flyway history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hibernate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ddl-auto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;validate&lt;/span&gt;     &lt;span class="c1"&gt;# Flyway owns the schema; Hibernate only checks it&lt;/span&gt;
    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hibernate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;default_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gamification&lt;/span&gt;
  &lt;span class="na"&gt;flyway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;schemas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gamification&lt;/span&gt;
    &lt;span class="na"&gt;default-schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gamification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ddl-auto: validate&lt;/code&gt; is the part I'd push on anyone starting out. &lt;code&gt;update&lt;/code&gt; feels great until&lt;br&gt;
Hibernate quietly diverges from what your migrations claim, and then you have two sources of truth&lt;br&gt;
and no way to tell which one production believes. Migrations own the schema; Hibernate is only&lt;br&gt;
allowed to complain.&lt;/p&gt;
&lt;h3&gt;
  
  
  Gamification — event-driven, and stubbornly so
&lt;/h3&gt;

&lt;p&gt;Logging an activity writes two rows in &lt;strong&gt;one transaction&lt;/strong&gt;: the log, and an outbox row describing&lt;br&gt;
what happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;activityLogRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activityLog&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// 1. the log FIRST&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ActivityLoggedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&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;saved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getActivity&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getXpEarned&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="n"&gt;outboxEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutboxEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;// 2. the event, SAME tx&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;aggregateType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ActivityLog"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;aggregateId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ActivityLogged"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;publishedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@Transactional&lt;/code&gt; is the whole trick: both rows commit or neither does. There is no window where a log&lt;br&gt;
exists without its event, and no synchronous call to another service that can fail this request. A&lt;br&gt;
scheduled relay ships whatever is unpublished, and only stamps &lt;code&gt;publishedAt&lt;/code&gt; on success — a broker&lt;br&gt;
outage just means the rows sit there until the next tick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelayString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${outbox.relay.delay-ms:2000}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;publishPending&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findTop100ByPublishedAtIsNullOrderByCreatedAtAsc&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;rabbitTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;convertAndSend&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;routingKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPayload&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;ActivityLoggedEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPublishedAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// stamped only on success&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to publish outbox row {} (will retry)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's at-&lt;em&gt;least&lt;/em&gt;-once delivery, which means the consumer has to be idempotent. It writes a guard row&lt;br&gt;
keyed on the log id &lt;em&gt;before&lt;/em&gt; applying XP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RabbitListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"${messaging.queue}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onActivityLogged&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActivityLoggedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logId&lt;/span&gt;&lt;span class="o"&gt;());&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;processedEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// fast path&lt;/span&gt;

    &lt;span class="c1"&gt;// Guard FIRST: the unique PK serializes concurrent duplicates. If a racing delivery&lt;/span&gt;
    &lt;span class="c1"&gt;// already inserted this key, THIS save throws, the whole tx rolls back (XP not applied),&lt;/span&gt;
    &lt;span class="c1"&gt;// the message is redelivered, existsById is now true -&amp;gt; skipped. Exactly once.&lt;/span&gt;
    &lt;span class="n"&gt;processedEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

    &lt;span class="n"&gt;levelTrackerService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LevelTrackerRequestDTO&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;activityId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;xpEarned&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;existsById&lt;/code&gt; check is only a fast path for sequential redelivery. The actual correctness comes&lt;br&gt;
from the primary key constraint — two concurrent deliveries both pass the check, and the database&lt;br&gt;
picks a winner.&lt;/p&gt;

&lt;p&gt;Downstream, the XP lands on a per-activity tracker guarded by an atomic upsert plus a&lt;br&gt;
&lt;code&gt;SELECT … FOR UPDATE&lt;/code&gt;, and the level falls out of a configurable curve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** xpRequiredFor(level) = baseXp * (level - 1) ^ exponent — base 100, exponent 1.5, cap 100. */&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;levelFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;totalXp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&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;totalXp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pow&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalXp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;baseXp&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;exponent&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxLevel&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// Math.pow/floor can land a hair off an exact boundary — nudge onto the correct side.&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;maxLevel&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;xpRequiredFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;totalXp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;++;&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;xpRequiredFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;totalXp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;--;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;while&lt;/code&gt; loops exist because floating-point &lt;code&gt;pow&lt;/code&gt; will occasionally put you at level 6 with&lt;br&gt;
exactly the XP that level 7 requires. Nothing is more annoying to a user than XP that visibly doesn't&lt;br&gt;
add up.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision I'd actually defend: shared instance, schema per service
&lt;/h2&gt;

&lt;p&gt;Textbook microservices says database-per-service. I run one PostgreSQL container with three schemas —&lt;br&gt;
&lt;code&gt;gateway&lt;/code&gt;, &lt;code&gt;activity&lt;/code&gt;, &lt;code&gt;gamification&lt;/code&gt; — each with its own migration history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The case against me:&lt;/strong&gt; it's a shared failure domain. That one container dying takes down all three&lt;br&gt;
services at once, so I've paid for microservices and kept a monolithic database. A long-running query&lt;br&gt;
in gamification competes for the same connection slots and buffer cache as an activity write. And the&lt;br&gt;
temptation to just &lt;code&gt;JOIN&lt;/code&gt; across schemas — quietly welding two services together forever — is one&lt;br&gt;
&lt;code&gt;grep&lt;/code&gt; away. Nothing but discipline stops it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The case for me:&lt;/strong&gt; it isn't shared &lt;em&gt;ownership&lt;/em&gt;. Each service has its own schema, its own Flyway&lt;br&gt;
history, and &lt;code&gt;ddl-auto: validate&lt;/code&gt; so nothing writes outside its lane. No service reads another's&lt;br&gt;
tables — the only cross-service data flow in the entire system is the RabbitMQ event. So the&lt;br&gt;
migration to true database-per-service is a connection-string change and a &lt;code&gt;pg_dump&lt;/code&gt;, not a&lt;br&gt;
refactor. Meanwhile I get one container instead of three, one backup, and a Compose file a&lt;br&gt;
contributor can actually run on a laptop.&lt;/p&gt;

&lt;p&gt;Physical separation buys fault isolation. Logical separation buys the freedom to &lt;em&gt;choose&lt;/em&gt; physical&lt;br&gt;
separation later. For a project whose contributors run everything on one machine, I'd rather have the&lt;br&gt;
option than the overhead — and I'd rather be honest that this is a tradeoff than pretend the shared&lt;br&gt;
container isn't a shared failure domain.&lt;/p&gt;
&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug that reshaped the project.&lt;/strong&gt; The original write path called gamification synchronously,&lt;br&gt;
&lt;em&gt;before&lt;/em&gt; saving the log. Gamification down meant the request threw, and the user's activity was never&lt;br&gt;
recorded at all. The fix wasn't a retry or a circuit breaker — it was deleting the synchronous call.&lt;br&gt;
Every pattern in the gamification section above exists to close that one hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async has a UX cost, and I ate it.&lt;/strong&gt; &lt;code&gt;POST /api/activitylog&lt;/code&gt; used to return &lt;code&gt;leveledUp: true&lt;/code&gt; in the&lt;br&gt;
response. It now always returns &lt;code&gt;false&lt;/code&gt;, because at the moment the response is written, nothing&lt;br&gt;
downstream has run. Correctness went up; the "you leveled up!" moment moved to a separate read. I&lt;br&gt;
haven't fully solved that — right now a client has to poll &lt;code&gt;/api/level&lt;/code&gt;, and it should be a push.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A type header nobody sees.&lt;/strong&gt; &lt;code&gt;ActivityLoggedEvent&lt;/code&gt; was originally declared separately in each&lt;br&gt;
service. Spring AMQP's Jackson converter stamps a &lt;code&gt;__TypeId__&lt;/code&gt; header with the producer's fully&lt;br&gt;
qualified class name, and the consumer couldn't resolve&lt;br&gt;
&lt;code&gt;com.tracker.activity.messaging.ActivityLoggedEvent&lt;/code&gt; because it had its own copy under a different&lt;br&gt;
package. Two fixes: a shared &lt;code&gt;contracts&lt;/code&gt; module for the wire type, and pinning type precedence to&lt;br&gt;
&lt;code&gt;INFERRED&lt;/code&gt; so the converter deserializes into the &lt;code&gt;@RabbitListener&lt;/code&gt; parameter type and ignores the&lt;br&gt;
header entirely — which also makes a rolling deploy safe in either order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spring Security ate my error responses.&lt;/strong&gt; Every proxied downstream error — a 404, a 500, anything —&lt;br&gt;
came back to clients as 401 or 403. Spring Security's filter chain also governs the container's&lt;br&gt;
internal &lt;code&gt;ERROR&lt;/code&gt; dispatch to Boot's &lt;code&gt;/error&lt;/code&gt;, and on that dispatch the re-authenticating filters are&lt;br&gt;
skipped, so the context is anonymous, &lt;code&gt;/error&lt;/code&gt; matches no &lt;code&gt;permitAll&lt;/code&gt; rule, and Security writes its&lt;br&gt;
own response &lt;em&gt;over&lt;/em&gt; the real one. One line, and it has to be first, because matchers evaluate in&lt;br&gt;
declaration order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispatcherTypeMatchers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DispatcherType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ERROR&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DispatcherType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FORWARD&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;permitAll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A JVM that didn't have my random generator.&lt;/strong&gt; &lt;code&gt;RandomGenerator.getDefault()&lt;/code&gt; returns&lt;br&gt;
&lt;code&gt;L32X64MixRandom&lt;/code&gt;, which isn't present on every JVM/container image I ran on — so the log endpoint&lt;br&gt;
500'd on some images and worked fine on others. &lt;code&gt;ThreadLocalRandom.current()&lt;/code&gt; and it was gone. A good&lt;br&gt;
reminder that "works on my machine" is a container image, not a machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd do differently:&lt;/strong&gt; I'd wire the Config Server &lt;em&gt;before&lt;/em&gt; writing five copies of the same&lt;br&gt;
&lt;code&gt;application.yaml&lt;/code&gt;. The module is built and the shared config repo exists, but no service imports it&lt;br&gt;
yet — so today it's a Phase 1 skeleton, and every service still carries its own duplicated Eureka and&lt;br&gt;
Actuator block. Doing that first would have cost an afternoon; retrofitting it now means touching&lt;br&gt;
every service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/prashant-singh-2001/gamified_tracker
&lt;span class="nb"&gt;cd &lt;/span&gt;gamified_tracker
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env          &lt;span class="c"&gt;# defaults are fine for local dev&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That brings up all four services plus Postgres, RabbitMQ, Redis, Eureka, Prometheus, Grafana and&lt;br&gt;
Zipkin, chained on &lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt; so nothing starts before its dependencies&lt;br&gt;
actually pass a healthcheck. Register a user at &lt;code&gt;POST localhost:8080/auth/register&lt;/code&gt;, and the Eureka&lt;br&gt;
dashboard at &lt;code&gt;localhost:8761&lt;/code&gt; shows everything registered.&lt;/p&gt;

&lt;p&gt;The repo is open to contributors and issues are labelled by type and priority — several pieces of&lt;br&gt;
what's above, the Flyway migration setup among them, landed from outside contributors.&lt;br&gt;
&lt;code&gt;docs/features/&lt;/code&gt; has a deep-dive per feature, and &lt;code&gt;docs/FLOWS.md&lt;/code&gt; maps what happens in what order&lt;br&gt;
across services, which is the doc I wish every project had.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Push notifications for level-ups, so the async tradeoff stops being a UX regression, and actually&lt;br&gt;
finishing the Config Server migration. If you've got opinions on the shared-instance-with-schemas&lt;br&gt;
call — especially if you think I'm wrong — I'd genuinely like to hear them.&lt;/p&gt;

</description>
      <category>restapi</category>
      <category>springboot</category>
      <category>microservices</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
