<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tunde Hub]]></title><description><![CDATA[Sharing practical insights, tools, and tutorials on .NET, ASP.NET Core, cloud, and open source, empowering developers to build with confidence and grow their cr]]></description><link>https://tundehub.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 03:34:06 GMT</lastBuildDate><atom:link href="https://tundehub.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building Type-Safe AI Integrations: Validating LLM Output with TypeScript and Zod]]></title><description><![CDATA[The first time I wired an LLM call into a production TypeScript service, I made an assumption that cost me a debugging afternoon: I trusted the response to come back in the shape I asked for. It mostl]]></description><link>https://tundehub.dev/building-type-safe-ai-integrations-validating-llm-output-with-typescript-and-zod</link><guid isPermaLink="true">https://tundehub.dev/building-type-safe-ai-integrations-validating-llm-output-with-typescript-and-zod</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Tue, 15 Sep 2026 22:11:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/000412b5-c070-4142-8db8-3b86f257f59e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first time I wired an LLM call into a production TypeScript service, I made an assumption that cost me a debugging afternoon: I trusted the response to come back in the shape I asked for. It mostly did. "Mostly" is not a word you want anywhere near production code, and the gap between "mostly" and "always" is exactly where AI integrations quietly break.</p>
<h3>The core problem: LLMs don't have a type system, your code does</h3>
<p>A REST API with a defined contract fails loudly and predictably when something's wrong, wrong status code, malformed JSON, an error you can catch. An LLM call can return something that's valid JSON, matches most of your expected shape, and is still subtly wrong: a field that's usually a number coming back as a string, an enum value that's close but not quite one of your defined options, a field silently missing because the model decided it wasn't relevant this time.</p>
<p>typescript</p>
<pre><code class="language-typescript">interface FraudAssessment {
  riskScore: number;
  flags: string[];
  recommendation: "approve" | "review" | "decline";
}

const response = await llmClient.assess(transaction);
const assessment: FraudAssessment = JSON.parse(response); // trusting blindly
</code></pre>
<p>That type annotation is a lie the moment the actual JSON doesn't match. TypeScript's type system checks your code at compile time. It has no way to verify that a string parsed from an LLM response actually conforms to the shape you've declared.</p>
<h3>Zod: validation that matches your types instead of trusting them</h3>
<p>typescript</p>
<pre><code class="language-typescript">import { z } from "zod";

const FraudAssessmentSchema = z.object({
  riskScore: z.number().min(0).max(1),
  flags: z.array(z.string()),
  recommendation: z.enum(["approve", "review", "decline"]),
});

type FraudAssessment = z.infer&lt;typeof FraudAssessmentSchema&gt;;

const parsed = JSON.parse(response);
const result = FraudAssessmentSchema.safeParse(parsed);

if (!result.success) {
  logger.error("LLM response failed validation", { errors: result.error.issues });
  return fallbackAssessment();
}

