<?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: Abhishek Sharma</title>
    <description>The latest articles on DEV Community by Abhishek Sharma (@abhishek_sharma_a9792aee8).</description>
    <link>https://dev.to/abhishek_sharma_a9792aee8</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%2F1904340%2F8b4e504b-61b0-4e3e-a1c7-a6f1bec45410.png</url>
      <title>DEV Community: Abhishek Sharma</title>
      <link>https://dev.to/abhishek_sharma_a9792aee8</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhishek_sharma_a9792aee8"/>
    <language>en</language>
    <item>
      <title>The AI Price War Just Got Real: GPT-5.6 Drops 80% as ChatGPT Hits 1B Weekly Users</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:39:08 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/the-ai-price-war-just-got-real-gpt-56-drops-80-as-chatgpt-hits-1b-weekly-users-4h84</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/the-ai-price-war-just-got-real-gpt-56-drops-80-as-chatgpt-hits-1b-weekly-users-4h84</guid>
      <description>&lt;p&gt;OpenAI cut GPT-5.6 Luna pricing by 80% this week, down to $0.20 per million input tokens, the same week it crossed 1 billion weekly active users and 2 million business customers. Layer on this: AI agent usage among professionals is now at 80.8% daily-or-more, up from 47.3% a year ago.&lt;/p&gt;

&lt;p&gt;This isn't a capability story anymore, it's a cost-curve story. When inference gets this cheap, use cases that were previously too expensive to justify start to pencil out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Always-on agents that poll and react continuously instead of running on a schedule&lt;/li&gt;
&lt;li&gt;Batch processing large document sets that used to get sampled instead of fully processed&lt;/li&gt;
&lt;li&gt;Embedding an LLM call into every step of a workflow instead of reserving it for the "hard" steps&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this matters for what you're building
&lt;/h2&gt;

&lt;p&gt;If your architecture still treats an LLM call as a scarce, rationed resource, gated behind caching layers and strict rate limits, it's worth revisiting that assumption this quarter. The unit economics that justified those guardrails six months ago may no longer hold.&lt;/p&gt;

&lt;p&gt;The interesting engineering question isn't "can we afford to call the model here" anymore. It's "what breaks if we call it ten times more than we do today, and is that actually a good idea."&lt;/p&gt;

&lt;p&gt;Curious what others are seeing: has this pricing shift already changed anything in your stack, or is the calculus still catching up to the sticker price?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>The AI Price War Just Got Real: 11 Models Shipped in 20 Days</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 24 Aug 2026 07:17:58 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/the-ai-price-war-just-got-real-11-models-shipped-in-20-days-7b4</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/the-ai-price-war-just-got-real-11-models-shipped-in-20-days-7b4</guid>
      <description>&lt;p&gt;August 2026 set a record: 11 major AI models shipped in 20 days from five-plus providers, including GLM-5.2 Turbo from Z.AI, Qwen3.8-Max from Alibaba, Grok Imagine 2.0 from xAI, and Seedance 2.5 from ByteDance. Cost per intelligence unit dropped roughly 50% across tiers in the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distribution war, not the model war
&lt;/h2&gt;

&lt;p&gt;For a while, the story in AI was "who has the best model." That's no longer the interesting question. When 11 frontier-adjacent models ship in three weeks and prices are cut in half, raw capability stops being a differentiator fast. If your product's pitch was "we have model access," that pitch doesn't hold up anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security is becoming the new battleground
&lt;/h2&gt;

&lt;p&gt;In the same window, both OpenAI (expanding its Daybreak program) and Anthropic (with its Mythos model) shipped dedicated cyber-defense models. That's not a coincidence. As the model layer commoditizes, the labs seem to be betting that trust, safety, and security are where the next round of differentiation happens, not benchmark scores.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you're building on top of models
&lt;/h2&gt;

&lt;p&gt;A few practical takeaways:&lt;/p&gt;

&lt;p&gt;Evaluate on total cost of ownership, not sticker price. Tokenizer changes and usage-based credit systems can eat into the "cheaper" pricing headlines fast.&lt;/p&gt;

&lt;p&gt;Don't build a moat on access alone. If a competitor can get comparable output from a different provider at half the cost next month, access was never the moat.&lt;/p&gt;

&lt;p&gt;Watch where labs are investing outside pure capability. Cyber-defense features, agentic tooling, and enterprise trust layers are early signals of where the next competitive line is being drawn.&lt;/p&gt;

&lt;p&gt;The model war looks mostly won by commoditization. The next fight is over distribution, trust, and what gets built on top.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>This week in AI + Indian markets: a zero-click exploit, an open-weight frontier, and a losing streak that snapped</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Fri, 21 Aug 2026 04:52:07 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/this-week-in-ai-indian-markets-a-zero-click-exploit-an-open-weight-frontier-and-a-losing-400h</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/this-week-in-ai-indian-markets-a-zero-click-exploit-an-open-weight-frontier-and-a-losing-400h</guid>
      <description>&lt;p&gt;Three stories this week span cybersecurity, model economics, and market structure — and they're more connected than they look. An AI coding agent turned a routine git clone into a full remote code execution. An open-weight model closed the gap with the world's best proprietary systems. And India's stock market found out that a seven-day losing streak looks very different when the people buying the dip are institutions with a decade-long mandate rather than traders chasing headlines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your IDE is now an attack surface
&lt;/h2&gt;

&lt;p&gt;Cursor's AI coding agent shipped with a zero-click remote code execution flaw, tracked as CVE-2026-26268. The mechanics are simple, and that's what makes it dangerous: an attacker crafts a malicious Git repository, the victim's Cursor agent touches it (even just to index or review it), and a Git hook fires arbitrary code on the developer's machine. No click, no approval prompt, no user action beyond letting the agent do its job.&lt;/p&gt;

&lt;p&gt;This wasn't an isolated bug. The same month, AWS Kiro was found rewriting its own MCP server configuration after reading hidden instructions embedded in a webpage, and GitHub's Agentic Workflows read private repository contents and posted them as a public comment. A separate deeplink flaw let an attacker get Cursor to install a malicious MCP server outright. The common thread is a feature interaction nobody flagged: once an agent starts autonomously executing operations — Git commands, config edits, tool calls — inside a repository or webpage it doesn't control, that surface becomes exploitable. Security teams have spent years hardening APIs, auth flows, and user inputs. The development environment itself, running with a developer's full local permissions, was never treated as something an outside party could reach into. CVE-2026-26268 is the clearest evidence yet that assumption doesn't hold anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open weights just caught the frontier
&lt;/h2&gt;

&lt;p&gt;Moonshot AI's Kimi K3 landed at #3 on Artificial Analysis's Intelligence Index this week, beaten only by Claude Fable 5 and GPT-5.6 Sol Max — and it's fully open weight. The model runs 2.8 trillion total parameters with 104 billion active per token across 896 experts, handles a 1,048,576-token context window, and works natively across text, images, and video. On BrowseComp, a benchmark for real-world research and browsing tasks, it scores 91.2 against Claude Fable 5's 88.0 and GPT-5.6 Sol's 90.4.&lt;/p&gt;

&lt;p&gt;What matters here isn't the leaderboard position, it's what it does to the buy decision. For most of this AI cycle, capability and openness moved together but pointed different directions: the best models were closed, and the open ones were a tier behind. That gap is now small enough that choosing a model is closer to a procurement decision than a capability one — weigh per-token cost, self-hosting control, and vendor lock-in against a few points of benchmark difference, rather than treating closed frontier models as the only serious option. A sensible architecture for a lot of production use cases: route the hard, judgment-heavy fraction of requests to a frontier closed model, and let an open-weight model handle the rest at a fraction of the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nifty snapped its losing streak. Retail didn't blink.
&lt;/h2&gt;

&lt;p&gt;The Nifty fell for seven straight sessions before snapping the streak on Thursday, August 20, when the Sensex jumped 628 points and the Nifty reclaimed the 24,200 level, closing at 24,232. Crude oil, rising US bond yields, and geopolitical tension had driven the pullback; a stabilisation in global bond markets and gains in IT and financial stocks drove the rebound.&lt;/p&gt;

