<?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: Aftab Bashir</title>
    <description>The latest articles on DEV Community by Aftab Bashir (@aftabkh4n).</description>
    <link>https://dev.to/aftabkh4n</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3863150%2F2da9fdf0-d0a8-4a55-b7ef-415aaeba5982.jpeg</url>
      <title>DEV Community: Aftab Bashir</title>
      <link>https://dev.to/aftabkh4n</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aftabkh4n"/>
    <language>en</language>
    <item>
      <title>Built a travel booking platform in .NET with an API gateway, MongoDB, and Angular</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Mon, 27 Apr 2026 04:24:44 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/built-a-travel-booking-platform-in-net-with-an-api-gateway-mongodb-and-angular-oo6</link>
      <guid>https://dev.to/aftabkh4n/built-a-travel-booking-platform-in-net-with-an-api-gateway-mongodb-and-angular-oo6</guid>
      <description>&lt;p&gt;Most tutorials show you how to build an API. Fewer show you what sits in front of the API in a real production system.&lt;/p&gt;

&lt;p&gt;This project is about that middle layer. A gateway that all requests go through before they reach the actual API. It checks authentication, enforces rate limits, and routes traffic. In Azure this is what API Management does. Here it runs locally with YARP, Microsoft's open source reverse proxy library.&lt;/p&gt;

&lt;p&gt;The actual application is a travel booking system. The gateway pattern is the interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a gateway matters
&lt;/h2&gt;

&lt;p&gt;When you build an API and expose it directly to clients, every client knows your API's address. They can call it as many times as they want. There is no central place to enforce authentication or throttle abusive callers.&lt;/p&gt;

&lt;p&gt;A gateway changes that. Clients only know the gateway address. The API address is internal. The gateway handles auth and rate limiting before requests ever reach your code.&lt;/p&gt;

&lt;p&gt;This is how companies like Qatar Airways, booking.com, and most large travel platforms structure their APIs. One gateway, many services behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Three .NET projects and one Angular app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TravelBooking.Api&lt;/strong&gt; is a standard ASP.NET Core 10 API. It connects to MongoDB, handles CRUD operations for bookings, and sends notifications when bookings are created or confirmed. The notification goes to a Logic Apps webhook in production. Locally it logs to the console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TravelBooking.Gateway&lt;/strong&gt; is the interesting one. Built with YARP. It does three things on every request: checks the X-Api-Key header, enforces rate limits, and proxies to the API with path transformation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TravelBooking.Core&lt;/strong&gt; holds shared models and interfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;travel-booking-ui&lt;/strong&gt; is Angular 19. It calls the gateway, never the API directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up YARP as a gateway
&lt;/h2&gt;

&lt;p&gt;YARP configuration lives in appsettings.json. You define routes and clusters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"ReverseProxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Routes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bookings-route"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ClusterId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bookings-cluster"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Match"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/gateway/{**catch-all}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Transforms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"PathRemovePrefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/gateway"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Clusters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bookings-cluster"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Destinations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"destination1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"Address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:5153"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any request to /gateway/api/bookings gets the /gateway prefix stripped and forwarded to &lt;a href="http://localhost:5153/api/bookings" rel="noopener noreferrer"&gt;http://localhost:5153/api/bookings&lt;/a&gt;. The client never needs to know the API address.&lt;/p&gt;

&lt;h2&gt;
  
  
  API key authentication
&lt;/h2&gt;

&lt;p&gt;This middleware runs before YARP proxies the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&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;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="p"&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;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-Api-Key"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&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="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Gateway:ApiKey"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;await&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unauthorized: Invalid or missing API key"&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&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;No API key, no access. The request never reaches the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting
&lt;/h2&gt;

&lt;p&gt;10 requests per 10 seconds per client. After that, requests queue up to a limit of 5 then get rejected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRateLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddFixedWindowLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fixed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PermitLimit&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="n"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Window&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&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="n"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueProcessingOrder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QueueProcessingOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OldestFirst&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueLimit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&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;In production you would use sliding window or token bucket depending on your traffic patterns. Fixed window is the simplest to understand and works fine for most cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  MongoDB for bookings
&lt;/h2&gt;

&lt;p&gt;The repository pattern keeps the MongoDB driver out of the controllers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Booking&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Booking&lt;/span&gt; &lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_bookings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InsertOneAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Booking {BookingId} created for {Customer}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
        &lt;span class="n"&gt;booking&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;booking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerName&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;booking&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;MongoDB is a good fit for bookings. The document structure matches naturally, and you do not need to define a schema before you start. Add a new field to the model and it just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Angular frontend
&lt;/h2&gt;