const assessment: FraudAssessment = result.data;
</code></pre>
<p>Deriving the TypeScript type from the Zod schema with <code>z.infer</code> means the compile-time type and the runtime validation can never silently drift apart, because there's only one source of truth instead of two. This single change turned "the model probably returned what we expected" into "we know exactly what we got, and if it's wrong, we know precisely how."</p>
<h3>Fallback behavior matters more than perfect prompting</h3>
<p>You can spend real effort tightening a prompt to reduce malformed responses, and you should. But no amount of prompt engineering gets you to zero malformed responses, because the model is fundamentally probabilistic, not deterministic. What actually matters more than prompt perfection is having a defined, tested fallback for validation failure: a conservative default decision, a retry with a stricter prompt, an escalation to human review. On a fraud detection integration, the fallback for a failed validation was always the more conservative outcome, flagging for review rather than silently approving, because the cost of that failure mode was asymmetric.</p>
<h3>Partial validation for streaming responses</h3>
<p>Streaming LLM output complicates this further, since you're validating an incomplete JSON structure as it arrives. For streaming cases, I validate at natural completion boundaries, a completed field, a completed array element, rather than attempting to validate a JSON fragment that isn't syntactically complete yet.</p>
<p>typescript</p>
<pre><code class="language-typescript">const partialSchema = FraudAssessmentSchema.partial();
const partialResult = partialSchema.safeParse(accumulatedSoFar);
</code></pre>
<h3>Logging the raw response, always</h3>
<p>When validation fails, I always log the raw, unparsed response alongside the validation errors. Prompt behavior drifts over time, model versions change, and having the actual raw output is what lets you distinguish "the model got worse at this task" from "our schema was too strict" when you're debugging a spike in validation failures three weeks later.</p>
<h3>The real discipline</h3>
<p>Treating an LLM response with the same skepticism you'd apply to unvalidated user input, not the trust you'd extend to your own internal service, is the entire lesson here. The model is powerful and useful. It is not your type system, and pretending otherwise is how a "mostly correct" response becomes a production incident.</p>
]]></content:encoded></item><item><title><![CDATA[Reviewing AI-Generated Code: What Changes and What Doesn't]]></title><description><![CDATA[The first pull request I reviewed that was substantially AI-generated, I caught myself doing something I wouldn't do with a colleague's code: skimming it more generously because it "looked clean." Cle]]></description><link>https://tundehub.dev/reviewing-ai-generated-code-what-changes-and-what-doesn-t</link><guid isPermaLink="true">https://tundehub.dev/reviewing-ai-generated-code-what-changes-and-what-doesn-t</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Sun, 13 Sep 2026 22:50:04 GMT</pubDate><content:encoded><![CDATA[<p>The first pull request I reviewed that was substantially AI-generated, I caught myself doing something I wouldn't do with a colleague's code: skimming it more generously because it "looked clean." Clean formatting and correct behavior are not the same thing, and that instinct to relax scrutiny the moment code looks polished is exactly backwards for AI-assisted output. It took a genuinely subtle bug slipping past that instinct for me to recalibrate how I review this code.</p>
<h3>The bug that recalibrated my review process</h3>
<p>A generated function for calculating a loan's remaining balance looked entirely reasonable: clear variable names, sensible structure, comments explaining each step. It also silently handled a negative payment amount by simply subtracting it from the balance, increasing the balance instead of raising a validation error, because nothing in the prompt had specified that payments couldn't be negative and the model filled that gap with a plausible-looking assumption instead of an error. Nothing about the code looked wrong. The wrongness was in an assumption baked into logic that was otherwise well-written.</p>
<h3>What doesn't change: the fundamentals of code review</h3>
<p>Correctness, edge cases, security implications, performance characteristics, none of these review dimensions change because of who or what wrote the code. A SQL injection vulnerability is exactly as dangerous whether a junior developer or a model produced it. I review AI-generated code against the same checklist I'd apply to any pull request, because the risks that checklist protects against haven't gone anywhere.</p>
<h3>What does change: where I concentrate extra scrutiny</h3>
<p><strong>Plausible-looking assumptions.</strong> Generated code tends to fill gaps in an underspecified prompt with something reasonable-sounding rather than flagging the ambiguity, the way a human developer might ask a clarifying question. I specifically look for logic that handles an edge case in a way nobody explicitly asked for, and check whether that handling is actually correct or just plausible.</p>
<p>typescript</p>
<pre><code class="language-typescript">function calculateRemainingBalance(current: number, payment: number): number {
  return current - payment; // what happens with payment = -500?
}
</code></pre>
<p><strong>Consistency with the rest of the codebase.</strong> Generated code often doesn't know about a project's existing patterns, error handling conventions, a shared validation utility, a specific logging format, and will confidently reinvent something that already exists elsewhere, sometimes slightly differently.</p>
<p><strong>Test coverage that matches the code's actual behavior</strong>, not just its apparent behavior. I've seen generated tests that assert the happy path works and never touch the exact edge case where the generated implementation is weakest, because the same gap in specification that produced the weak implementation also shaped what the model considered worth testing.</p>
<p><strong>Explanations that sound confident regardless of correctness.</strong> A generated comment explaining why code works reads with the same confident tone whether the explanation is accurate or not. I verify the explanation against the actual code rather than trusting the comment as confirmation.</p>
<h3>The habit I've built</h3>
<p>I read AI-generated code slower than I initially wanted to, specifically to counteract how much faster it is to produce than to properly review. The speed gain on the writing side is real. It doesn't transfer automatically to the reviewing side, and treating a clean-looking function as lower risk because it reads well is precisely the failure mode that let the balance calculation bug through in the first place.</p>
<h3>The real discipline</h3>
<p>Review AI-generated code with more attention to gaps and assumptions, not less scrutiny because it looks polished. The code still needs to be correct, and correctness was never about how confident or clean something looks on the page.</p>
]]></content:encoded></item><item><title><![CDATA[Unit Testing in .NET with xUnit and Moq]]></title><description><![CDATA[I inherited a codebase once where the previous team was proud of their test coverage number, ninety-something percent. I spent a week making a change that should have been routine, and the tests didn']]></description><link>https://tundehub.dev/unit-testing-in-net-with-xunit-and-moq</link><guid isPermaLink="true">https://tundehub.dev/unit-testing-in-net-with-xunit-and-moq</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Thu, 10 Sep 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/69c6e44c-c2f1-4dd3-a852-a2b9768fd4e1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I inherited a codebase once where the previous team was proud of their test coverage number, ninety-something percent. I spent a week making a change that should have been routine, and the tests didn't catch a single one of the three genuine bugs I introduced along the way. I found all three manually, in staging. That coverage number had measured almost nothing about whether the code actually worked.</p>
<p>Coverage measures execution, not correctness</p>
<p>A test that calls a method and asserts nothing meaningful, or asserts something trivially true, inflates coverage without verifying behavior. Coverage tells you code ran during a test. It tells you nothing about whether the test would have caught a real bug.</p>
<p>Arrange, Act, Assert, and why the shape matters</p>
<p>csharp public class LoanApprovalTests { [Fact] public void Approve_WhenPending_SetsStatusToApproved() { // Arrange var application = new LoanApplication(amount: 5000m); // Act application.Approve(approverId: Guid.NewGuid()); // Assert Assert.Equal(LoanStatus.Approved, application.Status); } }</p>
<p>Keeping the three phases visually distinct pays for itself the first time someone other than you has to debug a failing test six months later.</p>
<p>Mocking with Moq: isolate the thing you're actually testing</p>
<p>csharp [Fact] public async Task ProcessPayment_WhenGatewayFails_ReturnsFailureResult() { var mockGateway = new Mock(); mockGateway.Setup(g =&gt; g.ChargeAsync(It.IsAny())) .ThrowsAsync(new PaymentGatewayException());</p>
<pre><code class="language-plaintext">var service = new PaymentService(mockGateway.Object);
var result = await service.ProcessAsync(100m);

Assert.False(result.IsSuccess);
</code></pre>
<p>}</p>
<p>Mocking the gateway verifies your service's error handling specifically, without a real network call. The failure mode I've seen teams fall into is over-mocking, replacing so much of the real code path that the test ends up verifying the mock's configuration instead of your code's actual behavior.</p>
<p>Testing behavior, not implementation details</p>
<p>Tests coupled to internal implementation details break every time you refactor, even when behavior hasn't changed, which trains people to see failing tests as noise. Test through the public contract, what the class promises callers, not its private internals.</p>
<p>The tests that actually catch bugs are the edge cases</p>
<p>The happy path is usually the easiest part to get right. The genuinely valuable tests check a null input, an empty collection, a boundary value, a concurrent call. The bug that would have shipped on the loan portal wasn't in the standard approval flow, it was in approving an application that had already been rejected.</p>
<p>What I actually optimize for</p>
<p>Not a coverage percentage. Whether the test suite gives genuine confidence to change code without manually re-verifying every affected path. A smaller number of tests that check real behavior gives more of that confidence than a large number that mostly verify the code ran.</p>
]]></content:encoded></item><item><title><![CDATA[gRPC vs REST vs GraphQL: Choosing the Right Protocol]]></title><description><![CDATA[Every time a new project kicks off, someone asks which of these three we should use, and I've learned to be suspicious of anyone who has one universal answer. I've shipped services in all three, somet]]></description><link>https://tundehub.dev/grpc-vs-rest-vs-graphql-choosing-the-right-protocol</link><guid isPermaLink="true">https://tundehub.dev/grpc-vs-rest-vs-graphql-choosing-the-right-protocol</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Wed, 09 Sep 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/9a72f2b0-5423-4fac-a0e4-b2712286be73.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every time a new project kicks off, someone asks which of these three we should use, and I've learned to be suspicious of anyone who has one universal answer. I've shipped services in all three, sometimes on the same platform, and the right choice has always come down to who's calling the API and what they actually need, not which one is currently fashionable.</p>
<p><strong>REST: still the right default for most things</strong></p>
<p>For a public-facing or partner-integrated API, REST remains my default. It's understood by nearly every developer without explanation, works naturally with HTTP caching and load balancers, and its constraints, resources and verbs, are simple enough that a new consumer can guess their way through your API from the URL alone.</p>
<p>csharp</p>
<pre><code class="language-csharp">app.MapGet("/api/policies/{id}", async (Guid id, IPolicyService service) =&gt;
{
    var policy = await service.GetByIdAsync(id);
    return policy is not null ? Results.Ok(policy) : Results.NotFound();
});
</code></pre>
<p>Where REST strains is exactly where its resource-shaped model doesn't match what the consumer needs: a mobile client wanting three fields from what would be five different REST resources.</p>
<p><strong>GraphQL: for consumers with genuinely different data needs</strong></p>
<p>I reach for GraphQL when I have multiple consumers with meaningfully different shapes of the same data, and REST would otherwise force heavy over-fetching or an ever-growing set of specialized endpoints.</p>
<p>graphql</p>
<pre><code class="language-graphql">query { customer(id: "123") { name policies { status premium } } }
</code></pre>
<p>The cost is real: you lose simple URL-based caching, rate limiting has to reason about query complexity, and N+1 doesn't disappear, it just moves into resolvers, needing DataLoader-style batching.</p>
<p><strong>gRPC: for service-to-service calls where performance actually matters</strong></p>
<p>This one gets underused, specifically because it's less familiar. For internal, high-throughput paths where you control both ends, gRPC's binary protocol buffers and HTTP/2 multiplexing give real latency advantages over JSON-over-HTTP.</p>
<p>protobuf</p>
<pre><code class="language-protobuf">service PaymentService {
  rpc ProcessPayment (PaymentRequest) returns (PaymentResponse);
}
</code></pre>
<p>On a fraud detection integration where every millisecond mattered, moving that internal call to gRPC produced a real, noticeable improvement. I wouldn't make that same call for a public-facing API, where gRPC's tooling friction outweighs the benefit for most consumers.</p>
<p><strong>The question I actually ask</strong></p>
<p>Not "which is best," because that has no stable answer. I ask: who is calling this, do I control both ends, and does this specific interaction have a performance or fetching-shape problem the default, REST, doesn't already solve well enough.</p>
<p><strong>Mixing protocols is normal, not messy</strong></p>
<p>A platform I worked on used REST for partner integrations, GraphQL for the mobile app's flexible needs, and gRPC for internal transaction-processing calls. That's matching each interaction to what it needs, not architectural indecision.</p>
<p><strong>The real takeaway</strong></p>
<p>The protocol debate gets treated like a religious argument online. In practice it's an engineering decision: understand your consumers, understand your constraints, pick the tool that fits. None of the three are wrong. They're answers to different questions.</p>
]]></content:encoded></item><item><title><![CDATA[Domain-Driven Design in .NET: A Practical Introduction]]></title><description><![CDATA[I used to think Domain-Driven Design was academic, something you read about but never actually applied outside a conference talk. That changed on a loan management portal where the business rules arou]]></description><link>https://tundehub.dev/domain-driven-design-in-net-a-practical-introduction</link><guid isPermaLink="true">https://tundehub.dev/domain-driven-design-in-net-a-practical-introduction</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Tue, 08 Sep 2026 11:51:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/c950fcb9-e5c4-4be8-a34a-174ef9b28341.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I used to think Domain-Driven Design was academic, something you read about but never actually applied outside a conference talk. That changed on a loan management portal where the business rules around approvals were genuinely complicated, different member categories, different thresholds, exceptions that had exceptions. Trying to express all of that as a service class full of <code>if</code> statements and a bunch of boolean flags on an anemic model became unmanageable fast. DDD, applied practically rather than dogmatically, is what got that codebase back under control.</p>
<p><strong>The core shift: models that know their own rules</strong></p>
<p>Most codebases I've inherited have anemic domain models, classes that are really just data bags, with all the actual logic living in a separate service layer.</p>
<p>csharp</p>
<pre><code class="language-csharp">public class LoanApplication
{
    public decimal Amount { get; set; }
    public LoanStatus Status { get; set; }
}
</code></pre>
<p>DDD pushes validation into the model itself, so it's impossible to represent an invalid state in the first place:</p>
<p>csharp</p>
<pre><code class="language-csharp">public class LoanApplication
{
    public decimal Amount { get; private set; }
    public LoanStatus Status { get; private set; }

    public void Approve(Guid approverId)
    {
        if (Status != LoanStatus.Pending)
            throw new DomainException("Only pending applications can be approved.");
        Status = LoanStatus.Approved;
    }
}
</code></pre>
<p>The private setter matters more than it looks. Nobody outside this class can silently flip the status without going through <code>Approve()</code> and its validation. The rule lives in exactly one place instead of being re-implemented, inconsistently, everywhere the status gets changed.</p>
<p><strong>Ubiquitous language</strong></p>
<p>One underrated part of DDD is insisting code, conversation, and documentation all use the same vocabulary. On the loan portal, stakeholders said "disbursement," not "payout." Once the code matched, translation errors between what was asked for and what got built dropped, because there was no mental mapping step in between.</p>
<p><strong>Bounded contexts</strong></p>
<p>A "member" in the loan approval context has a credit history. A "member" in the notifications context just has an email address. Forcing one shared class to serve both either bloats it with irrelevant fields or creates hidden coupling. Bounded contexts give each part of the system its own accurate model, with an explicit translation layer where they need to talk.</p>
<p><strong>Aggregates</strong></p>
<p>An aggregate is a cluster of objects that must stay consistent as a unit, with one entity as the entry point for change. A <code>LoanApplication</code> and its <code>ApprovalHistory</code> entries formed one aggregate: you can't add a history entry without going through the application itself, which is what keeps the two from drifting out of sync.</p>
<p>csharp</p>
<pre><code class="language-csharp">public class LoanApplication
{
    private readonly List&lt;ApprovalHistoryEntry&gt; _history = new();
    public IReadOnlyList&lt;ApprovalHistoryEntry&gt; History =&gt; _history;

    public void Approve(Guid approverId)
    {
        Status = LoanStatus.Approved;
        _history.Add(new ApprovalHistoryEntry(approverId, DateTime.UtcNow));
    }
}
</code></pre>
<p>Getting aggregate boundaries right is genuinely hard, and I've drawn them wrong more than once. Too large, you get contention. Too small, you lose the consistency guarantee that was the whole point.</p>
<p><strong>Where I don't apply this</strong></p>
<p>A simple CRUD screen for product categories doesn't need rich domain modeling. DDD earns its complexity where business rules are genuinely complex and getting them wrong has real consequences, not uniformly across a codebase.</p>
<p><strong>The real payoff</strong></p>
<p>The loan portal's approval logic became something you could read and trust, because the rules lived in one place, in the business's own language, impossible to bypass accidentally. That's the value proposition: not a purity test, a way of making complex business logic legible and safe to change.</p>
]]></content:encoded></item><item><title><![CDATA["What Production-Grade Engineering Actually Means (Lessons From Four Different Failures)"]]></title><description><![CDATA[Four things I wrote about recently look unrelated on the surface: API versioning, idempotent payments, Kubernetes, technical debt. Put them side by side and they're actually the same lesson, told four]]></description><link>https://tundehub.dev/what-production-grade-engineering-actually-means-lessons-from-four-different-failures</link><guid isPermaLink="true">https://tundehub.dev/what-production-grade-engineering-actually-means-lessons-from-four-different-failures</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Mon, 07 Sep 2026 04:30:00 GMT</pubDate><content:encoded><![CDATA[<p>Four things I wrote about recently look unrelated on the surface: API versioning, idempotent payments, Kubernetes, technical debt. Put them side by side and they're actually the same lesson, told four different ways.</p>
<p>Every one of them is about what happens when reality doesn't cooperate with your plan.</p>
<p>An API versioning strategy exists because you will eventually need to change something a consumer already depends on, and pretending that won't happen doesn't make it not happen. The moment your API has a shape, that shape is a promise. Versioning is just how you keep that promise while still being allowed to grow.</p>
<p>Idempotency in payment APIs exists because networks fail, clients retry, and a request that should happen once can very easily happen twice if you haven't planned for it. I've seen what a missing idempotency check actually costs a customer, and it's never abstract. It's a duplicate charge on someone's real transaction.</p>
<p>Kubernetes forces the same lesson from a different angle. Your pod is not a stable, permanent thing. It will get killed and rescheduled for reasons that have nothing to do with your code being wrong. If your service assumes stability it was never promised, it breaks the first time reality disagrees with that assumption.</p>
<p>And technical debt is the same pattern stretched across time instead of a single request. Every shortcut is a bet that reality won't catch up to it before you get around to fixing it. Sometimes that bet is fine. Sometimes, especially near money or security, it isn't, and the cost compounds quietly until it isn't quiet anymore.</p>
<p>The thread through all four: good engineering isn't mainly about writing code that works the first time. Almost anything works the first time, in a demo, on your machine, with no real traffic hitting it. The actual skill is building things that keep working when a request gets retried, a pod gets killed mid-response, a consumer is still calling last year's endpoint, or a shortcut from eight months ago finally gets exercised by a case nobody tested for.</p>
<p>None of this is exotic. It's mostly a habit of asking "what happens when this doesn't go as planned" before shipping, instead of after an incident forces the question. That one habit, applied consistently, is most of what separates code that works from systems people can actually depend on.  </p>
<p>#SoftwareEngineering</p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes for .NET Developers: What You Actually Need to Know]]></title><description><![CDATA[Kubernetes has a reputation among application developers as something owned entirely by platform or DevOps teams, a black box you deploy into and otherwise ignore. I've worked on a distributed BNPL pl]]></description><link>https://tundehub.dev/kubernetes-for-net-developers-what-you-actually-need-to-know</link><guid isPermaLink="true">https://tundehub.dev/kubernetes-for-net-developers-what-you-actually-need-to-know</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Fri, 04 Sep 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/fe92cbb5-9cff-4f4a-b1de-8cf9ea3c011e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Kubernetes has a reputation among application developers as something owned entirely by platform or DevOps teams, a black box you deploy into and otherwise ignore. I've worked on a distributed BNPL platform running on Kubernetes, and my honest experience is that this reputation does .NET developers a disservice. You don't need to become a cluster administrator, but the developers who understand a handful of core concepts write noticeably better-behaved services than the ones who treat Kubernetes as someone else's problem.</p>
<h3>Pods aren't your application, they're disposable</h3>
<p>The mental shift that matters most: a pod running your .NET service is not a stable, long-lived server. It can be killed and rescheduled at any time, for reasons that have nothing to do with your code, a node failing, a deployment rolling out, the scheduler rebalancing load. Your application needs to assume it will be terminated and restarted regularly, and behave correctly through that.</p>
<p>This changes how you think about state. Anything held only in memory on a single pod, an in-process cache, a background job's progress, session state, disappears the moment that pod is replaced. Externalize what needs to survive: Redis for cache, a database for job state, a distributed session store if you genuinely need server-side sessions.</p>
<h3>Health checks aren't optional, they're how Kubernetes knows what to do with you</h3>
<p>csharp</p>
<pre><code class="language-csharp">builder.Services.AddHealthChecks()
    .AddSqlServer(connectionString, name: "database")
    .AddCheck&lt;DownstreamServiceHealthCheck&gt;("payment-gateway");

app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ =&gt; false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = check =&gt; check.Tags.Contains("ready") });
</code></pre>
<p>Liveness and readiness probes serve genuinely different purposes, and conflating them causes real problems. Liveness answers "should this pod be restarted because it's stuck." Readiness answers "should this pod currently receive traffic." A pod that's alive but not ready, say, still warming up a cache, or temporarily unable to reach its database, should be pulled from the load balancer without being killed and restarted. Mixing the two up means Kubernetes either restarts a healthy pod that's momentarily busy, or keeps sending traffic to one that genuinely can't serve it.</p>
<h3>Resource requests and limits change how your app actually behaves</h3>
<p>yaml</p>
<pre><code class="language-yaml">resources:
  requests:
    memory: "256Mi"
    cpu: "250m"
  limits:
    memory: "512Mi"
    cpu: "500m"