&lt;p&gt;What's more interesting than the one-day bounce is what held steady through the drawdown. Domestic institutional investors kept buying every single day of the seven-session slide, even as FIIs sold. That's not a coincidence — monthly SIP inflows have stayed above ₹31,000 crore, and total mutual fund assets under management have crossed ₹80 lakh crore, both driven by retail money going in on autopilot through systematic investment plans. That flow doesn't care about crude oil prices, US bond yields, or a week of bad headlines; it shows up on the same date every month regardless. The old trading heuristic — watch FII flows, trade the direction — has lost a lot of its predictive power because DII buying, powered by SIPs, now offsets it more often than not. That's genuinely good for volatility. It's also worth sitting with the flip side: retail investors, via their mutual funds, are increasingly the marginal buyer holding the market up. That's a different risk profile than a market driven by foreign capital, and it's one worth understanding rather than assuming away.&lt;/p&gt;

&lt;p&gt;The thread connecting all three: capability and control are being redistributed faster than the institutions built to manage them can adjust. AI agents now execute code with a developer's full permissions, and security models built for a slower, more supervised era haven't caught up. Open-weight models now sit a hair behind the closed frontier, and buying decisions built around "pay for the best" haven't caught up either. And retail capital, moving through SIPs on autopilot, now absorbs shocks that used to be FII-driven, and market narratives built around foreign flows haven't caught up. In all three cases, the tools moved first. The frameworks for thinking about them are still catching up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built two small Claude Code plugins this week - here's what I learned about hooks</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:01:47 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/i-built-two-small-claude-code-plugins-this-week-heres-what-i-learned-about-hooks-2f29</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/i-built-two-small-claude-code-plugins-this-week-heres-what-i-learned-about-hooks-2f29</guid>
      <description>&lt;p&gt;I use Claude Code daily as a Software Engineer at Citrix, and two small frictions kept showing up in long agentic sessions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I was reading every diff and tool-call output just to know if the agent was stuck, even though 90% of it was routine.&lt;/li&gt;
&lt;li&gt;On long sessions, the original goal I gave the agent would quietly drift as the conversation went on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Claude Code plugins can hook into lifecycle events (&lt;code&gt;Stop&lt;/code&gt;, &lt;code&gt;Notification&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, etc.) with a single command, so I built one plugin for each problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  earshot — narrates instead of reading
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/absep98/earshot" rel="noopener noreferrer"&gt;&lt;code&gt;earshot&lt;/code&gt;&lt;/a&gt; hooks &lt;code&gt;Stop&lt;/code&gt; and &lt;code&gt;Notification&lt;/code&gt;. Instead of reading the agent's full response, it narrates a short spoken summary — and by default says &lt;strong&gt;nothing at all&lt;/strong&gt; unless the agent is asking a question, blocked, or failed.&lt;/p&gt;

&lt;p&gt;That "silent by default" behavior wasn't my first instinct — it came from reading two peer-reviewed studies on blind and low-vision developers using AI coding tools. Both found the core problem was auditory overload, not silence. Developers already running a screen reader don't want a second voice narrating the same text.&lt;/p&gt;

&lt;h2&gt;
  
  
  waypoint — a goal that doesn't get lost
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/absep98/waypoint" rel="noopener noreferrer"&gt;&lt;code&gt;waypoint&lt;/code&gt;&lt;/a&gt; hooks &lt;code&gt;UserPromptSubmit&lt;/code&gt;. You set a goal once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/goal-set Fix the login bug on the checkout page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From then on, every prompt you send silently carries a one-line reminder of that goal as injected context — until you clear it with &lt;code&gt;/goal-done&lt;/code&gt;. No manual re-explaining 40 messages into a session that's drifted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/goal-status
&lt;span class="go"&gt;Active goal (set 2h 15m ago): Fix the login bug on the checkout page
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd tell someone building their first plugin
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hooks are just a CLI you already know how to write.&lt;/strong&gt; Read stdin, maybe write JSON to stdout, exit 0. No SDK, no framework — I used zero npm dependencies for both (pure Node fs/os/path).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silence is a feature.&lt;/strong&gt; For anything that fires on every prompt or every response, decide explicitly when it says nothing — a hook that's chatty by default gets disabled fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State lives in &lt;code&gt;~/.claude/&amp;lt;plugin&amp;gt;/&lt;/code&gt;,&lt;/strong&gt; plain JSON, no database needed for something this small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the hook path directly&lt;/strong&gt; by piping sample JSON into it via stdin before you ever load the plugin — it's the fastest feedback loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both plugins are MIT-licensed and install in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add absep98/earshot
/plugin marketplace add absep98/waypoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repos: &lt;a href="https://github.com/absep98/earshot" rel="noopener noreferrer"&gt;github.com/absep98/earshot&lt;/a&gt; · &lt;a href="https://github.com/absep98/waypoint" rel="noopener noreferrer"&gt;github.com/absep98/waypoint&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the hook lifecycle or plugin structure in the comments.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>This week in AI + Indian markets: a safety pause, a price war, and a pullback that isn't what it looks like</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 17 Aug 2026 05:00:06 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/this-week-in-ai-indian-markets-a-safety-pause-a-price-war-and-a-pullback-that-isnt-what-it-3p57</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/this-week-in-ai-indian-markets-a-safety-pause-a-price-war-and-a-pullback-that-isnt-what-it-3p57</guid>
      <description>&lt;p&gt;Three stories from the past week worth more than a scroll-past — one about how close AI safety came to a real test, one about what's happening to the economics of building with AI, and one about what's actually driving the Nifty right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. OpenAI paused a model — not for what it said, but for what it could do
&lt;/h2&gt;

&lt;p&gt;OpenAI halted internal work on its unreleased "Astra" model after internal testing found it could potentially develop zero-day exploits autonomously — without a human in the loop. That's not a hypothetical: Astra is the first model in the nearly three-year history of OpenAI's Preparedness Framework to trigger the "Critical" cybersecurity threshold, the point at which a system is treated as capable of causing severe harm if misused.&lt;/p&gt;

&lt;p&gt;In response, OpenAI put Astra under isolated testing environments, restricted network and tool access, encrypted model weights, sandboxed execution, and real-time chain-of-thought monitoring that can interrupt high-risk activity as it happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; AI safety discourse has largely lived in papers, frameworks, and hypotheticals. This is a frontier lab making a real product decision — delaying a model — because of what it demonstrated it could do, not what it said. For security teams and engineering leaders, it's worth understanding what "autonomous zero-day development" actually means in practice, and what containment looks like when it happens at a lab you don't control.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: Bloomberg, OpenAI&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The AI price war just got real — 80% off
&lt;/h2&gt;

&lt;p&gt;While one part of OpenAI was hitting the brakes on safety grounds, another part was cutting prices hard. GPT-5.6 Luna's API pricing dropped 80%, down to $0.20 per million input tokens, with GPT-5.6 Terra cut 20% — a direct response to pressure from DeepSeek V4 and other Chinese competitors undercutting on cost. The cut instantly pushed Luna into the "most attractive" tier of intelligence-per-dollar rankings from research firm Artificial Analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; Model intelligence is commoditizing faster than most roadmaps assumed. For anyone building AI-powered products, the competitive question is shifting from "which model is smartest" to "which model gives the most capability per dollar" — and that number is moving fast enough that architecture decisions made six months ago may already be outdated.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: CNBC, VentureBeat&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Nifty broke its winning streak — but the buyers didn't leave
&lt;/h2&gt;

&lt;p&gt;Indian equities snapped a two-week winning streak in the week of August 10–14: the Nifty 50 fell 0.83% to 24,366, and the Sensex dropped 0.62% to 78,009. Rising crude oil prices and renewed Middle East tensions were the drag, overshadowing resilient corporate earnings.&lt;/p&gt;