&lt;p&gt;The booking service sets the API key on every request automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Api-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;getBookings&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Booking&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Booking&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gatewayUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/bookings`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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 gateway URL is the only thing Angular knows about. It has no idea where the API runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real request looks like
&lt;/h2&gt;

&lt;p&gt;A booking creation goes through this path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Angular sends POST to &lt;a href="http://localhost:5177/gateway/api/bookings" rel="noopener noreferrer"&gt;http://localhost:5177/gateway/api/bookings&lt;/a&gt; with X-Api-Key header&lt;/li&gt;
&lt;li&gt;Gateway middleware validates the API key&lt;/li&gt;
&lt;li&gt;Rate limiter checks the request count&lt;/li&gt;
&lt;li&gt;YARP strips /gateway and forwards to &lt;a href="http://localhost:5153/api/bookings" rel="noopener noreferrer"&gt;http://localhost:5153/api/bookings&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;API saves to MongoDB and logs the notification&lt;/li&gt;
&lt;li&gt;Response comes back through the gateway to Angular&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing takes under 200ms locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it yourself
&lt;/h2&gt;

&lt;p&gt;You need .NET 10, Node.js 20, and Docker Desktop.&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://github.com/aftabkh4n/travel-booking-platform.git
&lt;span class="nb"&gt;cd &lt;/span&gt;travel-booking-platform

docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; travel-mongo &lt;span class="nt"&gt;-p&lt;/span&gt; 27017:27017 mongo:7

&lt;span class="nb"&gt;cp &lt;/span&gt;TravelBooking.Api/appsettings.example.json TravelBooking.Api/appsettings.json
&lt;span class="nb"&gt;cp &lt;/span&gt;TravelBooking.Gateway/appsettings.example.json TravelBooking.Gateway/appsettings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the API, then the gateway, then Angular. Open &lt;a href="http://localhost:4200" rel="noopener noreferrer"&gt;http://localhost:4200&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/aftabkh4n/travel-booking-platform" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/travel-booking-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have questions drop them in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>angular</category>
      <category>mongodb</category>
      <category>devops</category>
    </item>
    <item>
      <title>Built an event-driven order pipeline in .NET with Kafka and Azure Service Bus</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Wed, 22 Apr 2026 07:55:33 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/built-an-event-driven-order-pipeline-in-net-with-kafka-and-azure-service-bus-3i61</link>
      <guid>https://dev.to/aftabkh4n/built-an-event-driven-order-pipeline-in-net-with-kafka-and-azure-service-bus-3i61</guid>
      <description>&lt;p&gt;Most order processing systems I have worked with are synchronous. The API receives the request, does the work, and returns the result. That works fine until it does not. The database is slow, a third-party service is down, or you have 500 orders arriving at the same time. Everything backs up and the API starts timing out.&lt;/p&gt;

&lt;p&gt;This project is the async version of that. Orders come in through a REST API, get saved to PostgreSQL, and get published to Kafka. A separate consumer picks them up, processes them, and publishes a fulfilment event to Azure Service Bus. The API never waits for any of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;There are three projects in the solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OrderPipeline.Api&lt;/strong&gt; is an ASP.NET Core 10 API. It does two things when an order arrives: saves it to PostgreSQL and publishes an event to Kafka. That is it. The processing happens somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OrderPipeline.Consumer&lt;/strong&gt; is a .NET Worker Service. It runs continuously, reading from the Kafka &lt;code&gt;orders&lt;/code&gt; topic. When it picks up an order event, it updates the status to Processing, does the fulfilment work, marks it as Fulfilled in PostgreSQL, and publishes a fulfilment event to Azure Service Bus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OrderPipeline.Core&lt;/strong&gt; holds the shared models. Order, OrderItem, OrderEvent, and the interfaces for the repository and publisher.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order flow
&lt;/h2&gt;

&lt;p&gt;POST an order to the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:5125/api/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;customerName&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;John Smith&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;customerEmail&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;john@example.com&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;items&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: [{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;productName&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Laptop&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;quantity&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: 1, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;unitPrice&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: 999.99}]}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API responds immediately with a 201. In the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Order e6e20d66 created in database
Order e6e20d66 published to Kafka topic orders at offset 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the consumer picks it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Consumed message from partition [0] at offset 1
Processing order e6e20d66 for customer John Smith
Fulfilment event published to Service Bus for order e6e20d66
Order e6e20d66 fulfilled successfully
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client does not wait for any of that. It can poll &lt;code&gt;GET /api/orders/{id}&lt;/code&gt; to check the status whenever it wants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing to Kafka
&lt;/h2&gt;

&lt;p&gt;The publisher uses the Confluent.Kafka producer. The order ID is the message key, which ensures all events for the same order land on the same partition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&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;&amp;gt;&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;order&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="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&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;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kt"&gt;var&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;await&lt;/span&gt; &lt;span class="n"&gt;_kafkaProducer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProduceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_kafkaTopic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Order {OrderId} published to Kafka topic {Topic} at offset {Offset}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_kafkaTopic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Consuming from Kafka
&lt;/h2&gt;

&lt;p&gt;The consumer uses &lt;code&gt;AutoOffsetReset.Earliest&lt;/code&gt; so it picks up messages from the beginning of the topic on first start. Manual commit means a message only gets marked as processed after the work is done, not when it is received.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;ConsumerConfig&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;BootstrapServers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Kafka:BootstrapServers"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;GroupId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"order-pipeline-consumer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AutoOffsetReset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoOffsetReset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Earliest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EnableAutoCommit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the consumer crashes mid-processing, it picks up from the last committed offset when it restarts. No orders get lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing to Azure Service Bus
&lt;/h2&gt;

&lt;p&gt;Once an order is fulfilled, the consumer publishes a fulfilment event to a Service Bus queue. Downstream systems subscribe to that queue to handle shipping, notifications, invoicing, or whatever comes next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ServiceBusMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderEvent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;MessageId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;Subject&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"OrderFulfilled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ContentType&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendMessageAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For local development, this uses the official Microsoft Azure Service Bus emulator running in Docker. The connection string just needs &lt;code&gt;UseDevelopmentEmulator=true&lt;/code&gt; and it works identically to the real service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database
&lt;/h2&gt;

&lt;p&gt;The Consumer and API share the same PostgreSQL database but they access it independently through EF Core. The API writes new orders. The Consumer reads and updates them. No shared state, no coupling between the two services beyond the database schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Processing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// do the fulfilment work&lt;/span&gt;

&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fulfilled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Running it locally
&lt;/h2&gt;

&lt;p&gt;Everything runs with Docker Compose. One command starts PostgreSQL, Kafka, Zookeeper, Kafka UI, and the Service Bus emulator.&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://github.com/aftabkh4n/order-pipeline.git
&lt;span class="nb"&gt;cd &lt;/span&gt;order-pipeline
docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the API and Consumer in separate terminals and send a test order. The Kafka UI at &lt;code&gt;http://localhost:8080&lt;/code&gt; lets you watch messages flow through the topic in real time.&lt;/p&gt;

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

&lt;p&gt;Kafka startup takes time. On the first request after starting the containers, the producer waits while Kafka finishes initialising. Subsequent requests are fast. Worth knowing when you are testing and wondering why the first call takes 60 seconds.&lt;/p&gt;

&lt;p&gt;Manual offset commit is important. With auto-commit enabled, Kafka marks a message as processed the moment it is received. If the consumer crashes before finishing the work, that message is gone. Manual commit means you commit only after the work succeeds.&lt;/p&gt;

&lt;p&gt;The Service Bus emulator is genuinely useful. I expected it to be a rough approximation but it behaves exactly like the real service for basic queue operations. No need to touch a real Azure subscription during development.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/aftabkh4n/order-pipeline" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/order-pipeline&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have questions or run into issues getting it running, drop a comment below.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>azure</category>
      <category>microservices</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Down for the challenge</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Mon, 20 Apr 2026 07:00:17 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/down-for-the-challenge-5hai</link>
      <guid>https://dev.to/aftabkh4n/down-for-the-challenge-5hai</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/devteam/join-the-openclaw-challenge-1200-prize-pool-5682" class="crayons-story__hidden-navigation-link"&gt;Join the OpenClaw Challenge: $1,200 Prize Pool!&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/devteam/join-the-openclaw-challenge-1200-prize-pool-5682" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Share your OpenClaw experience&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;
          &lt;a class="crayons-logo crayons-logo--l" href="/devteam"&gt;
            &lt;img alt="The DEV Team logo" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F1%2Fd908a186-5651-4a5a-9f76-15200bc6801f.jpg" class="crayons-logo__image" width="800" height="800"&gt;
          &lt;/a&gt;

          &lt;a href="/jess" class="crayons-avatar  crayons-avatar--s absolute -right-2 -bottom-2 border-solid border-2 border-base-inverted  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F264%2Fb75f6edf-df7b-406e-a56b-43facafb352c.jpg" alt="jess profile" class="crayons-avatar__image" width="400" height="400"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/jess" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Jess Lee
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Jess Lee
                &lt;a href="/++"&gt;&lt;img alt="Subscriber" class="subscription-icon" src="https://assets.dev.to/assets/subscription-icon-805dfa7ac7dd660f07ed8d654877270825b07a92a03841aa99a1093bd00431b2.png" width="166" height="102"&gt;&lt;/a&gt;
              
              &lt;div id="story-author-preview-content-3506833" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/jess" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F264%2Fb75f6edf-df7b-406e-a56b-43facafb352c.jpg" class="crayons-avatar__image" alt="" width="400" height="400"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Jess Lee&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

            &lt;span&gt;
              &lt;span class="crayons-story__tertiary fw-normal"&gt; for &lt;/span&gt;&lt;a href="/devteam" class="crayons-story__secondary fw-medium"&gt;The DEV Team&lt;/a&gt;
            &lt;/span&gt;
          &lt;/div&gt;
          &lt;a href="https://dev.to/devteam/join-the-openclaw-challenge-1200-prize-pool-5682" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 16&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/devteam/join-the-openclaw-challenge-1200-prize-pool-5682" id="article-link-3506833"&gt;
          Join the OpenClaw Challenge: $1,200 Prize Pool!
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/openclawchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;openclawchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/openclaw"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;openclaw&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/devteam/join-the-openclaw-challenge-1200-prize-pool-5682" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;119&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/devteam/join-the-openclaw-challenge-1200-prize-pool-5682#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              22&lt;span class="hidden s:inline"&gt; comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>I built a self-healing Kubernetes system in .NET that fixes its own failures using Claude AI</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Mon, 20 Apr 2026 06:53:56 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/i-built-a-self-healing-kubernetes-system-in-net-that-fixes-its-own-failures-using-claude-ai-1pj0</link>
      <guid>https://dev.to/aftabkh4n/i-built-a-self-healing-kubernetes-system-in-net-that-fixes-its-own-failures-using-claude-ai-1pj0</guid>
      <description>&lt;p&gt;A pod crashes at 3am. Kubernetes restarts it. It crashes again. Kubernetes keeps trying.&lt;/p&gt;

&lt;p&gt;Meanwhile you are asleep. Nobody reads the logs. Nobody fixes anything. The pod just keeps crashing until someone wakes up, opens a terminal, and figures out what went wrong.&lt;/p&gt;

&lt;p&gt;I built a system that handles the reading and thinking part automatically. When a pod fails, it pulls the logs, asks Claude what went wrong, and opens a GitHub PR with a suggested fix. By the time you wake up, the analysis is already done.&lt;/p&gt;

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

&lt;p&gt;The system runs as a .NET background service alongside your cluster. It streams Kubernetes events in real time. When it sees a pod enter CrashLoopBackOff, OOMKilled, ImagePullError, or exit with a non-zero code, it kicks off a healing process.&lt;/p&gt;

&lt;p&gt;Here is what happens in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pod failure detected&lt;/li&gt;
&lt;li&gt;Last 100 lines of logs pulled from the pod&lt;/li&gt;
&lt;li&gt;Logs and failure details sent to Claude API&lt;/li&gt;
&lt;li&gt;Claude returns root cause, severity, and a suggested fix&lt;/li&gt;
&lt;li&gt;A branch is created on GitHub&lt;/li&gt;
&lt;li&gt;The analysis is committed as a markdown file&lt;/li&gt;
&lt;li&gt;A PR is opened automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing takes about 10 seconds from crash to PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real example
&lt;/h2&gt;

&lt;p&gt;I deployed a pod that intentionally fails to connect to a database. Here is what Claude wrote in the PR:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The application failed to establish a connection to the PostgreSQL database at postgres://db:5432. The pod crashed with exit code 1 after logging ERROR: Database connection failed and FATAL: Cannot connect to postgres://db:5432. This indicates either the database service is unreachable, not running, or the connection string is incorrect."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then it generated the Kubernetes Service YAML to fix the service discovery and listed seven kubectl commands to diagnose the issue. In a PR. Automatically. While I was watching the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it is built
&lt;/h2&gt;

&lt;p&gt;There are four pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KubernetesWatcher&lt;/strong&gt; is a .NET BackgroundService that uses KubernetesClient to stream pod events. It looks for specific failure conditions in the container status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;FailureEvent&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;DetectFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;V1Pod&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V1ContainerStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Waiting&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;terminated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Terminated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"CrashLoopBackOff"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;CreateFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FailureType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CrashLoopBackOff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"ImagePullBackOff"&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"ErrImagePull"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;CreateFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FailureType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImagePullError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;terminated&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"OOMKilled"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;CreateFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FailureType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OOMKilled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"OOMKilled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Container exceeded memory limit"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;terminated&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;ExitCode&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RestartCount&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;CreateFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FailureType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PodCrash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CrashExit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;$"Container exited with code &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;terminated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExitCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&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;null&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;strong&gt;ClaudeAnalyser&lt;/strong&gt; takes the failure and logs, builds a prompt, and calls the Anthropic API. It asks for structured JSON back so the response is easy to parse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetClaudeMessageAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;MessageParameters&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"claude-haiku-4-5-20251001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MaxTokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Messages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;Role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RoleType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ContentBase&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TextContent&lt;/span&gt; &lt;span class="p"&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;prompt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="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 prompt tells Claude to act as a Kubernetes expert and return root cause, severity, a plain English fix, and the actual code or config change. Haiku is fast enough for this and costs around $0.0008 per analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHubService&lt;/strong&gt; uses Octokit to create a branch, commit the analysis as a markdown file, and open a PR:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;_settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GitHubRepoOwner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;_settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GitHubRepoName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateFileRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$"fix: self-healing patch for &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OriginalFailure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; in &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PodName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Convert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToBase64String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixContent&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;branchName&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;HealingOrchestrator&lt;/strong&gt; coordinates the three above and makes sure the same failure does not trigger duplicate healing attempts while the first one is still running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt matters more than the code
&lt;/h2&gt;

&lt;p&gt;I spent more time on the Claude prompt than on anything else in this project. Telling it to return structured JSON, referencing actual log lines, and specifying the failure type all produce much better output than a generic ask.&lt;/p&gt;

&lt;p&gt;The prompt I settled on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;You&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;are&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Kubernetes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;expert&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.NET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;engineer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;analysing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;production&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;failure.&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Analyse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Kubernetes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;failure&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;respond&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ONLY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;valid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON.&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;FAILURE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;DETAILS:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Pod:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;pod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Namespace:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Failure&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Reason:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;POD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;LOGS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(last&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lines):&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Respond&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;exact&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;structure:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rootCause"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Clear explanation of what caused the failure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Critical|High|Medium|Low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"suggestedFix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Step by step fix in plain English"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"codeFix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The actual code or config change needed. Empty string if none."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"fixType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"config|code|resources|image|none"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Be&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;specific.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Reference&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;actual&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;log&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lines&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;relevant.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key instruction is "respond ONLY with valid JSON". Without that, Claude adds explanation text around the JSON and the parser breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it locally
&lt;/h2&gt;

&lt;p&gt;You need .NET 10, Docker Desktop with Kubernetes enabled, an Anthropic API key, and a GitHub personal access token with repo permissions.&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://github.com/aftabkh4n/genai-devops-platform.git
&lt;span class="nb"&gt;cd &lt;/span&gt;genai-devops-platform/DeploymentService
dotnet run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your keys to appsettings.json and you will see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[INF] Kubernetes watcher started. Watching namespace: default
[INF] Now listening on: http://localhost:0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To test it, deploy the crash test included in the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; crash-test.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within a few seconds you will see the failure detected in the console, Claude called, and a PR URL printed. Go check your GitHub repo.&lt;/p&gt;

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

&lt;p&gt;The watcher reconnects automatically when the Kubernetes stream disconnects. This happens more often than you would think, especially after cluster restarts. Wrapping the watch loop in a try/catch with a 5 second delay before reconnecting keeps it stable.&lt;/p&gt;

&lt;p&gt;The SemaphoreSlim in HealingOrchestrator is important. A pod in CrashLoopBackOff generates events every few seconds. Without deduplication you end up with ten simultaneous Claude API calls for the same pod, ten branches, and ten PRs. Not ideal.&lt;/p&gt;

&lt;p&gt;Using jq to build JSON payloads is safer than shell string interpolation. Pod names and log lines contain characters that break JSON when interpolated directly.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/aftabkh4n/genai-devops-platform" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/genai-devops-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you try it and find a failure type it misses, open an issue.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kubernetes</category>
      <category>claude</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why AI assistants forget everything , and how I fixed it in .NET</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Thu, 16 Apr 2026 09:43:33 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/why-ai-assistants-forget-everything-and-how-i-fixed-it-in-net-28ep</link>
      <guid>https://dev.to/aftabkh4n/why-ai-assistants-forget-everything-and-how-i-fixed-it-in-net-28ep</guid>
      <description>&lt;p&gt;I was building an AI chat assistant in Blazor. It worked fine. But every new conversation started from scratch. The user would say "I'm a software engineer who loves C#" and the assistant would respond warmly , then forget it completely the next time they opened the app.&lt;/p&gt;

&lt;p&gt;That's not a memory problem. That's just a chat window.&lt;/p&gt;

&lt;p&gt;I wanted something better. Something that actually remembers the user across sessions, extracts facts from conversations, and uses them to give better responses over time. I looked around for a .NET library that did this. Nothing existed. So I built it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;BlazorMemory&lt;/strong&gt; is an open-source AI memory layer for .NET. It sits between your chat logic and your LLM and does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extracts facts from conversations using an LLM&lt;/li&gt;
&lt;li&gt;Stores them as vector embeddings&lt;/li&gt;
&lt;li&gt;Injects relevant memories into future prompts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User message → extract facts → embed → store
                                          ↓
Next message → embed query → vector search → inject memories → LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works in Blazor WASM with zero backend , everything stays in the browser using IndexedDB. It also works server-side with EF Core if you need SQL storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part wasn't storage
&lt;/h2&gt;

&lt;p&gt;Storing memories is easy. The hard part is making them useful.&lt;/p&gt;

&lt;p&gt;Early versions just stored everything. After a few conversations, the memory panel was full of duplicates. "User likes C#." "User works with C#." "User is a C# developer." Three separate entries saying the same thing.&lt;/p&gt;

&lt;p&gt;I solved this with a two-step pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 , Extract:&lt;/strong&gt; The LLM reads the conversation and pulls out discrete facts. One sentence per fact, starting with "User". If the conversation already produced "User is a C# engineer", it won't also extract "User loves C#" , the preference is already implied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 , Consolidate:&lt;/strong&gt; For each new fact, the system finds similar existing memories using vector search. Then it asks the LLM: should I ADD this, UPDATE an existing memory, DELETE a contradiction, or do NOTHING?&lt;/p&gt;

&lt;p&gt;The consolidation prompt has a clear priority order: &lt;code&gt;NONE &amp;gt; UPDATE &amp;gt; DELETE &amp;gt; ADD&lt;/code&gt;. It prefers doing less. That keeps the memory clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package BlazorMemory
dotnet add package BlazorMemory.Storage.IndexedDb
dotnet add package BlazorMemory.Embeddings.OpenAi
dotnet add package BlazorMemory.Extractor.OpenAi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it up in &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddBlazorMemory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseIndexedDbStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOpenAiEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOpenAiExtractor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it in your chat service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IMemoryService&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ChatAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Pull relevant memories&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;QueryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&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;new&lt;/span&gt; &lt;span class="n"&gt;QueryOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0.65f&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Build system prompt&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;$"- &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"You are a helpful assistant.\n\nWhat you know:\n&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Call your LLM&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;CallLlm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Extract and store new facts&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExtractAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"User: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;\nAssistant: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&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;That's it. The assistant now remembers things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Namespaces
&lt;/h2&gt;

&lt;p&gt;v0.2.0 added namespace support. You can scope memories by topic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Store work memories separately from personal ones&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExtractAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation&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;namespace&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="nn"&gt;work&lt;/span&gt;&lt;span class="s"&gt;");
&lt;/span&gt;
&lt;span class="c1"&gt;// Query only work memories&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;QueryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;new&lt;/span&gt; &lt;span class="n"&gt;QueryOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"work"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful if you're building an assistant that handles multiple contexts.&lt;/p&gt;

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

&lt;p&gt;Seven NuGet packages, all at v0.2.0:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BlazorMemory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Core library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BlazorMemory.Storage.IndexedDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser storage, zero backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BlazorMemory.Storage.InMemory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;For testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BlazorMemory.Storage.EfCore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SQL Server, PostgreSQL, SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BlazorMemory.Embeddings.OpenAi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI embeddings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BlazorMemory.Extractor.OpenAi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI fact extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BlazorMemory.Extractor.Anthropic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude fact extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repo is at &lt;a href="https://github.com/aftabkh4n/BlazorMemory" rel="noopener noreferrer"&gt;github.com/aftabkh4n/BlazorMemory&lt;/a&gt;. Issues and PRs welcome.&lt;/p&gt;

&lt;p&gt;If you're building AI assistants in .NET and want them to actually remember users, give it a try.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>blazor</category>
      <category>ai</category>
      <category>openai</category>
    </item>
    <item>
      <title>I added AI-generated release notes to my CI/CD pipeline using Claude and GitHub Actions</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Thu, 16 Apr 2026 05:41:59 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/i-added-ai-generated-release-notes-to-my-cicd-pipeline-using-claude-and-github-actions-3940</link>
      <guid>https://dev.to/aftabkh4n/i-added-ai-generated-release-notes-to-my-cicd-pipeline-using-claude-and-github-actions-3940</guid>
      <description>&lt;p&gt;Every time I merged a PR I had to write a changelog entry manually. It took two minutes but I kept forgetting to do it. So I automated it with Claude.&lt;/p&gt;

&lt;p&gt;When a PR merges to main, a GitHub Actions workflow reads the PR title, description, and changed files, sends them to the Claude API, and gets back a structured changelog entry. That entry gets committed to CHANGELOG.md automatically. The whole thing runs in about 10 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP has to do with this
&lt;/h2&gt;

&lt;p&gt;Nothing, directly. But this pipeline lives inside my MCP Kubernetes Manager project, which already lets Claude manage Kubernetes clusters through natural language. Adding AI-generated release notes felt like a natural fit. The project now manages its own changelog the same way it manages deployments.&lt;/p&gt;

&lt;p&gt;If you want context on the MCP server itself, I wrote about it here: &lt;a href="https://dev.to/aftabkh4n/i-built-an-mcp-server-in-net-that-lets-claude-manage-my-kubernetes-cluster-through-natural-language-3cji"&gt;https://dev.to/aftabkh4n/i-built-an-mcp-server-in-net-that-lets-claude-manage-my-kubernetes-cluster-through-natural-language-3cji&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pipeline works
&lt;/h2&gt;

&lt;p&gt;A GitHub Actions workflow triggers on &lt;code&gt;pull_request&lt;/code&gt; with type &lt;code&gt;closed&lt;/code&gt;. The first thing it checks is whether the PR was actually merged, not just closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.pull_request.merged == &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That condition is important. Without it the workflow runs on every closed PR including ones that were rejected.&lt;/p&gt;

&lt;p&gt;Then it collects the PR metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PR_TITLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.title }}&lt;/span&gt;
  &lt;span class="na"&gt;PR_BODY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.body }}&lt;/span&gt;
  &lt;span class="na"&gt;PR_NUMBER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.number }}&lt;/span&gt;
  &lt;span class="na"&gt;PR_AUTHOR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.user.login }}&lt;/span&gt;
  &lt;span class="na"&gt;PR_MERGED_AT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.merged_at }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And gets the list of changed files from git:&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="nv"&gt;CHANGED_FILES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; HEAD~1 HEAD | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Calling the Claude API
&lt;/h2&gt;

&lt;p&gt;The API call is a standard POST to the Anthropic messages endpoint. The key part is building the payload with &lt;code&gt;jq&lt;/code&gt; so special characters in PR titles and descriptions do not break the JSON:&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="nv"&gt;PAYLOAD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; model &lt;span class="s2"&gt;"claude-haiku-4-5-20251001"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; pr_num &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PR_NUMBER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; pr_title &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PR_TITLE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; pr_body &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PR_BODY_SAFE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; files &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHANGED_FILES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{
    model: $model,
    max_tokens: 1024,
    messages: [{
      role: "user",
      content: ("Generate a changelog entry for PR #" + $pr_num + ": " + $pr_title)
    }]
  }'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;RESPONSE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.anthropic.com/v1/messages &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-api-key: &lt;/span&gt;&lt;span class="nv"&gt;$ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"anthropic-version: 2023-06-01"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"content-type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PAYLOAD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;ENTRY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESPONSE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.content[0].text'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model is &lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt; which is the fastest and cheapest Claude model. For a changelog entry it produces the same quality as larger models at a fraction of the cost.&lt;/p&gt;

&lt;p&gt;The API key is stored as a GitHub Actions secret called &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;. Never hardcode it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the changelog entry back
&lt;/h2&gt;

&lt;p&gt;Once Claude returns the entry, the workflow prepends it to CHANGELOG.md so the newest entry is always at the top:&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="nv"&gt;TEMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-4&lt;/span&gt; CHANGELOG.md &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENTRY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +5 CHANGELOG.md &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; CHANGELOG.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then commits and pushes as &lt;code&gt;github-actions[bot]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config user.name &lt;span class="s2"&gt;"github-actions[bot]"&lt;/span&gt;
git config user.email &lt;span class="s2"&gt;"github-actions[bot]@users.noreply.github.com"&lt;/span&gt;
git pull &lt;span class="nt"&gt;--rebase&lt;/span&gt; origin main
git add CHANGELOG.md
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"docs: AI-generated changelog for PR #&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;{ github.event.pull_request.number &lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;git pull --rebase&lt;/code&gt; before the push is important. The PR merge itself updates main, so without pulling first the push gets rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the output looks like
&lt;/h2&gt;

&lt;p&gt;After merging a PR that adds Kubernetes tools documentation, Claude wrote this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## [PR #5] docs: add tools list to README&lt;/span&gt;
&lt;span class="ge"&gt;*2026-04-16T05:13:52Z by @aftabkh4n*&lt;/span&gt;

&lt;span class="gu"&gt;### Changes&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Added documentation describing the AI-powered changelog automation workflow
&lt;span class="p"&gt;-&lt;/span&gt; Documented how Claude generates structured changelog entries automatically when PRs merge to main
&lt;span class="p"&gt;-&lt;/span&gt; Explained the process of reading PR metadata to produce changelog entries

&lt;span class="gu"&gt;### Files changed&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the PR description and infers what actually changed. The better your PR description, the better the changelog entry.&lt;/p&gt;

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

&lt;p&gt;The Anthropic API is not free but it is very cheap for this use case. Haiku costs $0.80 per million input tokens. A typical PR payload is around 500 tokens. That works out to roughly $0.0004 per changelog entry, less than a cent per merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up in your repo
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Get an API key from console.anthropic.com&lt;/li&gt;
&lt;li&gt;Add it as a secret in your repo: Settings, Secrets and variables, Actions, New repository secret, name it &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;.github/workflows/release-notes.yml&lt;/code&gt; with the full workflow&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full source code: &lt;a href="https://github.com/aftabkh4n/mcp-kubernetes-manager" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/mcp-kubernetes-manager&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would add next
&lt;/h2&gt;

&lt;p&gt;A way to skip the changelog for trivial PRs would be useful. Something like checking whether the PR title starts with &lt;code&gt;chore:&lt;/code&gt; or &lt;code&gt;wip:&lt;/code&gt; and skipping the API call entirely. That keeps the changelog clean without adding friction to the merge process.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>ai</category>
      <category>claude</category>
      <category>devops</category>
    </item>
    <item>
      <title>I added AI code review and failure analysis to my CI/CD pipeline using GitHub Actions and GPT-4o-mini</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Wed, 15 Apr 2026 05:56:29 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/i-added-ai-code-review-and-failure-analysis-to-my-cicd-pipeline-using-github-actions-and-85j</link>
      <guid>https://dev.to/aftabkh4n/i-added-ai-code-review-and-failure-analysis-to-my-cicd-pipeline-using-github-actions-and-85j</guid>
      <description>&lt;p&gt;Every pull request in my IDP Platform project now gets an automatic AI &lt;br&gt;
code review before anyone looks at it. When the pipeline fails, an AI &lt;br&gt;
posts a root cause analysis explaining what went wrong and how to fix it.&lt;/p&gt;

&lt;p&gt;Both run automatically inside GitHub Actions using GPT-4o-mini. No &lt;br&gt;
external services, no extra infrastructure, no monthly subscription.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem with manual code review
&lt;/h2&gt;

&lt;p&gt;Code review is valuable but it has a bottleneck. The reviewer needs &lt;br&gt;
context, time, and attention. For a solo developer or a small team, &lt;br&gt;
that bottleneck slows everything down. Even experienced developers miss &lt;br&gt;
things when they are tired or rushing.&lt;/p&gt;

&lt;p&gt;AI does not replace code review. It adds a first pass that catches &lt;br&gt;
obvious issues before a human spends time on them. Things like missing &lt;br&gt;
error handling, security anti-patterns, performance problems, and &lt;br&gt;
violations of framework conventions.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the AI code review works
&lt;/h2&gt;

&lt;p&gt;When a pull request is opened or updated, a GitHub Actions workflow runs &lt;br&gt;
automatically. It gets the diff of all changed C# files, sends it to &lt;br&gt;
GPT-4o-mini with a prompt describing the review criteria, and posts the &lt;br&gt;
response as a comment on the PR.&lt;/p&gt;

&lt;p&gt;The whole thing runs in under 15 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Get the diff using subprocess for reliability
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;git&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;diff&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;origin/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;...HEAD&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*.cs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;diff&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="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Send to OpenAI
&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a senior .NET engineer reviewing a pull 
            request. Give concise actionable feedback on correctness, 
            security, performance, and .NET best practices.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review this diff:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;```
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
diff&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
```&lt;/span&gt;&lt;span class="sh"&gt;"&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 system prompt is what controls the quality of the review. I spent &lt;br&gt;
more time on the prompt than on the code around it. Telling the AI to &lt;br&gt;
act as a senior .NET engineer and focus on specific categories produces &lt;br&gt;
much more useful output than a generic review request.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real review from the pipeline
&lt;/h2&gt;

&lt;p&gt;Here is what the AI posted on an actual PR in my project:&lt;/p&gt;

&lt;p&gt;It flagged an invalid port number in a comment I had left in Program.cs. &lt;br&gt;
It questioned whether database migration error handling was sufficient. &lt;br&gt;
It noted that Swagger should be restricted in production environments. &lt;br&gt;
It pointed out a missing newline at the end of the file.&lt;/p&gt;

&lt;p&gt;None of those are critical issues but all of them are worth knowing about &lt;br&gt;
before merging. The AI caught them in 14 seconds before I even looked at &lt;br&gt;
the PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the failure analysis works
&lt;/h2&gt;

&lt;p&gt;The second workflow runs when the CI/CD pipeline fails. It fetches the &lt;br&gt;
build logs from the GitHub API, sends them to GPT-4o-mini, and posts the &lt;br&gt;
analysis as a check on the commit.&lt;/p&gt;

&lt;p&gt;This is particularly useful for cryptic build errors. Instead of reading &lt;br&gt;
through hundreds of lines of MSBuild output, you get a plain English &lt;br&gt;
explanation of what failed and what to do about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI Code Review&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**/*.cs'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**/*.csproj'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI Code Review&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OPENAI_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;BASE_REF&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.base_ref }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;python3 &amp;lt;&amp;lt; 'PYTHON'&lt;/span&gt;
          &lt;span class="s"&gt;# get diff, call OpenAI, post comment&lt;/span&gt;
          &lt;span class="s"&gt;PYTHON&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;paths&lt;/code&gt; filter is important - the workflow only runs when C# files &lt;br&gt;
change. Updating a README does not trigger a code review. This keeps the &lt;br&gt;
pipeline fast and the API costs minimal.&lt;/p&gt;

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

&lt;p&gt;GPT-4o-mini charges roughly $0.15 per million input tokens and $0.60 per &lt;br&gt;
million output tokens. A typical code review diff is around 2000 tokens. &lt;br&gt;
At that rate you could run 500 code reviews for about $0.15.&lt;/p&gt;

&lt;p&gt;For a portfolio project or small team this is effectively free. Even at &lt;br&gt;
50 PRs a month the cost is under $0.02.&lt;/p&gt;

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

&lt;p&gt;The system prompt matters more than anything else. A vague prompt like &lt;br&gt;
"review this code" produces generic output. A specific prompt that names &lt;br&gt;
the language, the role, the focus areas, and the output format produces &lt;br&gt;
review comments that are actually useful.&lt;/p&gt;

&lt;p&gt;Passing environment variables into Python heredocs requires care. GitHub &lt;br&gt;
Actions expressions like ${{ github.base_ref }} are not expanded inside &lt;br&gt;
heredocs. The fix is to set them as environment variables first and read &lt;br&gt;
them with os.environ inside the script.&lt;/p&gt;

&lt;p&gt;subprocess is more reliable than os.popen for running shell commands in &lt;br&gt;
Python. It captures stdout and stderr separately and handles errors more &lt;br&gt;
predictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;The natural next step is AI-generated release notes. When a PR is merged &lt;br&gt;
to main, the AI reads all the commit messages and diff since the last &lt;br&gt;
release and writes a structured changelog entry automatically. No more &lt;br&gt;
manually writing release notes.&lt;/p&gt;

&lt;p&gt;I am also looking at adding a security scan step that uses AI to check &lt;br&gt;
for common vulnerability patterns before the standard SAST tools run.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/aftabkh4n/idp-platform" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/idp-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are building something similar or have ideas for improving the &lt;br&gt;
prompts, drop a comment below.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>ai</category>
      <category>dotnet</category>
      <category>devops</category>
    </item>
    <item>
      <title>Built an MCP server in .NET that lets Claude manage my Kubernetes cluster through natural language</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Tue, 14 Apr 2026 07:01:29 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/i-built-an-mcp-server-in-net-that-lets-claude-manage-my-kubernetes-cluster-through-natural-language-3cji</link>
      <guid>https://dev.to/aftabkh4n/i-built-an-mcp-server-in-net-that-lets-claude-manage-my-kubernetes-cluster-through-natural-language-3cji</guid>
      <description>&lt;p&gt;I got tired of switching between my AI assistant and a terminal every time &lt;br&gt;
I needed to check what was running in my Kubernetes cluster. So I built &lt;br&gt;
something that removes the terminal from the equation entirely.&lt;/p&gt;

&lt;p&gt;This is an MCP server written in .NET 9 that connects Claude directly to &lt;br&gt;
a Kubernetes cluster. Instead of running kubectl commands, you just ask &lt;br&gt;
Claude what you want in plain English and it figures out which tool to &lt;br&gt;
call, talks to the cluster, and returns real data.&lt;/p&gt;
&lt;h2&gt;
  
  
  What MCP actually is
&lt;/h2&gt;

&lt;p&gt;MCP stands for Model Context Protocol. It is an open standard created by &lt;br&gt;
Anthropic that lets AI assistants talk to external tools and services in &lt;br&gt;
a structured way. Think of it as a plugin system for AI - you build a &lt;br&gt;
server that exposes tools, and any MCP-compatible AI can call those tools &lt;br&gt;
during a conversation.&lt;/p&gt;

&lt;p&gt;The difference from a regular API is that the AI decides when and how to &lt;br&gt;
use the tools based on what you ask. You do not write code to call the &lt;br&gt;
tools. You just have a conversation.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;The server exposes eight tools that Claude can call:&lt;/p&gt;

&lt;p&gt;ListPods - lists all pods across all namespaces or a specific one, with &lt;br&gt;
status and age.&lt;/p&gt;

&lt;p&gt;GetPodLogs - fetches recent log output from any pod, useful for debugging &lt;br&gt;
without opening a terminal.&lt;/p&gt;

&lt;p&gt;ListDeployments - shows all deployments with their desired versus ready &lt;br&gt;
replica counts.&lt;/p&gt;

&lt;p&gt;ScaleDeployment - scales a deployment to any number of replicas up to a &lt;br&gt;
hard limit of 10, with the previous count included in the response so you &lt;br&gt;
know what changed.&lt;/p&gt;

&lt;p&gt;RestartDeployment - triggers a rolling restart by updating the &lt;br&gt;
restartedAt annotation, which is exactly what kubectl rollout restart &lt;br&gt;
does under the hood.&lt;/p&gt;

&lt;p&gt;GetDeploymentStatus - returns detailed rollout status and conditions, &lt;br&gt;
useful for checking whether a deployment completed successfully.&lt;/p&gt;

&lt;p&gt;ListNamespaces - lists all namespaces with status and age.&lt;/p&gt;

&lt;p&gt;GetNamespaceSummary - returns pods, deployments, and services for a &lt;br&gt;
namespace in one view.&lt;/p&gt;
&lt;h2&gt;
  
  
  A real conversation with the cluster
&lt;/h2&gt;

&lt;p&gt;Here is what an actual session looks like. I open Claude Desktop and ask:&lt;/p&gt;

&lt;p&gt;"List all pods in my Kubernetes cluster"&lt;/p&gt;

&lt;p&gt;Claude calls ListPods, my MCP server talks to the cluster, and I get back &lt;br&gt;
something like:&lt;/p&gt;

&lt;p&gt;idp-platform/idp-platform-xxx - Running - 1d&lt;br&gt;
monitoring/prometheus-stack-grafana-xxx - Running - 1d&lt;br&gt;
monitoring/alertmanager-xxx - Running - 1d&lt;br&gt;
kube-system/coredns-xxx - Running - 1d&lt;/p&gt;

&lt;p&gt;Then I ask:&lt;/p&gt;

&lt;p&gt;"Scale the idp-platform deployment to 2 replicas"&lt;/p&gt;

&lt;p&gt;Claude calls ScaleDeployment with the right parameters, the cluster &lt;br&gt;
updates, and I get back a confirmation that it scaled from 1 to 2 &lt;br&gt;
replicas. No kubectl, no terminal, no context switching.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works technically
&lt;/h2&gt;

&lt;p&gt;The server is built with the ModelContextProtocol .NET SDK. Each tool &lt;br&gt;
class is decorated with McpServerToolType and each method with &lt;br&gt;
McpServerTool plus a Description attribute that tells the AI what the &lt;br&gt;
tool does and when to use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;McpServerToolType&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeploymentTools&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;McpServerTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Scale a Kubernetes deployment to a "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
        &lt;span class="s"&gt;"specified number of replicas."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ScaleDeployment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Name of the deployment to scale."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;deploymentName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Number of replicas. Maximum 10."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;replicas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Namespace. Defaults to default."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;namespaceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// safety check, then scale&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 descriptions are what the AI reads to decide which tool to call. &lt;br&gt;
Writing good descriptions is the most important part of building an MCP &lt;br&gt;
server - vague descriptions lead to wrong tool calls.&lt;/p&gt;

&lt;p&gt;The server communicates over stdio, which means Claude Desktop spawns it &lt;br&gt;
as a child process and the two communicate through standard input and &lt;br&gt;
output. No HTTP server, no ports, no firewall rules.&lt;/p&gt;
&lt;h2&gt;
  
  
  Connecting to Claude Desktop
&lt;/h2&gt;

&lt;p&gt;Add this to your Claude Desktop config file at &lt;br&gt;
%APPDATA%\Claude\claude_desktop_config.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"kubernetes-manager"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Program Files&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;dotnet&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;dotnet.exe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;path&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;to&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;mcp-kubernetes-manager&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;src&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Mcp.KubernetesManager"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Claude Desktop, go to Settings, and you will see the server &lt;br&gt;
listed under Local MCP servers with all eight tools available.&lt;/p&gt;

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

&lt;p&gt;Good tool descriptions matter more than good code. The AI uses the &lt;br&gt;
Description attributes to decide which tool to call. If your description &lt;br&gt;
is ambiguous the AI will either call the wrong tool or ask you to clarify. &lt;br&gt;
Spending time on descriptions pays off more than optimising the &lt;br&gt;
implementation.&lt;/p&gt;

&lt;p&gt;Stdio transport is simpler than it sounds. There is no networking to &lt;br&gt;
configure, no authentication to set up, and no ports to expose. The &lt;br&gt;
parent process starts the server and they communicate through pipes. It &lt;br&gt;
just works.&lt;/p&gt;

&lt;p&gt;Safety guards belong in the tools themselves. I added a hard limit of 10 &lt;br&gt;
replicas to ScaleDeployment and input validation throughout. The AI has &lt;br&gt;
no awareness of what is safe in your environment - that responsibility &lt;br&gt;
stays with the tool developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;The natural next step is adding tools for Helm releases, ConfigMap &lt;br&gt;
updates, and event streaming so Claude can watch what is happening in the &lt;br&gt;
cluster in real time. I am also looking at adding a confirmation step for &lt;br&gt;
destructive operations - the AI would describe what it is about to do and &lt;br&gt;
wait for explicit approval before making changes.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/aftabkh4n/mcp-kubernetes-manager" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/mcp-kubernetes-manager&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have questions or ideas for additional tools, drop a comment below.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>kubernetes</category>
      <category>mcp</category>
      <category>devops</category>
    </item>
    <item>
      <title>From zero to full infrastructure as code - Terraform, Kubernetes, Prometheus and Grafana with a single command</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Mon, 13 Apr 2026 08:47:45 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/from-zero-to-full-infrastructure-as-code-terraform-kubernetes-prometheus-and-grafana-with-a-3im2</link>
      <guid>https://dev.to/aftabkh4n/from-zero-to-full-infrastructure-as-code-terraform-kubernetes-prometheus-and-grafana-with-a-3im2</guid>
      <description>&lt;p&gt;Most backend developers write code that runs on infrastructure someone &lt;br&gt;
else built. They open a ticket, wait for an ops team to provision a &lt;br&gt;
database, and get a connection string back two days later. I wanted to &lt;br&gt;
understand what that ops team actually does so I built it myself.&lt;/p&gt;

&lt;p&gt;This is a complete local infrastructure setup that provisions everything &lt;br&gt;
with one command and tears it all down just as cleanly.&lt;/p&gt;
&lt;h2&gt;
  
  
  What gets provisioned
&lt;/h2&gt;

&lt;p&gt;Running terraform apply creates a PostgreSQL database in Docker with its &lt;br&gt;
own network, a Kubernetes namespace with environment labels, a full &lt;br&gt;
Deployment, Service and Ingress for the application, a ConfigMap for &lt;br&gt;
configuration, Kubernetes Secrets for sensitive values, and a complete &lt;br&gt;
Prometheus and Grafana monitoring stack.&lt;/p&gt;

&lt;p&gt;Running terraform destroy removes all of it. No manual cleanup, no &lt;br&gt;
orphaned containers, no forgotten resources sitting idle.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Terraform instead of kubectl or Docker Compose
&lt;/h2&gt;

&lt;p&gt;Docker Compose is great for running services locally but it has no &lt;br&gt;
concept of state. Run it twice and you get duplicate containers. If &lt;br&gt;
something fails halfway through you have no way of knowing what was &lt;br&gt;
created and what was not.&lt;/p&gt;

&lt;p&gt;kubectl applies manifests but has no dependency management. You need to &lt;br&gt;
know the correct order to apply things and rolling back means manually &lt;br&gt;
hunting down and deleting resources one by one.&lt;/p&gt;

&lt;p&gt;Terraform tracks state. It knows what exists, what needs creating, and &lt;br&gt;
what needs updating. Run it ten times and it only makes changes when &lt;br&gt;
something is actually different. That predictability is what makes it &lt;br&gt;
safe to use in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  The module structure
&lt;/h2&gt;

&lt;p&gt;I split the infrastructure into three modules so each piece is &lt;br&gt;
independently testable and reusable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/postgres"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/kubernetes"&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"monitoring"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/monitoring"&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kubernetes&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 depends_on blocks enforce ordering. Kubernetes waits for PostgreSQL, &lt;br&gt;
monitoring waits for Kubernetes. Terraform handles the sequencing &lt;br&gt;
automatically so you never have to think about it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Secrets management
&lt;/h2&gt;

&lt;p&gt;One thing that catches people out with Kubernetes is putting secrets in &lt;br&gt;
ConfigMaps. ConfigMaps are not encrypted. They are just base64 encoded &lt;br&gt;
which is not the same thing at all.&lt;/p&gt;

&lt;p&gt;I store sensitive values in Kubernetes Secrets and non-sensitive &lt;br&gt;
configuration in ConfigMaps, then mount both into the container using &lt;br&gt;
env_from. The application reads everything as environment variables &lt;br&gt;
without needing to know where they came from. Swapping the secret source &lt;br&gt;
from a Kubernetes Secret to HashiCorp Vault requires no application code &lt;br&gt;
changes at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Monitoring with Helm
&lt;/h2&gt;

&lt;p&gt;Rather than writing Kubernetes manifests for Prometheus and Grafana from &lt;br&gt;
scratch, I used the kube-prometheus-stack Helm chart. This is the same &lt;br&gt;
chart used in production Kubernetes clusters at companies running &lt;br&gt;
thousands of pods.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"helm_release"&lt;/span&gt; &lt;span class="s2"&gt;"prometheus_stack"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prometheus-stack"&lt;/span&gt;
  &lt;span class="nx"&gt;repository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://prometheus-community.github.io/helm-charts"&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"kube-prometheus-stack"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kubernetes_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monitoring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once applied, Grafana comes pre-loaded with dashboards for CPU usage, &lt;br&gt;
memory consumption, pod restarts and network traffic without any manual &lt;br&gt;
configuration. You open the browser and the data is already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan before the apply
&lt;/h2&gt;

&lt;p&gt;One of Terraform's most useful features is terraform plan. Before making &lt;br&gt;
any changes it shows you exactly what will be created, modified or &lt;br&gt;
destroyed. In a team environment this output goes into a pull request so &lt;br&gt;
everyone can see what infrastructure is about to change before it &lt;br&gt;
happens. No surprises, no rollbacks, no 2am incident calls because &lt;br&gt;
someone applied changes directly to production.&lt;/p&gt;

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

&lt;p&gt;Helm charts save hours. Writing Kubernetes manifests for Prometheus from &lt;br&gt;
scratch would take days and require deep knowledge of how everything &lt;br&gt;
wires together internally. The Helm chart encapsulates all of that and &lt;br&gt;
exposes only the configuration you actually care about.&lt;/p&gt;

&lt;p&gt;State is what makes infrastructure tools production ready. Docker Compose &lt;br&gt;
and shell scripts are fine for quick local work but they have no memory. &lt;br&gt;
Terraform remembers what it built which means it can update it safely and &lt;br&gt;
destroy it completely without you having to track anything manually.&lt;/p&gt;

&lt;p&gt;Dependency ordering matters more than you think. If you do not declare &lt;br&gt;
dependencies explicitly with depends_on, Terraform may try to create &lt;br&gt;
resources in parallel before their dependencies exist. The error messages &lt;br&gt;
when this happens are confusing because the resource technically exists, &lt;br&gt;
it just is not ready yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it yourself
&lt;/h2&gt;

&lt;p&gt;Clone the repo, copy the example vars file, fill in your own values, &lt;br&gt;
then run terraform init followed by terraform apply. Everything provisions &lt;br&gt;
automatically. When you are done, terraform destroy removes it all.&lt;/p&gt;

&lt;p&gt;You will need Docker Desktop with Kubernetes enabled, Terraform installed, &lt;br&gt;
and Helm with the Prometheus community repo added. The README in the repo &lt;br&gt;
covers all the steps in detail.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/aftabkh4n/terraform-idp" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/terraform-idp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have questions or want to talk through any of the decisions, drop &lt;br&gt;
a comment below.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>I upgraded a .NET library into a microservices platform - RabbitMQ, OpenTelemetry, and Azure AI</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Sun, 12 Apr 2026 07:19:13 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/i-upgraded-a-net-library-into-a-microservices-platform-rabbitmq-opentelemetry-and-azure-ai-2896</link>
      <guid>https://dev.to/aftabkh4n/i-upgraded-a-net-library-into-a-microservices-platform-rabbitmq-opentelemetry-and-azure-ai-2896</guid>
      <description>&lt;p&gt;I had a .NET library that wrapped Azure OpenAI and Azure AI Search into&lt;br&gt;
clean domain services for travel applications. It worked, but it was a&lt;br&gt;
monolith - one process doing everything synchronously. When the AI call&lt;br&gt;
took 30 seconds, the HTTP request sat open for 30 seconds.&lt;/p&gt;

&lt;p&gt;This is how I fixed that.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem with synchronous AI calls
&lt;/h2&gt;

&lt;p&gt;The original architecture was simple:&lt;br&gt;
POST /api/itinerary/generate&lt;br&gt;
→ calls Azure OpenAI GPT-4o directly&lt;br&gt;
→ waits 10-30 seconds&lt;br&gt;
→ returns itinerary&lt;/p&gt;

&lt;p&gt;That works for a demo. It does not work in production. One slow AI call&lt;br&gt;
blocks a thread. Under load, you run out of threads. The service falls over.&lt;/p&gt;
&lt;h2&gt;
  
  
  The solution - async messaging with RabbitMQ
&lt;/h2&gt;

&lt;p&gt;I split the system into three independent services:&lt;br&gt;
TravelAI.Api        - HTTP gateway, publishes messages&lt;br&gt;
TravelAI.SearchWorker - consumes search requests&lt;br&gt;
TravelAI.AiWorker   - consumes AI generation requests&lt;/p&gt;

&lt;p&gt;Now the flow looks like this:&lt;br&gt;
The HTTP response comes back in under 100ms. The AI work happens&lt;br&gt;
independently. If the AI service is slow, requests queue up in RabbitMQ&lt;br&gt;
rather than blocking threads in the API.&lt;/p&gt;
&lt;h2&gt;
  
  
  The message contracts
&lt;/h2&gt;

&lt;p&gt;I defined the messages as simple C# records in the core library so all&lt;br&gt;
three services share the same types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ItineraryRequested&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt;     &lt;span class="n"&gt;CorrelationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="n"&gt;TravellerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="n"&gt;TravellerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateOnly&lt;/span&gt; &lt;span class="n"&gt;Departure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DateOnly&lt;/span&gt; &lt;span class="n"&gt;ReturnDate&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;AdditionalInstructions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ItineraryGenerated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt;    &lt;span class="n"&gt;CorrelationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;object&lt;/span&gt;  &lt;span class="n"&gt;Itinerary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt;    &lt;span class="n"&gt;Success&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;ErrorMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using records keeps contracts immutable. The correlation ID lets you trace&lt;br&gt;
a request across all three services even in distributed logs.&lt;/p&gt;
&lt;h2&gt;
  
  
  MassTransit for the messaging layer
&lt;/h2&gt;

&lt;p&gt;Rather than talking to RabbitMQ directly, I used MassTransit as an&lt;br&gt;
abstraction. This means I can swap RabbitMQ for Azure Service Bus later&lt;br&gt;
without touching the consumer code.&lt;/p&gt;

&lt;p&gt;A consumer looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ItineraryRequestedConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IItineraryGenerationService&lt;/span&gt; &lt;span class="n"&gt;aiService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ItineraryRequestedConsumer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IConsumer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ItineraryRequested&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Consume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConsumeContext&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ItineraryRequested&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="p"&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;Message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;itinerary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;aiService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GenerateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;traveller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Departure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReturnDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdditionalInstructions&lt;/span&gt;&lt;span class="p"&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;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ItineraryGenerated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CorrelationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;itinerary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrorMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MassTransit handles retries, dead-letter queues, and error handling&lt;br&gt;
automatically. If the Azure OpenAI call fails, MassTransit retries it&lt;br&gt;
with exponential backoff before moving the message to the error queue.&lt;/p&gt;
&lt;h2&gt;
  
  
  Structured logging with Serilog
&lt;/h2&gt;

&lt;p&gt;Every service logs in structured JSON using Serilog. The correlation ID&lt;br&gt;
flows through every log entry so you can trace a single request across&lt;br&gt;
all three services in a centralised log system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Generating itinerary {CorrelationId} for {Traveller} to {Destination}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CorrelationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TravellerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production you would ship these logs to Seq, Azure Log Analytics, or&lt;br&gt;
Datadog. Locally they stream to the console in readable format.&lt;/p&gt;
&lt;h2&gt;
  
  
  OpenTelemetry distributed tracing
&lt;/h2&gt;

&lt;p&gt;The API is instrumented with OpenTelemetry so every request generates a&lt;br&gt;
trace that shows exactly where time was spent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracing&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tracing&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetResourceBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ResourceBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TravelAI.Api"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAspNetCoreInstrumentation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddConsoleExporter&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production you would export traces to Jaeger, Zipkin, or Azure Monitor&lt;br&gt;
instead of the console exporter.&lt;/p&gt;
&lt;h2&gt;
  
  
  Docker Compose for local development
&lt;/h2&gt;

&lt;p&gt;The whole system starts with 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;docker-compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compose file uses health checks to ensure RabbitMQ is ready before&lt;br&gt;
the services start connecting to it - a detail that matters because&lt;br&gt;
services connecting to a broker that is still starting up will crash and&lt;br&gt;
require manual restarts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rabbitmq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rabbitmq-diagnostics"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

&lt;span class="na"&gt;travelai-api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;rabbitmq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Async messaging changes how you think about APIs.&lt;/strong&gt; Instead of&lt;br&gt;
returning data, you return a correlation ID and a status URL. The client&lt;br&gt;
polls or subscribes for the result. This feels strange at first but it is&lt;br&gt;
the right model for any operation that takes more than a second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MassTransit v9 requires a commercial license.&lt;/strong&gt; I hit this when&lt;br&gt;
upgrading - v8 is fully open source and supports RabbitMQ without any&lt;br&gt;
license. Worth knowing before you add it to a project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution build ordering matters in .NET.&lt;/strong&gt; The solution file needs&lt;br&gt;
explicit &lt;code&gt;Build.0&lt;/code&gt; entries for each project in each configuration. When&lt;br&gt;
this is missing, dependent projects compile before their references are&lt;br&gt;
built, and you get confusing type-not-found errors even though the&lt;br&gt;
project reference is correctly set.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;.NET 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;Azure OpenAI (GPT-4o)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search&lt;/td&gt;
&lt;td&gt;Azure AI Search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging&lt;/td&gt;
&lt;td&gt;RabbitMQ via MassTransit 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;Serilog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tracing&lt;/td&gt;
&lt;td&gt;OpenTelemetry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/aftabkh4n/TravelAI.Core" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/TravelAI.Core&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you found this useful or have questions, drop a comment below.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>rabbitmq</category>
      <category>azure</category>
      <category>microservices</category>
    </item>
    <item>
      <title>I built a Travel Data Platform API in .NET - search, analytics, and recommendations with Redis caching</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Thu, 09 Apr 2026 04:43:45 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/i-built-a-travel-data-platform-api-in-net-search-analytics-and-recommendations-with-redis-1f5l</link>
      <guid>https://dev.to/aftabkh4n/i-built-a-travel-data-platform-api-in-net-search-analytics-and-recommendations-with-redis-1f5l</guid>
      <description>&lt;p&gt;Most travel APIs I have seen treat search, analytics, and recommendations&lt;br&gt;
as completely separate systems. Different teams, different databases,&lt;br&gt;
different codebases. This project explores what it looks like to build&lt;br&gt;
all three on a single clean data model, with caching built in from the&lt;br&gt;
start rather than bolted on later.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;The platform has four areas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingestion&lt;/strong&gt; accepts bulk flight and user data via POST endpoints.&lt;br&gt;
In a real system this would connect to partner feeds or scrapers.&lt;br&gt;
Here it seeds 240 flights across 12 routes with realistic pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search&lt;/strong&gt; lets you filter flights by origin, destination, airline,&lt;br&gt;
price, and departure date. All results are paginated. Every unique&lt;br&gt;
combination of filters gets its own Redis cache key, so a repeated&lt;br&gt;
search returns in under 100ms instead of hitting the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics&lt;/strong&gt; exposes three aggregated views - popular routes ranked&lt;br&gt;
by volume, monthly price trends for any route, and peak travel&lt;br&gt;
periods by month. These are the endpoints a commercial or product&lt;br&gt;
team would actually use to make decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommendations&lt;/strong&gt; takes a user ID and returns personalised flights&lt;br&gt;
based on their preferred origin, airline, and maximum budget. It&lt;br&gt;
falls back gracefully when preferred airline results are sparse,&lt;br&gt;
topping up with other options so the user always gets a full list.&lt;/p&gt;
&lt;h2&gt;
  
  
  The caching strategy
&lt;/h2&gt;

&lt;p&gt;Every read endpoint uses Redis with a cache-aside pattern. The key&lt;br&gt;
is built from every filter parameter so different queries never&lt;br&gt;
collide with each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cacheKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"flights:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;airline&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;maxPrice&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStringAsync&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PagedResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Flight&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// ... query the database ...&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetStringAsync&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="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&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;new&lt;/span&gt; &lt;span class="n"&gt;DistributedCacheEntryOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;AbsoluteExpirationRelativeToNow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&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;TTLs are tuned per endpoint. Search results expire in 5 minutes&lt;br&gt;
because flight availability changes. Analytics results last 15&lt;br&gt;
minutes because aggregates change slowly. Recommendations sit at&lt;br&gt;
10 minutes - long enough to feel fast, short enough to stay&lt;br&gt;
relevant.&lt;/p&gt;

&lt;p&gt;A cold search on LHR to DXB takes around 500ms. The same search&lt;br&gt;
cached returns in under 100ms. That gap matters at scale.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pagination
&lt;/h2&gt;

&lt;p&gt;Every list endpoint returns a typed wrapper instead of a raw array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PagedResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Items&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;            &lt;span class="n"&gt;TotalCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;            &lt;span class="n"&gt;Page&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;            &lt;span class="n"&gt;PageSize&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;            &lt;span class="n"&gt;TotalPages&lt;/span&gt; &lt;span class="p"&gt;=&amp;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="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ceiling&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;TotalCount&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;           &lt;span class="n"&gt;HasNext&lt;/span&gt;    &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;TotalPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;           &lt;span class="n"&gt;HasPrevious&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;1&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 client gets everything it needs to build pagination controls&lt;br&gt;
without making a second request for the total count.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Cache serialization needs a concrete type.&lt;/strong&gt; Early on I cached&lt;br&gt;
anonymous types and deserialized to &lt;code&gt;object&lt;/code&gt;. The properties came&lt;br&gt;
back as &lt;code&gt;JsonElement&lt;/code&gt; instead of named fields, so the JavaScript&lt;br&gt;
dashboard got &lt;code&gt;undefined&lt;/code&gt; everywhere. Switching to named classes&lt;br&gt;
fixed it cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis and PostgreSQL complement each other well.&lt;/strong&gt; PostgreSQL&lt;br&gt;
handles the complex GROUP BY queries that power analytics.&lt;br&gt;
Redis handles the repeated reads that would hammer the database&lt;br&gt;
under load. Neither tries to do the other's job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seed data matters for demos.&lt;/strong&gt; Random data with a fixed seed&lt;br&gt;
produces realistic-looking results every time. Using &lt;code&gt;new Random(42)&lt;/code&gt;&lt;br&gt;
means the demo looks the same whether you run it locally or show&lt;br&gt;
it to someone in an interview.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;ASP.NET Core 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL + Entity Framework Core 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Redis (StackExchange.Redis)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;Serilog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API docs&lt;/td&gt;
&lt;td&gt;Scalar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Source code is on GitHub:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/aftabkh4n/data-platform" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/data-platform&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Clone it, spin up the Docker containers, and run the API.&lt;br&gt;
The database seeds automatically on first run.&lt;/p&gt;




&lt;p&gt;If you have questions or feedback, drop a comment below.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiforgm4zix7fb4x6529w.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiforgm4zix7fb4x6529w.gif" alt=" " width="600" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>api</category>
      <category>redis</category>
      <category>postgres</category>
    </item>
    <item>
      <title>I built a Mini Internal Developer Platform in .NET -GitHub repos + Kubernetes deployments from a single API call</title>
      <dc:creator>Aftab Bashir</dc:creator>
      <pubDate>Mon, 06 Apr 2026 05:15:36 +0000</pubDate>
      <link>https://dev.to/aftabkh4n/i-built-a-mini-internal-developer-platform-in-net-github-repos-kubernetes-deployments-from-a-4l0d</link>
      <guid>https://dev.to/aftabkh4n/i-built-a-mini-internal-developer-platform-in-net-github-repos-kubernetes-deployments-from-a-4l0d</guid>
      <description>&lt;p&gt;Most teams I've worked with waste 2-3 hours every time they spin up a &lt;br&gt;
new microservice. Create the repo, write the Dockerfile, set up CI, &lt;br&gt;
write Kubernetes manifests, configure ingress, same boilerplate, every &lt;br&gt;
single time.&lt;/p&gt;

&lt;p&gt;I decided to automate all of it. One API call. Everything done &lt;br&gt;
automatically.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;POST to &lt;code&gt;/api/services&lt;/code&gt; with a service name and language, and the &lt;br&gt;
platform automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates a GitHub repository&lt;/li&gt;
&lt;li&gt;Commits a Dockerfile and GitHub Actions CI pipeline&lt;/li&gt;
&lt;li&gt;Creates a Kubernetes Deployment, Service, and Ingress&lt;/li&gt;
&lt;li&gt;Streams real-time status updates to a live dashboard via SignalR&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The HTTP response comes back in under 200ms. All the work happens in a &lt;br&gt;
background worker.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The system is split into four .NET projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Idp.Api&lt;/code&gt; - ASP.NET Core 9 API, the entry point&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Idp.Core&lt;/code&gt; - domain models and interfaces&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Idp.Infrastructure&lt;/code&gt; - GitHub and Kubernetes integrations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Idp.Worker&lt;/code&gt; - background provisioning pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a request comes in, the controller does one thing - saves a record &lt;br&gt;
to PostgreSQL with status &lt;code&gt;queued&lt;/code&gt; and returns &lt;code&gt;202 Accepted&lt;/code&gt;. The &lt;br&gt;
background worker polls every 5 seconds, picks up queued jobs, and runs &lt;br&gt;
the full provisioning pipeline.&lt;/p&gt;
&lt;h2&gt;
  
  
  The GitHub integration
&lt;/h2&gt;

&lt;p&gt;I used Octokit.net to talk to the GitHub REST API. Here is what happens &lt;br&gt;
when you provision a new service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;NewRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Private&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AutoInit&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Give GitHub a moment to initialise&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Commit Dockerfile&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;organisation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Dockerfile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateFileRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chore: add Dockerfile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;GetDockerfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="c1"&gt;// Commit CI workflow&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;organisation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".github/workflows/ci.yml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CreateFileRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chore: add CI workflow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;GetCiWorkflow&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;AutoInit: true&lt;/code&gt; creates an initial commit so we can push files &lt;br&gt;
immediately. The 2 second delay is important - without it, GitHub &lt;br&gt;
sometimes returns a 404 when you try to commit to a freshly created repo.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Kubernetes integration
&lt;/h2&gt;

&lt;p&gt;I used the official &lt;code&gt;KubernetesClient&lt;/code&gt; NuGet package to create K8s &lt;br&gt;
objects from C#:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Create Deployment&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppsV1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateNamespacedDeploymentAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Create Service&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CoreV1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateNamespacedServiceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Create Ingress&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NetworkingV1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateNamespacedIngressAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ingress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service gets its own namespace, which keeps things clean and makes &lt;br&gt;
it easy to delete a service and all its resources in one command.&lt;/p&gt;
&lt;h2&gt;
  
  
  Real-time updates with SignalR
&lt;/h2&gt;

&lt;p&gt;The background worker pushes status updates to all connected browsers &lt;br&gt;
as provisioning progresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hubContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;All&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"StatusChanged"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ServiceId&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ServiceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// queued → creating_repo → deploying_k8s → deployed&lt;/span&gt;
    &lt;span class="n"&gt;RepoUrl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repoUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ServiceUrl&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serviceUrl&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard listens for these events and updates the UI in real time &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no polling, no page refresh.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Always use async provisioning.&lt;/strong&gt; The first version was synchronous - &lt;br&gt;
the HTTP request sat open for 6 seconds while GitHub did its work. &lt;br&gt;
Moving to a background worker with a queue made the API feel instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub needs a moment after repo creation.&lt;/strong&gt; If you immediately try &lt;br&gt;
to commit files to a freshly created repo, you get a 404. A 2 second &lt;br&gt;
delay after &lt;code&gt;AutoInit&lt;/code&gt; fixes it reliably.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handle conflicts gracefully in Kubernetes.&lt;/strong&gt; If you try to create a &lt;br&gt;
resource that already exists, K8s returns a 409 Conflict. Catching that &lt;br&gt;
and either replacing or skipping makes the system idempotent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets in git history are permanent.&lt;/strong&gt; I accidentally committed my &lt;br&gt;
GitHub token inside the &lt;code&gt;bin/&lt;/code&gt; folder early on. Even after deleting it, &lt;br&gt;
it was in git history. I had to force push a rewritten history and &lt;br&gt;
immediately rotate the token. Always add &lt;code&gt;**/bin/&lt;/code&gt; and &lt;code&gt;**/obj/&lt;/code&gt; to &lt;br&gt;
&lt;code&gt;.gitignore&lt;/code&gt; before your first commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;ASP.NET Core 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL + Entity Framework Core 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub automation&lt;/td&gt;
&lt;td&gt;Octokit.net&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes automation&lt;/td&gt;
&lt;td&gt;KubernetesClient&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time updates&lt;/td&gt;
&lt;td&gt;SignalR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;Serilog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API docs&lt;/td&gt;
&lt;td&gt;Scalar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;The full source code is on GitHub:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/aftabkh4n/idp-platform" rel="noopener noreferrer"&gt;https://github.com/aftabkh4n/idp-platform&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Clone it, add your GitHub token to &lt;code&gt;appsettings.json&lt;/code&gt;, spin up a &lt;br&gt;
PostgreSQL container, and run it. You should have a working IDP in under &lt;br&gt;
5 minutes.&lt;/p&gt;




&lt;p&gt;This is one of four portfolio projects I am building to demonstrate &lt;br&gt;
senior backend engineering skills. Next up: a full Data Platform API &lt;br&gt;
with search, analytics, and recommendations.&lt;/p&gt;

&lt;p&gt;If you found this useful or have questions, drop a comment below.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw3svmzmifke8c2ymngvq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw3svmzmifke8c2ymngvq.png" alt=" " width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>kubernetes</category>
      <category>github</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