</code></pre>
<p>Requests are what Kubernetes guarantees your pod when scheduling it. Limits are the hard ceiling. Set memory limits too low for a .NET service and you'll see the runtime get OOMKilled during garbage collection spikes that would have been completely normal with more headroom. .NET's server garbage collector, in particular, can be memory-hungry under load, and I've had to explicitly tune <code>DOTNET_gcServer</code> and memory limits together to avoid pods dying under perfectly normal traffic.</p>
<h3>Graceful shutdown is your responsibility, not the platform's</h3>
<p>When Kubernetes terminates a pod, it sends a <code>SIGTERM</code> and gives a grace period, typically 30 seconds by default, before force-killing it. <a href="http://ASP.NET">ASP.NET</a> Core listens for this and gives you <code>IHostApplicationLifetime</code> to hook into it:</p>
<p>csharp</p>
<pre><code class="language-csharp">app.Lifetime.ApplicationStopping.Register(() =&gt;
{
    // stop accepting new work, finish in-flight requests, close connections cleanly
});
</code></pre>
<p>Ignore this and in-flight requests get cut off mid-execution when the grace period expires, exactly the kind of failure that's hard to reproduce locally and painful to debug from logs alone.</p>
<h3>ConfigMaps and Secrets, and knowing which is which</h3>
<p>Configuration that changes per environment but isn't sensitive belongs in a ConfigMap. Credentials, connection strings, API keys belong in a Secret, and even then, a Kubernetes Secret is base64-encoded, not encrypted, by default. On systems handling financial data, I've paired Kubernetes Secrets with an external secrets manager rather than trusting base64 encoding as if it were real protection.</p>
<p>csharp</p>
<pre><code class="language-csharp">builder.Configuration.AddEnvironmentVariables();
</code></pre>
<p>Both ConfigMaps and Secrets typically surface as environment variables or mounted files, and <code>IConfiguration</code> picks them up the same way it picks up anything else, which keeps the application code itself blissfully unaware of where its configuration actually came from.</p>
<h3>Namespaces and resource quotas: know your neighborhood</h3>
<p>On a shared cluster, your service isn't running in isolation. Resource quotas at the namespace level prevent one team's runaway deployment from starving every other service on the same cluster. I've been on both sides of this, the team that got starved, and later, the team that learned to set sane resource requests so we wouldn't be that neighbor.</p>
<h3>The real value of understanding this layer</h3>
<p>None of this makes you a platform engineer. What it does is close the gap between "my service works on my machine" and "my service behaves correctly when Kubernetes inevitably kills, reschedules, or scales it without asking permission." That gap is exactly where a lot of production incidents live, and it's a gap application developers can close themselves, without needing to own the cluster.</p>
]]></content:encoded></item><item><title><![CDATA[Idempotency in Payment APIs: Why It's Non-Negotiable in FinTech]]></title><description><![CDATA[A network blip that would be a minor annoyance almost anywhere else becomes a genuine incident when the request in flight is moving someone's money. On remittance and insurance platforms, I've seen ex]]></description><link>https://tundehub.dev/idempotency-in-payment-apis-why-it-s-non-negotiable-in-fintech</link><guid isPermaLink="true">https://tundehub.dev/idempotency-in-payment-apis-why-it-s-non-negotiable-in-fintech</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Thu, 03 Sep 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/b16f18f5-c458-40a6-aba3-ee6db4b06125.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A network blip that would be a minor annoyance almost anywhere else becomes a genuine incident when the request in flight is moving someone's money. On remittance and insurance platforms, I've seen exactly how this plays out: a client's request times out, their app retries automatically, and without the right protection in place, a customer gets charged twice for a single transfer. Idempotency is the piece of engineering discipline that closes this gap, and in payments, it's not optional.</p>
<h3>What idempotency actually means</h3>
<p>An idempotent operation produces the same result no matter how many times it's executed. <code>GET</code> requests are naturally idempotent, you can call them a thousand times and nothing changes. <code>POST</code> requests that create a resource are not, by default, and a payment endpoint is almost always a <code>POST</code>. That mismatch is exactly where duplicate charges come from.</p>
<h3>Idempotency keys: the standard pattern</h3>
<p>csharp</p>
<pre><code class="language-csharp">app.MapPost("/api/transfers", async (
    TransferRequest request,
    [FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
    ITransferService service) =&gt;
{
    var existing = await service.GetByIdempotencyKeyAsync(idempotencyKey);
    if (existing is not null)
        return Results.Ok(existing);

    var result = await service.ProcessAsync(request, idempotencyKey);
    return Results.Ok(result);
});
</code></pre>
<p>The client generates a unique key per logical operation, usually a GUID, and sends it with every attempt at that same operation, including retries. The server checks whether it's already seen that key before doing anything else. If it has, it returns the original result instead of processing the request again. The client doesn't need to know whether their first attempt actually succeeded before timing out. They just retry safely with the same key.</p>
<h3>Where the naive version breaks</h3>
<p>A common mistake is checking for the idempotency key and processing the request as two separate, non-atomic steps. Under concurrent requests, arriving within milliseconds of each other, both can pass the check before either has recorded the key, and both proceed to charge the customer. The check-and-record needs to happen atomically, typically with a unique database constraint on the idempotency key that causes the second concurrent insert to fail cleanly rather than silently succeed twice.</p>
<p>csharp</p>
<pre><code class="language-csharp">public async Task&lt;TransferResult&gt; ProcessAsync(TransferRequest request, string idempotencyKey)
{
    try
    {
        await _repository.InsertIdempotencyRecordAsync(idempotencyKey);
    }
    catch (UniqueConstraintViolationException)
    {
        return await _repository.GetResultByIdempotencyKeyAsync(idempotencyKey);
    }

    return await ExecuteTransferAsync(request);
}
</code></pre>
<p>The unique constraint at the database level is what actually makes this safe under concurrency, not the application-level check that runs before it.</p>
<h3>What happens while the first request is still processing</h3>
<p>There's a subtle window where a retry can arrive while the original request is still being processed, not yet finished, not yet failed. A naive implementation might not find a completed result yet and process the request again. Handling this properly means recording the idempotency key as "in progress" before starting the actual work, and having a retry that hits an in-progress record wait for or poll the original result, rather than starting a second execution.</p>
<h3>Idempotency keys need to expire, deliberately</h3>
<p>Keeping every idempotency key forever isn't practical, and it's also not usually necessary. A reasonable retention window, often 24 hours, covers realistic retry scenarios like a client retrying after a timeout or a brief outage. After that window, a repeated key can be treated as a genuinely new request. Where I draw that line depends on the specific failure modes I'm protecting against, not a default I copy from project to project.</p>
<h3>This applies beyond the payment call itself</h3>
<p>It's tempting to think idempotency is only about the initial charge, but the same problem shows up anywhere a retry could duplicate an effect: sending a confirmation email twice, reserving inventory twice, triggering a webhook twice. Any operation triggered by an event that might be delivered more than once needs to ask the same question a payment endpoint does: what happens if this runs twice?</p>
<h3>The real takeaway</h3>
<p>Idempotency isn't a defensive afterthought bolted onto a payment API. It's a basic acknowledgment that networks are unreliable, clients retry, and a system handling real money has to behave correctly under those conditions by design, not by luck. Every payment-adjacent endpoint I build starts with this question before a single line of business logic gets written.</p>
]]></content:encoded></item><item><title><![CDATA[API Versioning Strategies That Don't Break Your Consumers]]></title><description><![CDATA[The first time I had to make a breaking change to a live API with real, external consumers, I learned a lesson the hard way: it doesn't matter how good your new design is if it takes down someone else]]></description><link>https://tundehub.dev/api-versioning-strategies-that-don-t-break-your-consumers</link><guid isPermaLink="true">https://tundehub.dev/api-versioning-strategies-that-don-t-break-your-consumers</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Wed, 02 Sep 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/900058c7-5079-4e1b-a1eb-0d61924e5732.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first time I had to make a breaking change to a live API with real, external consumers, I learned a lesson the hard way: it doesn't matter how good your new design is if it takes down someone else's production system without warning. Versioning isn't a nice-to-have feature you add when you get around to it. It's a promise you make to every consumer the moment your API goes live, whether you've thought about it explicitly or not.</p>
<h3>The promise you're actually making</h3>
<p>Every API response shape, every field, every status code behavior becomes a contract the instant someone builds against it. Change it without warning and you've broken that contract, regardless of how justified the change was on your end. Versioning is how you change your mind without breaking that promise.</p>
<h3>URL versioning: the one I default to</h3>
<p>csharp</p>
<pre><code class="language-csharp">app.MapGroup("/api/v1/policies").MapPolicyEndpointsV1();
app.MapGroup("/api/v2/policies").MapPolicyEndpointsV2();
</code></pre>
<p>Header-based versioning is arguably more "correct" from a REST purist's standpoint, keeping the URL stable while the version travels in an <code>Accept</code> header. In practice, on systems where third parties integrate against you, URL versioning wins because it's impossible to get wrong by accident. A consumer can see the version in the URL, log it, debug against it, and never wonder if they forgot to set a header correctly.</p>
<h3>Additive changes don't need a new version at all</h3>
<p>Not every change is breaking. Adding a new optional field to a response, adding a new endpoint, adding a new optional query parameter, none of these break an existing consumer who ignores fields they don't recognize. I reserve new versions specifically for breaking changes: removing a field, changing a field's type, changing required behavior. Bumping a version for every minor addition trains consumers to distrust your versioning scheme entirely, because now they can't tell which version bumps actually matter.</p>
<h3>Deprecation needs a timeline, not just a warning</h3>
<p>csharp</p>
<pre><code class="language-csharp">app.MapGroup("/api/v1/policies")
   .MapPolicyEndpointsV1()
   .WithMetadata(new ApiDeprecatedAttribute(sunset: "2026-12-01"));
</code></pre>
<p>Announcing a version is deprecated without a concrete sunset date is close to meaningless. Consumers deprioritize open-ended warnings behind whatever's actually on fire this week. A specific date, communicated well in advance and included in response headers, gives them something to actually plan around.</p>
<pre><code class="language-plaintext">Sunset: Tue, 01 Dec 2026 00:00:00 GMT
Deprecation: true
Link: &lt;https://api.example.com/docs/migration-v1-v2&gt;; rel="deprecation"
</code></pre>
<h3>Running two versions in parallel is real operational cost</h3>
<p>This is the part that gets underestimated. Maintaining v1 and v2 side by side means double the test surface, double the documentation, and double the places a bug can hide. I don't take on that cost casually. Before shipping a breaking change, I ask whether it's genuinely necessary or whether it could be delivered as an additive change instead. Most of the time, with enough thought, it can.</p>
<h3>Internal APIs get a different calculus</h3>
<p>For APIs with a small number of known internal consumers, I've sometimes skipped formal versioning entirely in favor of coordinated deployments, where the API and its consumers ship together and breaking changes are a conversation, not a migration. That only works when you actually control both sides. The moment an API has consumers outside your direct coordination, formal versioning stops being optional.</p>
<h3>The real discipline</h3>
<p>Versioning done well is invisible to consumers, they simply never get surprised. Versioning done poorly announces itself loudly, usually through a support ticket at 2am from someone whose integration just broke. The difference isn't the versioning scheme you pick. It's whether you treat every public-facing change as a promise you're responsible for keeping.</p>
]]></content:encoded></item><item><title><![CDATA[After A Decade of Experience]]></title><description><![CDATA[I've had a lot of conversations with developers over the years. Mentoring sessions, Podcast sessions, late-night messages when someone's stuck on a bug, conversations after meetups that go longer than]]></description><link>https://tundehub.dev/after-a-decade-of-experience</link><guid isPermaLink="true">https://tundehub.dev/after-a-decade-of-experience</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Mon, 31 Aug 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/d7a16be9-8045-4ca0-85bf-03db95d504eb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've had a lot of conversations with developers over the years. Mentoring sessions, Podcast sessions, late-night messages when someone's stuck on a bug, conversations after meetups that go longer than the meetup itself. A few things keep coming up, often enough that I think they're worth writing down properly instead of just repeating in passing.</p>
<p>The developers who grow fastest aren't usually the most naturally talented ones. They're the ones who ask why something works, not just how to make it work, and who have someone around willing to answer honestly instead of just fixing it for them. I learned this the hard way as a mentor too. Early on, when someone brought me a bug, I'd just look at the code and tell them the fix. It felt helpful in the moment. It taught them almost nothing. Now I ask what they've already tried and what they expected to happen instead. More often than not, they find the bug themselves halfway through explaining it to me, and they walk away having actually practiced debugging instead of watching me do it for them. If you're mentoring someone, that shift alone is worth making. If you're the one being mentored, ask for that kind of space instead of just the answer.</p>
<p>AI has changed how I work, and how almost every developer I talk to works. I'm not going to pretend otherwise. Writing a first version of something, generating tests, working through a rough draft of an implementation, all of that moves faster than it did a few years ago. But the developers who are actually thriving with these tools aren't the ones who've handed over their thinking. They're the ones who've gotten sharper at the parts AI still can't do for them. Knowing which approach actually fits the problem in front of you. Catching when a generated solution looks right but will quietly break under real load or edge cases. Being able to explain a tradeoff clearly to someone who isn't technical. AI has made it faster to produce code. It hasn't made it faster to develop judgment, and judgment is still what separates someone who ships working software from someone who ships software that survives contact with real users. If you're early in your career, that's the skill worth building deliberately, because it's the one that doesn't get replaced by a better model next year.</p>
<p>I get asked a lot whether .NET is still worth learning, usually by developers weighing it against something newer. I always give the same answer. The platforms that last aren't the ones that chase trends, they're the ones that keep solving real problems as those problems change. .NET has done that for two decades now, and it's still doing it. Minimal APIs made it easier to get started without giving up the maturity the platform is known for. Performance work has made it genuinely competitive in places it wasn't before. The newer tooling around cloud-native development is aimed at the unglamorous, everyday friction of running services locally and in production, which is exactly the kind of problem that actually matters once you're past the tutorial stage. A platform that keeps solving real problems tends to keep the developers who build on it.</p>
<p>If there's one thing I would want a developer earlier in their career to take from all of this, it's this: the mechanical part of writing code is getting easier and faster for everyone, so it stops being what makes you valuable. What doesn't get automated is knowing why you're building something a certain way, being able to teach that reasoning to someone else, and being honest about tradeoffs instead of hiding behind a tool's output. Build that, and you'll be fine no matter what changes next.</p>
]]></content:encoded></item><item><title><![CDATA[Background Jobs in .NET with Hangfire]]></title><description><![CDATA[Not every piece of work belongs in the request-response cycle. Sending a confirmation email, generating a report, syncing data with a third-party service, none of that needs to happen before you retur]]></description><link>https://tundehub.dev/background-jobs-in-net-with-hangfire</link><guid isPermaLink="true">https://tundehub.dev/background-jobs-in-net-with-hangfire</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Fri, 28 Aug 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/e82b01d2-c42e-4dd6-9d23-8f35dfe586c5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Not every piece of work belongs in the request-response cycle. Sending a confirmation email, generating a report, syncing data with a third-party service, none of that needs to happen before you return a response to the user, and forcing it to happen synchronously just makes your API slower for no real benefit. Hangfire has been my default for background job processing in .NET for exactly this reason: it's simple to set up and handles the operational concerns, persistence, retries, dashboards, that you'd otherwise have to build yourself.</p>
<h3>Fire-and-forget for work that shouldn't block the response</h3>
<p>csharp</p>
<pre><code class="language-csharp">app.MapPost("/api/orders", async (Order order, IOrderService service) =&gt;
{
    await service.CreateAsync(order);
    BackgroundJob.Enqueue&lt;INotificationService&gt;(n =&gt; n.SendOrderConfirmationAsync(order.Id));
    return Results.Ok(order);
});
</code></pre>
<p>The API returns immediately once the order is actually saved. The confirmation email goes out on its own timeline, on its own thread, without the customer waiting on an email provider's response time before they see their order confirmed.</p>
<h3>Delayed jobs for anything that needs to happen later</h3>
<p>csharp</p>
<pre><code class="language-csharp">BackgroundJob.Schedule&lt;ISubscriptionService&gt;(
    s =&gt; s.SendRenewalReminderAsync(subscriptionId),
    TimeSpan.FromDays(subscription.DaysUntilExpiry - 7));