&lt;p&gt;Here's the detail that matters more than the headline: institutional flows didn't turn negative. FIIs were net buyers of ₹508 crore and DIIs net buyers of ₹356 crore in the cash segment on August 14. The 24,200–24,300 zone is holding as support, backed by the 50-day EMA, while a decisive move above 24,700 could reopen the path to 25,000–25,200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; A pullback driven by external shocks (oil, geopolitics) with institutions still net buying reads very differently from a pullback driven by institutional selling. The first usually resolves as consolidation; the second is often the start of something worse.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: Goodreturns&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The through-line this week:&lt;/strong&gt; capability, cost, and confidence are all moving independently right now — a model got too capable for its own safety framework, the price of capability is collapsing, and market confidence is being tested by external shocks rather than fundamentals. Worth tracking whether any of these three threads reverse next week.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>markets</category>
      <category>fintech</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>AI Pricing This Week: DeepSeek Gets Cheaper, Claude Sonnet 5 Gets Pricier</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 03 Aug 2026 05:07:13 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/ai-pricing-this-week-deepseek-gets-cheaper-claude-sonnet-5-gets-pricier-3aec</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/ai-pricing-this-week-deepseek-gets-cheaper-claude-sonnet-5-gets-pricier-3aec</guid>
      <description>&lt;p&gt;Two pricing stories dropped this week that are worth a pause if you're building on LLM APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  DeepSeek V4 Flash exits preview — and undercuts its own flagship
&lt;/h2&gt;

&lt;p&gt;DeepSeek V4 Flash left preview at &lt;strong&gt;$0.14 / $0.28 per million tokens&lt;/strong&gt; (input/output) — and it's beating its own larger Pro model on agentic benchmarks, hitting &lt;strong&gt;82.7% on Terminal-Bench&lt;/strong&gt;. That's a smaller, cheaper model outperforming its own bigger sibling on agent tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Sonnet 5's introductory pricing ends September 1
&lt;/h2&gt;

&lt;p&gt;Anthropic's introductory pricing for Claude Sonnet 5 ends September 1. The headline price rises from &lt;strong&gt;$2 to $3 per million tokens&lt;/strong&gt; — but the bigger change is under the hood: a new tokenizer adds &lt;strong&gt;up to 35% more tokens&lt;/strong&gt; for the same text. Combine the two and the real-world cost increase is closer to &lt;strong&gt;2x&lt;/strong&gt;, not 1.5x.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you're building
&lt;/h2&gt;

&lt;p&gt;If your stack leans on frontier models by default, this is a good week to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Re-benchmark smaller/cheaper models against your actual task, not just leaderboard scores&lt;/li&gt;
&lt;li&gt;Audit token usage before the September 1 pricing change hits&lt;/li&gt;
&lt;li&gt;Separate "frontier because it's necessary" from "frontier because it's the default"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cheap isn't always what it looks like on the label, and expensive doesn't always show up as a sticker price change — sometimes it's a tokenizer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: llm-stats.com, aitoolsrecap.com&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>The AI Landscape This Week: New Models and the Rise of Vertical AI Tools</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 27 Jul 2026 04:42:19 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/the-ai-landscape-this-week-new-models-and-the-rise-of-vertical-ai-tools-l9l</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/the-ai-landscape-this-week-new-models-and-the-rise-of-vertical-ai-tools-l9l</guid>
      <description>&lt;h2&gt;
  
  
  Three Models, One Week
&lt;/h2&gt;

&lt;p&gt;Anthropic's Claude Sonnet 5, OpenAI's GPT-5.6 (shipped as three variants: Sol, Terra, and Luna), and xAI's Grok 4.5 all launched within weeks of each other this July. That's an unusual amount of frontier-model activity packed into a single month, even by 2026 standards.&lt;/p&gt;

&lt;p&gt;But the more interesting story isn't the competition itself — it's the shift in strategy. Instead of chasing one model that does everything, providers are shipping deliberately specialized variants tuned for different jobs: speed vs. depth, cost vs. capability, latency vs. context length.&lt;/p&gt;

&lt;p&gt;For developers, this changes the practical question. It's no longer "which model is best" in the abstract. It's "which model fits this specific use case, cost profile, and latency budget" — and that calculus can change per feature, not just per product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Quieter Trend: Vertical AI
&lt;/h2&gt;

&lt;p&gt;Alongside the big model launches, a batch of narrower, vertical-specific AI tools shipped with a lot less noise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Netchex's Mesh&lt;/strong&gt; — an AI HR system built specifically for restaurants, hotels, and dealerships, handling payroll, compliance, and scheduling for deskless teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redSling's Zenith&lt;/strong&gt; — a no-code agentic development platform for enterprises that want to build AI apps without giving up control of their data or models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LALAL.AI's Lynx&lt;/strong&gt; — a model built only for voice isolation and noise removal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;d1g1t's MCP server&lt;/strong&gt; — connecting its wealth management platform directly into Claude, ChatGPT, and Copilot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are trying to be general-purpose. Each solves one operational problem well, for one industry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for Builders
&lt;/h2&gt;

&lt;p&gt;The pattern across both stories is the same: less "one model/tool to rule them all," more specialization. For teams building on top of AI, that suggests two practical takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build a real evaluation framework for swapping models as new variants ship — don't hard-wire your stack to one provider's assumptions.&lt;/li&gt;
&lt;li&gt;Look at narrow, painful workflows in your own industry. The teams shipping value right now aren't building another general chatbot — they're building the AI layer for one specific job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What's a narrow workflow in your stack that a purpose-built model or agent could take off your plate? Curious what others are seeing in their own domains.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>webdev</category>
      <category>news</category>
    </item>
    <item>
      <title>I got tired of running 4 browser extensions, so I built one</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:54:14 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/i-got-tired-of-running-4-browser-extensions-so-i-built-one-4i8f</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/i-got-tired-of-running-4-browser-extensions-so-i-built-one-4i8f</guid>
      <description>&lt;p&gt;I had a website blocker, a Pomodoro timer, a tab suspender, and a time tracker installed at the same time — four separate extensions, four separate settings pages, none of them talking to each other. Starting a focus session meant manually turning on the blocker, then starting the timer, and neither knew the other existed. So I built &lt;strong&gt;TabInsights&lt;/strong&gt;, which does all four and actually connects them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Website blocker&lt;/strong&gt; — block by domain, category, or schedule, with an optional typed "unblock challenge" for the days willpower isn't enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pomodoro focus timer&lt;/strong&gt; — one click starts a 15/25/45-minute sprint, which also auto-blocks distracting categories for the duration and unblocks them automatically when it ends. This is the part that actually solves my original problem — the timer and the blocker are the same feature, not two extensions coincidentally running at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory saver&lt;/strong&gt; — auto-suspends tabs you haven't touched in a configurable window (15–60 min), freeing roughly 50MB of RAM each via &lt;code&gt;chrome.tabs.discard()&lt;/code&gt;. Suspended tabs stay in your tab bar and reload exactly where you left off with one click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic time tracking&lt;/strong&gt; — logs time per domain with no manual start/stop, and shows a daily focus score.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few implementation notes
&lt;/h2&gt;

&lt;p&gt;Manifest V3 removed persistent background pages, which meant every "ongoing" feature — sprint timers, the daily summary, auto-suspend checks, license re-validation — had to be rebuilt on &lt;code&gt;chrome.alarms&lt;/code&gt; instead of a long-lived timer. The gotcha: Chrome clamps alarm intervals to a minimum of 1 minute in packaged (published) extensions, so anything needing finer granularity has to accept that floor rather than fight it.&lt;/p&gt;

&lt;p&gt;The blocker uses &lt;code&gt;declarativeNetRequest&lt;/code&gt; — you hand Chrome a set of match rules and it enforces them at the browser level. The extension never actually reads the blocked request; it can't, by design, which is also the honest answer any time someone asks whether a blocker "sees" their browsing.&lt;/p&gt;

&lt;p&gt;The bigger architectural decision was privacy: the extension makes &lt;strong&gt;zero external network requests&lt;/strong&gt; during normal use. That meant bundling the UI font locally instead of pulling from Google Fonts, using Chrome's on-device favicon cache instead of a favicon API, and replacing what used to be rotating Unsplash background photos with a CSS-only gradient. None of that needed a server to begin with — it was just easier to reach for one during early development, and ended up being worth ripping out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's early
&lt;/h2&gt;