</code></pre>
<p>This replaces what used to require a separate scheduled task or a homegrown polling mechanism, with a single line that Hangfire persists and guarantees will run, even across application restarts.</p>
<h3>Recurring jobs, declared once</h3>
<p>csharp</p>
<pre><code class="language-csharp">RecurringJob.AddOrUpdate&lt;IReportService&gt;(
    "daily-transaction-summary",
    r =&gt; r.GenerateDailySummaryAsync(),
    Cron.Daily);
</code></pre>
<p>I've replaced more than one fragile cron-job-plus-console-app setup with this pattern. It's persisted, visible in the dashboard, and doesn't depend on a separate deployment or a server-level scheduled task that nobody remembers exists until it silently stops running.</p>
<h3>Retries need the same thought as any other resilience concern</h3>
<p>By default, Hangfire retries a failed job automatically, which is useful but not automatically safe. A job that partially succeeded before failing, say, it charged a customer but failed to send the confirmation, will retry the entire job, including the part that already succeeded, unless you've made the job idempotent.</p>
<p>csharp</p>
<pre><code class="language-csharp">public async Task ProcessPaymentAsync(Guid paymentId)
{
    if (await _repository.IsAlreadyProcessedAsync(paymentId)) return;

    await _paymentGateway.ChargeAsync(paymentId);
    await _repository.MarkProcessedAsync(paymentId);
}
</code></pre>
<p>This is the same idempotency discipline that matters for message consumers in an event-driven system, and it applies here for exactly the same reason: at-least-once execution is the guarantee you actually get, not exactly-once.</p>
<h3>The dashboard is more useful than it looks at first</h3>
<p>Hangfire's built-in dashboard shows queued, processing, succeeded, and failed jobs, and it's saved me real debugging time on more than one occasion, being able to see exactly which job failed, how many times, and with what exception, without digging through log files first.</p>
<p>csharp</p>
<pre><code class="language-csharp">app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[] { new HangfireAuthorizationFilter() }
});
</code></pre>
<p>That authorization filter isn't optional. An unsecured Hangfire dashboard exposes internal job details and lets anyone trigger jobs manually, which is exactly as risky as it sounds.</p>
<h3>Job priorities and queues for mixed workloads</h3>
<p>Not every job deserves equal footing. A time-sensitive payment retry shouldn't sit behind a low-priority batch report in the same queue.</p>
<p>csharp</p>
<pre><code class="language-csharp">[Queue("critical")]
public async Task ProcessRefundAsync(Guid refundId) { }

[Queue("low-priority")]
public async Task GenerateMonthlyReportAsync() { }
</code></pre>
<p>Separate queues, processed by separate workers if needed, keep a backlog of low-priority work from delaying something time-sensitive.</p>
<h3>Where I draw the line</h3>
<p>Hangfire is excellent for jobs measured in seconds to minutes. For genuinely long-running batch processing, or workloads that need horizontal scaling well beyond what a single server's worker pool handles comfortably, I look at dedicated tools built for that scale instead of stretching Hangfire past what it's suited for. Knowing that boundary matters as much as knowing how to use the tool itself.</p>
<h3>The real value</h3>
<p>Background jobs let your API stay fast and responsive while the actual work happens on its own schedule. The part that's easy to get wrong isn't the setup, Hangfire makes that simple, it's the idempotency and monitoring discipline around it that determines whether background processing quietly does its job or becomes its own source of confusing, hard-to-reproduce bugs.</p>
]]></content:encoded></item><item><title><![CDATA[Code Review Culture: How to Give Feedback That Improves Code]]></title><description><![CDATA[I've sat on both sides of enough code reviews to notice a pattern: the teams that ship the most reliable code aren't the ones with the strictest reviewers. They're the ones where code review actually ]]></description><link>https://tundehub.dev/code-review-culture-how-to-give-feedback-that-improves-code</link><guid isPermaLink="true">https://tundehub.dev/code-review-culture-how-to-give-feedback-that-improves-code</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Thu, 27 Aug 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/d8cb4934-9074-46dd-aff8-3294b33f523a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've sat on both sides of enough code reviews to notice a pattern: the teams that ship the most reliable code aren't the ones with the strictest reviewers. They're the ones where code review actually functions as a conversation instead of a gate someone has to get past.</p>
<h3>The gate mentality is the root problem</h3>
<p>When code review is treated purely as approval-seeking, something a developer has to survive to merge their work, the incentives get twisted. Reviewers rubber-stamp things to avoid conflict, or developers game the process by picking the reviewer least likely to push back. Neither produces better code. The review stops being about the code and starts being about clearing a hurdle.</p>
<p>I try to frame reviews, explicitly, as a second set of eyes on a shared problem, not a checkpoint one person has to pass and another has to guard.</p>
<h3>Every comment should carry a reason</h3>
<p>"This is wrong" tells someone nothing they can use next time. "This will fire a separate query per item in this loop, worth wrapping it in <code>.Include()</code> to batch it" tells them exactly what's wrong and why, and they'll likely catch it themselves next time without needing the comment repeated.</p>
<pre><code class="language-plaintext">// Less useful:
Change this.

// More useful:
This creates a new HttpClient per request, which can exhaust socket 
connections under load. Worth injecting IHttpClientFactory instead.
</code></pre>
<p>The extra sentence costs almost nothing to write and changes whether the feedback teaches something or just gets grudgingly applied and forgotten.</p>
<h3>Separate "this will break" from "I would have done it differently"</h3>
<p>Not every comment carries the same weight, and treating them all the same in tone flattens that distinction in a way that erodes trust over time. A correctness issue, a missing null check, an unhandled exception path, a genuine security gap, deserves a firm, clear comment. A stylistic preference deserves a much softer one, or sometimes no comment at all. I've seen reviewers who apply the same tone to both, and it trains people to either ignore all feedback as noise or become defensive about all of it, neither of which helps.</p>
<p>I try to be explicit about which category a comment falls into: "this will break under concurrent load" versus "small nit, feel free to ignore."</p>
<h3>Ask questions instead of issuing verdicts, when you're not certain</h3>
<p>If I don't fully understand why a decision was made, I ask rather than assume it's wrong. "What led you to this approach over X?" often surfaces a constraint I didn't know about, and it keeps the conversation collaborative instead of adversarial. Sometimes the answer reveals a genuine problem. Sometimes it reveals I was missing context. Either way, the question got there faster and with less friction than a confident, wrong correction would have.</p>
<h3>Review the design before the syntax</h3>
<p>Catching a missing semicolon or an inconsistent variable name matters, but it matters far less than catching a fundamentally wrong approach before it's fully built out. I try to review structure and approach first, ideally before a PR is even opened, in a quick design conversation, rather than only encountering a bad architectural decision after someone has spent two days implementing it in full.</p>
<h3>Respond to reviews as quickly as you'd want your own reviewed</h3>
<p>A PR that sits unreviewed for three days doesn't just delay one developer, it often blocks whatever depended on that work, and it quietly teaches people that code review is a bottleneck to route around rather than a normal part of shipping. I treat review turnaround as seriously as I treat my own deadlines, because from the other side of the review, it's exactly that.</p>
<h3>The measure of a healthy review culture</h3>
<p>The best signal I've found isn't review speed or comment count, it's whether people feel comfortable submitting work they're genuinely unsure about, without fear of the review turning into a personal critique. That comfort is what actually produces honest, catchable-early mistakes instead of code that's been polished defensively to survive review rather than to be correct.</p>
]]></content:encoded></item><item><title><![CDATA[Caching Strategies with Redis in .NET Applications]]></title><description><![CDATA[Caching has a reputation for being simple: store the result, skip the expensive work next time. In practice, it's one of the areas where a well-intentioned shortcut most often turns into a subtle prod]]></description><link>https://tundehub.dev/caching-strategies-with-redis-in-net-applications</link><guid isPermaLink="true">https://tundehub.dev/caching-strategies-with-redis-in-net-applications</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Wed, 26 Aug 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/2041b9e7-05ad-4ff2-86bd-6556ec113a83.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Caching has a reputation for being simple: store the result, skip the expensive work next time. In practice, it's one of the areas where a well-intentioned shortcut most often turns into a subtle production bug, usually involving stale data showing up somewhere a user notices before an engineer does. Redis has been my default caching layer across several platforms, and the patterns below are the ones that have kept it a genuine performance win instead of a source of confusing bugs.</p>
<h3>Cache-aside: the pattern I reach for most</h3>
<p>csharp</p>
<pre><code class="language-csharp">public async Task&lt;Customer&gt; GetCustomerAsync(Guid id)
{
    var cached = await _cache.GetStringAsync($"customer:{id}");
    if (cached is not null)
        return JsonSerializer.Deserialize&lt;Customer&gt;(cached);

    var customer = await _repository.GetByIdAsync(id);
    await _cache.SetStringAsync($"customer:{id}", JsonSerializer.Serialize(customer),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });

    return customer;
}
</code></pre>
<p>Check the cache first, fall back to the source of truth on a miss, populate the cache for next time. It's simple, and simple is a feature when a dozen developers will touch this code over the life of a project.</p>
<h3>Expiration is a business decision, not a technical afterthought</h3>
<p>The single most common caching mistake I see isn't a missing cache, it's a cache with no thought put into how long the data should live. Reference data that rarely changes, product categories, country lists, can safely live in cache for hours. A customer's account balance cannot, and treating both the same way, either both cached aggressively or both cached cautiously, gets one of them wrong every time.</p>
<p>csharp</p>
<pre><code class="language-csharp">var expiration = category switch
{
    CacheCategory.ReferenceData =&gt; TimeSpan.FromHours(6),
    CacheCategory.UserProfile =&gt; TimeSpan.FromMinutes(15),
    CacheCategory.FinancialBalance =&gt; TimeSpan.FromSeconds(30),
    _ =&gt; TimeSpan.FromMinutes(5)
};
</code></pre>
<p>I ask a specific question for every cached value: what's the actual cost of this being stale for the expiration window I've chosen? For financial data, that cost is high and the window should be short or nonexistent. For a list of insurance product categories, the cost is close to zero.</p>
<h3>Cache invalidation on write, not just expiration</h3>
<p>Waiting for a TTL to expire is fine for data where brief staleness is acceptable. It's not fine when a user just updated their own profile and immediately sees the old version reflected back at them. Explicit invalidation on write closes that gap:</p>
<p>csharp</p>
<pre><code class="language-csharp">public async Task UpdateCustomerAsync(Customer customer)
{
    await _repository.SaveAsync(customer);
    await _cache.RemoveAsync($"customer:{customer.Id}");
}
</code></pre>
<p>This is a small addition that prevents one of the more common and more visible caching bugs: a user makes a change, refreshes, and sees their old data staring back at them.</p>
<h3>The thundering herd problem</h3>
<p>When a popular cache key expires, a burst of concurrent requests can all miss the cache simultaneously and all hammer the database at once to repopulate it, briefly recreating exactly the load problem the cache exists to prevent. On a high-traffic ticketing-adjacent platform, this was a real issue during popular event releases. Locking around cache population, so only one request repopulates the cache while others wait briefly for that result, solves it:</p>
<p>csharp</p>
<pre><code class="language-csharp">public async Task&lt;Customer&gt; GetCustomerAsync(Guid id)
{
    var cached = await _cache.GetStringAsync($"customer:{id}");
    if (cached is not null) return JsonSerializer.Deserialize&lt;Customer&gt;(cached);

    using var lockHandle = await _lockProvider.AcquireLockAsync($"lock:customer:{id}", TimeSpan.FromSeconds(5));
    // re-check cache after acquiring lock, another request may have already populated it
    cached = await _cache.GetStringAsync($"customer:{id}");
    if (cached is not null) return JsonSerializer.Deserialize&lt;Customer&gt;(cached);

    var customer = await _repository.GetByIdAsync(id);
    await _cache.SetStringAsync($"customer:{id}", JsonSerializer.Serialize(customer));
    return customer;
}
</code></pre>
<h3>Distributed cache, not per-instance memory cache, in a multi-instance deployment</h3>
<p><code>IMemoryCache</code> is fast because it lives in-process, but that's also its limitation: in a load-balanced deployment with multiple instances, each instance has its own separate cache, and invalidating on one instance does nothing for the others. Redis, as a shared distributed cache, keeps every instance seeing the same data. I use <code>IMemoryCache</code> only for genuinely instance-local concerns, and Redis for anything that needs to be consistent across a fleet of running instances.</p>
<h3>What I don't cache</h3>
<p>Not everything benefits from caching. Data that changes on nearly every read, or data where correctness matters more than the milliseconds saved, sometimes just isn't worth the invalidation complexity. Caching is a tool for a specific problem, expensive or frequent reads of relatively stable data, not a default applied everywhere out of habit.</p>
<h3>The real discipline</h3>
<p>Redis makes caching technically easy. What actually keeps a cache trustworthy is being deliberate about expiration per data type, invalidating on write where staleness is visible to users, and thinking through concurrent access patterns before they become a production incident. The technology is the easy part.</p>
]]></content:encoded></item><item><title><![CDATA[Event-Driven Architecture with .NET and RabbitMQ]]></title><description><![CDATA[The shift from calling services directly to communicating through events is one of the harder architectural jumps to make, not because the code is complicated, but because it requires unlearning a men]]></description><link>https://tundehub.dev/event-driven-architecture-with-net-and-rabbitmq</link><guid isPermaLink="true">https://tundehub.dev/event-driven-architecture-with-net-and-rabbitmq</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Mon, 24 Aug 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/687360129578b6eb869e36ab/3a11b352-9357-4f2a-baac-eb204be27399.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The shift from calling services directly to communicating through events is one of the harder architectural jumps to make, not because the code is complicated, but because it requires unlearning a mental model most of us start with: that a service should know who it's talking to. On systems where I've introduced event-driven patterns, the payoff has been real, but so has the learning curve for teams making that shift for the first time.</p>
<h3>What actually changes</h3>
<p>In a request-response world, Service A calls Service B directly, waits for a response, and Service B has to be up and responsive for that to work. In an event-driven model, Service A publishes "OrderPlaced" and moves on. Whoever cares about that event, inventory, notifications, billing, subscribes independently, and Service A never knows or cares who's listening.</p>
<p>csharp</p>
<pre><code class="language-csharp">public async Task PlaceOrderAsync(Order order)
{
    await _repository.SaveAsync(order);
    await _publisher.PublishAsync(new OrderPlacedEvent(order.Id, order.CustomerId, order.Total));
}
</code></pre>
<p>The publishing service is now decoupled from every downstream consumer. You can add a new consumer, say, a fraud check that runs on every new order, without touching the order service at all. That's the real value: change isolation, not just async processing for its own sake.</p>
<h3>Setting up RabbitMQ in .NET</h3>
<p>MassTransit has become my default abstraction over raw RabbitMQ client code, because it handles a lot of the operational plumbing, retries, dead-lettering, serialization, that you'd otherwise hand-roll and get subtly wrong.</p>
<p>csharp</p>
<pre><code class="language-csharp">builder.Services.AddMassTransit(x =&gt;
{
    x.AddConsumer&lt;OrderPlacedConsumer&gt;();
    x.UsingRabbitMq((context, cfg) =&gt;
    {
        cfg.Host("rabbitmq://localhost");
        cfg.ConfigureEndpoints(context);
    });
});
</code></pre>
<p>csharp</p>
<pre><code class="language-csharp">public class OrderPlacedConsumer : IConsumer&lt;OrderPlacedEvent&gt;
{
    public async Task Consume(ConsumeContext&lt;OrderPlacedEvent&gt; context)
    {
        var order = context.Message;
        await _inventoryService.ReserveStockAsync(order.OrderId);
    }
}
</code></pre>
<h3>The question everyone underestimates: what happens when a message fails</h3>
<p>This is where event-driven systems separate teams that have done it before from teams doing it for the first time. A message that fails processing shouldn't just vanish, and it shouldn't infinitely retry and block the queue either. Dead-letter queues, where a message that's failed a defined number of times gets routed somewhere for inspection rather than lost or endlessly retried, are not optional in production.</p>
<p>csharp</p>
<pre><code class="language-csharp">cfg.ReceiveEndpoint("order-placed-queue", e =&gt;
{
    e.UseMessageRetry(r =&gt; r.Interval(3, TimeSpan.FromSeconds(5)));
    e.ConfigureConsumer&lt;OrderPlacedConsumer&gt;(context);
});
</code></pre>
<p>I treat the dead-letter queue as something a human actually monitors, not a place messages go to be forgotten. A growing dead-letter queue is an early warning sign of a bug or a downstream outage, and it's easy to miss if nobody's watching it.</p>
<h3>Idempotent consumers, always</h3>
<p>Message brokers generally guarantee at-least-once delivery, not exactly-once. That means your consumer will, eventually, receive the same message twice, whether from a network blip, a retry, or a redelivery after a crash before acknowledgment. If processing that event twice reserves stock twice or charges a customer twice, that's a bug in your consumer, not a broker malfunction.</p>
<p>csharp</p>
<pre><code class="language-csharp">public async Task Consume(ConsumeContext&lt;OrderPlacedEvent&gt; context)
{
    if (await _repository.AlreadyProcessedAsync(context.MessageId)) return;

    await _inventoryService.ReserveStockAsync(context.Message.OrderId);
    await _repository.MarkProcessedAsync(context.MessageId);
}
</code></pre>
<h3>Eventual consistency is a real tradeoff, not a footnote</h3>
<p>The moment you go event-driven, you're accepting that different parts of your system will briefly disagree about the state of the world. Inventory might show a reservation a few hundred milliseconds before billing has processed the corresponding charge. For most business processes, that's completely fine. For anything where a customer could act on stale information in that gap, this needs to be a deliberate design decision, not something discovered after a customer complaint.</p>
<h3>When I don't reach for this pattern</h3>
<p>Not every interaction needs to be an event. If Service A genuinely needs a response before it can proceed, and that response needs to be synchronous, forcing that into an event-driven shape adds complexity without adding value. I reach for events specifically where the interaction is naturally "notify and move on," not for every service-to-service call by default.</p>
<h3>The real payoff</h3>
<p>The decoupling event-driven architecture buys you is real, but it comes with genuine operational complexity: monitoring dead-letter queues, designing idempotent consumers, and accepting eventual consistency where it doesn't hurt the business. Teams that adopt the pattern without budgeting for that complexity usually end up with a system that's harder to debug than the tightly coupled one it replaced.</p>
]]></content:encoded></item><item><title><![CDATA[Dependency Injection in .NET: Beyond the Basics]]></title><description><![CDATA[Most .NET developers know how to register a service and inject it into a constructor. Far fewer have thought carefully about service lifetimes, and that gap is where I've seen some of the more confusi]]></description><link>https://tundehub.dev/dependency-injection-in-net-beyond-the-basics</link><guid isPermaLink="true">https://tundehub.dev/dependency-injection-in-net-beyond-the-basics</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Fri, 21 Aug 2026 04:30:00 GMT</pubDate><content:encoded><![CDATA[<p>Most .NET developers know how to register a service and inject it into a constructor. Far fewer have thought carefully about service lifetimes, and that gap is where I've seen some of the more confusing bugs show up in production, the kind that only appear under load and are miserable to trace back to their actual cause.</p>
<h3>The three lifetimes, and why the choice isn't cosmetic</h3>
<p>csharp</p>
<pre><code class="language-csharp">builder.Services.AddSingleton&lt;ICacheService, CacheService&gt;();
builder.Services.AddScoped&lt;IOrderRepository, OrderRepository&gt;();
builder.Services.AddTransient&lt;IEmailFormatter, EmailFormatter&gt;();
</code></pre>
<p>Singleton creates one instance for the entire application lifetime. Scoped creates one instance per request. Transient creates a new instance every time it's injected. This isn't a stylistic choice, it directly determines whether your service is safe to share state in, and getting it wrong produces bugs that only show up under concurrent load.</p>
<h3>The mistake that actually causes production incidents</h3>
<p>Injecting a scoped service into a singleton is the classic trap, and .NET's default container will throw at startup if you try it directly, which is a mercy. But it's easy to work around that protection accidentally through a factory or a manually resolved service, and end up with a singleton silently holding onto a DbContext instance from the very first request it ever handled, then reusing that same, now-stale context for every request afterward.</p>
<p>csharp</p>
<pre><code class="language-csharp">// Dangerous: capturing a scoped DbContext inside a singleton
public class BackgroundReportService
{
    private readonly AppDbContext _context; // captured once, reused forever

    public BackgroundReportService(AppDbContext context) =&gt; _context = context;
}
</code></pre>
<p>The fix is to inject <code>IServiceScopeFactory</code> and create a fresh scope whenever the singleton needs to talk to something scoped:</p>
<p>csharp</p>
<pre><code class="language-csharp">public class BackgroundReportService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public BackgroundReportService(IServiceScopeFactory scopeFactory) =&gt; _scopeFactory = scopeFactory;

    public async Task GenerateReportAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
        // work with a fresh, correctly-scoped context
    }
}
</code></pre>
<p>This exact pattern is the fix for one of the more confusing bugs I've debugged: a background service that appeared to work fine in testing and then started returning stale data in production once real concurrent traffic hit it.</p>
<h3>Constructor injection is the default for a reason</h3>
<p>Property injection and service locator patterns exist in .NET, but I avoid both outside of narrow edge cases. Constructor injection makes a class's dependencies explicit and visible in one place, which means a class with twelve constructor parameters is honest, uncomfortable feedback that the class is doing too much. Hiding dependencies behind property injection just delays that discomfort without removing the underlying problem.</p>
<h3>Interfaces for testability, not ceremony</h3>
<p>I don't create an interface for every single class reflexively. I create one when there's a genuine reason: a dependency I need to mock in tests, or a real chance of swapping the implementation later. An interface with exactly one implementation and no test that mocks it is pure ceremony, extra indirection with no actual benefit.</p>
<h3>Options pattern over injecting raw configuration</h3>
<p>csharp</p>
<pre><code class="language-csharp">builder.Services.Configure&lt;PaymentSettings&gt;(builder.Configuration.GetSection("Payment"));
</code></pre>
<p>csharp</p>
<pre><code class="language-csharp">public class PaymentService
{
    private readonly PaymentSettings _settings;
    public PaymentService(IOptions&lt;PaymentSettings&gt; options) =&gt; _settings = options.Value;
}
</code></pre>
<p>This keeps configuration strongly typed and testable, instead of scattering <code>IConfiguration["Payment:ApiKey"]</code> string lookups throughout the codebase where a typo in a key name fails silently at runtime instead of loudly at startup.</p>
<h3>The habit that prevents most of this</h3>
<p>Before registering a new service, I ask what it needs to hold onto, and for how long. That single question, asked consistently, prevents most of the lifetime mismatches that otherwise only surface once real concurrent traffic finds them.</p>
]]></content:encoded></item><item><title><![CDATA[Building Fraud Detection Systems: Lessons from a BNPL Platform]]></title><description><![CDATA[Leading the design of fraud detection for a distributed BNPL platform taught me something I didn't fully appreciate going in: fraud detection isn't primarily a machine learning problem. The model is o]]></description><link>https://tundehub.dev/building-fraud-detection-systems-lessons-from-a-bnpl-platform</link><guid isPermaLink="true">https://tundehub.dev/building-fraud-detection-systems-lessons-from-a-bnpl-platform</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Thu, 20 Aug 2026 04:30:00 GMT</pubDate><content:encoded><![CDATA[<p>Leading the design of fraud detection for a distributed BNPL platform taught me something I didn't fully appreciate going in: fraud detection isn't primarily a machine learning problem. The model is one piece. The harder engineering problem is building a system that can make a real-time decision, under latency pressure, without either blocking legitimate customers or letting obvious fraud through, while staying maintainable as fraud patterns keep shifting.</p>
<h3>The latency constraint changes everything</h3>
<p>A fraud check that takes three seconds is often worse than a slightly less accurate one that takes 200 milliseconds, because the customer is standing at a checkout screen waiting. This constraint shaped almost every architectural decision on that platform. We couldn't run every possible check for every transaction. We had to build a tiered system: fast, cheap rules that caught obvious fraud immediately, backed by a more expensive model-based check only when the cheap rules didn't produce a confident answer either way.</p>
<p>csharp</p>
<pre><code class="language-csharp">public async Task&lt;FraudDecision&gt; EvaluateAsync(Transaction transaction)
{
    var quickResult = _ruleEngine.Evaluate(transaction);
    if (quickResult.Confidence &gt; 0.9) return quickResult.Decision;

    return await _mlScoringService.ScoreAsync(transaction);
}
</code></pre>
<p>This tiered approach kept the common case fast while still giving genuinely ambiguous transactions the deeper evaluation they needed.</p>
<h3>Rules and models need to coexist, not compete</h3>
<p>Early on there was a temptation to treat the ML model as the eventual replacement for hand-written rules. In practice, the two served different purposes and both stayed valuable. Rules were transparent, fast to update, and easy to explain to a compliance team asking why a specific transaction was flagged. The model caught subtler patterns rules couldn't easily express, but its decisions were harder to explain after the fact. Keeping both, with rules as the first line and the model as the deeper check, gave us both speed and explainability where each mattered most.</p>
<h3>Feedback loops matter more than the initial model</h3>
<p>Fraud patterns don't stay still. A model trained on last year's fraud data degrades against new tactics without anyone touching the code. Building a reliable feedback loop, where confirmed fraud and confirmed false positives flow back into retraining, mattered more long-term than any single model's initial accuracy. Teams that treat the model as a one-time deliverable are setting themselves up for quietly worsening detection over time.</p>
<h3>False positives have a real cost, not just a theoretical one</h3>
<p>It's easy to optimize purely for catching fraud and lose sight of the cost of blocking legitimate customers. A customer wrongly declined at checkout doesn't just fail one transaction, they often don't come back. We tracked false positive rate as seriously as we tracked fraud catch rate, and reviewed both together rather than treating fraud detection as a problem with only one failure mode.</p>
<h3>Explaining decisions, even to yourself</h3>
<p>When a transaction gets flagged, someone eventually asks why. If your system can't answer that clearly, you're flying blind on your own fraud detection, and worse, you can't improve a decision process you can't inspect. We logged the specific rule or model signal that drove each decision, not just the final outcome, because that log became the single most useful input for improving the system over time.</p>
<p>csharp</p>
<pre><code class="language-csharp">_logger.LogInformation("Fraud decision {Decision} for transaction {Id}, triggered by {Signal}",
    decision, transaction.Id, triggeredSignal);