&lt;p&gt;This is genuinely early — first real push toward finding users happened this week, after building it primarily for myself. If you try it and something's missing or feels like four extensions wearing a trenchcoat instead of one, I'd like to hear it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free&lt;/strong&gt;, with an optional one-time $4.99 Pro upgrade (site time limits, scheduled blocking, unlimited workspaces, cloud sync) — no subscription either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome Web Store:&lt;/strong&gt; &lt;a href="https://chromewebstore.google.com/detail/jfnfcaeagpcloialnbhibiecbphohebm" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/jfnfcaeagpcloialnbhibiecbphohebm&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a VS Code extension that reads your code aloud and narrates git diffs with AI</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 13 Jul 2026 19:07:58 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/i-built-a-vs-code-extension-that-reads-your-code-aloud-and-narrates-git-diffs-with-ai-31b4</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/i-built-a-vs-code-extension-that-reads-your-code-aloud-and-narrates-git-diffs-with-ai-31b4</guid>
      <description>&lt;p&gt;I do a lot of doc-heavy and review-heavy work, and I kept losing time re-reading dense markdown and PRs instead of just listening to them. "Just use a screen reader" doesn't really work for code — it reads asterisks and pound signs literally instead of understanding markdown structure. So I built &lt;strong&gt;Markdown TTS&lt;/strong&gt;, a VS Code extension that actually parses the content first, then speaks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Two distinct modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Literal reading&lt;/strong&gt; — strips markdown syntax properly (headings become "Heading level 2: ...", tables read as CSV, code blocks skipped, links keep their text) and reads it aloud via your OS's built-in voice — SAPI on Windows, &lt;code&gt;say&lt;/code&gt; on macOS. Fully offline, no API key, no account. There's also an optional Microsoft Edge neural voice mode if you want higher quality and don't mind the request leaving your machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. AI narration (bring your own key)&lt;/strong&gt; — instead of reading a file verbatim, an LLM explains what it does in plain English, then narrates that explanation. There's also a command that runs &lt;code&gt;git diff&lt;/code&gt; locally and speaks an AI summary of your uncommitted changes or last commit, which I now use as a last check before committing instead of re-reading a wall of red and green text. Works with OpenAI, Anthropic, or Groq (free tier, no card needed). Only these two commands ever contact an API — literal reading stays completely offline.&lt;/p&gt;

&lt;p&gt;It also does voice input into Copilot Chat, triggering your OS's native dictation (Win+H on Windows, Start Dictation on macOS) instead of implementing its own speech recognition.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few implementation details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Single-file extension, ~1100 lines, no framework&lt;/li&gt;
&lt;li&gt;Windows local TTS is done by spawning PowerShell running &lt;code&gt;System.Speech.Synthesis.SpeechSynthesizer&lt;/code&gt;. Pause/resume goes through a control file, since SAPI has no native pause primitive exposed from that surface&lt;/li&gt;
&lt;li&gt;The Edge neural voice path splits text into ~1.5KB chunks and prefetches two chunks ahead over parallel WebSockets, so long files start speaking in seconds instead of blocking on a multi-minute synth job&lt;/li&gt;
&lt;li&gt;Voice input triggers OS-native dictation rather than doing speech recognition itself — much simpler, and it means dictation quality is whatever your OS already gives you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One decision I'm fairly happy with: the extension ships &lt;strong&gt;no default keybindings&lt;/strong&gt;. The obvious choice, &lt;code&gt;Ctrl+Alt+&amp;lt;letter&amp;gt;&lt;/code&gt;, collides with AltGr on a lot of non-US keyboard layouts (Indian, German, French, Spanish, Nordic, and others), where the OS types an accented character instead of running the command. Rather than hijack keys that silently break for a large share of users, you bind exactly the ones you want yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A use case I didn't originally plan for: reading agent skill files
&lt;/h2&gt;

&lt;p&gt;A lot of AI coding tools now configure their behavior entirely through markdown — Claude Code's &lt;code&gt;SKILL.md&lt;/code&gt; files, &lt;code&gt;CLAUDE.md&lt;/code&gt; project instructions, Cursor's &lt;code&gt;.cursor/rules&lt;/code&gt; files, and similar formats elsewhere. These are exactly the kind of structured markdown — nested headings, bullet lists, embedded code blocks — that this extension already handles well for docs and PRs. Pointing it at an agent skill file works the same way as pointing it at any other doc. Given how much of a developer's actual working files are turning into agent-config markdown instead of prose these days, this has quietly become one of the more practical everyday uses of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's useful beyond the obvious
&lt;/h2&gt;

&lt;p&gt;A few people who've tried it mentioned it's genuinely helpful for dyslexia and general screen-reader-adjacent use cases — that wasn't the original motivation (I built it for my own PR-review workflow), but it's turned out to matter more than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current state
&lt;/h2&gt;

&lt;p&gt;Free, 121 installs so far with basically zero marketing push until this week. Linux only gets the Edge voice path for now — no local TTS binary to shell out to there yet.&lt;/p&gt;

&lt;p&gt;If you try it, I'd genuinely like to hear what's missing, or where it feels like a gimmick instead of something you'd actually keep in your workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Marketplace:&lt;/strong&gt; &lt;a href="https://marketplace.visualstudio.com/items?itemName=AbhishekShr.markdown-tts" rel="noopener noreferrer"&gt;https://marketplace.visualstudio.com/items?itemName=AbhishekShr.markdown-tts&lt;/a&gt;&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>opensource</category>
      <category>a11y</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Tests Required a Running Database. Interfaces Fixed That.</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Wed, 03 Jun 2026 12:46:11 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/my-tests-required-a-running-database-interfaces-fixed-that-2l8b</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/my-tests-required-a-running-database-interfaces-fixed-that-2l8b</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-deployed-my-go-backend-to-a-real-vps-heres-exactly-what-happened-2dpj"&gt;Part 15&lt;/a&gt;, I deployed the backend to a real VPS. But there was a problem I'd been ignoring: the test suite needed a running PostgreSQL database and a Redis instance just to run. No database? Tests skip. No Redis? Panic.&lt;/p&gt;

&lt;p&gt;That's not unit testing. That's integration testing pretending to be unit testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Global Variables Everywhere
&lt;/h2&gt;

&lt;p&gt;Every handler was a package-level function that reached directly into global state:&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;// Before — handlers called globals directly&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CreateEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InsertEntry&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;Context&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;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&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;Mood&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;Category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delete&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;cacheKey&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;db.InsertEntry&lt;/code&gt; called the global &lt;code&gt;db.DB&lt;/code&gt; connection. &lt;code&gt;cache.Delete&lt;/code&gt; called the global &lt;code&gt;redis.Client&lt;/code&gt;. The handler was welded to its dependencies — no way to swap them out.&lt;/p&gt;

&lt;p&gt;This meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tests needed a running Postgres → slow, flaky, CI requires a database service&lt;/li&gt;
&lt;li&gt;Tests needed Redis (or at least a non-nil client) → without it, &lt;code&gt;cache.Delete&lt;/code&gt; panicked&lt;/li&gt;
&lt;li&gt;Can't test a handler without testing the entire stack underneath it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Fix: Interfaces + Dependency Injection
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Define What Handlers Need
&lt;/h3&gt;

&lt;p&gt;Instead of importing &lt;code&gt;db&lt;/code&gt; and calling &lt;code&gt;db.InsertEntry&lt;/code&gt;, define an interface — "here's what I need, I don't care how you implement it":&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;// internal/handlers/handler.go&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;InsertEntry&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;userID&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;text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mood&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;category&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="kt"&gt;int64&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;GetEntryByID&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;entryID&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;userID&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entry&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;GetAllEntriesPaginated&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;userID&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;limit&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;offset&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;models&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entry&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;GetEntryCount&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;userID&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&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="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;UpdateEntry&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;entryID&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;userID&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;text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mood&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;category&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="kt"&gt;int64&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;DeleteEntry&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;entryID&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;userID&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int64&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;CreateUser&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;email&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passwordHash&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="kt"&gt;int64&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;GetUserByEmail&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;email&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="kt"&gt;int64&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;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;type&lt;/span&gt; &lt;span class="n"&gt;Cache&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Set&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;key&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;expiration&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="n"&gt;Get&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;key&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="kt"&gt;string&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;Del&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;key&lt;/span&gt; &lt;span class="kt"&gt;string&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;type&lt;/span&gt; &lt;span class="n"&gt;Handler&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;Store&lt;/span&gt;     &lt;span class="n"&gt;Store&lt;/span&gt;
    &lt;span class="n"&gt;Cache&lt;/span&gt;     &lt;span class="n"&gt;Cache&lt;/span&gt;
    &lt;span class="n"&gt;JWTSecret&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;The &lt;code&gt;Handler&lt;/code&gt; struct holds its dependencies. It doesn't know (or care) if &lt;code&gt;Store&lt;/code&gt; is backed by Postgres, SQLite, or a fake in-memory map.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Convert Functions to Methods