</code></pre>
<h3>Resilience matters here too</h3>
<p>A fraud service that goes down shouldn't take checkout down with it. We built explicit fallback behavior, a conservative default when the fraud service was unreachable, rather than letting an outage in one service cascade into blocking every transaction platform-wide. This is the same resilience thinking I'd apply to any critical downstream dependency, applied specifically to a service where both failure directions, blocking everyone or blocking no one, carry real cost.</p>
<h3>The real lesson</h3>
<p>Fraud detection succeeds or fails on the engineering around the model as much as the model itself: latency budgets, explainability, feedback loops, and graceful degradation when the detection service itself has a bad day. The model gets most of the attention. The system around it is what actually makes it trustworthy in production.</p>
]]></content:encoded></item><item><title><![CDATA[Rate Limiting and Throttling APIs in .NET]]></title><description><![CDATA[I didn't take rate limiting seriously until I watched a single misbehaving client script hammer an endpoint hard enough to degrade response times for every other consumer of that API. Nothing maliciou]]></description><link>https://tundehub.dev/rate-limiting-and-throttling-apis-in-net</link><guid isPermaLink="true">https://tundehub.dev/rate-limiting-and-throttling-apis-in-net</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Wed, 19 Aug 2026 04:30:00 GMT</pubDate><content:encoded><![CDATA[<p>I didn't take rate limiting seriously until I watched a single misbehaving client script hammer an endpoint hard enough to degrade response times for every other consumer of that API. Nothing malicious, just a retry loop with no backoff on their end. That incident changed how I think about rate limiting: it's not primarily a security feature, it's a stability feature that happens to also help with security.</p>
<h3>The built-in middleware makes this genuinely easy</h3>
<p>.NET's rate limiting middleware, introduced in .NET 7, removed most of the excuses for skipping this. It's a few lines to get real protection:</p>
<p>csharp</p>
<pre><code class="language-csharp">builder.Services.AddRateLimiter(options =&gt;
{
    options.AddFixedWindowLimiter("standard", opt =&gt;
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.QueueLimit = 0;
    });
});

app.UseRateLimiter();
</code></pre>
<p>That's a fixed window limiter, and it's a reasonable default for most APIs. Its weakness is bursts at window boundaries, where a client could send 100 requests right at the end of one window and another 100 right at the start of the next.</p>
<h3>Choosing the right algorithm for the actual problem</h3>
<p>For APIs where burst behavior at window edges genuinely matters, sliding window or token bucket limiters handle it more gracefully:</p>
<p>csharp</p>
<pre><code class="language-csharp">options.AddTokenBucketLimiter("burst-friendly", opt =&gt;
{
    opt.TokenLimit = 100;
    opt.TokensPerPeriod = 20;
    opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
    opt.QueueLimit = 5;
});
</code></pre>
<p>Token bucket allows short bursts while still enforcing an average rate over time, which fits real usage patterns better than a strict fixed window for most consumer-facing APIs. I pick the algorithm based on the actual traffic shape I'm protecting against, not by default habit.</p>
<h3>Different limits for different endpoints, deliberately</h3>
<p>Not every endpoint deserves the same limit. Authentication endpoints, as I've mentioned before, need tighter limits because they're a common target for credential stuffing. Expensive reporting endpoints that hit multiple downstream services need lower limits than a simple lookup endpoint. Treating every route identically usually means either being too permissive on the endpoints that matter most, or too restrictive on the ones that don't.</p>
<p>csharp</p>
<pre><code class="language-csharp">app.MapGroup("/api/auth").RequireRateLimiting("strict");
app.MapGroup("/api/reports").RequireRateLimiting("expensive");
app.MapGroup("/api/lookup").RequireRateLimiting("standard");
</code></pre>
<h3>Partition by client, not just globally</h3>
<p>A global rate limit protects your infrastructure but doesn't protect individual consumers from each other. If you're serving multiple API clients or tenants, partition the limiter by API key or user ID so one noisy client can't consume the shared budget that other clients depend on.</p>
<p>csharp</p>
<pre><code class="language-csharp">options.AddPolicy("per-client", context =&gt;
    RateLimitPartition.GetTokenBucketLimiter(
        context.User.GetClientId(),
        _ =&gt; new TokenBucketRateLimiterOptions { TokenLimit = 50, TokensPerPeriod = 10, ReplenishmentPeriod = TimeSpan.FromSeconds(10) }));
</code></pre>
<p>This is the piece that would have prevented the incident that first got me paying attention to this. One noisy client, isolated to their own budget, never should have been able to touch anyone else's experience.</p>
<h3>Give consumers something to work with</h3>
<p>A rejected request should tell the caller when they can try again, not just fail silently. <code>Retry-After</code> headers turn a rate limit from a confusing dead end into something a well-behaved client can actually respect and build around.</p>
<p>csharp</p>
<pre><code class="language-csharp">options.OnRejected = async (context, token) =&gt;
{
    context.HttpContext.Response.Headers.RetryAfter = "60";
    await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Try again later.", token);
};
</code></pre>
<h3>The real value</h3>
<p>Rate limiting isn't primarily about stopping bad actors, most of the time it never gets tested by one. It's about making sure one client's usage pattern, intentional or accidental, can't degrade the experience for everyone else sharing the same infrastructure. That's a stability property every production API needs, whether or not you ever expect abuse.</p>
]]></content:encoded></item><item><title><![CDATA[CQRS in .NET: Separating Reads from Writes Without Overengineering]]></title><description><![CDATA[CQRS gets a reputation as an "enterprise architecture" pattern that's overkill for most applications, and honestly, that reputation is often deserved. I've seen teams adopt full CQRS with separate rea]]></description><link>https://tundehub.dev/cqrs-in-net-separating-reads-from-writes-without-overengineering</link><guid isPermaLink="true">https://tundehub.dev/cqrs-in-net-separating-reads-from-writes-without-overengineering</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Mon, 17 Aug 2026 04:30:00 GMT</pubDate><content:encoded><![CDATA[<p>CQRS gets a reputation as an "enterprise architecture" pattern that's overkill for most applications, and honestly, that reputation is often deserved. I've seen teams adopt full CQRS with separate read and write databases and event sourcing for a system that had maybe five write operations a day. That's not architecture, that's ceremony. But the core idea, separating the model you use to change data from the model you use to read it, has genuinely saved me real complexity on systems where reads and writes have very different shapes.</p>
<h3>The problem CQRS actually solves</h3>
<p>On a loan management portal I worked on, the write side needed strict validation, business rules, and a normalized data model to keep application, verification, and disbursement processes consistent. The read side needed something completely different: fast, denormalized views for a dashboard showing loan status across thousands of members. Forcing both needs through the same model meant compromises on both sides. Validation logic leaked into read paths that didn't need it. Read performance suffered because the write-optimized schema wasn't shaped for the queries the dashboard actually ran.</p>
<p>CQRS just names that split explicitly instead of pretending one model can serve both jobs well.</p>
<h3>What it looks like without going overboard</h3>
<p>You don't need a message bus or separate databases to get real value from this. The simplest version is just separating your command and query handling in code:</p>
<p>csharp</p>
<pre><code class="language-csharp">public record ApproveLoanCommand(Guid ApplicationId, Guid ApprovedBy);

public class ApproveLoanHandler
{
    public async Task&lt;Result&gt; HandleAsync(ApproveLoanCommand command)
    {
        var application = await _repository.GetByIdAsync(command.ApplicationId);
        application.Approve(command.ApprovedBy);
        await _repository.SaveAsync(application);
        return Result.Success();
    }
}

public record LoanSummaryQuery(Guid MemberId);

public class LoanSummaryHandler
{
    public async Task&lt;LoanSummaryDto&gt; HandleAsync(LoanSummaryQuery query)
    {
        return await _context.Loans
            .Where(l =&gt; l.MemberId == query.MemberId)
            .Select(l =&gt; new LoanSummaryDto { Id = l.Id, Status = l.Status, Amount = l.Amount })
            .FirstOrDefaultAsync();
    }
}
</code></pre>
<p>Commands go through your domain model with full validation and business rules. Queries bypass that entirely and project straight to the shape the UI actually needs. No event sourcing required, no separate database, just an honest acknowledgment that reading and writing are different jobs with different constraints.</p>
<h3>When separate read models actually earn their cost</h3>
<p>For most applications, that code-level separation is enough. Where I've reached for an actual separate read store, a denormalized reporting database updated asynchronously from the write side, was specifically for reporting and dashboard scenarios where the read load was heavy, the queries were complex joins across many entities, and a few seconds of staleness was completely acceptable.</p>
<p>That last condition matters more than people give it credit for. If your business genuinely cannot tolerate any staleness between a write and the next read, full CQRS with eventual consistency adds a real, hard-to-debug complexity for a use case that doesn't want it.</p>
<h3>MediatR, and why I'm careful with it</h3>
<p>A lot of .NET CQRS implementations reach for MediatR to route commands and queries to handlers automatically. It's a fine library. My caution is specifically about letting it become an excuse to skip thinking about what actually needs separating. I've seen codebases where every single operation, no matter how trivial, gets wrapped in a command and a handler because "that's the pattern," which just adds indirection without adding any of the benefit CQRS is supposed to provide.</p>
<p>csharp</p>
<pre><code class="language-csharp">public class ApproveLoanHandler : IRequestHandler&lt;ApproveLoanCommand, Result&gt;
{
    // same logic, now routed through MediatR
}
</code></pre>
<p>Use it when the routing and cross-cutting concerns, like logging or validation pipelines, genuinely earn their place. Don't use it as a substitute for deciding whether CQRS is the right tool at all.</p>
<h3>My take on this</h3>
<p>I ask one question before reaching for this pattern: do my read and write needs actually conflict? If a single model serves both without meaningful compromise, CQRS is solving a problem I don't have. If validation, performance, and data shape genuinely pull in different directions, the separation pays for itself quickly. The mistake isn't using CQRS. It's using it as a default instead of a decision.</p>
]]></content:encoded></item><item><title><![CDATA[Securing ASP.NET Core APIs with JWT and OAuth2]]></title><description><![CDATA[Security is the part of API development where "it works" and "it's actually safe" can look identical right up until someone finds the gap. I've worked on fintech and insurance platforms where a missed]]></description><link>https://tundehub.dev/securing-asp-net-core-apis-with-jwt-and-oauth2</link><guid isPermaLink="true">https://tundehub.dev/securing-asp-net-core-apis-with-jwt-and-oauth2</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Fri, 14 Aug 2026 04:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Security is the part of API development where "it works" and "it's actually safe" can look identical right up until someone finds the gap. I've worked on fintech and insurance platforms where a missed authorization check isn't a minor bug, it's a real exposure. Here's how I approach authentication and authorization in <a href="http://ASP.NET">ASP.NET</a> Core, and the mistakes I've seen most often.</p>
<h3>Authentication vs authorization: keep the distinction sharp</h3>
<p>Authentication answers "who is this?" Authorization answers "what are they allowed to do?" Conflating the two is where a lot of security gaps start. A user can be perfectly authenticated with a valid token and still have no business accessing another customer's policy data. I've reviewed code where a valid JWT was treated as sufficient proof that a request was allowed to proceed, with no check on whether the authenticated user actually owned the resource being requested.</p>
<h3>JWT setup, done properly</h3>
<p>csharp</p>
<pre><code class="language-csharp">builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =&gt;
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = config["Jwt:Issuer"],
            ValidAudience = config["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]))
        };
    });