&lt;/h3&gt;

&lt;p&gt;Every handler changed from a package-level function to a method on &lt;code&gt;*Handler&lt;/code&gt;:&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;// Before — package-level function, calls global db directly&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CreateEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InsertEntry&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="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// After — method on Handler, calls the injected Store&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;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CreateEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InsertEntry&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One character changed in the function signature (&lt;code&gt;h *Handler&lt;/code&gt;). Every &lt;code&gt;db.Something()&lt;/code&gt; became &lt;code&gt;h.Store.Something()&lt;/code&gt;. Every &lt;code&gt;cache.Something()&lt;/code&gt; became &lt;code&gt;h.Cache.Something()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The handler's logic didn't change at all. It still validates the request, calls the store, invalidates cache, returns JSON. It just doesn't know which store it's calling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Wire Real Dependencies in main.go
&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;// main.go — production wiring&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostgresStore&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="n"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRedisCache&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;JWTSecret&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;JWTSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Routes use h.Method instead of handlers.Function&lt;/span&gt;
&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/login"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Login&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;db.PostgresStore&lt;/code&gt; already had methods like &lt;code&gt;InsertEntry&lt;/code&gt; — they just needed to be on a struct instead of being standalone functions. Same with &lt;code&gt;redis.RedisCache&lt;/code&gt; implementing &lt;code&gt;Set/Get/Del&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Build FakeStore for Tests
&lt;/h3&gt;

&lt;p&gt;This is where it pays off. A &lt;code&gt;FakeStore&lt;/code&gt; that satisfies the &lt;code&gt;Store&lt;/code&gt; interface with zero infrastructure:&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;// fake_store_test.go — only exists in test files&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;FakeStore&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;InsertEntryFn&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="kt"&gt;int64&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;users&lt;/span&gt;         &lt;span class="k"&gt;map&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;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;int64&lt;/span&gt;
        &lt;span class="n"&gt;passwordHash&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="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FakeStore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;InsertEntry&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;userID&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;text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mood&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;category&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="kt"&gt;int64&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;return&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InsertEntryFn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c"&gt;// returns whatever the test tells it to&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;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FakeStore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CreateUser&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;email&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passwordHash&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="kt"&gt;int64&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;exists&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="o"&gt;&amp;amp;&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="p"&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;"23505"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;// simulate duplicate email&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="kt"&gt;int64&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;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&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="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&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;int64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;passwordHash&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passwordHash&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;id&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="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;FakeCache&lt;/span&gt; &lt;span class="k"&gt;struct&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;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FakeCache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Set&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;key&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;expiration&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="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="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FakeCache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Get&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;key&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="kt"&gt;string&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;return&lt;/span&gt; &lt;span class="s"&gt;""&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;"cache miss"&lt;/span&gt;&lt;span class="p"&gt;)&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;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FakeCache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Del&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;key&lt;/span&gt; &lt;span class="kt"&gt;string&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="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;FakeCache&lt;/code&gt; always returns "cache miss." &lt;code&gt;FakeStore&lt;/code&gt; returns whatever the test configures via &lt;code&gt;InsertEntryFn&lt;/code&gt;. No database. No Redis. No network calls. Tests run in milliseconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Tests That Don't Need Infrastructure
&lt;/h3&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;TestCreateEntry&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;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;FakeStore&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;InsertEntryFn&lt;/span&gt;&lt;span class="o"&gt;:&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="kt"&gt;int64&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;return&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;  &lt;span class="c"&gt;// simulate successful insert&lt;/span&gt;
        &lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="n"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;FakeCache&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`{"text":"feeling good","mood":8,"category":"mood"}`&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;httptest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/entries"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&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;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithValue&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;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;userIDKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int64&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;req&lt;/span&gt; &lt;span class="o"&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;WithContext&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;rec&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;httptest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRecorder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rec&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="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCreated&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;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected 201, got %d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&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;Three lines to set up the handler. No &lt;code&gt;TestMain&lt;/code&gt; initializing a database. No env vars. No cleanup. &lt;code&gt;go test&lt;/code&gt; runs instantly with zero external dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token Refresh: Fixing the 24-Hour Session Problem
&lt;/h2&gt;

&lt;p&gt;While refactoring handlers, I also fixed the authentication model. The old system issued a single JWT valid for 24 hours. If it leaked, an attacker had access for an entire day.&lt;/p&gt;