</code></pre>
<p>Every one of those <code>Validate*</code> flags matters. I've seen <code>ValidateLifetime</code> left false during development and never turned back on, which means expired tokens work forever. Small oversight, serious consequence.</p>
<h3>Authorization at the group level, not endpoint by endpoint</h3>
<p>csharp</p>
<pre><code class="language-csharp">app.MapGroup("/api/policies")
   .RequireAuthorization("PolicyOwner")
   .MapPolicyEndpoints();
</code></pre>
<p>Applying authorization at the group level means nothing slips through because someone added a new endpoint and forgot to decorate it individually. I've caught this exact gap during a security review before, a new endpoint added under time pressure, authorization decoration simply forgotten.</p>
<h3>Resource-level authorization, not just role checks</h3>
<p>Role-based checks answer "is this user an admin." They don't answer "does this user own this specific policy." For that, I use policy-based authorization with resource-specific handlers:</p>
<p>csharp</p>
<pre><code class="language-csharp">public class PolicyOwnerHandler : AuthorizationHandler&lt;PolicyOwnerRequirement, Policy&gt;
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, PolicyOwnerRequirement requirement, Policy resource)
    {
        if (resource.CustomerId == context.User.GetUserId())
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}
</code></pre>
<p>This is the check that actually prevents one customer from accessing another customer's data with a perfectly valid token. Role checks alone won't catch that.</p>
<h3>Refresh tokens: convenience without giving up control</h3>
<p>Short-lived access tokens paired with refresh tokens balance usability against exposure. If an access token leaks, the damage window is small. Refresh tokens should be stored securely, rotated on use, and revocable, so a compromised refresh token doesn't become a permanent backdoor.</p>
<h3>Rate limiting auth endpoints specifically</h3>
<p>Login and token endpoints are prime targets for credential stuffing and brute force attempts. I rate limit these more aggressively than general API endpoints, using the built-in rate limiting middleware, because the cost of a false positive here is much lower than the cost of an unthrottled login endpoint.</p>
<p>csharp</p>
<pre><code class="language-csharp">app.MapGroup("/api/auth")
   .RequireRateLimiting("strict");
</code></pre>
<h3>OAuth2 for third-party integrations</h3>
<p>When external partners need access to specific parts of your API, I don't hand out long-lived API keys with full access. Authorization code flow with scoped permissions lets a partner request exactly the access they need, and lets you revoke it without touching anyone else's integration.</p>
<h3>The habit that matters most</h3>
<p>None of these individual pieces are exotic. What actually prevents incidents is treating security review as a standing part of the development process, not a one-time audit before launch. New endpoints get the same scrutiny as the first ones. That discipline, more than any single technique, is what's kept the systems I've built out of the wrong kind of headlines.</p>
]]></content:encoded></item><item><title><![CDATA[Mentoring Junior Developers: What Actually Works?]]></title><description><![CDATA[I've mentored developers on nearly every team I've led, and I've made most of the mistakes you can make while doing it. The biggest one, early on, was thinking mentorship meant having the answer ready]]></description><link>https://tundehub.dev/mentoring-junior-developers-what-actually-works</link><guid isPermaLink="true">https://tundehub.dev/mentoring-junior-developers-what-actually-works</guid><dc:creator><![CDATA[Esanju Babatunde]]></dc:creator><pubDate>Thu, 13 Aug 2026 04:00:00 GMT</pubDate><content:encoded><![CDATA[<p>I've mentored developers on nearly every team I've led, and I've made most of the mistakes you can make while doing it. The biggest one, early on, was thinking mentorship meant having the answer ready whenever someone got stuck. It doesn't. It means building someone's ability to get unstuck without you.</p>
<h3>Answering less than you think you should</h3>
<p>When a junior developer comes to me with a bug, my instinct used to be to look at the code and just tell them the fix. It's faster in the moment. It also teaches them nothing except that the fastest path to a solution runs through me.</p>
<p>Now I ask questions instead. What have you tried? What did you expect to happen, and what actually happened? Where do you think the problem might be? Most of the time, they find the bug themselves halfway through explaining it to me. The ones who don't still walk away having practiced the actual skill of debugging, not just watched me do it.</p>
<h3>Code review as a teaching tool, not a gate</h3>
<p>A code review that just says "change this" transfers nothing. A code review that explains why transfers something every time. I try to make every comment carry a reason, even a short one: "this will fire a query per item in the loop, worth using <code>.Include()</code> here to batch it." Over time, people stop needing that specific comment because they've internalized the reasoning, not just the rule.</p>
<p>I'm also deliberate about what I don't comment on. Not every stylistic preference needs to become someone else's problem in a review. I save the feedback for things that actually matter: correctness, maintainability, and patterns that will bite them later, not whether they'd have named a variable slightly differently than I would have.</p>
<h3>Pairing on the hard problems, not just the easy ones</h3>
<p>There's a tendency to hand junior developers the simple tickets and keep the interesting architectural problems for senior engineers. I understand the instinct, deadlines are real, but it quietly caps how fast someone grows. I try to pull junior developers into at least some of the harder design conversations, even if they're mostly listening at first. Watching how a senior engineer reasons through a tradeoff teaches things a ticket never will.</p>
<h3>Letting people fail safely</h3>
<p>Some of the fastest growth I've watched happen came from someone making a mistake in a low-stakes environment and working through the consequences themselves, not from someone preventing every mistake before it happened. Part of mentoring is knowing which mistakes are safe to let happen and which ones genuinely need to be caught before they ship. A bad naming choice, let it go and revisit later. A change that could take down a payment flow, that gets caught before merge, every time.</p>
<h3>Consistency matters more than any single conversation</h3>
<p>The developers I've watched grow the most weren't the ones who had one great mentoring conversation. They were the ones who had a steady stream of small, honest feedback over months, delivered consistently enough that it became normal rather than a big event. Mentorship isn't a program you run for a quarter. It's a habit you build into how you already work with people.</p>
<h3>The return on this isn't just their growth</h3>
<p>Every developer I've helped grow into someone who needs less of my time has freed me up for the problems only I can solve. That's not a cynical read on mentorship, it's just true, and it's part of why I keep doing it deliberately instead of treating it as something that happens automatically when people work near each other.</p>
]]></content:encoded></item></channel></rss>