&lt;p&gt;The new model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access token&lt;/strong&gt;: JWT, 15 minutes, stateless (server doesn't store it)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token&lt;/strong&gt;: UUID, 7 days, stored in Redis (server can revoke it)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Login Now Returns Two Tokens
&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;// Access token — short-lived, for API calls&lt;/span&gt;
&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MapClaims&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"user_id"&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;"exp"&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15&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;Minute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;tokenString&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;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignedString&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;secret&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c"&gt;// Refresh token — long-lived, stored in Redis&lt;/span&gt;
&lt;span class="n"&gt;refreshUUID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"refresh_token:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;refreshUUID&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&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;key&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;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%d"&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="m"&gt;7&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;24&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;Hour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Return both&lt;/span&gt;
&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoginResponse&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;tokenString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RefreshToken&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;refreshUUID&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;
  
  
  POST /refresh — Rotate Tokens
&lt;/h3&gt;

&lt;p&gt;When the access token expires, the client sends the refresh token:&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;Refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&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;"refresh_token:"&lt;/span&gt; &lt;span class="o"&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;RefreshToken&lt;/span&gt;
    &lt;span class="n"&gt;val&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;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&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="c"&gt;// Invalid or expired → force re-login&lt;/span&gt;
        &lt;span class="n"&gt;errorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Invalid or expired refresh token"&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;userID&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;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Delete the old refresh token (one-time use)&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Del&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Issue new access token + new refresh token&lt;/span&gt;
    &lt;span class="c"&gt;// ... (same JWT creation as login)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old refresh token is deleted immediately — each refresh token is single-use. If an attacker steals a refresh token and the real user uses it first, the attacker's stolen token is already invalidated.&lt;/p&gt;

&lt;h3&gt;
  
  
  POST /logout — Server-Side Revocation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Del&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;"refresh_token:"&lt;/span&gt; &lt;span class="o"&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;RefreshToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. Delete the refresh token from Redis. The access token will expire in ≤15 minutes naturally. No token blacklist needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-User Rate Limiting
&lt;/h2&gt;

&lt;p&gt;The existing rate limiter tracked by IP address. Problem: behind a shared network (office, university), hundreds of users share one IP. One heavy user triggers the limit for everyone.&lt;/p&gt;

&lt;p&gt;The fix is simple once you have authenticated users with user IDs in context:&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;UserRateLimitMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&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;ok&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;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;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userIDKey&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="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&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;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;errorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"User not authenticated"&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;key&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;"user:%d"&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;IsAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserRateLimitRequests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserRateLimitWindow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Rate limit exceeded. Try again later."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusTooManyRequests&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;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&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;The Redis key changed from the IP address to &lt;code&gt;user:123&lt;/code&gt;. Same &lt;code&gt;IsAllowed&lt;/code&gt; function, same fixed-window algorithm, different key. The global rate limiter still exists on unauthenticated routes (&lt;code&gt;/login&lt;/code&gt;, &lt;code&gt;/register&lt;/code&gt;) — it runs before &lt;code&gt;AuthMiddleware&lt;/code&gt;. The per-user limiter runs after, on protected routes.&lt;/p&gt;

&lt;p&gt;Two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;IP-based → blocks brute-force login attempts&lt;/li&gt;
&lt;li&gt;User-based → blocks individual users who abuse the API&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Changed Under the Hood
&lt;/h2&gt;

&lt;p&gt;The middleware chain in &lt;code&gt;main.go&lt;/code&gt; tells the full story:&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;// Unauthenticated routes — IP rate limiting&lt;/span&gt;
&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/login"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RateLimitMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoggingMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Login&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="c"&gt;// Authenticated routes — IP + user rate limiting&lt;/span&gt;
&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/entries"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RateLimitMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;UserRateLimitMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateEntry&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;Request hits IP rate limit first → then auth middleware extracts user_id → then per-user rate limit checks against that user's quota → then the handler runs with injected dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Interfaces in Go aren't for abstraction — they're for testability.&lt;/strong&gt; I didn't define &lt;code&gt;Store&lt;/code&gt; because I might switch databases. I defined it because my tests shouldn't need a database to verify that a handler returns 400 on invalid input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The _test.go suffix is powerful.&lt;/strong&gt; &lt;code&gt;FakeStore&lt;/code&gt; lives in &lt;code&gt;fake_store_test.go&lt;/code&gt;. Go only compiles &lt;code&gt;_test.go&lt;/code&gt; files during &lt;code&gt;go test&lt;/code&gt;. The fakes don't ship in the production binary — zero runtime cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token refresh isn't optional.&lt;/strong&gt; A 24-hour JWT means a leaked token is valid for a full day. A 15-minute access token with a revocable refresh token means you can kill any session immediately via &lt;code&gt;/logout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting changes meaning after authentication.&lt;/strong&gt; Before auth, you're limiting IPs (blunt). After auth, you're limiting users (precise). Both are necessary — they protect against different attack vectors.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 16 of "Learning Go in Public". &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/why-i-decided-to-learn-go-and-what-my-first-commit-looked-like-38ii"&gt;Part 1&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-stopped-watching-tutorials-and-started-building-a-rest-api-in-go-4e84"&gt;Part 2&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/building-authentication-from-scratch-in-go-no-libraries-no-magic-2c46"&gt;Part 3&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/tests-update-delete-and-the-refactor-i-didnt-plan-5g2o"&gt;Part 4&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/what-happens-when-your-api-has-10000-rows-i-added-pagination-and-caching-to-find-out-2jkk"&gt;Part 5&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-built-rate-limiting-from-scratch-in-go-then-replaced-it-with-redis-82i"&gt;Part 6&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-server-was-silently-dropping-requests-heres-the-one-line-fix-that-changed-everything-5b4g"&gt;Part 7&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-built-my-backend-on-sqlite-then-i-deleted-it-15a"&gt;Part 8&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-database-disappeared-every-time-i-restarted-docker-migrations-fixed-that-n93"&gt;Part 9&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/every-request-looked-the-same-in-my-logs-then-i-added-one-line-19m7"&gt;Part 10&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-had-no-idea-which-endpoint-was-slowest-so-i-built-my-own-metrics-5fjp"&gt;Part 11&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-backend-crashed-every-time-redis-went-down-three-patterns-fixed-that-43j2"&gt;Part 12&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-needed-to-send-an-http-request-without-slowing-down-my-api-goroutines-fixed-that-20o5"&gt;Part 13&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-webhooks-were-sending-data-anyone-could-fake-hmac-signing-fixed-that-gpm"&gt;Part 14&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-deployed-my-go-backend-to-a-real-vps-heres-exactly-what-happened-2dpj"&gt;Part 15&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>testing</category>
      <category>backend</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I Deployed My Go Backend to a Real VPS. Here's Exactly What Happened.</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Fri, 29 May 2026 12:36:26 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/i-deployed-my-go-backend-to-a-real-vps-heres-exactly-what-happened-2dpj</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/i-deployed-my-go-backend-to-a-real-vps-heres-exactly-what-happened-2dpj</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-webhooks-were-sending-data-anyone-could-fake-hmac-signing-fixed-that-gpm"&gt;Part 14&lt;/a&gt;, I finished HMAC webhook signing. The backend was complete — JWT auth, PostgreSQL, Redis caching, rate limiting, circuit breaker, worker pool, webhook delivery, migrations, Docker. All running locally.&lt;/p&gt;

&lt;p&gt;But "runs on my machine" isn't a portfolio project. It's a homework assignment.&lt;/p&gt;

&lt;p&gt;Time to ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack Being Deployed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go backend&lt;/strong&gt; — ~15MB Docker image (multi-stage build, CGO_ENABLED=0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 16&lt;/strong&gt; — with golang-migrate running schema migrations on startup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; — for caching, rate limiting, and refresh token storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oracle Cloud Free Tier&lt;/strong&gt; — 1GB RAM, 45GB disk, already provisioned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything wired together with &lt;code&gt;docker-compose.yml&lt;/code&gt;. One command to start the entire stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The VM Was Fine, Actually
&lt;/h2&gt;

&lt;p&gt;I was worried about the free tier specs. Turned out the "1GB" in the tier name refers to RAM, not disk. The actual disk is 45GB — plenty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt;    &lt;span class="c"&gt;# 954MB RAM, 552MB available&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;      &lt;span class="c"&gt;# 45GB disk, 41GB free&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real constraint: &lt;strong&gt;no swap&lt;/strong&gt;. Go's compiler is memory-hungry. Without swap, building the Docker image on the VM would exhaust RAM and kill the process. More on this in a moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install Docker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | &lt;span class="nb"&gt;sudo &lt;/span&gt;sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker ubuntu
newgrp docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official install script handles everything — Docker Engine, containerd, Docker Compose plugin. One command, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Clone the Repo
&lt;/h2&gt;

&lt;p&gt;The repo is private. Created a fine-grained GitHub personal access token with &lt;strong&gt;Contents: Read-only&lt;/strong&gt; permission. Used it to clone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://TOKEN@github.com/absep98/Go_learn.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Security note: never paste tokens in chat, email, or anywhere visible. Type them directly into the terminal. Tokens in chat history are compromised tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Create the .env File
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; from my local machine needed two changes for Docker:&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;# LOCAL (wrong for Docker):&lt;/span&gt;
&lt;span class="nv"&gt;DB_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;localhost
&lt;span class="nv"&gt;REDIS_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;localhost

&lt;span class="c"&gt;# DOCKER (correct):&lt;/span&gt;
&lt;span class="nv"&gt;DB_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres    &lt;span class="c"&gt;# ← service name in docker-compose.yml&lt;/span&gt;
&lt;span class="nv"&gt;REDIS_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;redis    &lt;span class="c"&gt;# ← service name in docker-compose.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the Docker networking model: each container has its own &lt;code&gt;localhost&lt;/code&gt;. The service names (&lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt;) are DNS hostnames that Docker Compose registers automatically on the internal network. Containers talk to each other by service name, not by &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: The OOM Problem
&lt;/h2&gt;

&lt;p&gt;First build attempt:&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;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build ran for 30 minutes on the compile step, then the VM became unresponsive. SSH wouldn't connect. Classic OOM kill — the kernel killed processes when RAM was exhausted during &lt;code&gt;go build&lt;/code&gt; inside Docker.&lt;/p&gt;

&lt;p&gt;The fix: add swap space before building.&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;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/zero &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/swapfile &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1024
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab  &lt;span class="c"&gt;# persist across reboots&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 1GB swap added, the build completed in ~5 minutes. The Go compiler used swap when it needed more than physical RAM allowed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does building inside Docker use more RAM than running the binary?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The multi-stage Dockerfile has two stages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stage 1 (builder): &lt;code&gt;golang:1.25-alpine&lt;/code&gt; (~300MB) + Go compiler + all source code. The compiler is memory-hungry — it loads your entire dependency graph to type-check and optimize.&lt;/li&gt;
&lt;li&gt;Stage 2 (runner): &lt;code&gt;alpine:latest&lt;/code&gt; (~5MB) + just the compiled binary (~10MB)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The builder stage is thrown away after compilation. But during the build, both stages are in memory simultaneously. On a 1GB machine with no swap, this is tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The Firewall
&lt;/h2&gt;

&lt;p&gt;Containers started. Tried hitting the health endpoint from my local machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Invoke-RestMethod : Unable to connect to the remote server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app was running — &lt;code&gt;docker compose ps&lt;/code&gt; showed all three containers up. The issue was Oracle Cloud's network security — a firewall layer outside the VM that blocks all ports by default except SSH (22).&lt;/p&gt;

&lt;p&gt;Navigation path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compute → Instances → your instance → Networking tab&lt;/li&gt;
&lt;li&gt;Click the Subnet link&lt;/li&gt;
&lt;li&gt;Security tab → Default Security List&lt;/li&gt;
&lt;li&gt;Add Ingress Rule: TCP, 0.0.0.0/0, port 8080&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After adding the rule, immediate success:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Invoke-RestMethod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http://140.245.202.129:8080/health&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;------&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;--------&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;-----&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;healthy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: Full End-to-End Test
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Register&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"test@example.com"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"test123"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConvertTo-Json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nx"&gt;Invoke-RestMethod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http://140.245.202.129:8080/register&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Post&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ContentType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# → success: True, user_id: 1&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Login&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Invoke-RestMethod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http://140.245.202.129:8080/login&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Post&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ContentType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;access_token&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Create entry&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;Authorization&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"First live entry!"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mood&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"milestone"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConvertTo-Json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nx"&gt;Invoke-RestMethod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http://140.245.202.129:8080/entries&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Method&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Post&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ContentType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# → success: True, id: 1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything worked. The full stack — registration, JWT auth, PostgreSQL insert, Redis caching — running on a real public IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Multi-Stage Build Actually Does
&lt;/h2&gt;

&lt;p&gt;This is worth understanding. The Dockerfile has two &lt;code&gt;FROM&lt;/code&gt; lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: Heavy construction site (~300MB)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.25-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux go build &lt;span class="nt"&gt;-o&lt;/span&gt; server ./cmd/server

&lt;span class="c"&gt;# Stage 2: Clean delivery box (~15MB)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:latest&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/server /app/server&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/app/server"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stage 1 builds the binary. Stage 2 takes only the binary — not the Go compiler, not the source code, not the build tools. The final image is ~15MB instead of ~300MB.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CGO_ENABLED=0&lt;/code&gt; makes the binary fully static — it doesn't link against any C libraries from the OS. The binary runs on any Linux regardless of which C library it has. Copy it to a machine with no Go installed, it runs.&lt;/p&gt;

&lt;p&gt;This is different from Java: a &lt;code&gt;.class&lt;/code&gt; file needs the JVM to run. A Go binary is machine code — the CPU executes it directly with no runtime required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently in Production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No HTTPS&lt;/strong&gt;: The API is HTTP only. Production needs TLS — either via nginx as a reverse proxy terminating SSL, or Let's Encrypt directly. Anyone sniffing traffic can see the JWT tokens in plain text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No domain name&lt;/strong&gt;: &lt;code&gt;140.245.202.129:8080&lt;/code&gt; works but isn't professional. A domain + HTTPS is the next step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets in .env on disk&lt;/strong&gt;: The &lt;code&gt;.env&lt;/code&gt; file sitting in the repo directory isn't ideal. Production would use Docker secrets, environment injection from a secrets manager, or at minimum ensure the file has restricted permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual deployment&lt;/strong&gt;: Every code change requires SSH + &lt;code&gt;git pull&lt;/code&gt; + &lt;code&gt;docker compose up --build&lt;/code&gt;. Production would have a CI/CD pipeline (GitHub Actions) that automatically builds and deploys on push.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No health monitoring&lt;/strong&gt;: If the server crashes at 3am, nobody knows. Production needs uptime monitoring (UptimeRobot is free) and alerting.&lt;/p&gt;

&lt;p&gt;These are real production concerns — but for a portfolio project demonstrating backend fundamentals, a live public IP with a working API is the right stopping point.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;http://140.245.202.129:8080&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /health&lt;/code&gt; — database + Redis status&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /register&lt;/code&gt; — create account&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /login&lt;/code&gt; — get JWT + refresh token&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /refresh&lt;/code&gt; — rotate tokens&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /logout&lt;/code&gt; — invalidate session&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET/POST/PATCH/DELETE /entries&lt;/code&gt; — journal entries (protected)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /metrics&lt;/code&gt; — request counts, latency per endpoint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full source: &lt;a href="https://github.com/absep98/Go_learn" rel="noopener noreferrer"&gt;github.com/absep98/Go_learn&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 15 of "Learning Go in Public". &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/why-i-decided-to-learn-go-and-what-my-first-commit-looked-like-38ii"&gt;Part 1&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-stopped-watching-tutorials-and-started-building-a-rest-api-in-go-4e84"&gt;Part 2&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/building-authentication-from-scratch-in-go-no-libraries-no-magic-2c46"&gt;Part 3&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/tests-update-delete-and-the-refactor-i-didnt-plan-5g2o"&gt;Part 4&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/what-happens-when-your-api-has-10000-rows-i-added-pagination-and-caching-to-find-out-2jkk"&gt;Part 5&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-built-rate-limiting-from-scratch-in-go-then-replaced-it-with-redis-82i"&gt;Part 6&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-server-was-silently-dropping-requests-heres-the-one-line-fix-that-changed-everything-5b4g"&gt;Part 7&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-built-my-backend-on-sqlite-then-i-deleted-it-15a"&gt;Part 8&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-database-disappeared-every-time-i-restarted-docker-migrations-fixed-that-n93"&gt;Part 9&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/every-request-looked-the-same-in-my-logs-then-i-added-one-line-19m7"&gt;Part 10&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-had-no-idea-which-endpoint-was-slowest-so-i-built-my-own-metrics-5fjp"&gt;Part 11&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-backend-crashed-every-time-redis-went-down-three-patterns-fixed-that-43j2"&gt;Part 12&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-needed-to-send-an-http-request-without-slowing-down-my-api-goroutines-fixed-that"&gt;Part 13&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-webhooks-were-sending-data-anyone-could-fake-hmac-signing-fixed-that-gpm"&gt;Part 14&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>docker</category>
      <category>devops</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>My Webhooks Were Sending Data Anyone Could Fake. HMAC Signing Fixed That.</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Mon, 18 May 2026 18:11:26 +0000</pubDate>
      <link>https://dev.to/abhishek_sharma_a9792aee8/my-webhooks-were-sending-data-anyone-could-fake-hmac-signing-fixed-that-gpm</link>
      <guid>https://dev.to/abhishek_sharma_a9792aee8/my-webhooks-were-sending-data-anyone-could-fake-hmac-signing-fixed-that-gpm</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-needed-to-send-an-http-request-without-slowing-down-my-api-goroutines-fixed-that"&gt;Part 13&lt;/a&gt;, I built a worker pool that fires webhooks in the background when entries are created. The webhook delivery worked — but there was a security gap I'd already documented in the trade-offs section of my own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// TRADE-OFFS / WHAT PRODUCTION WOULD DO DIFFERENTLY:
// No request signing — production webhooks use HMAC-SHA256 signature in a header
// (X-Webhook-Signature) so the receiver can verify the payload wasn't tampered with
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time to close that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Unsigned Webhooks Are Unauthenticated
&lt;/h2&gt;

&lt;p&gt;When my server fires a webhook, the receiver gets a JSON payload at their URL. But they have no way to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did this actually come from my server?&lt;/li&gt;
&lt;li&gt;Was the payload modified in transit?&lt;/li&gt;
&lt;li&gt;Is someone replaying an old webhook?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An attacker who knows the webhook URL can send fake events. Without signing, the receiver has to either trust everything blindly or build their own authentication on top.&lt;/p&gt;

&lt;p&gt;GitHub, Stripe, Twilio — every production webhook system solves this with HMAC signing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How HMAC Signing Works
&lt;/h2&gt;

&lt;p&gt;HMAC (Hash-based Message Authentication Code) uses a shared secret to produce a signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signature = HMAC-SHA256(payload_bytes, shared_secret)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sender:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Computes &lt;code&gt;signature = HMAC-SHA256(body, secret)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sends both the body AND the signature in a header: &lt;code&gt;X-Webhook-Signature: &amp;lt;hex&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The receiver:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Gets the body and the header&lt;/li&gt;
&lt;li&gt;Computes &lt;code&gt;expected = HMAC-SHA256(body, same_secret)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Compares &lt;code&gt;expected == header_value&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If they match → payload is genuine and untampered&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An attacker can see the payload in transit but can't produce a valid signature without the secret. If anything in the payload changes (even one byte), the HMAC changes completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: The Sign Function
&lt;/h3&gt;

&lt;p&gt;A pure function — same inputs always produce the same output:&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;// internal/webhook/sign.go&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"crypto/hmac"&lt;/span&gt;
    &lt;span class="s"&gt;"crypto/sha256"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/hex"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;secret&lt;/span&gt; &lt;span class="kt"&gt;string&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;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&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;New&lt;/span&gt;&lt;span class="p"&gt;,&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;secret&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum&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;Why hex encoding? Raw HMAC output is bytes — some are non-printable characters. You can't put &lt;code&gt;byte(3)&lt;/code&gt; or &lt;code&gt;byte(255)&lt;/code&gt; in an HTTP header. Hex encodes every byte as two printable characters (0-9, a-f). The result is always a clean, safe string.&lt;/p&gt;

&lt;p&gt;Why &lt;code&gt;[]byte(secret)&lt;/code&gt;? &lt;code&gt;hmac.New&lt;/code&gt; takes the key as bytes. &lt;code&gt;secret&lt;/code&gt; is a string. Go doesn't auto-convert — explicit cast required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The Test
&lt;/h3&gt;

&lt;p&gt;Before wiring it anywhere, verify the function produces the expected output for known inputs:&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;Test_HexCode&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;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hexString&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Sign&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="s"&gt;"hello"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"mysecret"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hardCodedHexString&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"f09399f0c446d84b31a080e57ec483392d41e6f512f3e7ada5027abbcd358c2a"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hexString&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;hardCodedHexString&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;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expected %s, got %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hardCodedHexString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hexString&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;The expected value was computed from an online HMAC-SHA256 calculator. If the function produces the wrong value, the test catches it immediately — before touching any HTTP code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Wire It Into HTTPSender
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;HTTPSender&lt;/code&gt; previously had no secret. Added a &lt;code&gt;Secret&lt;/code&gt; field:&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;// Before&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;HTTPSender&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="c"&gt;// After&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;HTTPSender&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;Secret&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;Then updated &lt;code&gt;Send()&lt;/code&gt; to sign the payload and set the header. The key change: switched from &lt;code&gt;http.Post()&lt;/code&gt; (which doesn't let you set headers) to &lt;code&gt;http.NewRequest()&lt;/code&gt; + &lt;code&gt;httpClient.Do()&lt;/code&gt;:&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;h&lt;/span&gt; &lt;span class="n"&gt;HTTPSender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;interface&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;data&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;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;"failed to marshal payload: %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;span class="c"&gt;// Sign BEFORE wrapping in a reader (data is still []byte here)&lt;/span&gt;
    &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// http.NewRequest gives us a request object we can modify&lt;/span&gt;
    &lt;span class="n"&gt;req&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&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;"failed to create request: %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;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&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;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Webhook-Signature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&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="c"&gt;// ... error handling, status check&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why sign before &lt;code&gt;bytes.NewReader&lt;/code&gt;? &lt;code&gt;data&lt;/code&gt; is &lt;code&gt;[]byte&lt;/code&gt; at that point — perfect for &lt;code&gt;Sign&lt;/code&gt;. After &lt;code&gt;bytes.NewReader(data)&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt; is an &lt;code&gt;io.Reader&lt;/code&gt; stream. You could still access &lt;code&gt;data&lt;/code&gt; since it's still in scope, but signing from the raw bytes is cleaner and more explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Thread the Secret Through Config
&lt;/h3&gt;

&lt;p&gt;The webhook secret is separate from &lt;code&gt;JWT_SECRET&lt;/code&gt; — they protect different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;JWT_SECRET&lt;/code&gt; signs auth tokens (inbound requests prove who the user is)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WEBHOOK_SECRET&lt;/code&gt; signs outbound webhooks (receivers verify the payload came from us)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Added to &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WEBHOOK_SECRET=your-webhook-secret-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Added to &lt;code&gt;config.go&lt;/code&gt;, read into &lt;code&gt;cfg.WebhookSecret&lt;/code&gt;, and passed to &lt;code&gt;HTTPSender&lt;/code&gt; at startup:&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;// main.go&lt;/span&gt;
&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StartWorkerPool&lt;/span&gt;&lt;span class="p"&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;WorkerPoolSize&lt;/span&gt;&lt;span class="p"&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;WebhookURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPSender&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Secret&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;WebhookSecret&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;This is the dependency injection pattern from earlier — secrets are passed in, not read from &lt;code&gt;os.Getenv()&lt;/code&gt; inside the function. Makes testing straightforward: pass a test secret, no env vars needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Had to Learn to Write This
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;http.Post&lt;/code&gt; vs &lt;code&gt;http.NewRequest&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;http.Post&lt;/code&gt; is a one-liner convenience function — great for quick calls but returns no request object. To set custom headers, you need &lt;code&gt;http.NewRequest&lt;/code&gt; to get a &lt;code&gt;*http.Request&lt;/code&gt; you can modify, then &lt;code&gt;httpClient.Do(req)&lt;/code&gt; to send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hmac.New&lt;/code&gt; argument order&lt;/strong&gt;: &lt;code&gt;hmac.New(hashFunction, key)&lt;/code&gt; — the key is the second argument. I initially passed them backwards. The test caught it immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why hex, not base64?&lt;/strong&gt; Both would work for the header value. Hex is more common for webhook signatures (GitHub uses it), slightly more readable when debugging, and produces a fixed-length string that's easy to compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Receiver Side
&lt;/h2&gt;

&lt;p&gt;I didn't implement the receiver in this project — the webhook goes to webhook.site for inspection. But the receiver pattern would be:&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;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&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;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="kt"&gt;string&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="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&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;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&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;expected&lt;/span&gt;&lt;span class="p"&gt;),&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;signature&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;Note &lt;code&gt;hmac.Equal&lt;/code&gt; instead of &lt;code&gt;==&lt;/code&gt;. This does a constant-time comparison — it takes the same amount of time regardless of where the strings differ. A regular &lt;code&gt;==&lt;/code&gt; comparison short-circuits on the first mismatch, which leaks timing information that an attacker could use to guess the signature byte by byte. For security comparisons, always use constant-time equality.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test pure functions first, before wiring them anywhere.&lt;/strong&gt; &lt;code&gt;Sign&lt;/code&gt; is a pure function — no HTTP, no state, no side effects. Writing a unit test for it before touching &lt;code&gt;Send()&lt;/code&gt; meant I knew the core logic was correct before dealing with HTTP plumbing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "strict mode" learning method works.&lt;/strong&gt; For this feature, I wrote all the code myself with zero reference to existing files — only Go's standard library docs when I forgot a function signature. First attempt had several mistakes. Second attempt had two. Third attempt was clean. Real retrieval practice is painful but it sticks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security gaps you document will eventually come back.&lt;/strong&gt; I wrote "No request signing" in my trade-offs section knowing I'd close it later. Documenting trade-offs isn't just for interviews — it's a backlog for future improvements.&lt;/p&gt;




&lt;p&gt;Up next: shipping this to a real VPS.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is Part 14 of "Learning Go in Public". &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/why-i-decided-to-learn-go-and-what-my-first-commit-looked-like-38ii"&gt;Part 1&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-stopped-watching-tutorials-and-started-building-a-rest-api-in-go-4e84"&gt;Part 2&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/building-authentication-from-scratch-in-go-no-libraries-no-magic-2c46"&gt;Part 3&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/tests-update-delete-and-the-refactor-i-didnt-plan-5g2o"&gt;Part 4&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/what-happens-when-your-api-has-10000-rows-i-added-pagination-and-caching-to-find-out-2jkk"&gt;Part 5&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-built-rate-limiting-from-scratch-in-go-then-replaced-it-with-redis-82i"&gt;Part 6&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-server-was-silently-dropping-requests-heres-the-one-line-fix-that-changed-everything-5b4g"&gt;Part 7&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-built-my-backend-on-sqlite-then-i-deleted-it-15a"&gt;Part 8&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-database-disappeared-every-time-i-restarted-docker-migrations-fixed-that-n93"&gt;Part 9&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/every-request-looked-the-same-in-my-logs-then-i-added-one-line-19m7"&gt;Part 10&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-had-no-idea-which-endpoint-was-slowest-so-i-built-my-own-metrics-5fjp"&gt;Part 11&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/my-backend-crashed-every-time-redis-went-down-three-patterns-fixed-that-43j2"&gt;Part 12&lt;/a&gt; | &lt;a href="https://dev.to/abhishek_sharma_a9792aee8/i-needed-to-send-an-http-request-without-slowing-down-my-api-goroutines-fixed-that"&gt;Part 13&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>backend</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
