<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Thoughts and Musings — Architect4Hire</title>
        <link>https://www.architect4hire.com/blog</link>
        <description>Field notes on .NET and Azure architecture, legacy modernization, and AI-assisted development that survives contact with production.</description>
        <language>en-us</language>
        <lastBuildDate>Tue, 21 Jul 2026 10:52:09 GMT</lastBuildDate>
        <atom:link href="https://www.architect4hire.com/rss.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title>The Same Stack Answers the Door Twice: Composing Onion, Outbox, Projection, and CQRS in JobBoard</title>
            <link>https://www.architect4hire.com/blog/job-board-onion-pattern</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/job-board-onion-pattern</guid>
            <pubDate>Tue, 21 Jul 2026 10:52:09 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>Onion layering, a transactional outbox, an event-fed projection, and CQRS aren&apos;t four separate patterns — they&apos;re one composed system, and JobBoard proves it on a single screen. Here&apos;s the code, the honest caveats, and the seed-data bug that revealed exactly how they depend on each other.</description>
            <content:encoded><![CDATA[<h1 id="the-same-stack-answers-the-door-twice-composing-onion-outbox-projection-and-cqrs-in-jobboard">The Same Stack Answers the Door Twice: Composing Onion, Outbox, Projection, and CQRS in JobBoard</h1>
<p><em>Or: why a controller and a Service Bus consumer are two front doors onto one pipeline — and why the &quot;onion&quot; in this repo isn&#39;t the onion you think it is</em></p>
<p>TL;DR — the code lives at <a href="https://github.com/architect4hire/jobboard" target="_blank" rel="noopener noreferrer">github.com/architect4hire/jobboard</a>. JobBoard is an event-driven, multi-service .NET reference build, and this post is about four patterns inside it — the layered &quot;onion&quot; stack, a hand-rolled transactional outbox/inbox, an event-fed read projection, and the lightweight CQRS shape they add up to. The angle isn&#39;t &quot;here are four patterns.&quot; Everybody&#39;s got four patterns. The angle is that <strong>these four aren&#39;t independent choices — they compose</strong>, and pulling the thread on one drags the other three into view. Everything below is me showing my work, with verbatim code from the repo at HEAD.</p>
<hr>
<p>Let me open with the sentence I want you to hold onto for the whole post, because it&#39;s the thing most pattern write-ups get exactly backwards.</p>
<p><strong>The outbox is what makes the projection reliable. The projection is what makes the CQRS read fast and decoupled. And the same five-layer stack is the <em>one</em> skeleton reused for both the write path and the projection-maintenance path.</strong></p>
<p>Read a hundred blog posts and you&#39;ll get the outbox pattern in isolation, the read-model pattern in isolation, a CQRS diagram with a lightning bolt in the middle labeled &quot;events (magic happens here).&quot; What you rarely get is the load-bearing truth that these things only earn their complexity <em>together</em>. So I&#39;m going to walk one feature front to back — a candidate viewing their own job applications — and show you all four patterns firing on a single screen, then take each one apart.</p>
<h2 id="the-one-feature-that-demonstrates-all-four-at-once">The one feature that demonstrates all four at once</h2>
<p><code>GET /applications/mine</code> — a candidate&#39;s applications, enriched with each job&#39;s title and the employer&#39;s company name, with <strong>zero synchronous cross-service calls on the read path</strong>.</p>
<p>Nobody writes to <code>applicationsdb</code> except Applications&#39; own code. Jobs and Profiles publish facts to the bus and never learn who&#39;s listening. Here&#39;s the wiring that makes it real — and note the detail that makes it <em>cheap</em> (<code>src/JobBoard.AppHost/AppHost.cs:60-88</code>):</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Jobs already published JobPosted (Notifications and Audit consume it). Applications is a THIRD</span>
<span class="hljs-comment">// subscriber bolted onto a pre-existing topic — it mirrors Title/EmployerId into a local projection</span>
<span class="hljs-comment">// so &quot;my applications&quot; can show a job title without ever calling back into Jobs (ADR-0012 option B).</span>
<span class="hljs-keyword">var</span> jobPostedTopic = serviceBus.AddServiceBusTopic(<span class="hljs-string">&quot;JobPosted&quot;</span>);
jobPostedTopic.AddServiceBusSubscription(<span class="hljs-string">&quot;notifications-jobposted&quot;</span>);
jobPostedTopic.AddServiceBusSubscription(<span class="hljs-string">&quot;audit-jobposted&quot;</span>);
jobPostedTopic.AddServiceBusSubscription(<span class="hljs-string">&quot;applications-jobposted&quot;</span>);   <span class="hljs-comment">// bolted onto an existing topic</span>

<span class="hljs-comment">// State-transfer twin of ProfileUpdated: this one carries the company name itself, so it is</span>
<span class="hljs-comment">// Applications-only — ProfileUpdated stays the PII-free fact Audit sees.</span>
<span class="hljs-keyword">var</span> employerProfileChangedTopic = serviceBus.AddServiceBusTopic(<span class="hljs-string">&quot;EmployerProfileChanged&quot;</span>);   <span class="hljs-comment">// genuinely new</span>
employerProfileChangedTopic.AddServiceBusSubscription(<span class="hljs-string">&quot;applications-employerprofilechanged&quot;</span>);</code></pre>
<p>That comment is doing more than documenting a subscription — it&#39;s the whole thesis in eight lines. <code>JobPosted</code> costs one new subscription on a topic that already existed. Only <code>EmployerProfileChanged</code> is genuinely new, because Profiles had no event carrying a company name and I refused to leak PII into the audit fact to get one. Adding a reader was additive and boring. That&#39;s what &quot;decoupled&quot; is supposed to <em>feel</em> like, and almost never does.</p>
<p>Now let&#39;s take the four apart.</p>
<hr>
<h2 id="pattern-1-the-layered-stack-and-the-onion-everyone-copies">Pattern 1 — The layered stack (and the onion everyone copies)</h2>
<p>Every service in JobBoard is a <strong>thin host</strong> (<code>Controllers</code>/<code>Consumers</code> + a <code>Program.cs</code> composition root) plus a <strong><code>.Core</code> class library</strong> holding the whole pipeline: <strong>Controller/Consumer → Facade → Business → Data layer → Repository</strong>, with three model types at the boundary — <strong>ViewModel</strong> in, <strong>Domain</strong> internal, <strong>ServiceModel</strong> out. Only the Repository touches EF.</p>
<p>Here&#39;s one complete write, all five layers, top to bottom. The controller binds and delegates. Nothing else (<code>ApplicationsController.cs:43-50</code>):</p>
<pre class="blog-code"><code class="hljs language-csharp">[<span class="hljs-meta">HttpPost</span>]
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;ActionResult&lt;ApplicationDetailServiceModel&gt;&gt; Submit(
    [<span class="hljs-meta">FromBody</span>] SubmitApplicationViewModel viewModel,
    CancellationToken cancellationToken)
{
    <span class="hljs-keyword">var</span> application = <span class="hljs-keyword">await</span> _facade.SubmitAsync(viewModel, cancellationToken);
    <span class="hljs-keyword">return</span> CreatedAtAction(<span class="hljs-keyword">nameof</span>(Get), <span class="hljs-keyword">new</span> { id = application.Id }, application);
}</code></pre>
<p>The facade validates, then delegates (<code>ApplicationFacade.cs:38-44</code>):</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;ApplicationDetailServiceModel&gt; <span class="hljs-title">SubmitAsync</span>(<span class="hljs-params">
    SubmitApplicationViewModel viewModel, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">await</span> _submitValidator.ValidateAndThrowAsync(viewModel, cancellationToken);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _business.SubmitAsync(viewModel, cancellationToken);
}</code></pre>
<p>The business layer does the shape translation and builds the integration event — no EF, no bus, no validation (<code>ApplicationBusiness.cs:56-65</code>):</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;ApplicationDetailServiceModel&gt; <span class="hljs-title">SubmitAsync</span>(<span class="hljs-params">
    SubmitApplicationViewModel viewModel, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">var</span> application = viewModel.ToEntity();
    <span class="hljs-keyword">var</span> @event = application.ToApplicationSubmitted(_requestContext.RootThread());

    <span class="hljs-keyword">var</span> saved = <span class="hljs-keyword">await</span> _dataLayer.SubmitAsync(application, @event, cancellationToken);
    <span class="hljs-keyword">return</span> saved.ToDetailServiceModel();
}</code></pre>
<p>The data layer is where the interesting part lives — it composes the repository write and the outbox enqueue into one atomic unit, and translates persistence failures into domain exceptions (<code>ApplicationDataLayer.cs:39-63</code>):</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;Application&gt; <span class="hljs-title">SubmitAsync</span>(<span class="hljs-params">
    Application application, ApplicationSubmitted @<span class="hljs-keyword">event</span>, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">try</span>
    {
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _repository.ExecuteInTransactionAsync(
            <span class="hljs-keyword">async</span> token =&gt;
            {
                <span class="hljs-keyword">var</span> saved = <span class="hljs-keyword">await</span> _repository.AddAsync(application, token);
                <span class="hljs-comment">// Same DbContext, same transaction: the event ships iff this row commits.</span>
                <span class="hljs-keyword">await</span> _outbox.EnqueueAsync(@event, token);
                <span class="hljs-keyword">return</span> saved;
            },
            cancellationToken);
    }
    <span class="hljs-keyword">catch</span> (DbUpdateException ex) <span class="hljs-keyword">when</span> (ApplicationRepository.IsDuplicateApplicationViolation(ex))
    {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> DomainException(
            <span class="hljs-string">&quot;application.duplicate&quot;</span>,
            <span class="hljs-string">$&quot;Candidate &#x27;<span class="hljs-subst">{application.CandidateId}</span>&#x27; has already applied to job &#x27;<span class="hljs-subst">{application.JobId}</span>&#x27;.&quot;</span>,
            StatusCodes.Status409Conflict);
    }
}</code></pre>
<p>And the repository is pure EF, nothing else — it doesn&#39;t even call <code>SaveChanges</code> (<code>ApplicationRepository.cs:53-57</code>):</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;Application&gt; <span class="hljs-title">AddAsync</span>(<span class="hljs-params">Application application, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">await</span> Context.Applications.AddAsync(application, cancellationToken);
    <span class="hljs-keyword">return</span> application;
}</code></pre>
<p><strong>The three boundary types are a structural defense, not decoration.</strong> Look at what the client can and can&#39;t set:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">record</span> <span class="hljs-title">SubmitApplicationViewModel</span>   <span class="hljs-comment">// in</span>
{
    <span class="hljs-keyword">public</span> Guid CandidateId { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; }
    <span class="hljs-keyword">public</span> Guid JobId { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; }
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span>? ResumeReference { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; }
}

<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">Application</span>                          <span class="hljs-comment">// internal / EF entity</span>
{
    <span class="hljs-keyword">public</span> Guid Id { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> ApplicationStatus Status { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> DateTime SubmittedOnUtc { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-comment">// ...</span>
}</code></pre>
<p><code>SubmitApplicationViewModel</code> has no <code>Id</code>, no <code>Status</code>, no <code>SubmittedOnUtc</code>. A client can&#39;t set <code>&quot;status&quot;: &quot;Offered&quot;</code> because the type it binds to <em>doesn&#39;t expose the property</em>. If the controller bound straight to the <code>Application</code> entity, the model binder would happily walk a request body right past <code>ApplicationStatus.Submitted</code> — the classic ASP.NET Core over-posting hole. The distinct ViewModel closes it, and it closes it structurally. There&#39;s no attribute to forget.</p>
<h3 id="here-and-39-s-the-contrarian-bit">Here&#39;s the contrarian bit</h3>
<p>I&#39;ve been calling this the &quot;onion.&quot; The repo&#39;s own docs call it &quot;the facade → business → data layer → repository stack.&quot; <strong>And if you&#39;re picturing Jason Taylor&#39;s Clean Architecture template — concentric projects, <code>Domain.csproj</code> at the dead center depending on nothing, Dependency Inversion doing compiler-enforced work — you&#39;re picturing the wrong thing.</strong> Let me be precise, because &quot;half of these diagrams get the arrows backwards&quot; is a hill I&#39;ll die on.</p>
<p>The five-layer stack in JobBoard is <strong>one project, five folders, not five projects.</strong> Controller → Facade → Business → Data → Repository all live inside a single <code>.Core</code> library. Nothing stops <code>ApplicationBusiness</code> from <code>using</code> a <code>Data/</code> type directly — and it does. The dependency direction is straight-line, not inverted: Business depends on <code>IApplicationDataLayer</code>, an interface declared in the <em>same folder</em> as its implementation, not in an inner layer that Data implements from the outside. There is no Dependency Inversion in the classic sense here. It&#39;s a top-down call chain with DI-for-testability at each hop. <strong>Architecturally, that&#39;s N-tier, not ports-and-adapters</strong> — and calling it &quot;onion&quot; without saying so is exactly the kind of copied-diagram imprecision that gets teams building a monolith with extra folders and congratulating themselves.</p>
<p>So where&#39;s the <em>real</em> onion? One level up, at the microservice boundary, and there it&#39;s compiler-enforced:</p>
<pre class="blog-code"><code class="hljs language-plaintext">Contracts ← Shared ← &lt;Service&gt;.Core ← &lt;Service&gt; (host) ← AppHost</code></pre>
<p><code>JobBoard.Contracts</code> is a leaf that references nothing (the Domain-project role). <code>JobBoard.Shared</code> depends only on Contracts (the cross-cutting-mechanism role). Each service&#39;s <code>.Core</code> depends on Shared but <strong>never on another service&#39;s <code>.Core</code></strong> — and that&#39;s not a convention, it&#39;s enforced by <em>the absence of a project reference</em> between, say, <code>JobBoard.Applications.Core.csproj</code> and <code>JobBoard.Jobs.Core.csproj</code>. Cross-service coupling isn&#39;t banned by a review rule you can forget. It&#39;s banned because there&#39;s no reference path for it to travel down. The only legal channel between services is an event flowing through <code>Contracts</code> and the bus.</p>
<p><strong>So the onion in this repo isn&#39;t the five-layer pipeline — that&#39;s an N-tier pipeline inside one project. The onion is <code>Contracts</code> at the center, <code>Shared</code> around it, each service as an outer, non-overlapping petal.</strong> A shape you could fairly call microservices-of-layered-monoliths.</p>
<p>Why keep even the loose N-tier split, then, if it&#39;s not buying swappability? Because one deployable unit with one database wouldn&#39;t get much from a compiler wall between Business and Data — there&#39;s only ever one Repository implementation; nothing is swappable independently. The split earns its keep for <strong>testability</strong> (mock <code>IApplicationDataLayer</code>, unit-test <code>ApplicationBusiness</code> with no database) and for keeping each layer legible. It is <em>not</em> buying the thing Clean Architecture&#39;s stricter separation buys. Knowing which of those you&#39;re paying for is the difference between an architect and someone who saw a nice diagram.</p>
<hr>
<h2 id="pattern-2-the-transactional-outbox-inbox-hand-rolled">Pattern 2 — The transactional outbox/inbox, hand-rolled</h2>
<p>No MassTransit. No third-party outbox. The whole mechanism lives once, in <code>JobBoard.Shared</code>, and it&#39;s about ten small files you can read in a sitting. Let me show you the load-bearing parts and then tell you the honest truth about what it does and doesn&#39;t guarantee.</p>
<p>The outbox table&#39;s identity is the trick. The event&#39;s own <code>Id</code> is reused as the row key <em>and</em> the Service Bus <code>MessageId</code>:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OutboxMessage</span>
{
    <span class="hljs-comment"><span class="hljs-doctag">///</span> The event&#x27;s own identity, reused as the row key and the Service Bus MessageId.</span>
    <span class="hljs-comment"><span class="hljs-doctag">///</span> Because it is deterministic, a retried write cannot enqueue the same event twice.</span>
    <span class="hljs-keyword">public</span> Guid Id { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> Type { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; } = <span class="hljs-literal">default</span>!;
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> Destination { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; } = <span class="hljs-literal">default</span>!;   <span class="hljs-comment">// the topic</span>
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> Payload { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; } = <span class="hljs-literal">default</span>!;        <span class="hljs-comment">// JSON</span>
    <span class="hljs-keyword">public</span> DateTime OccurredOnUtc { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> DateTime? ProcessedOnUtc { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }           <span class="hljs-comment">// null until relayed</span>
}</code></pre>
<pre class="blog-code"><code class="hljs language-csharp">builder.Property(m =&gt; m.Id).ValueGeneratedNever();   <span class="hljs-comment">// load-bearing: Id is ALWAYS the caller&#x27;s event.Id</span>
builder.HasIndex(m =&gt; <span class="hljs-keyword">new</span> { m.ProcessedOnUtc, m.OccurredOnUtc });   <span class="hljs-comment">// the dispatcher&#x27;s poll query</span></code></pre>
<p>Enqueue only <em>stages</em> — it never calls <code>SaveChanges</code>, and it guards against double-staging with a <code>FindAsync</code>:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">EnqueueAsync</span>(<span class="hljs-params">IIntegrationEvent @<span class="hljs-keyword">event</span>, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-comment">// Deterministic id → replay-safe. FindAsync checks the change tracker first, so a retry that</span>
    <span class="hljs-comment">// re-runs the operation finds the row it already staged and does not add a second one.</span>
    <span class="hljs-keyword">var</span> existing = <span class="hljs-keyword">await</span> _context.OutboxMessages.FindAsync([@event.Id], cancellationToken);
    <span class="hljs-keyword">if</span> (existing <span class="hljs-keyword">is</span> <span class="hljs-keyword">not</span> <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span>;

    <span class="hljs-keyword">var</span> eventType = @event.GetType();
    <span class="hljs-keyword">await</span> _context.OutboxMessages.AddAsync(<span class="hljs-keyword">new</span> OutboxMessage
    {
        Id = @event.Id,
        Type = eventType.Name,
        Destination = eventType.Name,   <span class="hljs-comment">// topic-per-event-type</span>
        Payload = JsonSerializer.Serialize(@event, eventType, SerializerOptions),
        OccurredOnUtc = DateTime.UtcNow,
        ProcessedOnUtc = <span class="hljs-literal">null</span>,
    }, cancellationToken);
}</code></pre>
<p>Why a <code>FindAsync</code> guard on an insert? Because of the shape of the transaction wrapper every write goes through:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> <span class="hljs-title">Task</span>&lt;<span class="hljs-title">T</span>&gt; <span class="hljs-title">ExecuteInTransactionAsync</span>&lt;<span class="hljs-title">T</span>&gt;(<span class="hljs-params">
    Func&lt;CancellationToken, Task&lt;T&gt;&gt; operation, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">var</span> strategy = Context.Database.CreateExecutionStrategy();
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> strategy.ExecuteAsync(<span class="hljs-keyword">async</span> token =&gt;
    {
        <span class="hljs-keyword">await</span> <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> transaction = <span class="hljs-keyword">await</span> Context.Database.BeginTransactionAsync(token);
        <span class="hljs-keyword">var</span> result = <span class="hljs-keyword">await</span> operation(token);
        <span class="hljs-comment">// Flush domain rows AND the outbox row inside the transaction, then commit together.</span>
        <span class="hljs-comment">// A throw on any leg skips the commit and rolls every staged change back.</span>
        <span class="hljs-keyword">await</span> Context.SaveChangesAsync(token);
        <span class="hljs-keyword">await</span> transaction.CommitAsync(token);
        <span class="hljs-keyword">return</span> result;
    }, cancellationToken);
}</code></pre>
<p>That&#39;s a callback, not a <code>using var tx = BeginTransaction()</code>, for a specific reason: Aspire&#39;s Npgsql integration turns on retry-on-failure, and EF&#39;s execution strategy refuses to run inside a transaction it didn&#39;t open itself. Handing the whole unit of work in as a delegate lets the strategy own the boundary <em>and replay the callback on a transient fault.</em> <strong>The consequence that ripples through the entire codebase: the callback may run more than once.</strong> Which is exactly why the outbox enqueue — and the inbox mark, below — guard with a deterministic-id no-op instead of blindly inserting. The retry safety isn&#39;t sprinkled around defensively; it&#39;s a direct, traceable consequence of one framework decision.</p>
<p>Dispatch is split deliberately: a <code>BackgroundService</code> timer loop (<code>OutboxDispatcher</code>) and the actual query-send-stamp logic (<code>OutboxRelay</code>), separated so the send logic is unit-testable against a real DbContext and a fake <code>ServiceBusClient</code> without booting a hosted service. The relay&#39;s failure handling is the part worth reading closely:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">var</span> row <span class="hljs-keyword">in</span> pending)   <span class="hljs-comment">// pending = unprocessed, OrderBy(OccurredOnUtc), Take(BatchSize)</span>
{
    <span class="hljs-keyword">try</span>
    {
        <span class="hljs-keyword">var</span> sender = _senders.GetOrAdd(row.Destination, _client.CreateSender);
        <span class="hljs-keyword">await</span> sender.SendMessageAsync(<span class="hljs-keyword">new</span> ServiceBusMessage(row.Payload)
        {
            MessageId = row.Id.ToString(),
            Subject = row.Type,
        }, cancellationToken);
        row.ProcessedOnUtc = DateTime.UtcNow;
    }
    <span class="hljs-keyword">catch</span> (Exception ex) <span class="hljs-keyword">when</span> (ex <span class="hljs-keyword">is</span> <span class="hljs-keyword">not</span> OperationCanceledException)
    {
        _logger.LogError(ex, <span class="hljs-string">&quot;Failed to relay outbox message {MessageId}; will retry.&quot;</span>, row.Id);
        <span class="hljs-keyword">break</span>;   <span class="hljs-comment">// preserves ordering — a later row never ships ahead of a stuck earlier one</span>
    }
}
<span class="hljs-keyword">await</span> context.SaveChangesAsync(cancellationToken);   <span class="hljs-comment">// persists whatever stamped successfully</span></code></pre>
<p>It sends, <em>then</em> stamps, <em>then</em> saves once per batch. Crash between send and stamp? The row resends next poll with the same <code>MessageId</code>, and Service Bus genuinely redelivers. Which brings us to the receive side, and the single most important line in the whole mechanism:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">var</span> processor = _client.CreateProcessor(topic, subscription, <span class="hljs-keyword">new</span> ServiceBusProcessorOptions
{
    AutoCompleteMessages = <span class="hljs-literal">false</span>,   <span class="hljs-comment">// ← the key line</span>
});

processor.ProcessMessageAsync += <span class="hljs-keyword">async</span> <span class="hljs-keyword">args</span> =&gt;
{
    <span class="hljs-keyword">await</span> _processor.ProcessAsync(<span class="hljs-keyword">args</span>.Message, <span class="hljs-keyword">args</span>.CancellationToken);
    <span class="hljs-keyword">await</span> <span class="hljs-keyword">args</span>.CompleteMessageAsync(<span class="hljs-keyword">args</span>.Message, <span class="hljs-keyword">args</span>.CancellationToken);   <span class="hljs-comment">// only on success</span>
};</code></pre>
<p><code>AutoCompleteMessages = false</code> means any throw inside processing — bad payload, missing DI registration, the consumer&#39;s own bug — leaves the message <em>unsettled</em>. The lock expires, Service Bus redelivers. And that redelivery is absorbed by the inbox, a dedupe ledger so thin it&#39;s almost nothing:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">MarkProcessedAsync</span>(<span class="hljs-params">Guid messageId, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">var</span> existing = <span class="hljs-keyword">await</span> _context.InboxMessages.FindAsync([messageId], cancellationToken);
    <span class="hljs-keyword">if</span> (existing <span class="hljs-keyword">is</span> <span class="hljs-keyword">not</span> <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span>;   <span class="hljs-comment">// guards the same execution-strategy replay as the outbox</span>
    <span class="hljs-keyword">await</span> _context.InboxMessages.AddAsync(
        <span class="hljs-keyword">new</span> InboxMessage { MessageId = messageId, ProcessedOnUtc = DateTime.UtcNow }, cancellationToken);
}</code></pre>
<h3 id="the-honest-version-of-what-this-guarantees">The honest version of what this guarantees</h3>
<p>Here&#39;s the part I want to say plainly, because it&#39;s the part that separates a pattern you understand from one you copied.</p>
<p><strong>The whole system is at-least-once, everywhere, never exactly-once.</strong> Three separate places dedupe on a deterministic id rather than trying to <em>prevent</em> a duplicate: the enqueue (the strategy can replay the whole business write), the send (a crash between send and stamp resends), and the receive (an unsettled message redelivers). Every one of those checks is plain <code>Guid</code> equality along a single unbroken identity chain: <code>IIntegrationEvent.Id</code> → <code>OutboxMessage.Id</code> → <code>ServiceBusMessage.MessageId</code> → <code>InboxMessage.MessageId</code>. No correlation logic, no payload hashing.</p>
<p><strong>The dispatcher has zero idempotency logic of its own, and doesn&#39;t need any</strong> — &quot;might send twice&quot; is an accepted outcome, not a bug to prevent. All the correctness burden for &quot;did we already do this?&quot; sits on the consumer&#39;s inbox check. That&#39;s the sentence to build the whole pattern around: <em>the outbox guarantees at-least-once send; the inbox is what turns that into effectively-once effect.</em> Neither half carries the guarantee alone.</p>
<p>And the caveats, labeled as caveats: ordering is best-effort (the <code>break</code> avoids shipping a later row ahead of a stuck earlier one, but there&#39;s no session-partitioned ordering configured, so it&#39;s a caveat, not a promise). There&#39;s <strong>no backoff and no dead-lettering</strong> — a permanently-unsendable row retries every five seconds forever and nothing notices. Versus MassTransit, I gave up the built-in retry/redelivery/error-queue pipeline, scheduling, sagas, versioning conventions — a large configurable surface. What I bought is roughly ten single-purpose files, readable start to finish, with one fixed auditable policy instead of a config dialog. On a reference build that trade is correct. On your production system, &quot;no dead-lettering&quot; might be the line item that makes MassTransit worth it. Name the trade out loud; don&#39;t inherit it by accident.</p>
<p><strong>Business translation, for the person signing off on this:</strong> at-least-once-plus-idempotency is what lets a service go down, come back, and <em>catch up</em> instead of losing writes or double-applying them. The outbox is why &quot;Payments was down for ten minutes&quot; is an availability blip and not a reconciliation nightmare.</p>
<hr>
<h2 id="pattern-3-the-event-fed-projection">Pattern 3 — The event-fed projection</h2>
<p>The rule the projection obeys, verbatim from <code>CLAUDE.md</code>: <em>&quot;Duplicate the little reference data you need; don&#39;t couple.&quot;</em> Applications needs a job&#39;s title and an employer&#39;s company name to render one screen. Its three options were: call Jobs and Profiles synchronously at read time (chatty, coupled, dead if either is down), share a database (banned outright), or <strong>keep its own local, denormalized copies, kept current by consuming events.</strong> It picked the third.</p>
<p>The projection tables are plain mirrors, keyed by the id they mirror, never a cross-service FK:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">JobReference</span>
{
    <span class="hljs-keyword">public</span> Guid JobId { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> Title { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; } = <span class="hljs-literal">default</span>!;
    <span class="hljs-keyword">public</span> Guid EmployerId { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
}</code></pre>
<p>And here&#39;s the part that ties Pattern 3 back to Pattern 1 — <strong>the consumer that maintains this projection re-enters the exact same five-layer stack a controller does.</strong> Consumer → Facade → Business → Data → Repository, top to bottom, except the &quot;write&quot; is a projection upsert gated by an inbox check:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Consumer — one-line delegation, same as a controller</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> Task <span class="hljs-title">ConsumeAsync</span>(<span class="hljs-params">JobPosted @<span class="hljs-keyword">event</span>, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span> =&gt;
    _facade.HandleJobPostedAsync(@event, cancellationToken);

<span class="hljs-comment">// Data layer — inbox check + repository upsert, one transaction</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> Task <span class="hljs-title">UpsertJobReferenceAsync</span>(<span class="hljs-params">Guid jobId, Guid messageId, <span class="hljs-built_in">string</span> title, Guid employerId, CancellationToken ct</span>)</span> =&gt;
    _repository.ExecuteInTransactionAsync(
        <span class="hljs-keyword">async</span> token =&gt;
        {
            <span class="hljs-keyword">if</span> (<span class="hljs-keyword">await</span> _inbox.HasProcessedAsync(messageId, token)) <span class="hljs-keyword">return</span>;
            <span class="hljs-keyword">await</span> _repository.UpsertJobReferenceAsync(jobId, title, employerId, token);
            <span class="hljs-keyword">await</span> _inbox.MarkProcessedAsync(messageId, token);
        },
        ct);</code></pre>
<p><strong>This is the clearest evidence that &quot;onion layering&quot; and &quot;event consumption&quot; aren&#39;t two architectures bolted together. A consumer is just another entry point at the top of the same stack a controller enters at. The stack does not know or care whether it was invoked by an HTTP request or a Service Bus message.</strong> Same skeleton, two front doors. That&#39;s the compose story made concrete.</p>
<h3 id="a-real-gotcha-worth-the-anecdote">A real gotcha, worth the anecdote</h3>
<p>Because the projection is <em>entirely</em> event-fed, anything that writes to Jobs or Profiles without going through their outbox never reaches it. This repo hit exactly that. The dev seed data originally wrote demo rows straight to the source <code>DbContext</code> — <code>db.Jobs.AddRange(...)</code> — bypassing the business layer and the outbox entirely. No <code>JobPosted</code> fired for seeded jobs. No <code>EmployerProfileChanged</code> for the seeded employer. So in a fresh environment, <code>JobReference</code> and <code>EmployerReference</code> were permanently empty, and every application against a seeded job rendered <strong>&quot;Unknown job / Unknown employer.&quot;</strong></p>
<p>The fix was to route the seeders through <code>IOutbox.EnqueueAsync</code> too, using the same mappers production code uses, so seed data exercises the real event path.</p>
<p><strong>The lesson is bigger than seed data:</strong> a projection&#39;s correctness is only as good as <em>every</em> write path to its sources going through the same outbox — including the ones that don&#39;t look like &quot;real&quot; writes: seed data, admin scripts, one-off data fixes. There&#39;s no compiler and no migration that catches a write that skips the outbox. The projection just silently starves. If you&#39;ve read my other stuff, you&#39;ll recognize the shape of this — it&#39;s the same move as debugging the specification instead of the output. The bug wasn&#39;t in the read code. It was in a write path nobody thought of as a write path.</p>
<p>The read itself is deliberately <em>three separate queries</em> stitched in memory, not one SQL <code>JOIN</code>, so a row whose reference data hasn&#39;t caught up yet still shows up instead of vanishing:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">return</span> applications.Select(a =&gt;
{
    jobs.TryGetValue(a.JobId, <span class="hljs-keyword">out</span> <span class="hljs-keyword">var</span> job);
    <span class="hljs-keyword">var</span> employerId = job?.EmployerId ?? Guid.Empty;
    employers.TryGetValue(employerId, <span class="hljs-keyword">out</span> <span class="hljs-keyword">var</span> employer);

    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> ApplicationHistoryServiceModel(
        a.Id, a.JobId, job?.Title ?? <span class="hljs-string">&quot;Unknown job&quot;</span>,
        employerId, employer?.CompanyName ?? <span class="hljs-string">&quot;Unknown employer&quot;</span>,
        a.Status, a.SubmittedOnUtc, a.StatusChangedOnUtc);
}).ToList();</code></pre>
<p>Graceful degradation by construction: eventual consistency means the projection lags the source by however long the poll and processing take, and this read shows a sensible placeholder during that window instead of a 500 or a missing row. One honest wrinkle to flag precisely — the employer id set is derived from the <code>jobs</code> dictionary&#39;s <em>values</em>, so a missing <code>JobReference</code> cascades into a missing employer lookup even when the <code>EmployerReference</code> row exists. A narrow, real consequence of joining in application code instead of SQL. Worth knowing before it surprises you in a log.</p>
<hr>
<h2 id="pattern-4-lightweight-cqrs-and-where-the-pattern-actually-ends">Pattern 4 — Lightweight CQRS (and where the pattern actually ends)</h2>
<p>Two things in this codebase get called &quot;CQRS-ish,&quot; and only one of them is the real thing.</p>
<p><strong>The minor sense:</strong> every write takes a ViewModel, every read returns a ServiceModel — command/query separation at the type level (that&#39;s the over-posting argument from Pattern 1). That&#39;s real and useful, but it&#39;s the same store, same schema, same code path, typed narrowly at each edge. One sentence, not a pattern.</p>
<p><strong>The real sense</strong> is <code>JobReference</code>/<code>EmployerReference</code> themselves, and they qualify on four counts. They&#39;re a <em>different shape</em> than the write model (two aggregates in two databases, flattened into one row purpose-built for one query). They&#39;re <em>physically separate storage</em> — living in <code>applicationsdb</code>, a different database owned by a different service than the rows they mirror, which is more aggressive than the usual single-service two-tables-in-one-database CQRS split; the read model here crosses a boundary the write models can never cross back. They&#39;re <em>asynchronously, eventually consistent</em> — the read lags the write by the outbox poll plus consumer time, and the seed-data anecdote above is a direct consequence of that asynchrony. And they&#39;re <em>independently query-optimized</em> — the three-query stitch is shaped around one screen, with no obligation to serve any other access pattern.</p>
<p><strong>Now the part most CQRS posts skip: where the pattern ends.</strong> There is no command/query bus here — no MediatR <code>IRequest</code>/<code>IRequestHandler</code>. There&#39;s no event sourcing — the write side is plain CRUD over <code>Job</code>/<code>EmployerProfile</code>, not an append-only log replayed to build state. There&#39;s no separate read-side database technology — still Postgres, not a search index. And the read model <strong>isn&#39;t rebuildable from history</strong> — it&#39;s upserted incrementally by consumers, so recovering from data loss means re-seeding from the <em>source services&#39; current state</em>, not replaying old events, because the outbox doesn&#39;t retain processed rows forever.</p>
<p>So call it <strong>CQRS in spirit, not the full pattern</strong>: a materialized, service-owned read projection kept live by the outbox/inbox mechanism, without the heavier machinery — event sourcing, a command bus, snapshotting — that capital-C CQRS usually implies. Saying which parts you have and which you don&#39;t is the entire value of naming the pattern. &quot;We do CQRS&quot; with a hand-wave tells your team nothing about whether they can replay events (they can&#39;t) or whether the read lags (it does).</p>
<p>The read path even tightens security while it&#39;s at it. The candidate id comes from the JWT via the ambient request context the gateway projects — never a client-supplied parameter:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> Task&lt;IReadOnlyList&lt;ApplicationHistoryServiceModel&gt;&gt; ListMineAsync(CancellationToken ct = <span class="hljs-literal">default</span>)
{
    <span class="hljs-keyword">var</span> candidateId = _requestContext.ActorId
        ?? <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> DomainException(<span class="hljs-string">&quot;application.unauthenticated&quot;</span>,
            <span class="hljs-string">&quot;No authenticated candidate on the request.&quot;</span>, StatusCodes.Status401Unauthorized);
    <span class="hljs-keyword">return</span> _dataLayer.ListMineAsync(candidateId, ct);
}</code></pre>
<p>And the gateway needed <strong>zero changes</strong> for this whole feature — the catch-all route already proxied the path and already required the authenticated policy. That&#39;s the tell that this stayed a service-owned read model rather than edge-level composition. The frontend just reads enriched fields off one response, no client-side fan-out to a Jobs endpoint and a Profiles endpoint to assemble a row. The projection did that work asynchronously, ahead of time.</p>
<hr>
<h2 id="how-the-four-compose-the-whole-story-in-six-steps">How the four compose — the whole story in six steps</h2>
<p>Walk it front to back and every pattern shows up in order:</p>
<ol>
<li><strong>An employer posts a job.</strong> Pattern 1&#39;s five-layer write flow. Business builds <code>JobPosted</code> with <code>Title</code>/<code>EmployerId</code> already denormalized onto it. The data layer enqueues it into <code>jobsdb.OutboxMessages</code> <em>in the same transaction</em> as the <code>Job</code> row — durable before anything hits a broker.</li>
<li><strong>Jobs&#39; <code>OutboxDispatcher</code> notices within ~5s</strong> (Pattern 2), sends with <code>MessageId = the row&#39;s own Id</code>, stamps it processed.</li>
<li><strong>Three subscribers wake up</strong> on the pre-existing <code>JobPosted</code> topic — Notifications, Audit, and now Applications.</li>
<li><strong><code>JobPostedConsumer</code> re-enters the same five-layer stack</strong> (Pattern 1 again), except this &quot;write&quot; maintains a <strong>projection</strong> (Pattern 3), gated by an inbox check so redelivery is a no-op (Pattern 2).</li>
<li><strong>A candidate applies</strong> — another instance of the Pattern 1 write flow, publishing <code>ApplicationSubmitted</code> the same way.</li>
<li><strong>The candidate opens &quot;my applications.&quot;</strong> <code>GET /applications/mine</code> never touches Jobs or Profiles — it reads <code>Application</code> joined <em>in application code</em> against the projection built asynchronously in steps 1–4, scoped to the JWT-derived actor (Pattern 4).</li>
</ol>
<p><strong>The one sentence I&#39;d tattoo on this build:</strong> the outbox is what makes the projection safe to build — durable, at-least-once delivery instead of &quot;hope the HTTP call succeeds&quot;; the projection is what makes the CQRS-style read fast and decoupled — no fan-out at read time; and the same five-layer stack is the <em>one</em> piece of scaffolding reused for both halves, because a controller and a Service Bus consumer are just two different front doors onto an identical pipeline underneath.</p>
<p>Four patterns. One skeleton. Zero of them worth the complexity alone.</p>
<hr>
<h2 id="the-practical-version">The Practical Version</h2>
<p>Stealing this for your own build:</p>
<p><strong>Name your onion honestly.</strong> If it&#39;s five folders in one project with straight-line dependencies, it&#39;s N-tier — say so, and reserve &quot;onion&quot; for the boundary where the compiler actually enforces the arrows. Half the &quot;clean architecture&quot; diagrams in the wild are N-tier with better branding, and the imprecision costs you when someone tries to swap a layer that was never swappable.</p>
<p><strong>Make the correctness live where the duplicates land, not where they&#39;re born.</strong> At-least-once send plus idempotent-on-a-deterministic-id receive beats chasing exactly-once you&#39;ll never actually get. One identity chain, <code>Guid</code> equality at every hop, no payload hashing.</p>
<p><strong>Reuse one stack for both front doors.</strong> A consumer is an entry point, not a second architecture. If maintaining a projection looks nothing like handling a request, you&#39;ve built two things where one would do.</p>
<p><strong>Audit every write path to a projection&#39;s sources — including the ones that don&#39;t look like writes.</strong> Seed data, admin scripts, data fixes. Anything that skips the outbox silently starves the read model, and nothing in the toolchain will warn you.</p>
<p><strong>Say where the pattern ends.</strong> &quot;CQRS in spirit&quot; that can&#39;t replay events and lags by five seconds is a fine, honest thing to build — but only if you tell your team those two facts out loud. The naming is worthless if it hides the boundaries.</p>
<hr>
<p>The repo is at <a href="https://github.com/architect4hire/jobboard" target="_blank" rel="noopener noreferrer">github.com/architect4hire/jobboard</a>, and the <a href="https://www.architect4hire.com/scrub">SCRUB framework</a> is how the specification for all of this stayed honest while it was generated. Read the four patterns as one system, not four — that&#39;s the part the diagrams never show you.</p>
<hr>
<p><em>Robert Felkins is the principal architect at Architect4Hire and the author of the upcoming book</em> Practical AI Assisted Development. <em>He builds right-sized .NET and Azure systems, modernizes the ones you already have, and brings AI-assisted development into teams in a way that survives contact with production.</em></p>
<p><em>If your team is standing up an event-driven system that has to be correct under failure — outboxes, projections, the eventual-consistency gotchas that don&#39;t show up until production — that&#39;s the work I do. Fractional architecture engagements: senior architectural judgment a few days a month, without the full-time price tag. Reach out at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>.</em></p>
]]></content:encoded>
        </item>
        <item>
            <title>Audit Isn&apos;t Tracing: Building a Cradle-to-Grave Support Trail on an Event-Driven Stack</title>
            <link>https://www.architect4hire.com/blog/cradle-to-gravel-audits</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/cradle-to-gravel-audits</guid>
            <pubDate>Sun, 19 Jul 2026 18:23:28 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>A client asked for &apos;full traceability, cradle to grave.&apos; My first instinct was distributed tracing, and it was the wrong system entirely. Here&apos;s how reframing the ask — and the security hole it uncovered along the way — became a queryable audit trail on a five-service .NET stack.</description>
            <content:encoded><![CDATA[<h1 id="audit-isn-and-39-t-tracing-building-a-cradle-to-grave-support-trail-on-an-event-driven-stack">Audit Isn&#39;t Tracing: Building a Cradle-to-Grave Support Trail on an Event-Driven Stack</h1>
<p>A client needed full traceability — cradle to grave, every request — so support could replay a session and answer who did what, when, and where. Good actor or bad actor. My first plan was distributed tracing. It was wrong, and noticing it was wrong was the entire job. &quot;Audit&quot; and &quot;trace&quot; wear the same three words and point at opposite systems: one tells engineers how slow, the other tells support what happened and who.</p>
<p>This is how I built the second one into a five-service .NET stack with SCRUB and Claude Code — the reframe, the security hole it flushed out, the boring-tech call, and the code that folds it into an event-driven system I was already running. All grounded in real code and logic from <a href="https://github.com/architect4hire/jobboard" target="_blank" rel="noopener noreferrer">github.com/architect4hire/jobboard</a>.</p>
<p>There&#39;s a version of this post that&#39;s just an audit-trail implementation walkthrough. That&#39;s not the interesting part. The interesting part is that the request arrived wearing a disguise, and the single most valuable thing I did on the whole engagement was take the disguise off before writing a line of code. So let me start there, because the design lives or dies on it.</p>
<h2 id="the-three-words-that-hide-two-systems">The three words that hide two systems</h2>
<p>The ask, verbatim in spirit: <em>&quot;full cross-cutting audit, cradle to grave, for every request.&quot;</em> My instinct — and my first plan — was distributed tracing. OpenTelemetry, a <code>traceparent</code> propagated across the bus, a Jaeger or Seq backend to look at it all. It&#39;s the reflex any distributed-systems person has when they hear &quot;trace every request,&quot; and it was completely wrong.</p>
<p>Because two different use cases wear those exact three words, and they&#39;re opposites:</p>
<ul>
<li><strong>Distributed tracing</strong> answers <em>&quot;how slow, and where&#39;s the latency?&quot;</em> It&#39;s for <strong>engineers</strong>. Spans, sampling, ephemeral by design, viewed in a trace UI, reset every run. You keep it long enough to debug a performance problem, then it ages out.</li>
<li><strong>Support audit</strong> answers <em>&quot;what happened to this request, this entity — and who did it?&quot;</em> It&#39;s for <strong>support, compliance, and security</strong>. Durable, queryable, attributed to a real actor, kept.</li>
</ul>
<p>The tell was one phrase the client kept using: <em>&quot;so an agent can query it again.&quot;</em> That is not a trace viewer. A trace viewer answers &quot;give me this one trace.&quot; A support agent needs to answer &quot;what did user Y do on Tuesday,&quot; and &quot;what caused this account to end up in that state&quot; — months later, from a stored record. The moment you hear <em>query it again</em>, you&#39;re not building observability for engineers. You&#39;re building a <strong>queryable system of record</strong>.</p>
<p>This is the whole reason I frame the work the way I do. The first bug wasn&#39;t in any code — it was in the word &quot;audit.&quot; I spend a lot of time telling clients I practice <em>debugging the specification, not the output</em>, and this is the cleanest example I&#39;ve got:</p>
<blockquote>
<p>The hardest part of &quot;add full traceability&quot; was noticing it wasn&#39;t a tracing problem at all.</p>
</blockquote>
<p>Get that wrong and no amount of good engineering saves you. You&#39;d build a beautiful OTEL pipeline, demo it, and hand support a tool that can&#39;t answer a single one of their actual questions.</p>
<h2 id="why-not-opentelemetry-concretely">Why not OpenTelemetry, concretely</h2>
<p>&quot;Reframed away from the obvious tool&quot; is easy to say and easy to hand-wave. Here&#39;s the concrete reasoning, because the client&#39;s engineers rightly pushed on it:</p>
<ul>
<li>OTEL spans are <strong>ephemeral and engineering-facing</strong> by default. To keep them you stand up a trace backend, and even then the query model is trace-centric — <em>&quot;fetch this trace id&quot;</em> — not <em>&quot;everything user Y did today.&quot;</em> Wrong shape for the question.</li>
<li>There&#39;s <strong>no free path from OTEL to a general queryable store.</strong> You&#39;d hand-build an exporter or a collector-writer that lands spans somewhere SQL-shaped, and at that point you&#39;re reinventing an audit log — badly, and with someone else&#39;s data model.</li>
<li>OTEL answers <em>how slow.</em> Support needs <em>what happened, and who.</em> Different question, different tool.</li>
</ul>
<p>So I kept the <strong>one</strong> idea from the tracing world that actually mattered — a correlation id to tie a request&#39;s fan-out together — and dropped everything else. Which brings us to the spine.</p>
<h2 id="the-spine-put-the-thread-in-the-fact-not-the-transport">The spine: put the thread in the fact, not the transport</h2>
<p>The system already tells its own story. Every meaningful action publishes an integration event through a transactional outbox — <code>JobPosted</code>, <code>JobClosed</code>, <code>ApplicationSubmitted</code>, <code>ApplicationStatusChanged</code>. The events were there. They just didn&#39;t carry the thread that ties them together. So the spine of the whole design is two ids added to <code>IIntegrationEvent</code>:</p>
<ul>
<li><strong>CorrelationId</strong> — constant across a request&#39;s entire fan-out. The thread.</li>
<li><strong>CausationId</strong> — the id of the event that <em>directly</em> caused this one. The parent pointer.</li>
</ul>
<p>CausationId is the underrated one. Correlation alone gives you a flat pile: &quot;everything that happened in this request.&quot; Causation gives you the <strong>causal tree</strong> — <em>close-job → <code>JobClosed</code> → <code>ApplicationStatusChanged</code> → notification.</em> For support, &quot;what caused this?&quot; is the actual question, and a flat list physically cannot answer it. The parent pointer is the difference between a bucket of events and a story.</p>
<p>Then a design fork worth naming, because it&#39;s the kind of choice that quietly decides whether the whole thing works: do you carry the thread in <strong>transport metadata</strong> — Service Bus <code>ApplicationProperties</code> — or in the <strong>event contract</strong> itself?</p>
<p>I chose the contract. Transport headers don&#39;t land in the durable record a support query reads, and they&#39;re awkward to query after the fact. Putting the ids <em>in the fact</em> makes them part of what&#39;s permanently recorded. The cost is real and I&#39;m not going to pretend otherwise: it&#39;s a contract change that touches every event, every publisher, every consumer. That&#39;s the expensive step. It&#39;s also the right one.</p>
<p>Here&#39;s the event we&#39;ll trace for the rest of the post — a brand-new account registering. Notice what the contract carries, and what it refuses to:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">namespace</span> <span class="hljs-title">JobBoard.Contracts</span>;

<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">record</span> <span class="hljs-title">AccountCreated</span>(<span class="hljs-params">
    Guid Id,
    Guid AccountId,
    <span class="hljs-built_in">string</span> Email,
    <span class="hljs-built_in">string</span> Role,
    DateTime OccurredOnUtc</span>) : IIntegrationEvent</span>
{
    <span class="hljs-keyword">public</span> Guid CorrelationId { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; }
    <span class="hljs-keyword">public</span> Guid CausationId { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; }
    <span class="hljs-keyword">public</span> Guid? ActorId { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; }
}</code></pre>
<p>The event <em>is</em> the entire interface between the two services — no shared code, no shared table, just a fact on the bus. And notice what&#39;s deliberately absent: no password, no password hash. The event carries only what a support agent reading the trail needs — which account, what email, what role, when. That absence is a design decision I&#39;ll come back to, because privacy here is a property of the event&#39;s <em>shape</em>, not a filter bolted on downstream.</p>
<h2 id="and-quot-who-did-it-and-quot-quietly-forced-a-security-fix">&quot;Who did it&quot; quietly forced a security fix</h2>
<p>Here&#39;s where an observability feature turned into a security prerequisite, and it&#39;s my favorite part of the whole engagement.</p>
<p>You cannot record <em>who</em> did something if you don&#39;t actually <strong>trust</strong> your answer to &quot;who.&quot; And this repo had a latent BOLA/IDOR seam: some services derived the acting user from <strong>body-supplied ids</strong> rather than from the validated token. On a normal day that&#39;s a garden-variety authorization bug. The instant you try to write a trustworthy audit trail, it becomes load-bearing — because an &quot;actor&quot; field populated from a spoofable body value isn&#39;t an audit trail, it&#39;s a liability that <em>looks</em> like one.</p>
<p>So the &quot;who&quot; column turned a feature into a <strong>prerequisite fix</strong> — one that already had a <em>Proposed</em> ADR waiting: the gateway must project validated identity inward, and services must stop trusting the body. Which is really one observation stated twice:</p>
<blockquote>
<p>Observability and authorization are the same seam from two sides. &quot;Record who did this&quot; is unanswerable until identity is actually trustworthy.</p>
</blockquote>
<p>Registration is the interesting edge of this, because it happens <em>before</em> any token exists — there&#39;s no ambient authenticated user to attribute the action to. The answer is a <strong>self-originated actor</strong>: the account is its own actor. One small extension method, added alongside the existing <code>RootThread</code> (a normal edge-authenticated request) and <code>FollowOnThread</code> (an event-to-event chain):</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> AuditThread <span class="hljs-title">SelfOriginatedThread</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> IRequestContext context, Guid actorId</span>)</span> =&gt;
    <span class="hljs-keyword">new</span>(context.CorrelationId, context.CorrelationId, actorId);</code></pre>
<p>Correlation and causation still come from the request, so the row lines up with everything else in that HTTP call. But the actor is the id the action itself just established — the newly created account&#39;s own id — never a client-supplied or ambient one. In the business layer, that&#39;s the whole registration path:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;AccountServiceModel&gt; <span class="hljs-title">RegisterAsync</span>(<span class="hljs-params">RegisterAccountViewModel viewModel, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">var</span> passwordHash = _passwordHasher.Hash(viewModel.Password);
    <span class="hljs-comment">// Store email normalized so the unique index and login lookups are case/whitespace insensitive.</span>
    <span class="hljs-keyword">var</span> account = (viewModel <span class="hljs-keyword">with</span> { Email = Normalize(viewModel.Email) }).ToEntity(passwordHash);

    <span class="hljs-comment">// Registration runs before any token exists, so the edge projects no actor: the account is its own</span>
    <span class="hljs-comment">// actor (self-originated). The event ships iff the insert commits (same transaction, in the data layer).</span>
    <span class="hljs-keyword">var</span> created = account.ToAccountCreated(_requestContext.SelfOriginatedThread(account.Id));

    <span class="hljs-keyword">var</span> saved = <span class="hljs-keyword">await</span> _dataLayer.RegisterAsync(account, created, cancellationToken);
    <span class="hljs-keyword">return</span> saved.ToServiceModel();
}</code></pre>
<p>And because this is the kind of guarantee that regresses silently the first time someone &quot;cleans up&quot; the actor logic, it gets pinned by a test that asserts the property, not the plumbing:</p>
<pre class="blog-code"><code class="hljs language-csharp">[<span class="hljs-meta">Fact</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">RegisterAsync_BuildsAccountCreated_WithSelfOriginatedThread_AndNoSecrets</span>()</span>
{
    <span class="hljs-keyword">var</span> dataLayer = <span class="hljs-keyword">new</span> FakeAccountDataLayer();
    <span class="hljs-keyword">var</span> business = CreateBusiness(dataLayer);

    <span class="hljs-keyword">await</span> business.RegisterAsync(
        TestData.RegisterViewModel(email: <span class="hljs-string">&quot;New@Example.com&quot;</span>, password: <span class="hljs-string">&quot;s3cret-password&quot;</span>, role: AccountRole.Employer));

    <span class="hljs-keyword">var</span> account = dataLayer.RegisteredAccount!;
    <span class="hljs-keyword">var</span> created = dataLayer.CreatedEvent;
    Assert.NotNull(created);

    Assert.Equal(account.Id, created!.AccountId);
    Assert.Equal(<span class="hljs-string">&quot;new@example.com&quot;</span>, created.Email);
    Assert.Equal(<span class="hljs-string">&quot;Employer&quot;</span>, created.Role);
    Assert.DoesNotContain(<span class="hljs-string">&quot;s3cret-password&quot;</span>, created.ToString());

    <span class="hljs-comment">// Root of its request thread: correlation from the context, causation is the request&#x27;s own id, and</span>
    <span class="hljs-comment">// the actor is the account itself — NOT the ambient context actor (registration is anonymous).</span>
    Assert.Equal(CorrelationId, created.CorrelationId);
    Assert.Equal(CorrelationId, created.CausationId);
    Assert.Equal(account.Id, created.ActorId);
    Assert.NotEqual(ContextActorId, created.ActorId);
}</code></pre>
<p>That same privacy-by-shape instinct runs one level up in the event model. <code>AccountCreated</code> and <code>LoggedIn</code> physically cannot carry a secret because the record has no field for one. <code>LoginFailed</code> goes further: it has no <code>AccountId</code> field at all and a hardcoded, uniform reason — so the trail itself can&#39;t be used to enumerate which emails have accounts, mirroring the endpoint&#39;s uniform 401. You don&#39;t filter the trail to be safe. You shape the facts so an unsafe row is unrepresentable.</p>
<h2 id="where-the-trail-lives-a-new-service-on-boring-tech">Where the trail lives: a new service, on boring tech</h2>
<p>A cross-service, one-place-to-query trail runs headfirst into <strong>database-per-service</strong> — the single most load-bearing rule in this whole codebase. Three options were on the table:</p>
<ul>
<li><strong>Per-service audit tables.</strong> Most &quot;correct&quot; for the boundary, but a full-lifecycle query has to fan out across five databases and stitch the results. Wrong for &quot;query it from one place.&quot;</li>
<li><strong>Fold it into the existing Notifications service</strong> (already a write-only log). Cheap, but it conflates notifications with audit and makes one database dual-purpose. A shortcut that becomes a mess.</li>
<li><strong>A dedicated Audit service</strong> fed by the bus — a consumer that subscribes to <em>everything</em> and appends.</li>
</ul>
<p>I chose the dedicated service, and the reason matters more than the choice: this isn&#39;t a new <em>idea</em>. A cross-service read model is exactly what an audit trail <em>is</em>, and the architecture already had an ADR for the read-model pattern. Adding a service <em>felt</em> heavy right up until I realized I wasn&#39;t inventing a pattern — I was applying one I&#39;d already decided on. Per the repo&#39;s own constitution, a new service is &quot;propose, don&#39;t scaffold,&quot; so it went through an ADR and an explicit sign-off, not a silent addition.</p>
<p>On the datastore, Cosmos got floated — schemaless documents, easy query, seems tailor-made for heterogeneous event payloads. I talked us out of it, and this is the fork I&#39;m most glad we surfaced instead of defaulted:</p>
<ul>
<li>The Linux Cosmos emulator is <strong>preview-grade</strong>; it would&#39;ve been the only non-Postgres store in the stack, for one service.</li>
<li><strong>Postgres with a <code>jsonb</code> column already gives you document-shaped flexibility</strong> for heterogeneous payloads, <em>plus</em> plain SQL for the query surface.</li>
<li>&quot;An agent can query it&quot; is strictly <em>easier</em> on ubiquitous SQL than on a bespoke dialect.</li>
</ul>
<p>That&#39;s the boring-tech win in one line: reach for the new datastore only when the existing one genuinely can&#39;t do the job. <code>jsonb</code> meant it could.</p>
<p>None of this is hand-wired. The topic, the subscription, and every service reference are declared once in the Aspire orchestrator:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">var</span> accountCreatedTopic = serviceBus.AddServiceBusTopic(<span class="hljs-string">&quot;AccountCreated&quot;</span>);
accountCreatedTopic.AddServiceBusSubscription(<span class="hljs-string">&quot;audit-accountcreated&quot;</span>);

<span class="hljs-keyword">var</span> identity = builder.AddProject&lt;Projects.JobBoard_Identity&gt;(<span class="hljs-string">&quot;identity&quot;</span>)
    .WithReference(identityDb)
    .WithReference(serviceBus)
    .WaitFor(identityDb)
    .WaitFor(serviceBus)    <span class="hljs-comment">// runs the outbox dispatcher, so it uses the bus</span>
    .WithEnvironment(<span class="hljs-string">&quot;Jwt__SigningKey&quot;</span>, jwtSigningKey);</code></pre>
<p>Before this change, Identity was a pure request/response service with <em>no</em> bus reference at all. This diff is what turns it into an outbox publisher for the first time.</p>
<p>On the consume side, the thing I care most about is that the Audit service doesn&#39;t grow a bespoke class per event type. One generic consumer handles all of them:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">AuditConsumer</span>&lt;<span class="hljs-title">TEvent</span>&gt; : <span class="hljs-title">IIntegrationEventConsumer</span>&lt;<span class="hljs-title">TEvent</span>&gt;
    <span class="hljs-keyword">where</span> <span class="hljs-title">TEvent</span> : <span class="hljs-title">IIntegrationEvent</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IAuditFacade _facade;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">AuditConsumer</span>(<span class="hljs-params">IAuditFacade facade</span>)</span> =&gt; _facade = facade;

    <span class="hljs-function"><span class="hljs-keyword">public</span> Task <span class="hljs-title">ConsumeAsync</span>(<span class="hljs-params">TEvent @<span class="hljs-keyword">event</span>, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span> =&gt;
        _facade.RecordAsync(@event, cancellationToken);
}</code></pre>
<p>Registered once per event type — a single line:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Cradle-to-grave events (SCRUB A7): the actions that published nothing before now.</span>
builder.Services.AddIntegrationEventConsumer&lt;AccountCreated, AuditConsumer&lt;AccountCreated&gt;&gt;(<span class="hljs-string">&quot;audit-accountcreated&quot;</span>);</code></pre>
<p>And one mapper turns <em>any</em> event into one row, with a <code>switch</code> that decides the subject and the occurred-at per event type:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> AuditEntry <span class="hljs-title">ToAuditEntry</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> IIntegrationEvent @<span class="hljs-keyword">event</span></span>)</span>
{
    <span class="hljs-keyword">var</span> (subjectId, occurredOnUtc) = Describe(@event);

    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> AuditEntry
    {
        <span class="hljs-comment">// The event id is the row key: a redelivery collides on the PK and lines up with the inbox key.</span>
        Id = @event.Id,
        EventType = @event.GetType().Name,
        CorrelationId = @event.CorrelationId,
        CausationId = @event.CausationId,
        ActorId = @event.ActorId,
        SubjectId = subjectId,
        OccurredOnUtc = occurredOnUtc,
        Payload = JsonSerializer.Serialize(@event, @event.GetType(), PayloadOptions),
    };
}

<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> (<span class="hljs-params">Guid? SubjectId, DateTime OccurredOnUtc</span>) <span class="hljs-title">Describe</span>(<span class="hljs-params">IIntegrationEvent @<span class="hljs-keyword">event</span></span>)</span> =&gt; @event <span class="hljs-keyword">switch</span>
{
    JobPosted e =&gt; (e.JobId, e.PostedOnUtc),
    JobClosed e =&gt; (e.JobId, e.ClosedOnUtc),
    ApplicationSubmitted e =&gt; (e.ApplicationId, e.SubmittedOnUtc),
    ApplicationStatusChanged e =&gt; (e.ApplicationId, e.ChangedOnUtc),
    AccountCreated e =&gt; (e.AccountId, e.OccurredOnUtc),
    LoggedIn e =&gt; (e.AccountId, e.OccurredOnUtc),
    <span class="hljs-comment">// A failed login has no account id (an unknown email has no account, and we don&#x27;t disclose which):</span>
    <span class="hljs-comment">// no subject, just the attempt in the trail.</span>
    LoginFailed e =&gt; ((Guid?)<span class="hljs-literal">null</span>, e.OccurredOnUtc),
    ProfileUpdated e =&gt; (e.ProfileId, e.OccurredOnUtc),
    _ =&gt; <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> NotSupportedException(
        <span class="hljs-string">$&quot;No audit projection for event &#x27;<span class="hljs-subst">{@<span class="hljs-keyword">event</span>.GetType().Name}</span>&#x27;. Add its subject id and occurred-at &quot;</span> +
        <span class="hljs-string">&quot;to AuditEntryMapper.Describe when auditing a new event (SCRUB A7).&quot;</span>),
};</code></pre>
<p>Adding the <em>next</em> audited action is a few-line diff — a <code>Describe</code> case, a consumer registration, a topic and subscription in the AppHost — not a new consumer class. Hold onto that <code>NotSupportedException</code> at the bottom; it&#39;s doing quiet work I&#39;ll come back to.</p>
<h2 id="append-only-and-idempotent-by-reusing-what-was-already-there">Append-only and idempotent — by reusing what was already there</h2>
<p>Two properties make this an audit trail rather than a log that happens to store events, and neither one required new machinery.</p>
<p><strong>Append-only.</strong> The trail is never updated or deleted. A correction is a <em>new row</em>. An audit log you can edit isn&#39;t one.</p>
<p><strong>Idempotent.</strong> Service Bus — and the outbox relay in front of it — is at-least-once. That callback <em>will</em> fire twice eventually, and a handler that isn&#39;t safe to run twice is a latent double-effect bug. The audit consumer dedupes through the same inbox every other consumer in the system already uses.</p>
<p>The load-bearing piece on the publish side is that the account row and the outbox row commit in <strong>one transaction</strong> — so the event ships if and only if the account actually exists:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;Account&gt; <span class="hljs-title">RegisterAsync</span>(<span class="hljs-params">Account account, AccountCreated created, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">try</span>
    {
        <span class="hljs-comment">// The insert and the outbox row are one transaction, so AccountCreated ships iff the account</span>
        <span class="hljs-comment">// commits. The repository only stages the insert; the transaction is what SaveChanges/commits it.</span>
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _repository.ExecuteInTransactionAsync(
            <span class="hljs-keyword">async</span> token =&gt;
            {
                <span class="hljs-keyword">var</span> saved = <span class="hljs-keyword">await</span> _repository.AddAsync(account, token);
                <span class="hljs-keyword">await</span> _outbox.EnqueueAsync(created, token);
                <span class="hljs-keyword">return</span> saved;
            },
            cancellationToken);
    }
    <span class="hljs-keyword">catch</span> (DbUpdateException ex) <span class="hljs-keyword">when</span> (AccountRepository.IsDuplicateEmailViolation(ex))
    {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> DomainException(
            <span class="hljs-string">&quot;account.email_taken&quot;</span>,
            <span class="hljs-string">&quot;An account with this email already exists.&quot;</span>,
            StatusCodes.Status409Conflict);
    }
}</code></pre>
<p>There is no path where the account exists but the event doesn&#39;t, or vice versa. On the consume side, the inbox turns a redelivery into a no-op instead of a duplicate row:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> Task <span class="hljs-title">AppendAsync</span>(<span class="hljs-params">AuditEntry entry, Guid messageId, CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span> =&gt;
    _repository.ExecuteInTransactionAsync(
        <span class="hljs-keyword">async</span> token =&gt;
        {
            <span class="hljs-comment">// Idempotency: a redelivery of the same message finds its inbox row and no-ops.</span>
            <span class="hljs-keyword">if</span> (<span class="hljs-keyword">await</span> _inbox.HasProcessedAsync(messageId, token))
            {
                <span class="hljs-keyword">return</span>;
            }

            <span class="hljs-keyword">await</span> _repository.AddAsync(entry, token);
            <span class="hljs-keyword">await</span> _inbox.MarkProcessedAsync(messageId, token);
        },
        cancellationToken);</code></pre>
<p>Here&#39;s the detail I&#39;d frame on a wall: <strong>one id threads the entire pipeline.</strong> The event&#39;s own <code>Guid</code>, minted once at the publish site, becomes the outbox row&#39;s primary key, the Service Bus <code>MessageId</code>, the inbox dedupe key, <em>and</em> the audit row&#39;s own primary key. That single choice is what collapses &quot;at-least-once delivery&quot; into &quot;recorded exactly once&quot; with zero extra coordination. No distributed lock, no dedup table you have to reason about separately — the identity of the fact carries all the way through.</p>
<p>And because &quot;atomic&quot; is a claim, not a vibe, it gets a test that forces the failure:</p>
<pre class="blog-code"><code class="hljs language-csharp">[<span class="hljs-meta">Fact</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">RegisterAsync_LeavesNoAccountAndNoOutboxRow_WhenEnqueueThrows</span>()</span>
{
    <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> harness = <span class="hljs-keyword">new</span> IdentitySqliteHarness();
    <span class="hljs-keyword">var</span> account = TestData.Account(email: <span class="hljs-string">&quot;rollback@example.com&quot;</span>);

    <span class="hljs-keyword">await</span> <span class="hljs-keyword">using</span> (<span class="hljs-keyword">var</span> context = harness.CreateContext())
    {
        <span class="hljs-comment">// The insert stages inside the transaction, then the AccountCreated outbox write throws — the</span>
        <span class="hljs-comment">// account must roll back with it, leaving nothing committed.</span>
        <span class="hljs-keyword">var</span> dataLayer = CreateDataLayer(context, <span class="hljs-keyword">new</span> FakeOutbox { ThrowOnEnqueue = <span class="hljs-literal">true</span> });
        <span class="hljs-keyword">await</span> Assert.ThrowsAsync&lt;InvalidOperationException&gt;(() =&gt; RegisterAsync(dataLayer, account));
    }

    <span class="hljs-keyword">await</span> <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> assert = harness.CreateContext();
    Assert.Empty(<span class="hljs-keyword">await</span> assert.Accounts.ToListAsync());
    Assert.Empty(<span class="hljs-keyword">await</span> assert.OutboxMessages.ToListAsync());
}</code></pre>
<h2 id="coverage-rots-silently-so-i-made-a-robot-enforce-it">Coverage rots silently, so I made a robot enforce it</h2>
<p>Here&#39;s the trap in &quot;cradle to grave&quot;: it&#39;s only <em>true</em> if <strong>every</strong> mutating action is audited — including the ones that never emitted an event. The trail already covered the three job-and-application events. But Identity and Profiles were pure request/response services wired to zero bus references: registering an account, logging in, updating a résumé all left <em>no trace support could query</em>. That&#39;s a coverage gap, and coverage gaps don&#39;t announce themselves. They&#39;re discovered six months later by the one query that needed the row that was never written.</p>
<p>You can&#39;t fix that with willpower. You make the guardrail cheaper to follow than to skip, and you make something fail loudly when it&#39;s skipped:</p>
<ul>
<li>A <strong>rule</strong> (<code>audit.md</code>) that states the invariant — every mutating endpoint publishes an audited event — and loads when the agent touches the relevant code.</li>
<li>A <strong>skill</strong> (<code>add-audit-event</code>) that turns &quot;audit this action&quot; into a one-shot playbook: contract → business → data layer → AppHost topic → consumer → mapper case → tests, in the right order.</li>
<li>A read-only <strong>subagent</strong> (<code>audit-coverage-checker</code>) that walks the services and flags any mutating endpoint with no audited event.</li>
</ul>
<p>And remember that <code>NotSupportedException</code> at the bottom of the mapper&#39;s <code>switch</code>? That&#39;s the loud failure in the code itself. Subscribe Audit to a new topic without teaching the mapper about it, and the pipeline throws instead of quietly writing a half-populated row. The guardrail isn&#39;t a document nobody reads — it&#39;s a build that stops, a subagent that reports, and an exception that refuses to paper over a gap.</p>
<h2 id="the-contrarian-bit-the-code-wasn-and-39-t-the-deliverable">The contrarian bit: the code wasn&#39;t the deliverable</h2>
<p>Here&#39;s the part that surprises people, and it&#39;s the actual point of the post.</p>
<p>The valuable artifact of this engagement isn&#39;t the audit trail. It&#39;s the <strong>design, encoded</strong> — a handful of ADRs, a rule, a SCRUB prompt sequence, and two skills — such that the <em>next</em> session, human or agent, builds the next slice correctly without re-deriving any of it. The plan is the deliverable. The code is downstream.</p>
<p>That only worked because the system&#39;s own documents did the constraining. The <code>CLAUDE.md</code> is itself a SCRUB prompt; the ADRs record the decisions with their rejected alternatives; the area rules encode the boundaries. So when I drove this with Claude Code, the agent could check <em>itself</em> against &quot;no shared database,&quot; &quot;propose don&#39;t scaffold,&quot; &quot;the outbox is the only sender&quot; — and the plan landed inside those rails instead of inventing plausible nonsense outside them.</p>
<p>Three things made the difference, and none of them is &quot;the tool is clever&quot;:</p>
<ul>
<li><strong>Grounded in the real code first.</strong> Before a line was proposed, parallel exploration mapped the actual telemetry, the real outbox path, the existing conventions. You cannot reframe &quot;audit vs tracing&quot; correctly from a summary; you reframe it from what&#39;s actually wired up.</li>
<li><strong>Forks surfaced as decisions, not silently chosen.</strong> Audit vs tracing. New service vs fold-in. Postgres vs Cosmos. Each one got raised, reasoned, and recorded. The single best move of the session was the agent <em>disagreeing</em> with the Cosmos idea and explaining why — instead of cheerfully complying with a worse design.</li>
<li><strong>Small, verifiable steps.</strong> I ran this as a SCRUB prompt per layer, each independently reviewable — roughly 15 to 25 files at a time. Because here&#39;s the thing nobody selling AI velocity wants to say out loud: leveraging AI doesn&#39;t remove the code review from my chore list. It <em>magnifies</em> it. Lightweight, bespoke prompts keep each review small enough that I can actually do it properly.</li>
</ul>
<p>That&#39;s the discipline. Not &quot;generate faster.&quot; Generate in slices you can still verify, encode every decision that mattered, and let the repository carry the judgment forward.</p>
<h2 id="what-this-buys-a-business">What this buys a business</h2>
<p>I work with two kinds of decision-makers, usually in the same conversation. So, plainly:</p>
<ul>
<li><strong>Replayable answers to &quot;who did what, when.&quot;</strong> Support and compliance can reconstruct a session from a durable, queryable record — including the <em>causal</em> chain, not just a flat pile of events. That&#39;s the capability the client actually asked for, delivered as a system of record rather than a trace viewer they&#39;d have hated.</li>
<li><strong>A security hole closed as a side effect.</strong> Building trustworthy attribution forced a latent BOLA/IDOR seam into the open and onto the roadmap. Audit and authorization turned out to be the same seam; fixing one hardened the other.</li>
<li><strong>Privacy by construction.</strong> The events physically can&#39;t leak a password hash, and the failed-login record can&#39;t be used to enumerate accounts — because the facts are <em>shaped</em> to be safe, not filtered after the fact.</li>
<li><strong>Cheap to extend, and it stays honest.</strong> Auditing the next action is a few-line diff, and a subagent plus a fail-loud mapper make sure coverage can&#39;t quietly rot.</li>
<li><strong>You own all of it.</strong> SCRUB is a discipline, not a dependency, running on boring, ubiquitous tech — Postgres, SQL, the outbox you already had. No proprietary framework to keep paying for.</li>
</ul>
<h2 id="the-honest-part">The honest part</h2>
<p>Let me label the scaffold as a scaffold, because I always do.</p>
<p>This was a <strong>late-breaking, genuinely large change</strong> — a cross-cutting contract change touching every event, publisher, and consumer. On a reference build that stings; on a real enterprise system it&#39;s a project. And it&#39;s exactly the kind of thing that should have been a day-one non-functional requirement, reasoned out at inception rather than retrofitted. I&#39;ll own that I missed it the first time through on both JobBoard <em>and</em> OrderFlow. It&#39;s easy to miss and expensive to add late, which is precisely why it&#39;s worth writing about.</p>
<p>JobBoard is a reference build — a spike that runs on local Aspire-orchestrated containers talking to the real Service Bus SDK through an emulator. The cross-service read-model ADR that this work leans on was <em>Proposed</em>, not pre-solved, and I&#39;m not going to pretend otherwise. The register→<code>AccountCreated</code>→audit-row path in this post is one action traced clean end to end; the other new events (<code>LoggedIn</code>, <code>LoginFailed</code>, <code>ProfileUpdated</code>) follow the identical shape, and the same coverage discipline is what keeps the rest honest. And the standing advice in the toolkit holds: Aspire, the Service Bus emulator, and Claude Code all ship fast — verify the exact API names against the live docs before you lean on them.</p>
<h2 id="where-this-starts">Where this starts</h2>
<p>If your team has a system that publishes events but can&#39;t answer &quot;who did what to this entity, and what caused it&quot; — or if someone just asked you for &quot;full traceability&quot; and you&#39;re about to reach for a tracing stack — the first move isn&#39;t picking a tool. It&#39;s figuring out which of the two systems hiding inside that request they actually need. Get that wrong and the best engineering in the world builds the wrong thing beautifully.</p>
<p>That&#39;s the work I do: <a href="https://www.architect4hire.com/services/application-development">architect4hire.com/services/application-development</a>. Read the repo first — <a href="https://github.com/architect4hire/jobboard" target="_blank" rel="noopener noreferrer">github.com/architect4hire/jobboard</a> — and the SCRUB framework in full at <a href="https://www.architect4hire.com/scrub">architect4hire.com/scrub</a>. If it&#39;s the shape of what your team needs, reach out at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>. Fractional or contract, the first conversation is the same: what you&#39;re trying to build, and whether I&#39;m the right person to help you build it. If I&#39;m not, I&#39;ll tell you.</p>
<hr>
<p><em>Robert Felkins is the principal architect at Architect4Hire — Chicago-based fractional and contract architecture for small and mid-sized teams. I design right-sized .NET and Azure systems, modernize the ones you already have, and bring AI-assisted development into teams in a way that survives contact with production: senior judgment without the full-time price tag. Reach me at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>.</em></p>
]]></content:encoded>
        </item>
        <item>
            <title>Architecture-Powered Claude Engineering: The Judgment Under the Toolkit</title>
            <link>https://www.architect4hire.com/blog/architecture-powered-claude</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/architecture-powered-claude</guid>
            <pubDate>Sun, 19 Jul 2026 11:40:02 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>Everyone&apos;s racing to write a CLAUDE.md and a folder of skills — but the toolkit is the easy 20%. I built a five-service event-driven job board with Claude Code, and the real deliverable isn&apos;t the .claude/ folder; it&apos;s the architectural judgment that authored it and turns a plausible alpha into something you&apos;d actually ship.</description>
            <content:encoded><![CDATA[<h1 id="architecture-powered-claude-engineering-the-judgment-under-the-toolkit">Architecture-Powered Claude Engineering: The Judgment Under the Toolkit</h1>
<p><em>Everyone&#39;s racing to write a <code>CLAUDE.md</code> and a folder of skills. But those files are containers — what goes in them is architectural judgment, and if the judgment is wrong, you&#39;ve just automated a mistake five times faster. I built a five-service event-driven job board with Claude Code, and the <code>.claude/</code> toolkit isn&#39;t the achievement. The architecture that authored it is. Hand the same tools to someone without that experience, and you get a plausible alpha. With it, you get something you&#39;d actually push.</em></p>
<p>There&#39;s a lot of excitement right now about moving AI discipline into the repository — <code>CLAUDE.md</code> files, rules, skills, subagents, hooks. I&#39;m all for it; I do it, and I&#39;ll show you exactly how below. But there&#39;s a quiet assumption underneath the excitement that&#39;s worth dragging into the light: <strong>that the toolkit is the hard part.</strong> It isn&#39;t. Writing a rule that says &quot;no shared database&quot; takes thirty seconds. <em>Knowing</em> that rule is load-bearing — knowing the eleven other boundaries that matter, in what order to prove them, and what breaks in production if you get one wrong — is the work of years. The file is easy. The judgment inside it is not.</p>
<p>That&#39;s the whole thesis of this post, and the reason I frame what I do as <strong>Architecture-Powered Claude Engineering</strong> rather than just Claude engineering. The public proof is at <a href="https://github.com/architect4hire/jobboard" target="_blank" rel="noopener noreferrer">github.com/architect4hire/jobboard</a>: a genuinely functional job board built as five bounded .NET services behind one gateway, an Angular front end, an event bus, all orchestrated locally by Aspire, driven agentically with Claude Code. The <code>.claude/</code> toolkit that drives it is real, and it&#39;s good. But it&#39;s <em>downstream</em>. It&#39;s architectural experience compiled into a form an agent can execute — and it&#39;s only as good as the architecture it encodes.</p>
<h2 id="the-tool-is-neutral-the-judgment-is-not">The tool is neutral. The judgment is not.</h2>
<p>Here&#39;s the uncomfortable part for anyone selling &quot;just add a CLAUDE.md and go.&quot;</p>
<p>Claude Code will happily encode a <em>bad</em> architecture as faithfully as a good one. Ask it to build a job board with no guardrails, and it produces plausible code — code that compiles, demos, and looks right in the diff. Plausible is not correct. In a distributed system, plausible-but-wrong is where the money leaks out: the agent helpfully opens a second database connection to grab data it needs, and now two services share a table, and you have a distributed monolith wearing a microservices costume. It passes review if the reviewer doesn&#39;t know what they&#39;re looking at. I call this the Plausibility Trap, and no amount of tooling closes it — because the tool has no opinion about whether your boundaries are right. It just executes.</p>
<p>So the interesting question was never <em>can</em> Claude Code build a microservice system. It&#39;s: <strong>can it build one that behaves correctly when things go wrong, without the architecture drifting</strong> — and the answer to that lives entirely in the judgment you feed it. That&#39;s the experience I&#39;m actually selling. Not &quot;I know how to write a rules file.&quot; Anyone can. I know <em>which</em> rules are worth writing, because I&#39;ve watched the alternatives fail in production. As my application-development service puts it: I don&#39;t just draw the architecture — I build in it, so the design and the code never drift apart. This is how I build the toolkit now. The architecture is why the toolkit is right.</p>
<h2 id="trace-one-decision-and-you-and-39-ll-see-where-the-value-is">Trace one decision, and you&#39;ll see where the value is</h2>
<p>Take the single most important rule in the whole repo: <strong>no shared database, ever. A service reads and writes only its own database.</strong> Watch where that rule actually comes from, because the origin is the point.</p>
<p>It does not start in the <code>.claude/</code> folder. It starts as a <em>decision</em>, reasoned out in the High-Level Design and recorded in an Architecture Decision Record — ADR-0001, database-per-service — with its context, its consequences, and the alternatives I rejected and why. That ADR exists because I&#39;ve lived the failure it prevents: two services quietly coupled through a shared table, evolving independently right up until the day one migration takes them both down. The judgment is: <em>pay the cost of duplicating a little reference data now, or pay for a rewrite later.</em> That&#39;s not a preference. It&#39;s a scar.</p>
<p><em>Only then</em> does it become tooling. The decision becomes a rule that the agent loads when it touches a service. It turns into a subagent that reviews for the violation. It turns into a compiler-enforced reference graph — the project references are one-way and acyclic, <code>Contracts ← Shared ← Service.Core ← Service host ← AppHost</code>, so a service reaching sideways into another is a <em>build error</em>, not a code-review conversation. I checked the repo: no service&#39;s <code>.Core</code> references another&#39;s. Not because everyone remembered to be careful — because the architecture made carelessness impossible.</p>
<p>That&#39;s the chain that matters: <strong>experience → decision → record → guardrail → enforcement.</strong> The <code>.claude/</code> folder is the last two links. The first three are the architect. Remove the architect and the chain still runs — it just encodes whatever plausible thing the agent invented, and enforces <em>that</em> with the same mechanical rigor. You&#39;d get a beautifully automated, ruthlessly consistent mistake.</p>
<h2 id="the-toolkit-is-architecture-encoded-four-ways">The toolkit is architecture, encoded four ways</h2>
<p>With that framing, here&#39;s what&#39;s in the <code>.claude/</code> folder — and notice that every layer is a <em>decision an experienced architect made</em>, not a clever use of a feature.</p>
<p><strong>Rules</strong> are the conventions the agent should <em>know</em>, and they&#39;re path-scoped so they load exactly when relevant — five of them (<code>aspire.md</code>, <code>backend.md</code>, <code>frontend.md</code>, <code>gateway.md</code>, <code>messaging.md</code>). Touch a service, the layered-architecture rules load; touch the bus, the outbox-and-idempotency rules load. But the layered architecture those rules describe — thin host plus a <code>.Core</code> library, facade → business → data → repository, ViewModel in and ServiceModel out — is ADR-0005&#39;s judgment. The rule is just its enforcement. A different architect&#39;s rules file would describe a different architecture, and the agent would obey it just as well. The rule is only as good as the layering it encodes.</p>
<p><strong>Skills</strong> are procedures the agent should <em>follow</em> when a task matches — and the crown jewel, <code>add-endpoint</code>, encodes the single most error-prone operation in this architecture. Adding a REST endpoint here is a lot of correct steps in a specific order: pick the owning service, build the full controller → facade → business → data → repository slice, enqueue the integration event on the outbox <em>atomically in the same transaction as the write</em>, add the gateway route, wire the DI, write the tests. That ordering is architectural knowledge. The skill fires and the agent does it right every time — but only because someone who knew the right way wrote the skill. Hand the same skill mechanism to someone who thinks a fat controller is fine, and it&#39;ll reproduce a fat controller with perfect consistency.</p>
<p><strong>Subagents</strong> are work to <em>delegate</em>, and I keep all three strictly read-only on purpose — <code>code-reviewer</code>, <code>test-gap-analyzer</code>, <code>api-contract-checker</code>. They report; they never touch code. And what they hunt for is not generic — it&#39;s the specific mistakes <em>this</em> architecture is prone to. The test-gap analyzer flags a consumer with no idempotency test, because at-least-once delivery means that callback <em>will</em> fire twice and a handler that isn&#39;t safe to run twice is a latent double-effect bug. Knowing that&#39;s the test that matters is the judgment. The subagent is just where I parked it.</p>
<p><strong>Hooks</strong> are the non-negotiables — the things that happen whether the agent decides to or not — and there are two. <code>secret-guard.sh</code> runs <em>before</em> every edit and physically blocks anything credential-shaped from being written, no matter how I prompt or how tired I am. <code>format.sh</code> formats every touched file after the fact. Rules ask. Hooks enforce. Deciding <em>which</em> invariants deserve a hook instead of a rule — which things must be true versus merely encouraged — is, again, an architectural call.</p>
<p>Rules teach, skills guide, subagents check, hooks enforce. Four ways to encode a decision. But the encoding is the easy 20%. The decisions are the 80% you&#39;re hiring an architect for.</p>
<h2 id="scrub-how-i-serialize-judgment-into-something-an-agent-can-run">SCRUB: how I serialize judgment into something an agent can run</h2>
<p>The connective tissue is a discipline I&#39;ve used for months: <strong>SCRUB</strong> — Scope, Constraints, Restrictions, Usage, Behavior. It started as a way to write a good prompt. What it really is, I&#39;ve come to understand, is a <em>format for transferring architectural judgment into an executable form</em>. Scope names what&#39;s in and out of bounds. Constraints pin the stack and conventions. Restrictions are the hard <em>do-NOTs</em>. Usage points at the right tool. Behavior demands a plan before any change.</p>
<p>The JobBoard&#39;s <code>CLAUDE.md</code> — the constitution loaded every session — is itself written as a SCRUB prompt, because the constitution <em>is</em> architecture. Its Scope declares that adding a service is an architectural decision, not a feature: &quot;propose it, don&#39;t scaffold it.&quot; Its Restrictions are the load-bearing rulings — no shared database, the gateway is the only public door, publish through the outbox in the same transaction, consumers are idempotent. Every one of those is a decision with an ADR behind it. SCRUB is just the grammar that lets me hand those decisions to an agent without losing their meaning, and diagnose exactly which one failed when something goes wrong.</p>
<h2 id="alpha-versus-polished-which-is-the-whole-pitch">Alpha versus polished — which is the whole pitch</h2>
<p>Here&#39;s the contrast I most want a client to hold onto, because it&#39;s the value stated plainly.</p>
<p>Give Claude Code to a competent developer with no distributed-systems architecture background, and you&#39;ll get an <strong>alpha</strong>: a working, demoable job board that looks finished. Post a job, apply, watch a status change — it all works on the happy path. And it&#39;s quietly wrong at every seam. A declined payment leaves stock reserved because there&#39;s no compensation path. A redelivered message is double-processed because the consumer isn&#39;t idempotent. Two services share a table because that was the plausible way to get the data. The design and the code drift apart the day after the build ships, because nothing was recording <em>why</em> anything was done. It&#39;ll get to about ninety percent and stall there forever, because the last ten percent is all the correctness-under-failure work that doesn&#39;t show up in a demo.</p>
<p>Give an architect the <strong>same tools</strong>, and the guardrails carry the experience the developer didn&#39;t have. The outbox and idempotent inbox make it correct under partial failure — no lost events, no double effects — by design, not by luck. The acyclic references keep the boundaries safe, enforced by the compiler. The ADRs and developer docs keep the design and the code locked together so nothing drifts. That&#39;s the difference between an alpha and something you&#39;d push: not the toolkit, which is identical in both cases, but the judgment the toolkit is carrying. <strong>The architecture is what turns &quot;it runs&quot; into &quot;it&#39;s ready.&quot;</strong></p>
<p>And there&#39;s a document ecosystem that keeps it that way, each link feeding the next: the design (the high level design and its C4 diagrams) reasons out the decisions; the decisions become the <code>.claude/</code> guardrails; the guardrails drive Claude Code to produce the <code>src/</code>; the output is recorded in twelve ADRs; and the ADRs are explained to the next human in developer docs — an add-an-endpoint-by-hand walkthrough, a seed-data guide, an ongoing architecture review with a ranked risk table. Design → guardrails → code → record → explanation, kept deliberately in sync, because a design that&#39;s drifted from its code is worse than no design at all. That closed loop is the deliverable. The job board is just the proof it works.</p>
<h2 id="the-contrarian-bit-i-barely-write-prompts-anymore">The contrarian bit: I barely write prompts anymore</h2>
<p>Here&#39;s what surprises people. Once the architecture is encoded, I mostly stop writing prompts. The rules auto-load, the skills carry the procedures, the subagents catch the drift, the hooks enforce the invariants — so day-to-day work needs nothing more than &quot;add a withdraw-application endpoint,&quot; because the repository already knows what &quot;endpoint&quot; means here and which service owns applications.</p>
<p>But read that carefully, because the reason I <em>can</em> stop prompting is not that the tooling is clever. It&#39;s that <strong>the architecture is doing the constraining.</strong> The leverage was never the automation. It was the judgment the automation carries.</p>
<p>Prompt engineering optimizes the prompt; architecture-powered Claude engineering makes the prompt unnecessary — by building years of hard-won structural decisions into the repo once, so they&#39;re paid forward on every task without me re-typing a single one.</p>
<h2 id="what-the-architecture-experience-buys-a-business">What the architecture experience buys a business</h2>
<p>I work with two kinds of decision-makers, often in the same room. So, plainly:</p>
<ul>
<li><strong>Polished, not alpha.</strong> You get the ninety-to-a-hundred percent that the correctness-under-failure work represents — the part a toolkit alone never reaches.</li>
<li><strong>Correct when it counts.</strong> Compensation on every failure path, idempotent consumers, safe boundaries — stated as outcomes: partial-order incidents at zero, double-effects at zero, oversells at zero.</li>
<li><strong>No drift.</strong> The design, the code, and the reasoning stay locked together, enforced by the compiler and the hooks, so the system six months out still matches the diagram.</li>
<li><strong>Cheap onboarding.</strong> A new engineer reads the ADRs and developer docs and is productive in an afternoon, not a month.</li>
<li><strong>You own all of it.</strong> SCRUB is a discipline, not a dependency. No proprietary framework to keep paying for. Clone it, run it, keep it.</li>
</ul>
<h2 id="the-honest-part">The honest part</h2>
<p>Let me label the scaffold as a scaffold, because I always do. JobBoard is a reference build — a spike. It runs entirely on local Aspire-orchestrated containers at zero cloud spend, talking to the real Azure Service Bus SDK through an emulator; going live is a configuration and DevOps change, not a rewrite, but it <em>is</em> a change I haven&#39;t made here. Two of the twelve ADRs are still marked <em>Proposed</em>, not Accepted — the cross-service read-model strategy is named as a pending decision, not pre-built, because pretending to have solved it would be a dishonest move.</p>
<p>And the standing advice in the toolkit is <em>verify before trusting</em>: Aspire, the Service Bus emulator, YARP, and Claude Code all ship fast, so confirm the exact API names against the live docs before you lean on them. That caveat is in the repository, in writing, because a scaffold that lies about its completeness is worse than useless.</p>
<h2 id="where-this-starts">Where this starts</h2>
<p>If your team has adopted an AI coding assistant and it keeps producing plausible-but-wrong output — code that demos and then drifts, an architecture that erodes a little with every generated file, a build that&#39;s perpetually ninety percent — the missing piece isn&#39;t a better tool. You already have the tool. The missing piece is the architectural judgment that tells the tool what &quot;right&quot; is, and encodes it so it holds.</p>
<p>That&#39;s the work I do: <a href="https://www.architect4hire.com/services/application-development">architect4hire.com/services/application-development</a>. Read the repo first — <a href="https://github.com/architect4hire/jobboard" target="_blank" rel="noopener noreferrer">github.com/architect4hire/jobboard</a> — and the SCRUB framework in full at <a href="https://www.architect4hire.com/scrub">architect4hire.com/scrub</a>. Then, if it&#39;s the shape of what your team needs, reach out at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>. Fractional or contract, the first conversation is the same: what you&#39;re trying to build, and whether I&#39;m the right person to help you build it. If I&#39;m not, I&#39;ll tell you.</p>
<hr>
<p><em>Robert Felkins is the principal architect at Architect4Hire — Chicago-based fractional and contract architecture for small and mid-sized teams. I design right-sized .NET and Azure systems, modernize the ones you already have, and bring AI-assisted development into teams in a way that survives contact with production: senior judgment without the full-time price tag. Reach me at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>.</em></p>
]]></content:encoded>
        </item>
        <item>
            <title>The Work Shifted Left, Not Away: Building an Event-Driven Order Saga With SCRUB, Solo</title>
            <link>https://www.architect4hire.com/blog/shift-left</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/shift-left</guid>
            <pubDate>Wed, 15 Jul 2026 08:09:47 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>SCRUB Framework</category>
            <description>I stood up five .NET services and a compensating order saga solo, in six weeks, most of it not spent typing. The tempting read is that AI replaced the team — but having just done it, I&apos;m here to tell you the work shifted left, not away.</description>
            <content:encoded><![CDATA[<h1 id="the-work-shifted-left-not-away-building-an-event-driven-order-saga-with-scrub-solo">The Work Shifted Left, Not Away: Building an Event-Driven Order Saga with SCRUB — Solo</h1>
<p><em>Or: why one architect could stand up five services and a compensating saga, and why that is emphatically not the headcount story you think it is</em></p>
<p>TL;DR — the code lives at <a href="https://github.com/architect4hire/orderflow2026" target="_blank" rel="noopener noreferrer">https://github.com/architect4hire/orderflow2026</a>. It&#39;s an event-driven order-fulfillment system I built for a fictional client, using the SCRUB framework end to end: an Angular front end, an order saga, four reacting services, and a compensation path on every failure. This post is less about the topology and more about what the build <em>taught</em> me — including the part where I keep reading that AI is going to thin out engineering teams, and the part where, having just delivered a system solo that used to need a squad, I&#39;m here to tell you the developer never left the room.</p>
<hr>
<p>Let me start with the fact that&#39;s going to be misread, so I can un-misread it before we go anywhere.</p>
<p>I built OrderFlow alone. One architect, no development squad. Five .NET services — an order saga plus four reactors — talking over a service bus, backed by four different data stores, fronted by an Angular operations view, with a compensating transaction on every failure path in the system. The kind of build that a few years ago would have been a team and a quarter. Solo, in about six weeks, most of it not spent typing.</p>
<p>And I know exactly how that reads on a nervous Monday: <em>see, one senior person plus AI replaced the team.</em> So let me be the guy who actually did it and tell you that&#39;s the wrong lesson.</p>
<p><strong>Done right, AI-assisted development does not shrink your headcount. It relocates your work — out of the muddy middle where we used to hand-crank implementation, and to the two ends where it was always hardest.</strong></p>
<p>One architect could stand up this system not because the AI did the engineering, but because the engineering <em>moved</em>: forward into specifying it precisely, and backward into verifying it ruthlessly. Both are still done by a human, and both are more senior, not less. The keystrokes went away. The judgment did not. That&#39;s the whole post — everything below is me showing my work.</p>
<hr>
<h2 id="why-orderflow-exists-and-what-it-actually-is">Why OrderFlow Exists, and What It Actually Is</h2>
<p>OrderFlow is a proof-of-concept I stand up in the open, for a client who doesn&#39;t exist, so that &quot;I do disciplined AI-assisted development&quot; stops being a slide and starts being a repository you can read. This one models order fulfillment — the genuinely hard kind, where money moves and inventory is finite.</p>
<p>Here&#39;s the landscape. An Angular 20 app with two views — customer order-status and an operations view — calls an Order service over REST. From there it&#39;s events all the way down: the Order service runs a <strong>saga</strong> that sends commands over a service bus, and four services react and reply — <strong>Inventory</strong> (reserve stock), <strong>Payment</strong> (charge), <strong>Fulfillment</strong> (dispatch), and <strong>Notification</strong> (tell the customer). Cosmos holds the event log, Redis the status read model the UI reads from, SQL the inventory and payment records, Blob the rest — all on Aspire-local emulators against the real Azure SDKs, so it boots on a laptop with no cloud spend.</p>
<p>Now — regular readers know my default template is the <em>opposite</em> of this. On PlannerPro I argued hard for one API, one database, and no microservices, because right-sizing usually means resisting the urge to distribute. So why the saga-and-reactors sprawl here?</p>
<p>Because right-sizing is bidirectional, and this domain sizes <em>up</em>. An order that reserves stock, charges a card, and dispatches a package is a multi-step transaction across systems that fail independently, on a bus that delivers at least once. You cannot right-size that into a clever monolith and a database transaction — the distribution isn&#39;t resume-driven; it&#39;s the shape the problem actually is. This deliberately departs from my house template, and inside the build, I worked to a fictional client&#39;s code conventions rather than my own standards (which matters later). The template is a starting bias, not a religion. When the domain moves money, you follow the domain.</p>
<hr>
<h2 id="scrub-for-those-just-joining-us">SCRUB, For Those Just Joining Us</h2>
<p>If you&#39;ve read <a href="https://www.architect4hire.com/blog/one-team-three-projects-one-board-building-plannerpro-with-layered-scrub">the PlannerPro post</a>, skip ahead. Everyone else, ninety seconds.</p>
<p>Generative AI has a failure mode I call the Plausibility Trap: the output isn&#39;t wrong in a way that crashes. It&#39;s wrong in a way that <em>compiles</em>, passes its tests, looks idiomatic, and quietly encodes a domain decision you never made. The model knows how to write code. It does not know your business. Leave a gap in the prompt and it fills that gap with the most statistically average answer in its training data — which is, occasionally and silently, the wrong answer for you.</p>
<p><a href="https://www.architect4hire.com/scrub">SCRUB</a> is the checklist that closes those gaps before you submit. Five elements, every prompt: <strong>S</strong>cope (exactly what you&#39;re asking for), <strong>C</strong>onstraints (your framework, your architecture, your naming), <strong>R</strong>estrictions (what you explicitly do <em>not</em> want, and why — the element everyone skips and the one that matters most), <strong>U</strong>sage (who calls this, in what world), and <strong>B</strong>ehavior (what must not change). Hold onto Restrictions. On a system like this, it&#39;s the whole ballgame.</p>
<hr>
<h2 id="four-places-where-the-plausible-answer-is-the-wrong-answer">Four Places Where the Plausible Answer Is the Wrong Answer</h2>
<p>Here&#39;s the part of OrderFlow that made me want to write this at all. A CRUD app has maybe one spot where the training-data idiom and your business reality point in opposite directions. An order saga has <em>four</em> — and every one is the difference between a demo that dazzles and a production system that double-charges a customer. Each is a Restriction I had to write by hand, because I understood the domain and the model could not.</p>
<p><strong>Compensation on every failure path. (CRITICAL.)</strong> Ask an AI to build a saga step and it will hand you a beautiful happy path — reserve, charge, dispatch, done. What it won&#39;t do, unprompted, is compensate when step three fails after step two succeeded: a multi-step order failing mid-flow, leaving stock reserved or a charge captured with nothing shipped. It compiles, it demos, it passes the happy-path test — and it quietly rots your inventory and your customer trust in production. The restriction is CRITICAL: <em>every failure path compensates — a declined payment releases the reservation, a failed dispatch refunds and releases, no order is ever left partial.</em> That sentence isn&#39;t documentation. It&#39;s the product.</p>
<p><strong>Idempotent payment callbacks. (CRITICAL.)</strong> At-least-once delivery means the payment callback <em>will</em> arrive twice — not sometimes, will. The naive handler the model writes processes both. Two identical callbacks, two charges. The restriction: <em>a duplicate payment callback is a no-op; poison messages dead-letter, they do not loop.</em> Idempotency is not a nice-to-have here; it&#39;s the line between &quot;correct&quot; and &quot;class-action.&quot;</p>
<p><strong>Concurrency-correct inventory reservation. (IMPORTANT.)</strong> Two buyers race for the last unit. The obvious reservation code — read stock, check it&#39;s positive, decrement — lets both of them win, because the check and the decrement aren&#39;t atomic. The AI writes the obvious code; the obvious code oversells. The restriction pins the reservation to be concurrency-correct so oversell incidents go to zero, and the status read model converges in a second so the UI doesn&#39;t lie about it.</p>
<p><strong>A durable baseline. (IMPORTANT.)</strong> Without ADRs, C4 diagrams, and a committed design doc, the architecture and the code drift apart the moment the build ships — and six months on, nobody remembers <em>why</em> the payment handler is idempotent, so someone &quot;simplifies&quot; it. The restriction is the baseline itself: design doc, ADRs, and the SCRUB prompt library, committed as a durable source of truth.</p>
<p>Sit with those four, because they&#39;re the argument. Not one is a thing the AI can decide for you; every one is a senior human saying <em>the plausible version is wrong here, and here&#39;s why.</em> Take the human out of that loop and the AI ships four confident, compiling, demo-ready bugs that each cost real money. The developer isn&#39;t the bottleneck in that picture. The developer is the immune system.</p>
<hr>
<h2 id="lessons-from-the-trenches">Lessons From the Trenches</h2>
<p>With the stakes clear, here&#39;s what the build actually cost me — in the order I hit it.</p>
<p><strong>The first decision isn&#39;t code — it&#39;s documentation, and it&#39;s a coin-flip you can&#39;t afford to fumble.</strong> You can architect and scaffold the whole thing from a committed SCRUB prompt library — prompts as plan, spec, and record — or you can build it by hand and document it after. For the <em>initial</em> stand-up, those two roads cost about the same hours. All roads lead to Rome. But they diverge violently by next Tuesday: hand-built-then-documented gives you a system and a description that start drifting apart the moment you close the editor, while the prompt-library road gives you an executable, versioned, reviewable spec you can <em>reuse on the next engagement</em>. My recommendation, and what I did: build a default scaffolding prompt in your library and start from it every time. The juice is genuinely worth the squeeze — but I won&#39;t pretend the first pressing is free. It pays out on the second glass.</p>
<p><strong>My toolchain moved.</strong> Since the GitHub Copilot price change I&#39;ve shifted essentially all of this to Claude Code, with a cleaner division of labor: agentic Edit mode almost exclusively for the initial stand-up and the plan — the big multi-file scaffolding push where an autonomous build-and-verify loop earns its keep — and Chat mode for surgical edits, the one-file &quot;change this and nothing else&quot; work where I approve every character. The mode mapping evolved since PlannerPro, and that&#39;s the point: SCRUB is what survives a pricing email that reshuffles your whole toolchain overnight. The discipline is portable even when the buttons aren&#39;t.</p>
<p><strong>Your prompt library is code, and code has bugs.</strong> I write a big slice of the library up front, and on OrderFlow that bit me exactly the way real code bites you. I&#39;d named a project <code>OrderFlow.Order.Api</code>, and the singular <code>Order</code> collided with types and folders all over a system that already has an <em>Orders</em> saga. The fix wasn&#39;t in the generated C#. It was in the prompt: rename to <code>OrderFlow.Orders.Api</code>, regenerate, move on. Read that again — I debugged the <em>specification</em>, not the output. That is shift-left in one rename. The bug existed before the code did, which is exactly where I caught and killed it.</p>
<p><strong>The AI finds the holes you left — and when it&#39;s right, you feed the fix back into the prompts.</strong> Throughout the build, Claude surfaced gaps in my prompts: places I&#39;d underspecified, ambiguities it flagged, fixes it proposed. Often it was right. But here&#39;s the discipline that separates a tool that helps from a tool that quietly rewrites your intentions — <em>when its fix is correct, you don&#39;t just accept the diff. You update the prompt so the library reflects the system you actually built.</em> Otherwise, the record lies to you in six months. Keeping the spec true is your job. Still yours.</p>
<p><strong>And sometimes you fix it by hand, on purpose.</strong> My favorite small one: Claude cheerfully generated multiple classes in a single file. Compiles, runs, ships — and violates a standard I&#39;ve held for years, one class per file, and it grated every time I opened the offender. Fork in the road: fix the <em>prompt</em> so the generator never does it again, or fix the <em>files</em> by hand this once? For a PoC that isn&#39;t going to grow, I did it manually — pure annoyance-driven housekeeping, zero functional change, in service of a standard I refuse to drop. On a production system with a long road ahead, I&#39;d have fixed the generator, because there you pay once and it never reoffends. Both calls are legitimate. The point is that <em>a human weighed lifespan against effort</em>, because no prompt on earth was going to make that judgment for me.</p>
<p>Every one of those is a developer doing developer work — naming things right, catching a collision, keeping the spec honest, enforcing a standard the machine doesn&#39;t care about. The tool didn&#39;t delete any of it. It relocated it to the two ends that were always the hard part: saying precisely what you want, and verifying precisely what you got.</p>
<hr>
<h2 id="so-about-that-squad">So, About That Squad</h2>
<p>Back to the fact I opened with. Yes, I delivered this solo. No, that is not the layoff memo.</p>
<p>Here&#39;s the honest reconciliation. One architect could ship this saga because the irreducibly human parts — the four restrictions above, the event contracts, the compensation semantics, the failure-injection validation — got done by a senior person, and the mechanical middle got generated against them. That doesn&#39;t scale to &quot;fire the team.&quot; It scales the <em>opposite</em> way: the harder and more valuable the system, the <em>more</em> of that senior specification-and-verification work there is, because the Plausibility Trap grows with complexity, not with team size. A CRUD form has one silent-wrong bug waiting for you. A saga that moves money has four before breakfast, and everyone needs someone who understands the domain to pin it down.</p>
<p>The work shifted left, not away. On a reference build, you see that as &quot;one architect, no squad.&quot; On a real product with a real backlog, you see it as a team whose job stopped being <em>typing the implementation</em> and became <em>authoring the restrictions and verifying an ever-growing surface</em> — a job that gets bigger as the system does. If your headcount drops after adopting this, you didn&#39;t automate the work. You stopped doing part of it, and OrderFlow&#39;s four restrictions are the part you stopped doing. Production will send you the invoice.</p>
<hr>
<h2 id="the-practical-version">The Practical Version</h2>
<p>Running OrderFlow&#39;s playbook on your own next build:</p>
<p><strong>Decide the documentation fork first, out loud.</strong> SCRUB-library-first or hand-built-then-documented — pick before you generate. Equal effort on day one, wildly unequal on day thirty. Build a default scaffolding prompt and start every project from it.</p>
<p><strong>Right-size in both directions.</strong> Sometimes right-sizing means one API; sometimes it means a saga and four reactors. Let the domain decide, and bake &quot;match <em>this</em> architecture&quot; into Constraints on every prompt.</p>
<p><strong>Find <em>all</em> your no-project-filters.</strong> Every domain has the spots where the training-data idiom and your reality diverge. A saga has several: compensation on every failure path, idempotent callbacks, concurrency-correct reservation. Find them before the AI does and pin each with a tiered Restriction that carries its reason.</p>
<p><strong>Treat the prompt library as code.</strong> It has bugs. You&#39;ll rename an <code>Order</code> to <code>Orders</code> to kill a collision. Fix the spec, not just the output — that <em>is</em> the debugging now.</p>
<p><strong>Feed the AI&#39;s good catches back into the prompts.</strong> When the model finds a real hole, update the library so it matches what you shipped. A drifted record is worse than none.</p>
<p><strong>Know when to fix by hand vs. fix the generator.</strong> Manual for one-off annoyances on a short-lived PoC. Generator for anything that will live and reoffend. A human weighs lifespan against effort — that call isn&#39;t going anywhere.</p>
<hr>
<p>The repo is at <a href="https://github.com/architect4hire/orderflow2026" target="_blank" rel="noopener noreferrer">github.com/architect4hire/orderflow2026</a>. Read the prompt library alongside the code and watch how much of the real work happened <em>before</em> any C# existed and <em>after</em> it was generated — at the two ends, where the developer still very much lives.</p>
<p>Because that&#39;s the headline I&#39;d tattoo on the industry if I could: nobody got removed from this build. The work moved to where it was always hardest. If you were good at the hard part, you&#39;re about to be worth more, not less.</p>
<hr>
<p><em>Robert Felkins is the principal architect at Architect4Hire and the author of the upcoming book</em> Practical AI Assisted Development, <em>which covers the full SCRUB system — tiered restrictions, layered generation, Edit Mode patterns, prompt chaining, and applying the framework to codebases you inherited rather than started.</em></p>
<p><em>And if your team is somewhere in the middle of this transition — a distributed system that has to be correct under failure, tooling that changed under you, or a nagging worry about what all this means for the people on your team — that&#39;s the work I do. I take fractional architecture engagements: a few days a month of senior architectural judgment without the full-time price tag, covering .NET and Azure platform work, modernization, team enablement, and AI-assisted development that survives contact with production. Reach out at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>.</em></p>
]]></content:encoded>
        </item>
        <item>
            <title>What&apos;s It Like To Work With Architect4Hire</title>
            <link>https://www.architect4hire.com/blog/what-s-it-like-to-work-with-architect4hire</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/what-s-it-like-to-work-with-architect4hire</guid>
            <pubDate>Tue, 14 Jul 2026 20:36:09 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>Most consulting relationships ask you to buy on faith: you like the person on the call, you sign, and only then find out what &quot;senior architecture help&quot; actually means. So I put an entire engagement in a public repo — the ADRs, the prompt library, and the system built from them.</description>
            <content:encoded><![CDATA[<h1 id="what-it-and-39-s-like-to-hire-a-fractional-architect-an-engagement-you-can-actually-inspect">What It&#39;s Like to Hire a Fractional Architect: An Engagement You Can Actually Inspect</h1>
<p><em>How a fractional or contract engagement with Architect4Hire runs — from first conversation to handoff — told through OrderFlow2026, a reference build I put in the open so you can see the work before you buy it.</em></p>
<p>Most consulting relationships ask you to buy on faith. You read the case studies, you like the person on the call, you sign — and then you find out what &quot;senior architecture help&quot; actually means in practice. Sometimes it means a diagram and an invoice. Sometimes it means a stack of slides that never turns into code. You don&#39;t know until the money&#39;s spent.</p>
<p>I&#39;d rather you know first. So I put an entire engagement in a public repository — the design docs, the architecture decisions, the delivery plan, the prompt library, and a working system built from all of it — and left it open at <a href="https://github.com/architect4hire/orderflow2026" target="_blank" rel="noopener noreferrer">github.com/architect4hire/orderflow2026</a>. This post walks you through what hiring me actually looks like, using that repo as the worked example. Not a testimonial. The artifacts themselves.</p>
<h2 id="first-the-honest-part">First, the honest part</h2>
<p>OrderFlow2026 is a reference build for a client who doesn&#39;t exist. It&#39;s an event-driven order-fulfillment system I designed and built solo, and as I write this it&#39;s a proof-of-concept sitting at roughly ninety percent — fully scaffolded, mostly wired, deliberately still carrying a few TODOs where real client requirements would land.</p>
<p>I build these on purpose. &quot;I do disciplined, right-sized architecture&quot; is a claim, and claims are cheap. Anyone can put clean architecture and AI-assisted development on a slide. A public repo you can clone, read, and run is a different kind of evidence — it shows the process, the decisions, the trade-offs, and yes, the seams. I&#39;d rather show you a real build with an honest ninety-percent label than a polished lie at a hundred. If you&#39;re evaluating me, that repo is the most useful thing on this entire site, and it will tell you more in ten minutes of reading than any sales page.</p>
<h2 id="two-shapes-of-engagement-one-way-of-working">Two shapes of engagement, one way of working</h2>
<p>People hire me two ways, and the difference is mostly about time, not method.</p>
<p>A <strong>fractional</strong> engagement is ongoing: a few days a month of senior architectural judgment, embedded with your team, without the cost or commitment of a full-time principal architect. It&#39;s the right fit when you have developers who can build but need someone to set direction, make the hard calls, raise the team&#39;s floor, and stay accountable as the system evolves.</p>
<p>A <strong>contract</strong> engagement is bounded: a defined deliverable, a fixed scope, a clear start and end. It&#39;s the right fit when you know what you need built or modernized and you want a senior hand to design it, build it, and hand it over cleanly.</p>
<p>Underneath, the work runs the same way regardless of which one you pick — and OrderFlow2026 is the shape of it. Let me walk you through the phases, because the phases are what you&#39;re actually buying.</p>
<h2 id="how-the-work-runs">How the work runs</h2>
<p>Every engagement moves through five phases, and I scope and sequence them out loud before any meaningful money is spent. Here&#39;s how they played out on OrderFlow.</p>
<p><strong>Discovery.</strong> Before I write anything, I write down what I understand — the domain, the constraints, the failure modes that matter. On OrderFlow that produced a high-level design document, C4 diagrams, sequence diagrams, and a set of Architecture Decision Records. The business value here is unglamorous and enormous: misunderstandings get caught while they still cost a sentence to fix, not a sprint. The technical value is that the design exists as a durable artifact from day one, not as folklore in someone&#39;s head.</p>
<p><strong>Vision.</strong> We settle scope, service boundaries, and contracts — the shape of the thing and the promises its parts make to each other. This is where an experienced architect earns their fee by saying <em>no</em> to the features that don&#39;t belong in version one, and <em>yes, but not like that</em> to the ones that do. Right-sizing is the whole game, and it cuts both ways: sometimes it means resisting a distributed system, and sometimes — as with OrderFlow — it means the domain genuinely demands one.</p>
<p><strong>Breakdown.</strong> I turn the plan into a committed library of small, disciplined prompts using my <a href="https://www.architect4hire.com/scrub">SCRUB framework</a>, and I prove the risky path first. On OrderFlow that meant standing up the order saga&#39;s happy path before anything else, because if the orchestration spine doesn&#39;t hold, nothing built on top of it matters. You&#39;re paying for the risky decisions to be made early, cheaply, and visibly.</p>
<p><strong>Development.</strong> The build. All the services, the resilience, the frontend. This is the longest phase and, counterintuitively, the one where a disciplined approach spends the <em>least</em> of your attention — because the hard thinking already happened in the phases before it.</p>
<p><strong>Delivery.</strong> Failure-injection validation, observability, and a clean handoff: the ADRs, the diagrams, and the prompt library all updated to match what actually got built. You don&#39;t get a system and a stale description of a different system. You get a system and its true source of truth.</p>
<p>Discovery through Delivery, OrderFlow ran roughly six weeks, architect-only, no development squad to assemble. That&#39;s the shape of a bounded engagement. A fractional one spreads the same discipline across a longer, lighter cadence.</p>
<h2 id="what-you-actually-receive">What you actually receive</h2>
<p>Here&#39;s the part most consulting engagements are quietly bad at: what&#39;s left in your hands when I&#39;m gone.</p>
<p>Open the <code>docs/</code> folder in the repo and you&#39;ll find the durable baseline — the high-level design, the C4 and sequence diagrams, the ADRs that record <em>why</em> each significant decision was made, and the SCRUB prompt library that generated the code. The <code>src/</code> folder holds the scaffolded, near-complete system those documents describe. The two are kept in sync deliberately, because a design that has drifted from its code is worse than no design at all.</p>
<p>Why does that matter to a business, not just an engineer? Three reasons, and they&#39;re all about the day after I leave.</p>
<p><strong>Onboarding gets cheap.</strong> A new developer reads the ADRs and understands the system in an afternoon instead of reverse-engineering it over a month. That&#39;s real money, every time you add a person.</p>
<p><strong>The design stops drifting.</strong> Six months from now, when someone asks why the payment handler is built the way it is, the answer is in a committed file with a history — not lost, not guessed at, not &quot;simplified&quot; by someone who didn&#39;t know what it protected against.</p>
<p><strong>You own all of it.</strong> No proprietary framework you have to keep paying for, no black box, no lock-in. The architecture, the code, and the reasoning are yours. If you never call me again, you lose nothing.</p>
<h2 id="the-technical-spine-because-leadership-comes-in-two-flavors">The technical spine — because leadership comes in two flavors</h2>
<p>I work with two kinds of decision-makers, often in the same room: the business leader who needs to trust that the money buys an outcome, and the technical leader who needs to trust that the engineering is actually sound. I&#39;d be doing you a disservice if I only spoke to one of you, so here&#39;s the engineering, stated plainly.</p>
<p>OrderFlow is an event-driven system: an Angular front end calls an Order service, which runs a <strong>saga</strong> that orchestrates four reacting services — Inventory, Payment, Fulfillment, and Notification — over a message bus, with a Cosmos event log, a Redis read model behind the status screens, and SQL for the records that have to be exact. It runs on local emulators against the real cloud SDKs, so it boots on a laptop with no cloud spend to evaluate it.</p>
<p>That architecture wasn&#39;t chosen to look impressive. It was chosen because order fulfillment is a place where money moves and inventory is finite, and that forces four correctness problems that a naive build gets quietly wrong:</p>
<ul>
<li><strong>A multi-step order can fail halfway.</strong> Without a compensation path on every failure, a declined payment leaves stock reserved and a failed dispatch leaves a customer charged for nothing. OrderFlow compensates on every path — the measurable target is partial-order incidents at zero.</li>
<li><strong>Payment callbacks arrive more than once.</strong> Message delivery is at-least-once by nature, so the callback <em>will</em> fire twice. Without idempotency, that&#39;s a double charge. OrderFlow treats duplicates as no-ops — double-charge incidents at zero.</li>
<li><strong>Two buyers can race for the last unit.</strong> Naive inventory code lets both win. OrderFlow&#39;s reservation is concurrency-correct — oversell incidents at zero.</li>
<li><strong>Designs drift from code.</strong> Without a documented, versioned baseline, the two diverge the day the build ships. OrderFlow&#39;s ADRs and prompt library are that baseline.</li>
</ul>
<p>Notice that every one of those technical properties is stated as a <em>business outcome with a number attached</em>. That&#39;s not an accident. Correctness under failure isn&#39;t an engineering vanity project — it&#39;s the difference between a system that quietly erodes customer trust and one that doesn&#39;t. Good architecture is a business decision that happens to be written in code.</p>
<h2 id="what-it-costs-you-in-involvement-not-just-dollars">What it costs you — in involvement, not just dollars</h2>
<p>I keep specific figures out of blog posts because they change from engagement to engagement, and I&#39;d rather quote you honestly than have you hold me to a stale number. But cost isn&#39;t only money, so here&#39;s the part people forget to ask about: your team&#39;s time.</p>
<p>A bounded engagement like OrderFlow needs a subject-matter expert available a few hours a week — someone who can answer domain questions and make business calls when a trade-off surfaces. That&#39;s it. There&#39;s no squad to stand up, no coordination tax, no six-people-in-a-standup overhead. One senior person, working with discipline and modern tooling, carries the build. That&#39;s deliberately how I&#39;ve structured the practice: senior judgment without the full-time price tag, and without asking you to reorganize your world to receive it.</p>
<h2 id="why-this-de-risks-the-decision">Why this de-risks the decision</h2>
<p>Strip away the specifics and here&#39;s the actual pitch, stated as plainly as I can: <strong>you can inspect the work before you commit to it.</strong> The process is written down. The deliverables are real and public. The pricing is transparent and scoped before the spend. The outcomes are measurable, not vibes. And when it&#39;s over, you own everything and owe nothing.</p>
<p>That&#39;s not a claim I&#39;m asking you to take on faith. It&#39;s a repository. Go read it.</p>
<h2 id="how-to-start">How to start</h2>
<p>If your team is staring at a problem that needs senior architectural judgment — a distributed system that has to be correct under failure, a modernization that keeps stalling, a codebase that&#39;s drifting away from its own design, or an AI-assisted practice that isn&#39;t paying for itself yet — that&#39;s the work I do.</p>
<p>Fractional or contract, the first step is the same: a conversation about what you&#39;re trying to build and whether I&#39;m the right person to help you build it. If I&#39;m not, I&#39;ll tell you. If I am, you&#39;ll already know exactly what to expect, because you&#39;ll have read it.</p>
<p>Reach out at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>, or start with the repo at <a href="https://github.com/architect4hire/orderflow2026" target="_blank" rel="noopener noreferrer">github.com/architect4hire/orderflow2026</a>.</p>
<hr>
<p><em>Robert Felkins is the principal architect at Architect4Hire — Chicago-based fractional and contract architecture for small and mid-sized teams. I design right-sized .NET and Azure systems, modernize the ones you already have, and bring AI-assisted development into teams in a way that survives contact with production: senior judgment without the full-time price tag.</em></p>
]]></content:encoded>
        </item>
        <item>
            <title>One Team, Three Projects, One Board: Building PlannerPro With Layered SCRUB</title>
            <link>https://www.architect4hire.com/blog/one-team-three-projects-one-board-building-plannerpro-with-layered-scrub</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/one-team-three-projects-one-board-building-plannerpro-with-layered-scrub</guid>
            <pubDate>Wed, 08 Jul 2026 12:57:28 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>SCRUB Framework</category>
            <description>Every project management tool assumes a project has a team. This client had it backward — one team carrying three projects, and a senior developer with nine items in progress that no tool could see. So I built the board that could, using layered SCRUB prompts.</description>
            <content:encoded><![CDATA[<p><em>Or: what happens when every tool on the market assumes a fact about your team that isn&#39;t true</em></p>
<p>TL;DR — the code and the full prompt library live at <a href="https://github.com/architect4hire/plannerpro" target="_blank" rel="noopener noreferrer">https://github.com/architect4hire/plannerpro</a>. The prompts document at <code>docs/PLANNERPRO-SCRUB-PROMPTS.md</code> is the interesting part. This post explains why.</p>
<hr>
<p>I want to tell you about a standup.</p>
<p>Not a dramatic one. Nobody quit. Nothing shipped broken. The kind of standup that&#39;s much worse than that — the kind that happens every single morning, takes forty minutes, and produces less clarity than it started with.</p>
<p>The client had one delivery team. Six people. Good people. And at the moment I was engaged, that one team was carrying three completely independent projects: a customer portal rebuild, an internal reporting migration, and a compliance-driven integration with a hard regulatory date. Three backlogs. Three stakeholders. Three sets of priorities that had never once been in the same room together.</p>
<p>Their tooling handled this the way every project management tool handles it: three boards. Because every tool on the market is built on the same silent assumption — that a project has a team. One project, one board, one team, tidy.</p>
<p>This team had the assumption backward. The <em>team</em> had projects. Three of them. And the tooling had no answer for that at all.</p>
<p>So, the standup was tab-switching. Board one, board two, board three, back to board one because someone remembered something. The team lead kept a spreadsheet on the side — the real board, the one that actually reflected reality — updated by hand, wrong by Wednesday. And the thing that finally made someone call me: their most senior developer had nine items in progress. Nine. Three per board, and each board looked completely reasonable in isolation. No single view existed anywhere in the company that could show that number. The person carrying the most risk in the org was invisible to every tool they&#39;d paid for, precisely because the risk was spread across three views that never met.</p>
<p>That&#39;s not a process problem. That&#39;s a data model problem. The unit of capacity is the <em>person</em>, and every board they owned modeled capacity as a property of the <em>project</em>.</p>
<p>I couldn&#39;t find a tool that modeled it correctly without an enterprise license and a consulting engagement to configure it. So I built one. It&#39;s called PlannerPro; it&#39;s small, and it does exactly one thing the big tools don&#39;t: one board, columns for workflow state, swimlanes for projects, and work-in-progress limits that belong to the human being — enforced across every project simultaneously.</p>
<p>This post is about what I built, but mostly it&#39;s about <em>how</em> I built it — because PlannerPro went from an empty repository to a working, tested product using a committed library of small, layered prompts, and the discipline behind those prompts is the part you can steal.</p>
<hr>
<h2 id="scrub-for-those-just-joining-us">SCRUB, For Those Just Joining Us</h2>
<p>If you read <a href="/blog/the-prompt-is-the-plan-how-i-built-a-1-200-line-angular-component-with-a-framework-youve-never-heard-of">the BrandForge post</a>, you know the framework. Short version for everyone else.</p>
<p>AI coding tools have a failure mode I call the Plausibility Trap: the output isn&#39;t wrong in ways that crash. It&#39;s wrong in ways that compile, pass tests, look idiomatic, and quietly encode a domain decision you never made. The model knows how to write code. It does not know your domain. When your prompt leaves a gap, the model fills it with the most statistically common answer from training data — and the most common answer is, occasionally and silently, the wrong one for <em>you</em>.</p>
<p>SCRUB is the checklist that closes those gaps before you hit submit. Five elements, every prompt:</p>
<ul>
<li><strong>S — Scope.</strong> Exactly what you&#39;re asking for. Named methods, named files, nothing else.</li>
<li><strong>C — Constraints.</strong> The technical rules: framework versions, architecture, naming, DI style. <em>Your</em> codebase, not the training-data average.</li>
<li><strong>R — Restrictions.</strong> What you explicitly do not want, tiered CRITICAL / IMPORTANT / PREFERRED, each with a reason. The element everyone skips. The element that matters most.</li>
<li><strong>U — Usage.</strong> Who runs this, under what conditions, with what compliance and audit expectations?</li>
<li><strong>B — Behavior.</strong> What must not change. The star of every edit to existing code.</li>
</ul>
<p>I&#39;ve written a book about the full system — <em>Pratical AI Assisted Development</em>, out soon — which goes deep on tiered restrictions, Edit Mode patterns, prompt chaining, and applying the framework to codebases you inherited rather than started. But you don&#39;t need the book to follow this post. You need one idea from it:</p>
<p><strong>A prompt is a specification, and specifications deserve engineering.</strong></p>
<p>PlannerPro is what that looks like on a greenfield project, end to end.</p>
<hr>
<h2 id="the-architecture-and-the-choices-behind-it">The Architecture and the Choices Behind It</h2>
<p>Let me get the stack out of the way, then talk about the decisions that actually mattered.</p>
<p>.NET 10 Web API on the backend. EF Core 10 over SQL Server. Angular 20 on the front, standalone components, signals for all component state, Angular CDK for the drag-and-drop. If you&#39;ve read anything else I&#39;ve written, none of that surprises you.</p>
<p>Here&#39;s the first real decision: <strong>PlannerPro is one API project.</strong> Not microservices. Not Aspire orchestration. One Web API, one database, one Angular app.</p>
<p>I want to dwell on that for a second, because I&#39;ve spent a lot of this blog building distributed systems, and I can feel some of you raising an eyebrow. The honest architect&#39;s answer is that this system has one team of users, one write path that matters, and no independent scaling or deployment pressures anywhere. Microservices are a solution to organizational and operational problems that PlannerPro lacks. Reaching for them anyway would have been resume-driven development, and the whole point of this engagement was to solve a problem in days, not to build a platform. Right-sizing is an architectural decision too — arguably the one clients pay me for most often.</p>
<p>Inside that one project, the layering is strict, and it&#39;s the same layering I use everywhere: <strong>Controller → Facade → BusinessManager → DataManager → DbContext.</strong> Hand-rolled mapping extensions, no AutoMapper. Audit fields on every entity. Soft delete everywhere, enforced with a global query filter. Boring, deliberate, and — this matters for what comes later — <em>predictable enough that an AI can be pointed at it and told to conform</em>.</p>
<p>The domain model consists of three entities. Project. WorkItem. TeamMember. That&#39;s it — no sprints, no epics, no comments, no attachments. Version one is the board and the rule, and everything I declined to build was declined on purpose.</p>
<p>And the rule is the whole product, so let me state it precisely:</p>
<p><strong>A team member&#39;s WIP limit applies to their in-progress items across all projects.</strong> The query that backs it — <code>CountInProgressForMemberAsync</code> — deliberately has no project filter. There is a CRITICAL restriction in the prompt library that says exactly that: <em>do not filter this count by project</em>. Because an AI generating a data manager for a project-management tool will absolutely scope that query to the project. Every example in its training data does. Project-scoped WIP is the idiom. Project-scoped WIP is also, for this client, the <em>entire disease</em>. Their senior dev with nine in-progress items looked fine on every board precisely because every count was project-scoped. One <code>.Where()</code> clause, present or absent, is the difference between a tool that solves the problem and a tool that re-implements it with nicer CSS.</p>
<p>That&#39;s the Plausibility Trap in one sentence, by the way. The wrong version compiles, passes tests, and demos beautifully.</p>
<p>Two more decisions worth naming:</p>
<p><strong>The board is one payload.</strong> A single <code>GET /api/board</code> returns everything the screen needs, grouped and ready to render — the Angular component does no client-side regrouping. When five people are staring at a shared screen in standup, you want one fetch, one render, one source of truth.</p>
<p><strong>The server is the transition authority.</strong> Status moves one step forward or one step back — Backlog → Ready → In Progress → Review → Done, no skipping — and the <em>server</em> enforces it. The UI does a drag with an optimistic update and rolls back on rejection. I explicitly restricted the AI from greying out &quot;illegal&quot; columns during a drag, which is the clever UX idea it will offer you unprompted. A greyed-out column that&#39;s wrong is a lie with good intentions; an honest 422 toast after the fact is merely an inconvenience. When client-side cleverness and server truth disagree, cleverness loses.</p>
<p>One drag-and-drop restriction earns special mention because it&#39;s my favorite in the whole library: <strong>drop lists are connected only within a swimlane.</strong> A card can move between columns, never between projects. Why CRITICAL and not IMPORTANT? Because cross-lane drag isn&#39;t a UX bug — it&#39;s a <em>data corruption</em> bug wearing a UX feature&#39;s clothes. Dragging a card one lane down would silently reassign a work item to a different project, and the CDK will happily wire all the lists together if you don&#39;t tell it otherwise, because connecting everything is what the examples do. One restriction. One line. An entire category of &quot;why is this ticket in the wrong project&quot; support conversations, never born.</p>
<hr>
<h2 id="the-prompts-small-on-purpose">The Prompts: Small on Purpose</h2>
<p>Now the part I actually want to talk about.</p>
<p>There&#39;s a file in the repository — <a href="https://github.com/architect4hire/plannerpro" target="_blank" rel="noopener noreferrer"><code>docs/PLANNERPRO-SCRUB-PROMPTS.md</code></a> — that existed before any of the code did. Nineteen prompts. It&#39;s the plan I worked from, committed alongside the code it produced, and it follows the same chain the BrandForge component did: <strong>Context → Plan → Execute → Verify.</strong></p>
<p>But the style of these prompts is different from what most people write, and the difference is the point. These are <em>micro</em> prompts. Every one of them fits in a paragraph. The Scope blocks name exact methods with exact signatures. The Restrictions come tiered, and every CRITICAL restriction carries its reason — not as documentation garnish, but because the reason is the thing that tells the model <em>why</em> the plausible default is wrong here.</p>
<p>Compare the mega-prompt approach — &quot;build me a kanban tool with three projects and WIP limits, here are 40 requirements&quot; — with what&#39;s in that file. The mega-prompt produces two thousand lines in one shot. Somewhere in those two thousand lines, the WIP query has a project filter, the drop lists are cross-connected, and the rollback path doesn&#39;t exist. You will find those problems the way my client found their nine-item developer: later, in production, by accident.</p>
<p>The micro prompts produce the same two thousand lines. But no single generation is ever more than a couple hundred of them, and every one of those chunks got a review that took two minutes instead of an afternoon.</p>
<p>Each prompt also names its <strong>mode</strong>, because the tools are not interchangeable. Claude Code Chat for the layers where thinking matters — contracts, business rules, orchestration methods. Copilot Agent Mode for the layers that are mechanical and multi-file — data access, template slices, CSS — where the autonomous build-and-verify loop earns its keep. Claude Code Edit Mode for surgery after the fact, with Behavior as the dominant element. Reasoning in Chat, generation in Agent, precision in Edit. Matching the mode to the cognitive work is half the discipline.</p>
<hr>
<h2 id="why-layers-are-the-whole-ballgame">Why Layers Are the Whole Ballgame</h2>
<p>If you take one thing from this post, take this section.</p>
<p>The single most important structural decision in the prompt library is not any individual restriction. It&#39;s that <strong>the work is layered</strong>, and each layer constrains the next.</p>
<p>The backend is four layers. Layer 1 is contracts — interfaces, enums, entity skeletons, view model shapes. Signatures only, no bodies, maybe eighty lines. Layer 2 is data and mapping: the DbContext, the data managers, the mapping extensions. Layer 3 is the business rules — the transition table and the WIP check, the two things that <em>are</em> the product. Layer 4 is the HTTP surface and the wiring.</p>
<p>The frontend gets the same treatment, six layers deep: a signal skeleton with every method stubbed, then the template built in vertical slices — header and WIP strip, then the swimlane grid, then cards and drag wiring — then the method bodies, then the styles. The methods layer comes second to last deliberately: by the time the AI writes <code>onDrop()</code>, the entire template it orchestrates already exists in front of it. It is not writing orchestration in a vacuum and hoping the UI that arrives later happens to match.</p>
<p>Why does layering matter this much? Four reasons, in ascending order of importance.</p>
<p><strong>First, review becomes possible.</strong> Eighty lines of contracts is a code review. Two thousand lines of everything is a skim, and a skim is where the Plausibility Trap lives. Every ordering bug I&#39;ve ever shipped from AI-generated code went out through a review I did too fast because the diff was too big.</p>
<p><strong>Second, errors stay local.</strong> When Layer 2 has a mapping bug — and mapping bugs are the most common failure at that layer — I fix it with a Layer 2 prompt. Layers 3 and 4 never hear about it, because they reference Layer 2 through its contracts, not its internals. In a one-shot generation, a bug in one region sprays corrections across the entire output, and every correction risks disturbing something that was right.</p>
<p><strong>Third, naming compounds.</strong> Layer 1 is where I spend disproportionate review time, because the names chosen there propagate through every subsequent layer. A bad facade method name in Layer 1 costs five minutes to fix in Layer 1 and a five-file threading exercise to fix after Layer 4. Cheap-to-fix and expensive-to-fix are the same mistake at different timestamps; layering lets you always pay the cheap price.</p>
<p><strong>Fourth — and this is the one that changes how you work — each layer constrains the next.</strong> By Layer 3, the AI isn&#39;t inventing; it&#39;s <em>conforming</em>. The contracts exist. The data managers exist. The custom-instruction files pre-load the conventions. The generation space has been narrowed, layer by layer, until the plausible output and the correct output are nearly the same thing. That&#39;s the real trick. You&#39;re not hoping the model guesses right. You&#39;re removing its opportunities to guess.</p>
<p>The ordering echoes onion architecture, but the mapping isn&#39;t the point — the <em>size</em> is the point, and the direction of dependency is the point. Small enough to read in two minutes. Ordered so the shape exists before the behavior, and the behavior exists before the wiring.</p>
<p>And it doesn&#39;t feel slow. I expected it to, the first time. Nineteen prompts for a two-project solution? It felt fast, because I was never working on more than one small piece at a time, and I never once entered the mode every developer knows too well — spelunking through a giant generated diff looking for the line that&#39;s lying to you.</p>
<p>Fix the plan, not the code. If the plan is wrong, you fix a paragraph.</p>
<hr>
<h2 id="verify-the-step-that-catches-you-moving-too-fast">Verify: The Step That Catches You Moving Too Fast</h2>
<p>The chain ends with Verify prompts — one for the backend, one for the board component. Each is a list of specific PASS/FAIL checks the AI runs against the finished code, with file-and-line evidence required for any FAIL.</p>
<p>Not &quot;does it work.&quot; Specific. <em>Does the WIP count query have a project filter? Is any drop list connected across lanes? Does the rollback restore captured state, or does it cheat and re-fetch the whole board? Does any orchestration method contain a <code>.subscribe()</code>?</em> Every check on those lists maps back to a CRITICAL or IMPORTANT restriction from an earlier layer, because the Verify step exists for exactly one scenario: the day you review a diff too fast. On BrandForge, Verify caught a real bug my eyes had slid over. It stays in the chain forever.</p>
<p>The Verify lists are also the maintenance contract. When PlannerPro grows — per-column WIP limits are the obvious next ask, and a &quot;blocked&quot; flag is right behind it — the prompts document gets updated <em>first</em>: new restrictions, new Verify checks, then run the prompts. The document is versioned, committed, and reviewed like the code, because it is the reason the code looks the way it does. Six months from now, when somebody wonders why <code>CountInProgressForMemberAsync</code> pointedly ignores the project, the answer isn&#39;t in a comment or in my head. It&#39;s in a file with a commit history.</p>
<hr>
<h2 id="what-the-client-got">What the Client Got</h2>
<p>A board their standup actually runs on. Swimlanes for the three projects, five columns, drag-and-drop with honest server-side rules, and a WIP strip across the top showing every team member&#39;s cross-project count — the number that used to live in a hand-maintained spreadsheet, wrong by Wednesday.</p>
<p>The nine-item developer, by the way, is now capped at three, and the standup conversation changed shape the first week: it stopped being three sequential status reports and started being an argument about which project gets the constrained person next. That&#39;s the right argument. The tool didn&#39;t resolve their priority conflict — no tool does — but it made the conflict <em>visible on one screen</em>, which is the precondition for resolving it like adults.</p>
<p>And I got a repository where every line of code traces back to a small, reviewed, committed prompt — which means the next feature starts from a plan, not from archaeology.</p>
<hr>
<h2 id="the-practical-version">The Practical Version</h2>
<p>If you want to run this pattern on your next greenfield build:</p>
<p><strong>Write the domain brief first, and make the AI recite it back.</strong> One paragraph. The Context step costs five minutes and catches misunderstandings while they cost one sentence to fix.</p>
<p><strong>Argue with the Plan.</strong> In prose, before any code exists. The plan step is the cheapest insurance in the entire chain, and it is the step everyone skips.</p>
<p><strong>Layer the Execute.</strong> Contracts, then data, then rules, then wiring. Four to seven layers, each describable in three sentences. If a layer needs a fourth sentence, split it.</p>
<p><strong>Write Restrictions before Scope, and tier them.</strong> Ask what could compile and still be wrong. CRITICAL means silent wrong data. IMPORTANT means debt. PREFERRED means style. Attach reasons to the CRITICAL ones — the reason is what teaches the model why the idiom is wrong here.</p>
<p><strong>Find your no-project-filter.</strong> Every domain has one: the single line where the training-data idiom and your business reality point in opposite directions. Yours might be a tip default, a rounding rule, a scoped query. Find it before the AI does, and pin it with a CRITICAL restriction.</p>
<p><strong>Match the mode to the work.</strong> Chat for contracts, rules, and orchestration. Agent for mechanical multi-file layers. Edit for surgery, with Behavior dominant.</p>
<p><strong>End with Verify, and keep it.</strong> Specific PASS/FAIL checks derived from your restrictions. Re-run it after every change.</p>
<p><strong>Commit the prompts.</strong> They&#39;re not notes. They&#39;re the architecture record.</p>
<hr>
<p>The repo is at <a href="https://github.com/architect4hire/plannerpro" target="_blank" rel="noopener noreferrer">github.com/architect4hire/plannerpro</a>, prompts and all. Read the prompts document before the code — it&#39;s the better half of the repository, and it will make the code make sense in a way the code alone can&#39;t.</p>
<hr>
<p><em>Robert Felkins is the principal architect at Architect4Hire and the author of the upcoming book</em> Pratical AI Assisted Development, <em>which covers the full SCRUB system — tiered restrictions, layered generation, Edit Mode patterns, prompt chaining, and applying the framework to codebases you inherited rather than started.</em></p>
<p><em>And if your team is staring at a version of the three-boards problem — tooling, architecture, or an AI-assisted delivery practice that isn&#39;t paying for itself yet — that&#39;s the work I do. I take on fractional architecture engagements: a few days a month of senior architectural judgment without the full-time price tag, covering .NET and Azure platform work, team enablement, and AI-assisted development practices that survive contact with production. Reach out at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>.</em></p>
]]></content:encoded>
        </item>
        <item>
            <title>The Prompt Is the Plan: How I Built a 1,200-Line Angular Component With a Framework You&apos;ve Never Heard Of</title>
            <link>https://www.architect4hire.com/blog/the-prompt-is-the-plan-how-i-built-a-1-200-line-angular-component-with-a-framework-youve-never-heard-of</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/the-prompt-is-the-plan-how-i-built-a-1-200-line-angular-component-with-a-framework-youve-never-heard-of</guid>
            <pubDate>Thu, 04 Jun 2026 13:05:53 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>SCRUB Framework</category>
            <description>The AI does something genuinely terrifying: it writes code that runs fine, ships, and is quietly wrong. So I stopped typing instructions at it and started engineering them — and built a 1,200-line Angular component from a prompt document written before a single line of TypeScript.</description>
            <content:encoded><![CDATA[<p>Or: what happens when you stop typing instructions at the AI and start engineering them*</p>
<p>TL;DR - wanna check out the framework and sample application I built, grab the code at <a href="https://github.com/Architect4Hire/BrandForge" target="_blank" rel="noopener noreferrer">https://github.com/architect4hire/brandforge</a></p>
<hr>
<p>I want to tell you about a bug.</p>
<p>Not a dramatic one. No crash. No stack trace. The kind that&#39;s much worse than that — the kind where everything runs fine, the feature ships, and then three weeks later an operator notices that every single content card saved from the pipeline has the wrong social media copy attached to it.</p>
<p>Instagram post? Generic. TikTok hook? Missing. Pinterest description? Something the AI generated from the image name, not the actual image. The copy was there. It just belonged to the wrong image, from an earlier step in the workflow, because somewhere in twelve hundred lines of Angular TypeScript the social copy generation fired at the wrong moment. Before the operator had chosen an image. Before the system knew which image to describe.</p>
<p>The code compiled. The tests passed. The component ran. Nobody knew anything was wrong until they looked at the actual board cards.</p>
<p>That bug — or rather, the specific thing I did to prevent it from ever happening — is what this post is about.</p>
<hr>
<h2 id="the-ai-does-something-i-find-genuinely-terrifying">The AI Does Something I Find Genuinely Terrifying</h2>
<p>Before I get to the solution, I want to name the problem properly, because most people I talk to about AI-assisted development haven&#39;t quite articulated it yet.</p>
<p>The problem isn&#39;t that the AI gets things wrong. I can handle wrong. Wrong throws an error, wrong breaks a test, wrong crashes the app and puts a red line in the console. I can work with wrong.</p>
<p>The problem is that the AI gets things <em>plausibly right</em>.</p>
<p>Here&#39;s what that looks like in practice. You paste a task into Claude — build me a service, write me a component, add a method to this class — and the AI produces twelve hundred lines that are idiomatic. They reference the right services. The types are correct. The naming follows your conventions. The structure looks exactly like what a senior developer would write. You do a quick scan, it looks good, you run it, it runs. You ship it.</p>
<p>And then three weeks later you find out that the component has been defaulting the tip amount to twenty percent, or storing a PII field it shouldn&#39;t be touching, or firing an expensive AI call before the user has confirmed their intent, or — in my case — generating social copy off the wrong image because the ordering of two async calls was off by half a step.</p>
<p>I started calling this the Plausibility Trap. The output is so plausible-looking that you don&#39;t examine it carefully enough to catch the domain error buried inside it. A junior developer would produce code that&#39;s obviously wrong and you&#39;d catch it in review. The AI produces code that&#39;s only <em>subtly</em> wrong and you catch it in production.</p>
<p>The reason this happens is structural, not a flaw in the AI. The model is trained to produce idiomatic, coherent, conventionally-structured code. It has no idea what your product is actually supposed to do. It does not know that social copy should fire after image selection and not before. It doesn&#39;t know that your food blogger operators are going to be looking at that copy on a desktop CMS and not a mobile device, and that the UX decisions that flow from that distinction are significant. It doesn&#39;t know that your company had a legal incident around default values in financial fields. It knows how to write code. It does not know your domain.</p>
<p>So when you hand it a vague prompt and it fills in the gaps, it fills them with plausible defaults from training data. And the defaults are usually fine. Until they&#39;re not.</p>
<hr>
<h2 id="i-got-mad-about-this-and-built-a-framework">I Got Mad About This and Built a Framework</h2>
<p>I have been building software with AI tooling since before it was cool and after it got annoying. For a long time my standard approach was &quot;write a really long prompt and hope for the best.&quot; Which worked often enough to feel like a strategy, and failed spectacularly often enough that I spent a lot of time in debug mode wondering which part of my fifty-line prompt the AI had decided to ignore.</p>
<p>The thing I eventually figured out — the thing that changed how I work — is that the prompt failures are not random. They fall into patterns. And once you can see the pattern, you can address it systematically before you hit submit.</p>
<p>That systematic approach turned into a framework I call SCRUB.</p>
<p>It&#39;s not a brand new category of idea. It&#39;s a checklist. Five elements, every prompt. It forces you to address the five categories of AI-generated code failure before the code gets generated. I wrote a whole book about it — <em>Practical AI Assisted Development</em>, which covers the full framework plus a lot of the tooling philosophy around it. But the heart of it is five letters, and you can use it today, right now, without reading anything else I&#39;ve written.</p>
<p>Let me explain what each one means, and why the one most people skip is the most important.</p>
<hr>
<h2 id="what-scrub-actually-is">What SCRUB Actually Is</h2>
<p><strong>S is Scope.</strong> What are you asking for, named precisely. Not &quot;build me a service&quot; but &quot;build me exactly these four methods, with these exact signatures, in this file, and nothing else.&quot; The AI will expand your Scope if you let it. It will add helper methods you didn&#39;t ask for. It will create a base class when you just needed a concrete class. It will refactor adjacent code while it&#39;s in there because the adjacent code could be cleaner and the AI finds mess uncomfortable. Scope is the fence around what you actually want. Build it tight.</p>
<p><strong>C is Constraints.</strong> The technical rules your output must respect. Framework version. Naming conventions. Architectural patterns. DI style. Which async pattern you use. The AI doesn&#39;t know you&#39;re on Angular 20 standalone with signals and <code>@if</code>/<code>@for</code> control flow unless you say so. If you don&#39;t say so, you&#39;ll get <code>*ngFor</code> in your template and a <code>BehaviorSubject</code> in your class because those are the idioms from the version of Angular that&#39;s most represented in training data. I&#39;ve rebuilt the same component three times before I started writing constraints explicitly. I&#39;ve been on Angular 20 with signals for months now. I still write the constraint every time. The AI does not have session memory that says &quot;this person uses signal-based state management, use that.&quot;</p>
<p><strong>R is Restrictions.</strong> This is the one everyone skips, and it is the single most important element in the entire framework. Pay attention here.</p>
<p>Restrictions are the things you explicitly do not want. Not what you want built, what you want avoided. This is where you prevent the Plausibility Trap, because the Plausibility Trap lives inside the gap between what you asked for and what you said was wrong.</p>
<p>Here&#39;s the example from the book that sticks with me. Imagine you&#39;re building a tip calculator UI. You write a great Scope block, solid Constraints, the AI produces a beautiful component. Clean code. Nice UI. Runs great. The default tip is twenty percent.</p>
<p>You didn&#39;t specify the default. The AI picked twenty percent because twenty percent is the most common default in tip calculator UX in training data. Your company had a conversation six months ago about defaulting to fifteen percent because the restaurant group you work with operates in a market where twenty percent feels presumptuous and you got customer complaints. That decision is not in your codebase. It&#39;s not in any document the AI can read. It&#39;s in a Slack thread from March.</p>
<p>The Restriction is: &quot;Do NOT default the tip percentage to any value — the field should start empty and require explicit operator input.&quot; One line. Specific. With a reason.</p>
<p>That&#39;s what Restrictions do. They close the gaps that Scope leaves open. They&#39;re the part of the prompt that says &quot;I know you&#39;ll make a reasonable-sounding decision here — here&#39;s what&#39;s actually required.&quot;</p>
<p>I tier my Restrictions:</p>
<ul>
<li><strong>CRITICAL</strong>: &quot;Do NOT default the tip&quot; — missing this produces wrong data in production, silently</li>
<li><strong>IMPORTANT</strong>: &quot;Do NOT add helper methods to this file&quot; — missing this creates tech debt I&#39;ll pay later</li>
<li><strong>PREFERRED</strong>: &quot;Do NOT abbreviate variable names&quot; — style nit, not worth blocking on</li>
</ul>
<p>The CRITICAL ones are the ones that prevent the Plausibility Trap. Write them first. Write them with reasons. The reason is not documentation fluff — it&#39;s what tells the AI why the seemingly-reasonable default is wrong for this domain.</p>
<p><strong>U is Usage.</strong> Who runs this, under what conditions. A component built for a food blogger operator on a desktop CMS is different from the same component built for a tablet-based POS during a morning rush, even if the API contracts are identical. Usage is usually three to five lines. It is not usually interesting to write. It shapes dozens of downstream decisions and you won&#39;t notice most of them unless the AI gets them wrong.</p>
<p><strong>B is Behavior.</strong> What must not change. This is the element that stars in Edit Mode prompts — &quot;I want you to add these two validation rules and change nothing else, method order preserved, XML doc comments byte-for-byte identical.&quot; In greenfield work it&#39;s often N/A. In layered greenfield work — building a complex component in sequential steps — it becomes &quot;all signals and computed values from Layer 1 are preserved unchanged,&quot; and it matters more than you think because the AI will cheerfully improve your earlier work if you don&#39;t explicitly tell it not to.</p>
<p>Five elements. The discipline is not the template — it&#39;s the habit of asking, before you hit submit, where is R? Because R is the one people skip. And R is what prevents the social copy from firing off the wrong image.</p>
<hr>
<h2 id="recipeforge-and-why-this-component-specifically-was-a-problem">RecipeForge, and Why This Component Specifically Was a Problem</h2>
<p>RecipeForge is a food blogger CMS. It runs on .NET 10 with Aspire 13.3 on the backend, Angular 20 standalone on the frontend. The idea is that a food blogger picks a channel — we have six of them: Cozy Kitchen, Baker&#39;s Bench, Quick Bites, Grill Master, Plant Forward, World Flavors — picks a posting day, and the system walks them through building a ready-to-post image and full platform-tailored social copy in one workflow. Image generation via Venice.ai. Prompt composition via OpenAI. Copy for seven platforms: Instagram, TikTok, Pinterest, Facebook, X, Threads, and a blog intro paragraph. One screen, start to finish, everything saved to a Digital Asset Manager and dropped onto a Kanban board.</p>
<p>The content pipeline component is the centerpiece of that workflow. When I sat down to build it, here&#39;s what I was looking at:</p>
<p>Nine discrete steps, represented as a TypeScript union: <code>&#39;config&#39; | &#39;seeding&#39; | &#39;reviewSeed&#39; | &#39;composing&#39; | &#39;reviewPrompt&#39; | &#39;rendering&#39; | &#39;pickImage&#39; | &#39;social&#39; | &#39;done&#39;</code>. Thirty-one signals. Six API calls — two to OpenAI, two to Venice.ai, one to the DAM, one to the Kanban service. Two deliberate human-in-the-loop checkpoints where the operator can intervene and edit before anything expensive fires. A variant grid showing up to four generated images. A full-screen lightbox with keyboard navigation. Drag-and-drop reference image upload with a &quot;reverse-engineer this image into a prompt&quot; button. State that cleans itself up completely between runs.</p>
<p>That&#39;s not a complex component. That&#39;s a complex workflow expressed as a component. There&#39;s a difference. A complex component has a lot of moving parts. A complex workflow has ordering constraints — real business rules about what happens before what, enforced by the UI, with consequences if the ordering is wrong.</p>
<p>The consequence here, as I explained at the top, is social copy that doesn&#39;t match the image. Because there are three plausible places in this workflow where you could call <code>runSocial()</code>, and two of them are wrong. The AI, given a loose prompt, will pick the one that looks most natural from a &quot;I&#39;m building a step machine&quot; perspective, which is &quot;fire social copy generation after the render step completes.&quot; Completely reasonable. Wrong. Social needs to fire after image selection, not after image rendering, because social copy has to be written to describe a specific image — the one the operator actually kept — not a batch that might include images they&#39;re about to discard.</p>
<p>If I one-shotted this component, I would have gotten that wrong. And I probably wouldn&#39;t have caught it for days.</p>
<hr>
<h2 id="the-document-i-wrote-before-i-wrote-a-single-line-of-typescript">The Document I Wrote Before I Wrote a Single Line of TypeScript</h2>
<p>There&#39;s a file in the RecipeForge repository at <code>docs/SCRUB-PIPELINE-PROMPTS.md</code>. It exists before the component exists. The prompts document is not documentation of what I did — it&#39;s the plan I worked from. The thing I reviewed and revised before I opened Claude Code Chat.</p>
<p>The document follows a four-step chain: Context → Plan → Execute → Verify.</p>
<p>Context gets the AI oriented to the codebase. Plan aligns on architecture before code exists. Execute generates the code in layers. Verify confirms the invariants held. The whole chain took about half a day to run. The component compiled cleanly at each layer.</p>
<p>Let me walk through what&#39;s in each step and why the decisions look the way they do.</p>
<hr>
<h2 id="pre-load-context-making-the-ai-read-before-it-writes">Pre-Load Context: Making the AI Read Before It Writes</h2>
<p>The very first thing in the document is a snippet I paste at the start of every Claude Code Chat session before doing anything else. It lists six files and tells the AI to read them:</p>
<ul>
<li><code>domain.models.ts</code> — channel keys, pick lists, social platform definitions, all the seed and response types</li>
<li><code>scene-presets.ts</code> — nineteen food photography scenes across four scene groups</li>
<li><code>workflow-api.service.ts</code> — every API method the component will call, with their actual TypeScript signatures</li>
<li><code>dam-api.service.ts</code> — the <code>saveAsset</code> method</li>
<li><code>kanban-api.service.ts</code> — the <code>createCard</code> method</li>
<li><code>image.component.ts</code> — an existing working component that already does a version of the seed-and-render flow</li>
</ul>
<p>That last one is pulling a lot of weight. The instruction doesn&#39;t say &quot;here are the patterns I want you to follow, let me describe them.&quot; It says &quot;here is a component that already does a version of this correctly, read it and match what you see.&quot; That is the most reliable Constraint you can write. Not a description of the convention — a pointer to an exemplar. The AI reads the actual code and extracts the pattern from it. Every time I&#39;ve tried to describe a pattern in prose versus pointing at a live example, the example wins. Not by a little. By a lot.</p>
<p>The last line of the pre-load is: &quot;Do not generate any code yet.&quot;</p>
<p>This matters because Claude will sometimes start generating the moment it finishes reading files. It&#39;s being helpful. I don&#39;t want helpful yet. I want understanding. &quot;Do not generate any code yet&quot; is four words that prevent a premature response and keep the session in the right state for the Context step.</p>
<hr>
<h2 id="step-1-context-ask-for-understanding-before-you-ask-for-code">Step 1 — Context: Ask for Understanding Before You Ask for Code</h2>
<p>The Context prompt asks Claude to produce a structured summary. Not code — understanding. What signal and computed patterns does <code>image.component.ts</code> use? What are the exact shapes of <code>ContentPromptSeedResult</code>, <code>ImageBatchResponse</code>, and <code>SocialWorkflowResponse</code>? Which <code>WorkflowApiService</code> methods correspond to which pipeline stages?</p>
<p>There are two reasons I do this.</p>
<p>One: if the AI misunderstood something, I catch it here while it&#39;s cheap. A misunderstanding in the Context step costs me a one-line correction. A misunderstanding in Layer 4 costs me a template rewrite.</p>
<p>Two: asking the model to synthesize what it read before it produces new code improves the quality of the production step. I know this sounds like folk magic. It isn&#39;t. The model has to build an active representation of the relevant patterns before generating against them, and making that step explicit and separable — as a distinct prompt with a distinct deliverable — produces better output than jumping straight to execution. I noticed this pattern after running maybe thirty layered prompt chains. I tested it by removing the Context step and comparing results. The Context step wins.</p>
<p>I&#39;ve caught two real errors in RecipeForge&#39;s Context step — both about how <code>firstValueFrom()</code> was being used in the existing service layer. Both would have produced subtle async bugs in the methods layer if they&#39;d made it to Layer 6. One correction in prose, at the Context step, and neither showed up again.</p>
<hr>
<h2 id="step-2-plan-the-architecture-review-before-the-build">Step 2 — Plan: The Architecture Review Before the Build</h2>
<p>The Plan prompt asks for a prose proposal. The step union type. The thirty-one signals — which ones and what they represent. The two edit checkpoints and what state the operator needs editable at each. How the lightbox integrates with batch state. How social copy fits into the post-pick flow. All in prose, zero TypeScript.</p>
<p>This is the step everyone skips. I understand the impulse. You have the Context, you know what you want, just generate the code already. Skipping the Plan is how you get clean code that does the wrong thing.</p>
<p>A bad plan generates plausible code. A good plan generates code where the only surprises are in the formatting.</p>
<p>What I&#39;m specifically probing in the Plan review: does the AI agree that social fires after image selection? Does it understand that <code>reviewSeed</code> and <code>reviewPrompt</code> are active edit windows and not loading spinners? Does it know that the variant discard happens inside <code>selectImage()</code>, not in a separate cleanup step? These are the decisions with ordering consequences. If the plan is wrong about any of them, the Execute prompts will generate wrong code from a correct-looking starting point.</p>
<p>I push back in prose when I disagree with the proposal. Sometimes I don&#39;t push back — the AI&#39;s proposal is better than my initial instinct, and I update the prompts document before running Execute. This has happened twice on RecipeForge. The AI suggested a derived computed signal for the visible-platforms list that I hadn&#39;t thought of, and it was cleaner than what I had planned. The Plan step is not &quot;convince the AI to do what I decided&quot; — it&#39;s alignment. It takes maybe fifteen minutes. On a component this size, it is the best-spent fifteen minutes in the whole build.</p>
<hr>
<h2 id="execute-seven-layers-each-one-verifiable">Execute: Seven Layers, Each One Verifiable</h2>
<p>Execute is where the code gets built. Seven prompts, each one scoped to a piece of the component that can be checked independently, each one explicitly preserving what came before.</p>
<h3 id="layer-1-the-signal-skeleton">Layer 1: The Signal Skeleton</h3>
<p>Layer 1 produces the class with all signals, computed derivations, constants, injected services, and read-only pick lists — and nothing else. The <code>@Component</code> decorator. An empty template. No method bodies. Every method a stub.</p>
<p>This is the most boring layer. It&#39;s also the most important one.</p>
<p>If the signal names are wrong in Layer 1, every subsequent layer references the wrong names and you get a cascade of TypeScript errors that trace back to a naming decision that would have taken two minutes to get right at the start. The goal is a clean compile from Layer 1 onward. Everything on top of a passing build, never in the dark.</p>
<p>The CRITICAL restrictions here are blunt: &quot;Do NOT write template HTML beyond the bare decorator&quot; and &quot;Do NOT write any method bodies.&quot; The AI will try to fill these in because a component class with no template and no implementation is unusual-looking. &quot;Helpful&quot; means filling in what appears missing. Helpful is not what I want here. I want a typed signal inventory. I&#39;ll take helpful in Layer 2.</p>
<p>The IMPORTANT restriction — &quot;no RxJS Subject or BehaviorSubject for component state&quot; — is there because Angular 20 with signals means exactly that. Signals for state, <code>firstValueFrom()</code> to bridge HTTP observables into async methods. Every time I&#39;ve run a pipeline-style prompt without this restriction, at least one BehaviorSubject appears. Not because the AI is doing something wrong — BehaviorSubject is a completely correct Angular state pattern, just from a slightly older era. Without the restriction, the training distribution wins.</p>
<h3 id="layers-2-through-5-building-the-template-in-slices">Layers 2 Through 5: Building the Template in Slices</h3>
<p>The template is four layers. Each one adds a vertical slice of the UI without touching what already exists.</p>
<p><strong>Layer 2</strong> is the page header and config card — channel selector, posting day, variant count, the Run Pipeline button. Unremarkable on its own. The important thing here is the Behavior block: &quot;All signals and computed values from Layer 1 are preserved unchanged.&quot; That one line stops the AI from quietly improving my signal initializations while it adds the template. Without it, you get a template that&#39;s fine and a class that&#39;s been silently refactored. I have spent an afternoon tracing that particular class of diff-confusion. Not fun.</p>
<p><strong>Layer 3</strong> adds the mid-workflow steps: the seeding spinner, the reviewSeed edit block with scene selector and reference image upload zone, the composing spinner, the reviewPrompt block with the Venice prompt textarea and the character counter. These are the two human-in-the-loop checkpoints. This is where the operator can intervene.</p>
<p>One restriction from this layer I want to call out because it&#39;s a good illustration of how Restrictions work in practice: &quot;Do NOT show the character counter outside the reviewPrompt step.&quot;</p>
<p>The first time I generated this template without that restriction, the counter appeared in the reviewSeed block, attached to the concept text field. Which makes a certain template-generation logic — it&#39;s a textarea, there&#39;s a counter component nearby, they belong together. What the AI doesn&#39;t know is that concept text has no character limit, and showing a counter on a field with no limit creates visual anxiety the operator doesn&#39;t need at that stage of the workflow. They&#39;re not trying to stay under a character budget there. They&#39;re editing a concept description. The counter is noise.</p>
<p>That&#39;s domain knowledge. The AI has no way to derive it from the code. The only way it ends up right is if I put it in the Restriction. I put it in the Restriction. Never saw the problem again.</p>
<p><strong>Layer 4</strong> is the variant grid and the lightbox overlay. This layer has what I consider the most important CRITICAL restriction in the entire prompts document:</p>
<p>&quot;Do NOT allow the Keep button inside the lightbox to call <code>saveAll()</code> — it must call <code>selectFromLightbox()</code>, which chains to <code>selectImage()</code>, which chains to <code>runSocial()</code>.&quot;</p>
<p>Let me explain why this is CRITICAL and not just IMPORTANT.</p>
<p>The lightbox Keep button is the moment the operator says &quot;this is the image I want.&quot; When they click Keep, three things need to happen in sequence: the selection gets recorded, the unchosen variants get discarded, and the social copy gets generated based on the selected image. That entire sequence lives inside <code>selectImage()</code>.</p>
<p>If the Keep button calls <code>saveAll()</code> instead, the operator is saving a content card before the social copy has been generated. The DAM asset gets created. The Kanban card gets dropped. Both of them have empty or stale social copy fields because the social workflow hasn&#39;t run yet. No error. No warning. Just a board full of cards with wrong descriptions that an operator discovers weeks later.</p>
<p>Three words in a button binding. One CRITICAL restriction. The difference between a bug that&#39;s embarrassing to discover and a bug that&#39;s expensive to fix.</p>
<p><strong>Layer 5</strong> is the result grid — chosen image on the left, all seven platform copy blocks on the right, save bar below. The save bar has the platform selector, card title, scheduled date, and the Save button. The CRITICAL restriction: &quot;Do NOT show the Save button as enabled while <code>socialState() !== &#39;success&#39;</code>.&quot;</p>
<p>The operator cannot save until social copy generation has succeeded. That&#39;s not a UX detail — that&#39;s the product rule. Without the restriction, the AI produces a Save button that&#39;s enabled as soon as the step renders, because that&#39;s what most forms in training data look like. Forms have submit buttons and submit buttons are available. The contextually-gated submit button requires the explicit rule.</p>
<h3 id="layer-6-the-methods-layer">Layer 6: The Methods Layer</h3>
<p>Layer 6 drops method bodies into all the stubs Layer 1 created. <code>run()</code>, <code>continueFromSeed()</code>, <code>renderImage()</code>, <code>selectImage()</code>, <code>runSocial()</code>, <code>saveAll()</code>, the reference image handlers, the lightbox helpers, the copy button, the restart flow. Every orchestration method.</p>
<p>I put this layer last deliberately. The entire template exists before the method bodies are written. When the AI is building <code>selectImage()</code>, it can see the full template context it&#39;s operating in — it knows what the lightbox looks like, it knows what the social grid expects, it knows what the save bar requires. It&#39;s not writing methods in a vacuum and hoping the template that comes later matches.</p>
<p>The sequencing restrictions in this layer are the ones I care most about:</p>
<p>&quot;Do NOT call <code>runSocial()</code> before <code>selectImage()</code> resolves — social fires inside <code>selectImage()</code> only.&quot; CRITICAL because the violation produces silent wrong data. Social firing too early generates copy that describes the wrong image. No exception. No test failure. Just bad content on your board.</p>
<p>&quot;Do NOT delete the chosen image variant — delete only the unchosen variants.&quot; This one seems obvious until you&#39;re reading auto-generated code and find <code>.forEach(f =&gt; api.deleteImage(f).subscribe())</code> iterating over all the filenames, including the one the operator just kept. The AI is pattern-matching on &quot;clean up after image selection&quot; from training data. The correct behavior requires saying it explicitly, because &quot;delete all but one&quot; is a restriction, not a convention.</p>
<p>The Constraint for async style: &quot;All orchestration methods use <code>async/await</code> with <code>firstValueFrom()</code> — no <code>.subscribe()</code> inside orchestration logic.&quot; The reason is error handling. Async/await with try/catch in an orchestration method gives you predictable recovery — you know which step failed, you set the right error signal, you return the UI to the right state. A <code>.subscribe()</code> in the middle of an orchestration chain makes error recovery harder to reason about and harder to test. The rule isn&#39;t theoretical. Every time I&#39;ve let a <code>.subscribe()</code> sneak into orchestration code, I&#39;ve regretted it.</p>
<h3 id="layer-7-helpers-and-styles">Layer 7: Helpers and Styles</h3>
<p>Layer 7 adds the private derivation helpers and the full component CSS. Smaller than what came before, but <code>pickLocation()</code> is worth a mention because it&#39;s a clean example of domain knowledge that has to live in the prompt.</p>
<p><code>pickLocation()</code> maps cooking method keywords to scene presets — grilling and smoking go to outdoor scenes with a grill-adjacent preference, baking goes to kitchen scenes with a rustic preference, no-cook and raw-prep go to table setups. The function is maybe twenty lines. The mapping inside it represents product decisions we made about how food photography should relate to cooking context. Without those decisions in the Restriction block, the AI produces either a random mapping or a plausible-looking one based on whatever associations are strongest in training data.</p>
<p>Writing the mapping down in the prompt is also writing it down as product documentation. Anyone who reads the prompts file six months from now understands not just how the function works but why it maps the way it does.</p>
<hr>
<h2 id="the-verify-step-thirteen-specific-checks">The Verify Step: Thirteen Specific Checks</h2>
<p>After all seven layers compile, there&#39;s a Verify prompt. It asks Claude to re-read the completed component and run thirteen specific checks, reporting each as PASS or FAIL.</p>
<p>The CRITICAL checks hit the exact failure modes I was most worried about:</p>
<p>Does any step transition skip a review checkpoint? Does <code>runSocial()</code> fire anywhere except inside <code>selectImage()</code>? Does <code>selectImage()</code> accidentally delete the chosen filename? Does <code>saveAll()</code> run while <code>socialState() !== &#39;success&#39;</code>? Is there any <code>localStorage</code>, <code>sessionStorage</code>, or <code>document.cookie</code> anywhere in the file? Does any orchestration method use <code>.subscribe()</code> instead of <code>firstValueFrom()</code>? Are all seven social platforms rendered in the result grid? Is the Venice prompt character limit enforced at 1499 characters? Is there any <code>*ngIf</code> or <code>*ngFor</code> in the template? Does the lightbox Keep button call <code>saveAll()</code>?</p>
<p>That last one is on the list specifically because in an early iteration, before I had the CRITICAL restriction nailed down, the Keep button did call <code>saveAll()</code>. I caught it in the Verify step, not the diff review, because I was scanning the diff too fast. The Verify step is there precisely for that situation — the one where you&#39;re moving quickly and you miss the thing that matters.</p>
<p>Thirteen checks. Roughly half a day of build time. Zero production incidents related to ordering bugs on this component.</p>
<hr>
<h2 id="what-i-would-do-differently">What I Would Do Differently</h2>
<p>A few things.</p>
<p>I would write the Restrictions for each layer before writing the Scope. My current habit is still backward sometimes — I write what I want built, then I think about what could go wrong. The right habit is Restrictions-first. Sit down with a blank [R] block and ask &quot;what are the five most plausible ways this layer could compile and still be wrong?&quot; The list is almost always longer than five, but asking the question surfaces the critical ones before they bite you in production.</p>
<p>I&#39;d be more explicit about CSS variable usage in Layer 7. I had a restriction that said &quot;no hardcoded color values except platform dot colors,&quot; and the first pass had hex values showing up in animation keyframes and hover transitions. The restriction was right but not specific enough. Lesson filed: restrictions on styles should name specific properties, not just categories.</p>
<p>I would add a Known Limitations section to the prompts document earlier. I added it at the end. During the build I was tracking four edge cases in my head — the reference image <code>.subscribe()</code> pattern, the <code>BRAND_ARCHETYPES</code> dependency, the lightbox containing-block CSS caveat, the orphaned variants on navigation. Writing them down as I encountered them would have saved me the mental overhead of holding them until the build was done. The prompts document is not just a pre-build artifact. It&#39;s a living document you update as you learn things.</p>
<hr>
<h2 id="why-this-matters-more-than-it-looks-like-it-does">Why This Matters More Than It Looks Like It Does</h2>
<p>I want to be clear about what the SCRUB prompts document actually is, because I&#39;ve seen people mistake it for something else.</p>
<p>It is not documentation. It&#39;s not a write-up of what I did after the fact. It is the plan I worked from before I generated a single line of code for this component. And because it exists as a versioned, committed file in the repository, it becomes the first place I look when the component needs to change, not the last.</p>
<p>Buffer integration is on the roadmap — a &quot;Schedule in Buffer&quot; button that pushes copy and images directly to Buffer from the pipeline save step. When I add that feature, I will update <code>SCRUB-PIPELINE-PROMPTS.md</code> first. The new Layer 5 restrictions, the new Layer 6 method contracts, the additional Verify check for the Buffer call. Then I run the updated prompts. The component is built and maintained with the same discipline.</p>
<p>This is what the framework means when it says to treat prompts like code. Not a metaphor. A literal practice. The prompts directory is in source control. The prompts have authors and commit history. When something goes wrong in the component — and something always goes wrong eventually — the prompts tell me exactly which element of which layer broke.</p>
<p>Wrong default? Missing Restriction. Wrong state transition? Vague Scope or missing Restriction. Broke something I didn&#39;t touch? Weak Behavior block. The diagnostic card maps symptom to element. Element to fix. No hunting.</p>
<p>The other thing the prompts document gives me is a record of why the code is the way it is. Six months from now, when someone new is reading <code>pipeline.component.ts</code> and wondering why <code>runSocial()</code> only ever fires inside <code>selectImage()</code> and not in any of the three other plausible locations — the answer is in the prompts file. Not buried in a comment. Not in my head. In a file that&#39;s been reviewed, committed, and is available to anyone who runs <code>git log</code>.</p>
<p>That is worth the fifteen minutes it takes to write the plan before you write the code. Every time.</p>
<hr>
<h2 id="the-practical-version">The Practical Version</h2>
<p>If you want to try this on a component you&#39;re building:</p>
<p><strong>Start with Pre-load Context.</strong> Name the files the AI needs to read. Tell it explicitly not to generate anything yet. Five minutes, not fifty.</p>
<p><strong>Run a Context step first.</strong> Ask for a summary of what it read before asking for code. Read the summary. Correct misunderstandings in prose. This is the cheapest error-catching stage in the whole chain.</p>
<p><strong>Write a Plan prompt and read the proposal.</strong> Push back on anything with wrong ordering or wrong behavior. Do not skip this step because it feels slow. It is the cheapest insurance in the whole process.</p>
<p><strong>Split Execute into layers.</strong> Four to seven is usually right. If you can&#39;t describe what a layer produces in three sentences, the layer is too big. Split it.</p>
<p><strong>Write Restrictions before Scope.</strong> Ask yourself: what are the five ways this layer could compile and still be wrong? Write those down first. The Scope block is usually easier.</p>
<p><strong>Tier your Restrictions.</strong> CRITICAL means silent wrong data in production. IMPORTANT means technical debt you&#39;ll pay. PREFERRED means style. Know which ones you can compromise on and which ones you absolutely cannot.</p>
<p><strong>Write a Verify prompt.</strong> Not &quot;does it run&quot; — specifically, does it have the behaviors you required? Thirteen specific PASS/FAIL checks on a twelve-hundred-line component caught a real bug I missed in review.</p>
<p><strong>Commit the prompts document alongside the code.</strong> Update it when the component changes. It is not overhead. It is the architectural record of the component.</p>
<hr>
<p>The <a href="https://github.com/Architect4Hire/BrandForge/docs/SCRUB-PIPELINE-PROMPTS.md" target="_blank" rel="noopener noreferrer"><code>docs/SCRUB-PIPELINE-PROMPTS.md</code></a> file is in the RecipeForge repository if you want to read the actual prompts rather than my description of them. The component it produces — <code>brandforge-ui/src/app/features/pipeline/pipeline.component.ts</code> — is the result of running those prompts in sequence with the pre-loaded context. Clean compile at every layer. All thirteen Verify checks passed on first run.</p>
<p>That is what disciplined prompt engineering looks like. Not faster, necessarily. Not more impressive. Just fewer bugs that compile and run and don&#39;t get caught until production. And when something does go wrong, a clear map back to the decision that produced it.</p>
<p>That&#39;s a trade I&#39;ll take every time.</p>
<hr>
<p>*Robert Felkins is the principal architect at Architect4Hire and the author of Practical AI Assisted Development. If you want to go deeper on SCRUB — the full tiered restriction system, the Edit Mode prompt patterns, applying the framework to existing codebases — the book covers all of it. Find it at the companion repository, or reach out at <a href="mailto:robert@architect4hire.com">robert@architect4hire.com</a>.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Behind the Scenes: Why AspireHire’s UI Started with an Angular + Bootstrap Template</title>
            <link>https://www.architect4hire.com/blog/behind-the-scenes-why-aspirehires-ui-started-with-an-angular-bootstrap-template</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/behind-the-scenes-why-aspirehires-ui-started-with-an-angular-bootstrap-template</guid>
            <pubDate>Tue, 02 Sep 2025 11:45:55 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Building AspireHire</category>
            <description>The backend came together in five minutes: ASP.NET Core 9, Aspire, Azure SQL, microservices. The UI was a different beast entirely — and buying a template instead of building one was the right architectural call.</description>
            <content:encoded><![CDATA[<p><strong>The UI Crossroads</strong></p>
<p>When I first mapped out AspireHire’s architecture, the backend came together fast. ASP.NET Core 9, .NET Aspire for orchestration, Azure SQL and Cosmos for persistence, and microservices for Profiles, Jobs, Recruiters, Contracts, Messaging. That’s my comfort zone. I could sketch it out in five minutes and feel good about it.</p>
<p>The UI, though? That was a different beast.</p>
<p>We needed something recruiters would trust and developers would enjoy using. Clean, modern, responsive. But here’s the thing—I’m not a front-end developer. I’m a backend architect. My brain runs on APIs, services, queues, and data models. Drop me into a blank Angular project and I’ll be muttering at flexbox for days.</p>
<p>So I had a decision to make.</p>
<p>Do I build the UI from scratch and spend weeks wrangling layout and styling? Or do I start with a polished Angular + Bootstrap template and get to value faster?</p>
<p>I chose the template. That one move saved us months and gave AspireHire a head start.</p>
<p><strong>Why Angular Made Sense</strong></p>
<p>Let’s talk about the framework choice first.</p>
<ul>
<li>Angular has a strong structure and built-in TypeScript support. That’s great for long-term maintainability.</li>
<li>The ecosystem is deep. Charts, grids, auth helpers, translation tools—you name it, Angular has it.</li>
<li>It scales with the team. As AspireHire grows, we’ll bring in front-end devs who already know Angular. It’s a safe bet.</li>
<li>It works well with .NET. Angular and ASP.NET Core integrate smoothly when it comes to routing, authentication, and API calls.</li>
</ul>
<p>So Angular was the right fit. The next question was how to get a usable UI up and running quickly.</p>
<p><strong>Why Bootstrap Instead of Angular Material</strong></p>
<p>Angular Material is solid, but it comes with a strong Google design identity. AspireHire needed something more flexible and easier to brand.</p>
<p>Bootstrap gave us that.</p>
<ul>
<li>It’s familiar. Buttons, grids, and navbars look exactly how users expect.</li>
<li>It’s easy to customize. We could rebrand it with our own colors and typography without a fight.</li>
<li>It’s mobile-first. The grid system is responsive and well-tested.</li>
<li>The community is huge. Tons of examples, documentation, and support out there.</li>
</ul>
<p>Bootstrap gave us a clean, professional starting point that didn’t feel like a cookie-cutter template. And we could make it our own with just a few tweaks.</p>
<p><strong>Why I Didn’t Build It From Scratch</strong></p>
<p>If I’d gone the hand-crafted route, here’s what I would’ve been dealing with:</p>
<ul>
<li>Setting up Angular project scaffolding</li>
<li>Building layouts, navbars, and grid systems from scratch</li>
<li>Writing custom components for buttons, forms, tables, and modals</li>
<li>Debugging CSS across browsers</li>
<li>Wrestling with flexbox to get mobile breakpoints right</li>
</ul>
<p>That’s a lot of time before AspireHire even looks like an app. Meanwhile, stakeholders are asking when they’ll see something real.</p>
<p>Starting from scratch would’ve slowed us down and risked losing momentum.</p>
<p><strong>What the Template Delivered</strong></p>
<p>By choosing a solid Angular + Bootstrap template, we got:</p>
<ul>
<li>Clean layouts with dashboards, sidebars, and navbars already styled</li>
<li>Reusable components like buttons, forms, modals, and tables</li>
<li>Auth flows including login, signup, and password reset screens</li>
<li>A responsive grid system that worked across devices</li>
<li>Easy branding options to match AspireHire’s style</li>
</ul>
<p>Instead of building a design system from the ground up, we could plug in our backend APIs and start delivering value.</p>
<p><strong>How It Fit with .NET Aspire</strong></p>
<p>This wasn’t just about saving time on the front end. It was about making sure everything lined up with our backend architecture.</p>
<p>Here’s how it worked:</p>
<ol>
<li>A user logs in using a Bootstrap-styled form</li>
<li>Angular services call our .NET Identity microservice</li>
<li>JWT tokens come back and get stored in Angular</li>
<li>Angular interceptors attach those tokens to API calls</li>
<li>Microservices respond with JSON for profiles, jobs, recruiters, and contracts</li>
<li>Bootstrap components display the data in clean, responsive layouts</li>
</ol>
<p>The UI felt polished and professional. The backend stayed scalable and observable. It was a smooth handoff between layers.</p>
<p><strong>Branding with Bootstrap</strong></p>
<p>Out of the box, Bootstrap is clean but generic. AspireHire needed a little more personality.</p>
<p>So we customized:</p>
<ul>
<li>Updated the color palette with warm oranges, deep reds, vibrant purples, and balanced blues</li>
<li>Swapped in Google Fonts to match our brand voice—professional and approachable</li>
<li>Dropped in AspireHire logos across headers, footers, and login screens</li>
<li>Tweaked SCSS variables to give the UI a distinct look while keeping Bootstrap’s structure</li>
</ul>
<p>In just a few days, the UI looked like AspireHire. Not just another Bootstrap site.</p>
<p><strong>What I Learned Along the Way</strong></p>
<p>Templates are helpful, but they’re not magic. Here are a few takeaways:</p>
<ul>
<li>Learn the template’s structure. Even if you’re not a front-end expert, understand how routing, services, and components are wired.</li>
<li>Don’t over-customize too early. Focus on delivering business value first. Styling can come later.</li>
<li>Use Bootstrap’s utilities. The spacing, grid, and responsive helpers are there for a reason. Don’t reinvent them.</li>
<li>Keep your integration clean. Angular services should wrap backend APIs without adding business logic to the UI.</li>
<li>Plan for growth. Bootstrap is a great starting point, but as AspireHire scales, we’ll likely build a design system on top of it.</li>
</ul>
<p><strong>Why This Was a Strategic Move</strong></p>
<p>This wasn’t about cutting corners. It was about making smart choices.</p>
<ul>
<li>We got to demos faster. Stakeholders saw real screens within two weeks.</li>
<li>We saved time and money. Less boilerplate meant more focus on features.</li>
<li>We stayed focused. Developers worked on what mattered—like recruiter-matching algorithms and developer portfolios.</li>
<li>We built a foundation. Bootstrap’s familiar patterns make it easy for future hires to jump in and contribute.</li>
</ul>
<p>The template wasn’t a shortcut. It was leverage.</p>
<p><strong>Wrapping It Up</strong></p>
<p>AspireHire’s goal is to help developers showcase their skills. That meant we needed to get a working, branded UI in front of users quickly.</p>
<p>The Angular + Bootstrap template gave us that launchpad. It let me stay focused on backend architecture while still delivering a front end that looked and felt like AspireHire.</p>
<p>So if you’re a backend-first developer staring down a UI build, here’s my advice. Don’t get stuck trying to hand-roll every pixel. Use a template. Bootstrap it. Ship faster. And spend your energy where it counts.</p>
<p>Because at the end of the day, nobody’s impressed that you built a navbar from scratch. They care that your platform works, solves real problems, and feels trustworthy.</p>
<p>AspireHire is living proof that smart shortcuts lead to better outcomes.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Why We Built an Audit Trail into AspireHire (And Why You Should Care)</title>
            <link>https://www.architect4hire.com/blog/why-we-built-an-audit-trail-into-aspirehire-and-why-you-should-care</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/why-we-built-an-audit-trail-into-aspirehire-and-why-you-should-care</guid>
            <pubDate>Sun, 31 Aug 2025 16:28:16 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Building AspireHire</category>
            <description>&quot;Audit framework&quot; sounds about as exciting as watching paint dry. Then comes the &quot;oh shit&quot; moment where someone asks who changed a record and when — and you find you cannot answer. Here is what we built instead.</description>
            <content:encoded><![CDATA[<p><em>Or: How I Learned to Stop Worrying and Love Data Accountability</em></p>
<p>Hey folks! So, I&#39;ve been deep in the trenches building AspireHire lately, and I wanted to share something that might not seem sexy on the surface but is absolutely crucial for any serious application: our audit framework. Yeah, I know, &quot;audit framework&quot; sounds about as exciting as watching paint dry, but stick with me here—this stuff matters more than you think.</p>
<h2 id="the-and-quot-oh-shit-and-quot-moment-that-started-it-all">The &quot;Oh Shit&quot; Moment That Started It All</h2>
<p>Picture this: You&#39;re running a job platform where candidates are uploading resumes, employers are posting positions, and money is changing hands. Then one day, someone calls and says, &quot;Hey, my job posting disappeared, and I need to know exactly what happened and when.&quot;</p>
<p>Without an audit trail? You&#39;re basically playing detective with a blindfold on. With one? You can tell them exactly who did what, when they did it, and what changed. That&#39;s the difference between looking professional and looking like you&#39;re running your platform out of a garage (even if you literally are).</p>
<h2 id="what-we-actually-built">What We Actually Built</h2>
<p>Instead of reinventing the wheel or bolting on some third-party solution that would cost us an arm and a leg, we built our own <code>AuditDBContext</code> that sits right on top of Entity Framework Core. It&#39;s elegant, it&#39;s automatic, and it just works.</p>
<p>Here&#39;s the beautiful part, every time someone saves changes to our database, our audit framework automatically kicks in and says, &quot;Hold up, let me just record what&#39;s happening here.&quot; It captures:</p>
<ul>
<li><strong>What entity changed</strong> (User profile? Job posting? Application?)</li>
<li><strong>What type of action</strong> (Created, updated, deleted)</li>
<li><strong>The exact fields that changed</strong> (Old value vs. new value)</li>
<li><strong>When it happened</strong> (Down to the millisecond)</li>
</ul>
<p>The magic happens in our <code>SaveChangesAsync</code> override:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">async</span> Task&lt;<span class="hljs-built_in">int</span>&gt; <span class="hljs-title">SaveChangesAsync</span>(<span class="hljs-params">CancellationToken cancellationToken = <span class="hljs-literal">default</span></span>)</span>
{
    <span class="hljs-keyword">var</span> entityEntries = ChangeTracker.Entries()
        .Where(x =&gt; x.State != EntityState.Unchanged &amp;&amp; x.State != EntityState.Detached);

    <span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">var</span> entry <span class="hljs-keyword">in</span> entityEntries)
    {
        <span class="hljs-keyword">var</span> audit = <span class="hljs-keyword">new</span> Audit
        {
            TableName = entry.Entity.GetType().Name,
            Action = entry.State.ToString(),
            KeyValues = GetKeyValues(entry),
            Changes = GetFieldChanges(entry) != <span class="hljs-literal">null</span> ? System.Text.Json.JsonSerializer.Serialize(GetFieldChanges(entry)) : <span class="hljs-literal">null</span>
        };
        Audits.Add(audit);
    }

    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-keyword">base</span>.SaveChangesAsync(cancellationToken);
}</code></pre>
<p>Zero extra code in our business logic. Zero chance of forgetting to log something. It just happens automatically, like a good DevOps pipeline.</p>
<h2 id="real-talk-why-this-matters-for-aspirehire">Real Talk: Why This Matters for AspireHire</h2>
<h3 id="trust-is-everything-in-hiring">Trust is Everything in Hiring</h3>
<p>When you&#39;re dealing with people&#39;s careers and livelihoods, trust isn&#39;t optional. Candidates need to know their data is safe. Employers need confidence that their job postings and candidate information are handled properly.</p>
<p>Having a complete audit trail means we can:</p>
<ul>
<li><strong>Prove compliance</strong> when auditors come knocking</li>
<li><strong>Investigate issues</strong> quickly and thoroughly</li>
<li><strong>Build trust</strong> by being transparent about data handling</li>
<li><strong>Prevent disputes</strong> by having the receipts</li>
</ul>
<h3 id="the-technical-benefits-are-real-too">The Technical Benefits Are Real Too</h3>
<p>Beyond the obvious compliance stuff, our audit framework gives us superpowers:</p>
<p><strong>Debugging Production Issues</strong>: Ever had a bug report that starts with &quot;Something weird happened last Tuesday&quot;? With full audit trails, we can reconstruct exactly what &quot;weird&quot; means.</p>
<p><strong>Analytics and Insights</strong>: Want to know how users actually behave on the platform? Audit data is a goldmine for understanding user patterns and optimizing UX.</p>
<p><strong>Data Recovery</strong>: When (not if) someone accidentally deletes something important, we can see exactly what was deleted and potentially restore it.</p>
<h2 id="the-implementation-that-actually-works">The Implementation That Actually Works</h2>
<p>Here&#39;s what I love about our approach—it&#39;s dead simple but incredibly powerful. We have three main pieces:</p>
<ol>
<li><strong>The <code>AuditDBContext</code></strong> - The orchestrator that captures everything</li>
<li><strong>The <code>Audit</code> entity</strong> - Stores the high-level change information</li>
<li><strong>The <code>AuditFieldChange</code> class</strong> - Captures field-level details</li>
</ol>
<p>The framework is smart about what it tracks. It doesn&#39;t just dump everything into a log file like some solutions. Instead, it:</p>
<ul>
<li><strong>Serializes complex objects</strong> properly (goodbye, <code>[object Object]</code> logs)</li>
<li><strong>Handles collections</strong> intelligently</li>
<li><strong>Only tracks actual changes</strong> (not just &quot;something was saved&quot;)</li>
<li><strong>Maintains referential integrity</strong> with primary key tracking</li>
</ul>
<h2 id="lessons-learned-building-this">Lessons Learned Building This</h2>
<h3 id="start-simple-stay-flexible">Start Simple, Stay Flexible</h3>
<p>We started with basic audit logging and gradually added sophistication. The framework can handle complex scenarios now (like tracking changes to collections of related entities), but it started life tracking simple property changes.</p>
<h3 id="performance-matters">Performance Matters</h3>
<p>Adding audit trails to every database operation could theoretically slow things down. In practice? We&#39;ve seen zero noticeable impact. Entity Framework&#39;s change tracking is already doing most of the heavy lifting, so we&#39;re just piggyback riding on that work.</p>
<h3 id="json-is-your-friend">JSON is Your Friend</h3>
<p>Storing field changes as JSON gives us incredible flexibility. We can query it, we can deserialize it for reports, and we don&#39;t have to predict every possible audit scenario upfront.</p>
<h2 id="what-and-39-s-next">What&#39;s Next</h2>
<p>The audit framework is just the foundation. We&#39;re already thinking about:</p>
<ul>
<li><strong>Real-time audit dashboards</strong> for monitoring platform health</li>
<li><strong>AI-powered anomaly detection</strong> to catch suspicious patterns</li>
<li><strong>Automated compliance reporting</strong> for when we inevitably need SOC 2 certification</li>
<li><strong>User-facing audit logs</strong> so people can see their own activity</li>
</ul>
<h2 id="the-bottom-line">The Bottom Line</h2>
<p>Building AspireHire isn&#39;t just about connecting job seekers with employers—it&#39;s about doing it right. That means thinking about data integrity, compliance, and trust from day one, not retrofitting it later when some crisis forces our hand.</p>
<p>The audit framework might not be the flashiest feature we&#39;ll ever build, but it&#39;s the kind of foundational work that separates professional platforms from hobby projects. When candidates trust us with their career data and employers trust us with their hiring processes, we better have our act together.</p>
<p>And honestly? There&#39;s something deeply satisfying about knowing that every single change in our system is tracked, auditable, and recoverable. It&#39;s like having a really good backup strategy—you hope you never need it, but you sleep better knowing it&#39;s there.</p>
<hr>
<p><em>Building AspireHire has been a masterclass in balancing feature velocity with technical excellence. Want to follow along with our journey? Check out our other posts about the platform architecture, or better yet, come try the platform when we launch. We&#39;re building something special here, one properly-audited database change at a time.</em></p>
<p>💡 Want to see how it all comes together in real code? The full implementation of the AspireHire audit framework complete with AuditDBContext, entity classes, and sample usage, is up on GitHub. Check it out, fork it, or even open an issue if you’ve got ideas for making it better.</p>
<p>👉 github.com/architect4hire/aspirehire</p>
]]></content:encoded>
        </item>
        <item>
            <title>Kicking Off AspireHire</title>
            <link>https://www.architect4hire.com/blog/kicking-off-aspirehire</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/kicking-off-aspirehire</guid>
            <pubDate>Fri, 29 Aug 2025 11:31:07 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Building AspireHire</category>
            <description>The world does not need another side project that fizzles out after a GitHub repo and a half-finished README. It needs projects that teach as they grow. This is the blueprint for AspireHire — and the series that builds it in the open.</description>
            <content:encoded><![CDATA[<h3 id="building-a-proof-of-concept-and-teaching-developers-how-to-master-modern-asp-net-core-aspire-and-azure-development">Building a Proof of Concept and Teaching Developers How to Master Modern ASP.NET Core, Aspire, and Azure Development</h3>
<h2 id="setting-the-stage">Setting the Stage</h2>
<p>Let’s be real for a second. The world does not need another side project that fizzles out after a GitHub repo and a half-finished README. What it does need is projects that actually push developers forward, projects that teach as they grow, and projects that solve a real problem.</p>
<p>That’s why I’m launching <strong>AspireHire</strong>. At the surface, AspireHire looks like another freelance platform. The twist is that this one is built for developers, by a developer, with the entire journey shared in public. AspireHire is a proof of concept that doubles as a teaching tool. Over the next series of posts, I am going to break down every architectural choice, every piece of code, and every pipeline step so you can learn exactly how to build modern, cloud-native applications with ASP.NET Core 9, .NET Aspire, and Azure.</p>
<p>Think of this as a mix between a product launch and a masterclass in distributed system design. By the end, you will not only understand what AspireHire is but also how to apply the same patterns in your own work.</p>
<hr>
<h2 id="why-build-another-freelance-platform">Why Build Another Freelance Platform?</h2>
<p>If you’ve ever used Upwork or Fiverr, you know how they work. Upwork focuses on long-term projects and proposals. Fiverr is all about fast gigs at fixed prices. Both serve their markets well, but they share one big flaw: they don’t let developers show off what makes them special.</p>
<p>On Upwork you can write a nice cover letter and maybe share a portfolio. On Fiverr you can list a gig with a fixed price. Neither really helps a client figure out who is a serious technical professional and who just knows how to fill out a profile.</p>
<p>AspireHire flips the script. Instead of treating developers like interchangeable parts, it highlights what makes each one unique. Profiles include real portfolios, skill tags, certifications, GitHub integrations, and even coding test results. Clients can finally see more than a name, a rating, and a price tag.</p>
<p>This niche focus matters. By building only for software and IT work, AspireHire avoids the trap of being too broad. Developers get tools designed for them, and clients get confidence that they are hiring skilled professionals.</p>
<p>And for me, this is not just a product idea. It is a chance to show developers how to build a system like this from the ground up.</p>
<hr>
<h2 id="from-idea-to-proof-of-concept">From Idea to Proof of Concept</h2>
<p>Here is the important thing to understand. AspireHire is not just about launching another app. This project is a <strong>playground for learning</strong>. It is where I get to show you how a professional architect thinks about requirements, system design, data modeling, microservices, observability, and DevOps pipelines.</p>
<p>Instead of just handing you a GitHub repo at the end, I am going to walk you through the entire build. You will see the mistakes, the fixes, and the tradeoffs. You will learn not just what works but why it works.</p>
<p>If you have ever wanted to level up from hobby projects and tutorials into building production-ready systems, this series is going to give you that push.</p>
<hr>
<h2 id="what-the-series-will-cover">What the Series Will Cover</h2>
<p>Let me give you a roadmap of what’s coming. Each post will dive into a piece of AspireHire, with real examples and code along the way.</p>
<ol>
<li><strong>From Requirements to Architecture</strong>
How to translate a vision into a technical blueprint. We will break down roles, workflows, and data models, then map them to services.</li>
<li><strong>Setting Up .NET Aspire</strong>
Why Aspire is a game-changer for cloud-native apps and how to use it for service discovery, observability, and local orchestration.</li>
<li><strong>Microservice by Microservice</strong>
Building Auth, Profile, Job, Proposal, Contract, Payment, and Messaging services with ASP.NET Core 9 and Clean Architecture.</li>
<li><strong>Relational Data Models with EF Core</strong>
Designing tables and relationships that support complex workflows like proposals, contracts, and reviews.</li>
<li><strong>Event-Driven Messaging</strong>
Using Azure Service Bus to decouple services and handle workflows like notifications and escrow payments.</li>
<li><strong>CI/CD Pipelines with Azure DevOps</strong>
Containerizing everything, pushing to Azure Container Registry, and deploying to AKS with zero downtime.</li>
<li><strong>Observability that Actually Works</strong>
Logs, metrics, and traces using .NET Aspire defaults, OpenTelemetry, and Application Insights.</li>
<li><strong>Securing the Platform</strong>
Authentication with Azure AD B2C, protecting secrets with Key Vault, and designing for PCI and GDPR compliance.</li>
<li><strong>Building a Developer-First UI</strong>
Creating a web experience that makes sense for technical users, from dark mode to GitHub integrations.</li>
<li><strong>Scaling and Extending</strong>
Planning for AI-powered recommendations, advanced search, and premium features once the core platform is solid.</li>
</ol>
<p>By the time we are done, you will have the skills and the code samples to build your own distributed system on Azure.</p>
<hr>
<h2 id="the-starting-blueprint">The Starting Blueprint</h2>
<p>Every project starts with a mental picture of what it will look like in production. Here’s the high-level blueprint for AspireHire:</p>
<ul>
<li><strong>Platform</strong>: ASP.NET Core 9 with .NET Aspire</li>
<li><strong>Deployment</strong>: Azure Kubernetes Service</li>
<li><strong>Storage</strong>: Azure SQL for relational data, Blob Storage for files, Redis for caching</li>
<li><strong>Messaging</strong>: Azure Service Bus for events</li>
<li><strong>Observability</strong>: Application Insights with OpenTelemetry integration</li>
<li><strong>CI/CD</strong>: Azure DevOps Pipelines pushing Docker images to Azure Container Registry</li>
</ul>
<p><strong>The core microservices include:</strong></p>
<ul>
<li><strong>Auth Service</strong> for login and identity</li>
<li><strong>Profile Service</strong> for detailed freelancer and client profiles</li>
<li><strong>Job Service</strong> for creating and searching job postings</li>
<li><strong>Proposal Service</strong> for bids and negotiations</li>
<li><strong>Contract and Payment Service</strong> for escrow and transactions</li>
<li><strong>Messaging Service</strong> for real-time communication tied to jobs</li>
</ul>
<p>Each service owns its own data store and can scale independently in AKS. All of them get logging, tracing, and health checks out of the box with Aspire.</p>
<hr>
<h2 id="why-use-net-aspire">Why Use .NET Aspire?</h2>
<p>If you have ever built microservices the old way, you know the pain. You spend hours wiring up logging, tracing, configuration, and health checks before you even start writing business logic. Local orchestration is a mess.</p>
<p>.NET Aspire fixes that. With one method call, <code>AddServiceDefaults</code>, you get logging, tracing, metrics, and health endpoints already wired. Service discovery and orchestration happen in the AppHost, so running your entire stack locally feels like magic.</p>
<p>When you combine Aspire with Azure’s managed services, you get speed and reliability. Developers spend less time on plumbing and more time building features. That is why AspireHire is the perfect project to showcase what Aspire brings to the table.</p>
<hr>
<h2 id="building-with-azure-in-mind">Building with Azure in Mind</h2>
<p>This project is built on Azure from the start. Not bolted on later. Not a side thought. From day one the services run on AKS and integrate with managed Azure services.</p>
<ul>
<li><strong>AKS</strong> gives us container orchestration, scaling, and zero-downtime updates</li>
<li><strong>Azure SQL Database</strong> handles relational storage with built-in HA and backups</li>
<li><strong>Azure Blob Storage</strong> stores profile images, portfolio files, and message attachments</li>
<li><strong>Azure Service Bus</strong> manages asynchronous communication between services</li>
<li><strong>Application Insights</strong> collects telemetry and connects to Aspire’s OpenTelemetry pipeline</li>
<li><strong>Key Vault</strong> protects secrets and connects seamlessly to services through managed identities</li>
</ul>
<p>By designing cloud-first, we get to learn not just how to build apps but how to build apps that run reliably in the environments companies actually use.</p>
<hr>
<h2 id="teaching-through-real-problems">Teaching Through Real Problems</h2>
<p>One promise I want to make is this: I am not going to hide the rough edges. You will see the broken deployments. You will see the EF Core migrations that fail. You will see the pipeline steps that don’t work the first time.</p>
<p>Because that is how real projects go. The value is not in seeing a perfectly polished example. The value is in seeing how to troubleshoot and fix problems like a professional. That is how you level up as a developer.</p>
<hr>
<h2 id="what-you-will-get-out-of-this">What You Will Get Out of This</h2>
<p>If you stick with this series, here is what you will walk away with:</p>
<ul>
<li>Confidence to design and implement distributed systems on Azure</li>
<li>Hands-on examples of microservice patterns in ASP.NET Core 9</li>
<li>Real pipelines you can adapt for your own projects</li>
<li>A clear mental model of how to debug, monitor, and scale apps in production</li>
<li>A stronger resume, GitHub portfolio, or skill set for your next role or contract</li>
</ul>
<p>This is not another “todo list in microservices.” This is the kind of project you can point to in an interview and say, “Yes, I know how to build that.”</p>
<hr>
<h2 id="where-we-go-next">Where We Go Next</h2>
<p>This post is the kickoff. In the next one, we are going to dive into the architecture and requirements document in detail. I will show you how to take an idea like “freelance platform for developers” and turn it into concrete services, data models, and diagrams.</p>
<p>From there we will start building the services piece by piece. By the end, AspireHire will not only exist as a working app but also as a step-by-step guide to modern .NET cloud development.</p>
<hr>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>AspireHire is both a product experiment and a learning journey. By focusing on developers as users, it solves a real gap in the freelance market. By building it out loud, it creates a chance for you to see exactly how modern cloud apps are designed and deployed.</p>
<p>If you have been waiting for a project that goes deeper than tutorials and takes you inside the head of an architect, this is it. The best way to learn is by building something real, and that is exactly what we are about to do together.</p>
<p>So grab your coffee, fire up your IDE, and get ready. This is going to be a fun ride.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Outbox Pattern and the Two-Stage Commit Problem</title>
            <link>https://www.architect4hire.com/blog/outbox-pattern-and-the-two-stage-commit-problem</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/outbox-pattern-and-the-two-stage-commit-problem</guid>
            <pubDate>Thu, 21 Aug 2025 10:31:26 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>Save to the database, publish an event. Two writes, one of which can fail — and now your system is lying to itself. The Outbox Pattern makes the business operation and the event publish atomic, and this is how you actually implement it.</description>
            <content:encoded><![CDATA[<p>In the world of microservices and distributed systems, ensuring reliable message delivery while maintaining data consistency is one of the most challenging problems developers face. The <strong>Outbox Pattern</strong> provides an elegant solution to this dual-write problem, guaranteeing that business operations and event publishing happen atomically. This comprehensive guide will walk you through the implementation of the Outbox Pattern step-by-step, discuss the requirements for success, and highlight critical considerations.</p>
<h2 id="understanding-the-problem-the-dual-write-challenge">Understanding the Problem: The Dual-Write Challenge</h2>
<p>Before diving into the solution, let&#39;s understand the problem the Outbox Pattern solves. Consider this common scenario in a microservices architecture:</p>
<ol>
<li>A service processes a business operation (e.g., creating an order)</li>
<li>The service needs to persist data to a database</li>
<li>The service must publish an event to notify other services</li>
</ol>
<p>The challenge arises when trying to make both operations atomic. What happens if the database transaction succeeds but the message publishing fails? Or vice versa? This dual-write problem can lead to:</p>
<ul>
<li><strong>Data inconsistency</strong> between services</li>
<li><strong>Lost events</strong> that other services never receive</li>
<li><strong>Duplicate processing</strong> when retries occur</li>
<li><strong>System-wide reliability issues</strong></li>
</ul>
<h2 id="the-outbox-pattern-solution">The Outbox Pattern Solution</h2>
<p>The Outbox Pattern solves this by storing outbound messages in the same database transaction as the business data. A separate background process then reliably publishes these messages to the message broker. This approach ensures <strong>exactly-once delivery semantics</strong> and maintains consistency between local state changes and event publishing.</p>
<h3 id="system-components">System Components</h3>
<p>The solution consists of five main components:</p>
<ol>
<li><strong>OrderCoordinator</strong> - ASP.NET Core Web API (.NET 9)</li>
<li><strong>InventoryApi</strong> - ASP.NET Core Web API (.NET 9)</li>
<li><strong>PaymentsApi</strong> - ASP.NET Core Web API (.NET 9)</li>
<li><strong>RollbackWorker</strong> - Azure Functions (.NET 8)</li>
<li><strong>Contracts</strong> - Shared contract library (.NET 9)</li>
<li><strong>Azure Service Bus</strong> - Message broker for async communication</li>
</ol>
<h3 id="architectural-patterns">Architectural Patterns</h3>
<h4 id="1-microservices-architecture">1. <strong>Microservices Architecture</strong></h4>
<ul>
<li>Each service owns its data (SQLite databases)</li>
<li>Services communicate via HTTP APIs and async messaging</li>
<li>Loose coupling through well-defined contracts</li>
</ul>
<h4 id="2-outbox-pattern-implementation">2. <strong>Outbox Pattern Implementation</strong></h4>
<p>The <strong>Outbox Pattern</strong> is implemented in the <code>OrderCoordinator</code> service to ensure reliable message publishing:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OutboxMessage</span>
{
    <span class="hljs-keyword">public</span> Guid Id { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = Guid.NewGuid();
    <span class="hljs-keyword">public</span> DateTime OccurredUtc { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = DateTime.UtcNow;
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> Type { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = <span class="hljs-literal">default</span>!;
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> PayloadJson { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = <span class="hljs-literal">default</span>!;
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">int</span> AttemptCount { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> DateTime? LastAttemptUtc { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> OutboxMessage <span class="hljs-title">From</span>&lt;<span class="hljs-title">T</span>&gt;(<span class="hljs-params">T payload</span>) <span class="hljs-keyword">where</span> T : <span class="hljs-keyword">class</span></span>
        =&gt; <span class="hljs-keyword">new</span> OutboxMessage
        {
            Type = <span class="hljs-keyword">typeof</span>(T).FullName!,
            PayloadJson = JsonSerializer.Serialize(payload)
        };
}</code></pre>
<p><strong>Key Implementation Details:</strong></p>
<ol>
<li><strong>Transactional Consistency</strong>: Outbox messages are stored in the same database transaction as business operations</li>
<li><strong>Background Publisher</strong>: <code>OutboxPublisher</code> runs as a <code>BackgroundService</code> polling for pending messages</li>
<li><strong>Retry Logic</strong>: Failed messages track attempt counts and timestamps</li>
<li><strong>At-Least-Once Delivery</strong>: Messages are only removed after successful publishing</li>
</ol>
<h4 id="3-orchestration-based-saga-pattern">3. <strong>Orchestration-Based Saga Pattern</strong></h4>
<p>The <code>OrderCoordinator</code> implements an orchestration saga with compensating transactions:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Try-Confirm-Cancel pattern</span>
<span class="hljs-keyword">try</span>
{
    <span class="hljs-comment">// Try: Reserve inventory</span>
    <span class="hljs-keyword">var</span> resResp = <span class="hljs-keyword">await</span> inventory.PostAsJsonAsync(<span class="hljs-string">&quot;inventory/reservations&quot;</span>, ...);

    <span class="hljs-comment">// Try: Authorize payment</span>
    <span class="hljs-keyword">var</span> authResp = <span class="hljs-keyword">await</span> payments.PostAsJsonAsync(<span class="hljs-string">&quot;payments/authorize&quot;</span>, ...);

    <span class="hljs-comment">// Confirm both operations</span>
    <span class="hljs-keyword">await</span> inventory.PostAsJsonAsync(<span class="hljs-string">&quot;inventory/reservations/confirm&quot;</span>, ...);
    <span class="hljs-keyword">await</span> payments.PostAsJsonAsync(<span class="hljs-string">&quot;payments/capture&quot;</span>, ...);
}
<span class="hljs-keyword">catch</span> (Exception ex)
{
    <span class="hljs-comment">// Compensate on failure</span>
    db.Outbox.Add(OutboxMessage.From(<span class="hljs-keyword">new</span> OrderRollbackRequested(...)));
}</code></pre>
<h4 id="4-idempotency-pattern">4. <strong>Idempotency Pattern</strong></h4>
<p>Both the order coordinator and downstream services implement idempotency:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Order coordinator uses idempotency keys</span>
<span class="hljs-keyword">var</span> idem = <span class="hljs-keyword">await</span> db.Idempotency.FirstOrDefaultAsync(i =&gt; i.Key == idempotencyKey, ct);
<span class="hljs-keyword">if</span> (idem <span class="hljs-keyword">is</span> <span class="hljs-keyword">not</span> <span class="hljs-literal">null</span>)
    <span class="hljs-keyword">return</span> Results.Accepted(<span class="hljs-string">$&quot;/orders/<span class="hljs-subst">{idem.ResultOrderId}</span>&quot;</span>, <span class="hljs-keyword">new</span> { orderId = idem.ResultOrderId });

<span class="hljs-comment">// Downstream services check for existing operations</span>
<span class="hljs-keyword">var</span> existing = <span class="hljs-keyword">await</span> db.Authorizations.FirstOrDefaultAsync(a =&gt; a.OrderId == req.OrderId);
<span class="hljs-keyword">if</span> (existing <span class="hljs-keyword">is</span> <span class="hljs-keyword">not</span> <span class="hljs-literal">null</span>)
    <span class="hljs-keyword">return</span> Results.Ok(<span class="hljs-keyword">new</span> AuthorizePaymentResponse(existing.AuthorizationId));</code></pre>
<h2 id="step-by-step-implementation-guide">Step-by-Step Implementation Guide</h2>
<h3 id="step-1-design-the-outbox-message-entity">Step 1: Design the Outbox Message Entity</h3>
<p>The foundation of the Outbox Pattern is a database table that stores pending messages. Here&#39;s a well-designed outbox entity:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OutboxMessage</span>
{
    <span class="hljs-keyword">public</span> Guid Id { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = Guid.NewGuid();
    <span class="hljs-keyword">public</span> DateTime OccurredUtc { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = DateTime.UtcNow;
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> Type { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = <span class="hljs-literal">default</span>!;
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> PayloadJson { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">init</span>; } = <span class="hljs-literal">default</span>!;
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">int</span> AttemptCount { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> DateTime? LastAttemptUtc { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> OutboxMessage <span class="hljs-title">From</span>&lt;<span class="hljs-title">T</span>&gt;(<span class="hljs-params">T payload</span>) <span class="hljs-keyword">where</span> T : <span class="hljs-keyword">class</span></span>
        =&gt; <span class="hljs-keyword">new</span> OutboxMessage
        {
            Type = <span class="hljs-keyword">typeof</span>(T).FullName!,
            PayloadJson = JsonSerializer.Serialize(payload)
        };
}</code></pre>
<p><strong>Key Design Decisions:</strong></p>
<ul>
<li><strong>Immutable core properties</strong>: <code>Id</code>, <code>OccurredUtc</code>, <code>Type</code>, and <code>PayloadJson</code> are set once and never changed</li>
<li><strong>Tracking metadata</strong>: <code>AttemptCount</code> and <code>LastAttemptUtc</code> support retry logic and monitoring</li>
<li><strong>Generic serialization</strong>: The <code>From</code> method provides type-safe message creation</li>
<li><strong>Type information</strong>: Storing the full type name enables proper deserialization</li>
</ul>
<h3 id="step-2-integrate-with-your-data-model">Step 2: Integrate with Your Data Model</h3>
<p>Add the outbox table to your existing <code>DbContext</code>:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">AppDb</span>(<span class="hljs-params">DbContextOptions&lt;AppDb&gt; opt</span>) : <span class="hljs-title">DbContext</span>(<span class="hljs-params">opt</span>)</span>
{
    <span class="hljs-keyword">public</span> DbSet&lt;OrderRecord&gt; Orders =&gt; Set&lt;OrderRecord&gt;();
    <span class="hljs-keyword">public</span> DbSet&lt;IdempotencyRecord&gt; Idempotency =&gt; Set&lt;IdempotencyRecord&gt;();
    <span class="hljs-keyword">public</span> DbSet&lt;OutboxMessage&gt; Outbox =&gt; Set&lt;OutboxMessage&gt;();

    <span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnModelCreating</span>(<span class="hljs-params">ModelBuilder modelBuilder</span>)</span>
    {
        <span class="hljs-comment">// Add indexes for efficient querying</span>
        modelBuilder.Entity&lt;OutboxMessage&gt;()
            .HasIndex(x =&gt; x.OccurredUtc)
            .HasDatabaseName(<span class="hljs-string">&quot;IX_OutboxMessage_OccurredUtc&quot;</span>);

        modelBuilder.Entity&lt;OutboxMessage&gt;()
            .HasIndex(x =&gt; <span class="hljs-keyword">new</span> { x.AttemptCount, x.LastAttemptUtc })
            .HasDatabaseName(<span class="hljs-string">&quot;IX_OutboxMessage_Retry&quot;</span>);
    }
}</code></pre>
<h3 id="step-3-implement-transactional-message-storage">Step 3: Implement Transactional Message Storage</h3>
<p>The critical aspect is storing outbox messages within the same database transaction as your business operations:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IResult&gt; <span class="hljs-title">PlaceOrderAsync</span>(<span class="hljs-params">CreateOrderDto dto, <span class="hljs-built_in">string</span> idempotencyKey, CancellationToken ct</span>)</span>
{
    <span class="hljs-comment">// Check idempotency first</span>
    <span class="hljs-keyword">var</span> idem = <span class="hljs-keyword">await</span> db.Idempotency.FirstOrDefaultAsync(i =&gt; i.Key == idempotencyKey, ct);
    <span class="hljs-keyword">if</span> (idem <span class="hljs-keyword">is</span> <span class="hljs-keyword">not</span> <span class="hljs-literal">null</span>)
        <span class="hljs-keyword">return</span> Results.Accepted(<span class="hljs-string">$&quot;/orders/<span class="hljs-subst">{idem.ResultOrderId}</span>&quot;</span>, <span class="hljs-keyword">new</span> { orderId = idem.ResultOrderId });

    <span class="hljs-keyword">var</span> order = <span class="hljs-keyword">new</span> OrderRecord { Id = Guid.NewGuid(), Amount = dto.Amount, Status = <span class="hljs-string">&quot;Pending&quot;</span> };
    db.Orders.Add(order);
    <span class="hljs-keyword">await</span> db.SaveChangesAsync(ct);

    <span class="hljs-keyword">try</span>
    {
        <span class="hljs-comment">// Business logic with external services</span>
        <span class="hljs-keyword">await</span> ProcessInventoryReservation(order, dto.Items, ct);
        <span class="hljs-keyword">await</span> ProcessPaymentAuthorization(order, ct);
        <span class="hljs-keyword">await</span> ConfirmOrderComponents(order, ct);

        order.Status = <span class="hljs-string">&quot;Confirmed&quot;</span>;
    }
    <span class="hljs-keyword">catch</span> (Exception ex)
    {
        order.Status = <span class="hljs-string">&quot;Canceled&quot;</span>;
        <span class="hljs-comment">// Store rollback event in the same transaction</span>
        db.Outbox.Add(OutboxMessage.From(<span class="hljs-keyword">new</span> OrderRollbackRequested(
            order.Id, 
            order.InventoryReservationId, 
            order.PaymentAuthId, 
            ex.Message)));
    }

    <span class="hljs-comment">// Store idempotency record and save everything atomically</span>
    db.Idempotency.Add(<span class="hljs-keyword">new</span> IdempotencyRecord { Key = idempotencyKey, ResultOrderId = order.Id });
    <span class="hljs-keyword">await</span> db.SaveChangesAsync(ct);

    <span class="hljs-keyword">return</span> Results.Accepted(<span class="hljs-string">$&quot;/orders/<span class="hljs-subst">{order.Id}</span>&quot;</span>, <span class="hljs-keyword">new</span> { orderId = order.Id });
}</code></pre>
<p><strong>Critical Points:</strong></p>
<ul>
<li>Outbox messages are added to the same <code>DbContext</code> as business entities</li>
<li>A single <code>SaveChangesAsync()</code> call ensures atomicity</li>
<li>If the transaction fails, neither business data nor outbox messages are persisted</li>
</ul>
<h3 id="step-4-build-the-background-publisher">Step 4: Build the Background Publisher</h3>
<p>The background service continuously polls for pending messages and publishes them:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OutboxPublisher</span>(<span class="hljs-params">IServiceProvider sp, IConfiguration cfg, ILogger&lt;OutboxPublisher&gt; log</span>) : BackgroundService</span>
{
    <span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">ExecuteAsync</span>(<span class="hljs-params">CancellationToken stoppingToken</span>)</span>
    {
        <span class="hljs-keyword">var</span> pollSeconds = cfg.GetValue&lt;<span class="hljs-built_in">int</span>?&gt;(<span class="hljs-string">&quot;Outbox:PollSeconds&quot;</span>) ?? <span class="hljs-number">1</span>;
        <span class="hljs-keyword">var</span> batchSize = cfg.GetValue&lt;<span class="hljs-built_in">int</span>?&gt;(<span class="hljs-string">&quot;Outbox:BatchSize&quot;</span>) ?? <span class="hljs-number">100</span>;
        <span class="hljs-keyword">var</span> topicName = cfg[<span class="hljs-string">&quot;ServiceBus:TopicName&quot;</span>] ?? <span class="hljs-string">&quot;orders&quot;</span>;

        <span class="hljs-keyword">while</span> (!stoppingToken.IsCancellationRequested)
        {
            <span class="hljs-keyword">try</span>
            {
                <span class="hljs-keyword">await</span> ProcessPendingMessages(batchSize, topicName, stoppingToken);
            }
            <span class="hljs-keyword">catch</span> (Exception ex)
            {
                log.LogError(ex, <span class="hljs-string">&quot;Outbox processing loop error&quot;</span>);
            }

            <span class="hljs-keyword">await</span> Task.Delay(TimeSpan.FromSeconds(pollSeconds), stoppingToken);
        }
    }

    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">ProcessPendingMessages</span>(<span class="hljs-params"><span class="hljs-built_in">int</span> batchSize, <span class="hljs-built_in">string</span> topicName, CancellationToken stoppingToken</span>)</span>
    {
        <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> scope = sp.CreateScope();
        <span class="hljs-keyword">var</span> db = scope.ServiceProvider.GetRequiredService&lt;AppDb&gt;();
        <span class="hljs-keyword">var</span> sbClient = scope.ServiceProvider.GetRequiredService&lt;ServiceBusClient&gt;();
        <span class="hljs-keyword">var</span> sender = sbClient.CreateSender(topicName);

        <span class="hljs-keyword">var</span> pending = <span class="hljs-keyword">await</span> db.Outbox
            .OrderBy(x =&gt; x.OccurredUtc)
            .Take(batchSize)
            .ToListAsync(stoppingToken);

        <span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">var</span> message <span class="hljs-keyword">in</span> pending)
        {
            <span class="hljs-keyword">try</span>
            {
                <span class="hljs-keyword">await</span> PublishMessage(sender, message, stoppingToken);
                db.Outbox.Remove(message);
            }
            <span class="hljs-keyword">catch</span> (Exception ex)
            {
                <span class="hljs-keyword">await</span> HandlePublishFailure(message, ex);
            }
        }

        <span class="hljs-keyword">await</span> db.SaveChangesAsync(stoppingToken);
    }

    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">PublishMessage</span>(<span class="hljs-params">ServiceBusSender sender, OutboxMessage outboxMessage, CancellationToken ct</span>)</span>
    {
        <span class="hljs-keyword">var</span> message = <span class="hljs-keyword">new</span> ServiceBusMessage(outboxMessage.PayloadJson)
        {
            Subject = outboxMessage.Type,
            MessageId = outboxMessage.Id.ToString(),
            ContentType = <span class="hljs-string">&quot;application/json&quot;</span>
        };

        <span class="hljs-keyword">await</span> sender.SendMessageAsync(message, ct);
        log.LogInformation(<span class="hljs-string">&quot;Published message {MessageId} of type {Type}&quot;</span>, 
            outboxMessage.Id, outboxMessage.Type);
    }

    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">HandlePublishFailure</span>(<span class="hljs-params">OutboxMessage message, Exception ex</span>)</span>
    {
        message.AttemptCount++;
        message.LastAttemptUtc = DateTime.UtcNow;

        log.LogWarning(ex, <span class="hljs-string">&quot;Failed to publish message {MessageId} (attempt {AttemptCount})&quot;</span>, 
            message.Id, message.AttemptCount);

        <span class="hljs-comment">// Implement exponential backoff or dead letter logic here</span>
        <span class="hljs-keyword">if</span> (message.AttemptCount &gt;= <span class="hljs-number">5</span>)
        {
            log.LogError(<span class="hljs-string">&quot;Message {MessageId} exceeded max retry attempts&quot;</span>, message.Id);
            <span class="hljs-comment">// Consider moving to dead letter storage</span>
        }
    }
}</code></pre>
<h3 id="step-5-configure-dependency-injection">Step 5: Configure Dependency Injection</h3>
<p>Register all components in your DI container:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">var</span> builder = WebApplication.CreateBuilder(<span class="hljs-keyword">args</span>);

<span class="hljs-comment">// Database</span>
builder.Services.AddDbContext&lt;AppDb&gt;(opt =&gt; 
    opt.UseSqlite(builder.Configuration.GetConnectionString(<span class="hljs-string">&quot;AppDb&quot;</span>)));

<span class="hljs-comment">// Message Broker</span>
builder.Services.AddSingleton(sp =&gt;
{
    <span class="hljs-keyword">var</span> cfg = sp.GetRequiredService&lt;IConfiguration&gt;();
    <span class="hljs-keyword">var</span> conn = cfg[<span class="hljs-string">&quot;ServiceBus:ConnectionString&quot;</span>]!;
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> ServiceBusClient(conn);
});

<span class="hljs-comment">// Background Service</span>
builder.Services.AddHostedService&lt;OutboxPublisher&gt;();

<span class="hljs-comment">// HTTP Clients for external services</span>
builder.Services.AddHttpClient(<span class="hljs-string">&quot;Inventory&quot;</span>, c =&gt;
{
    c.BaseAddress = <span class="hljs-keyword">new</span> Uri(builder.Configuration[<span class="hljs-string">&quot;Services:Inventory&quot;</span>]!);
});
builder.Services.AddHttpClient(<span class="hljs-string">&quot;Payments&quot;</span>, c =&gt;
{
    c.BaseAddress = <span class="hljs-keyword">new</span> Uri(builder.Configuration[<span class="hljs-string">&quot;Services:Payments&quot;</span>]!);
});

<span class="hljs-keyword">var</span> app = builder.Build();

<span class="hljs-comment">// Ensure database is created</span>
<span class="hljs-keyword">await</span> <span class="hljs-keyword">using</span> (<span class="hljs-keyword">var</span> scope = app.Services.CreateAsyncScope())
{
    <span class="hljs-keyword">var</span> db = scope.ServiceProvider.GetRequiredService&lt;AppDb&gt;();
    <span class="hljs-keyword">await</span> db.Database.EnsureCreatedAsync();
}</code></pre>
<h3 id="step-6-add-monitoring-and-observability">Step 6: Add Monitoring and Observability</h3>
<p>Create an endpoint to monitor outbox health:</p>
<pre class="blog-code"><code class="hljs language-csharp">app.MapGet(<span class="hljs-string">&quot;/outbox&quot;</span>, <span class="hljs-keyword">async</span> (AppDb db) =&gt;
{
    <span class="hljs-keyword">var</span> messages = <span class="hljs-keyword">await</span> db.Outbox
        .AsNoTracking()
        .OrderBy(x =&gt; x.OccurredUtc)
        .ToListAsync();

    <span class="hljs-keyword">return</span> Results.Ok(messages.Select(m =&gt; <span class="hljs-keyword">new</span> 
    { 
        m.Id, 
        m.Type, 
        m.AttemptCount, 
        m.LastAttemptUtc, 
        m.OccurredUtc,
        Status = m.AttemptCount == <span class="hljs-number">0</span> ? <span class="hljs-string">&quot;Pending&quot;</span> : 
                m.AttemptCount &lt; <span class="hljs-number">5</span> ? <span class="hljs-string">&quot;Retrying&quot;</span> : <span class="hljs-string">&quot;Failed&quot;</span>
    }));
});

app.MapGet(<span class="hljs-string">&quot;/outbox/health&quot;</span>, <span class="hljs-keyword">async</span> (AppDb db) =&gt;
{
    <span class="hljs-keyword">var</span> stats = <span class="hljs-keyword">await</span> db.Outbox
        .GroupBy(m =&gt; m.AttemptCount == <span class="hljs-number">0</span> ? <span class="hljs-string">&quot;Pending&quot;</span> : 
                     m.AttemptCount &lt; <span class="hljs-number">5</span> ? <span class="hljs-string">&quot;Retrying&quot;</span> : <span class="hljs-string">&quot;Failed&quot;</span>)
        .Select(g =&gt; <span class="hljs-keyword">new</span> { Status = g.Key, Count = g.Count() })
        .ToListAsync();

    <span class="hljs-keyword">var</span> oldestPending = <span class="hljs-keyword">await</span> db.Outbox
        .Where(m =&gt; m.AttemptCount == <span class="hljs-number">0</span>)
        .OrderBy(m =&gt; m.OccurredUtc)
        .Select(m =&gt; m.OccurredUtc)
        .FirstOrDefaultAsync();

    <span class="hljs-keyword">return</span> Results.Ok(<span class="hljs-keyword">new</span> 
    { 
        Statistics = stats,
        OldestPendingMessage = oldestPending,
        HealthStatus = oldestPending == <span class="hljs-literal">default</span> || 
                      DateTime.UtcNow - oldestPending &lt; TimeSpan.FromMinutes(<span class="hljs-number">5</span>) 
                      ? <span class="hljs-string">&quot;Healthy&quot;</span> : <span class="hljs-string">&quot;Warning&quot;</span>
    });
});</code></pre>
<h3 id="requirements-for-success">Requirements for Success</h3>
<h3 id="1-transactional-database-support">1. Transactional Database Support</h3>
<p>The Outbox Pattern <strong>requires</strong> a database that supports ACID transactions. This includes:</p>
<ul>
<li><strong>Relational databases</strong>: PostgreSQL, SQL Server, MySQL, SQLite</li>
<li><strong>Document databases with transactions</strong>: MongoDB (with replica sets), Azure Cosmos DB</li>
</ul>
<p><strong>Note</strong>: Simple key-value stores or eventually consistent databases won&#39;t work for this pattern.</p>
<h3 id="2-exactly-once-processing-logic">2. Exactly-Once Processing Logic</h3>
<p>Your business logic must be <strong>idempotent</strong> to handle potential duplicate processing:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Always check for existing records</span>
<span class="hljs-keyword">var</span> existing = <span class="hljs-keyword">await</span> db.Orders.FirstOrDefaultAsync(o =&gt; o.Id == orderId);
<span class="hljs-keyword">if</span> (existing != <span class="hljs-literal">null</span>)
{
    <span class="hljs-keyword">return</span> existing; <span class="hljs-comment">// Idempotent response</span>
}

<span class="hljs-comment">// Proceed with creation only if not exists</span>
<span class="hljs-keyword">var</span> order = <span class="hljs-keyword">new</span> OrderRecord { Id = orderId, <span class="hljs-comment">/* ... */</span> };</code></pre>
<h3 id="3-message-ordering-considerations">3. Message Ordering Considerations</h3>
<p>If message order matters, consider these strategies:</p>
<ul>
<li><strong>Partition by entity</strong>: Group related messages using partition keys</li>
<li><strong>Sequence numbers</strong>: Add ordering metadata to messages</li>
<li><strong>Single-threaded processing</strong>: Process messages sequentially (impacts throughput)</li>
</ul>
<h3 id="4-error-handling-and-dead-letter-management">4. Error Handling and Dead Letter Management</h3>
<p>Implement robust retry logic with exponential backoff:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">readonly</span> TimeSpan[] RetryDelays = 
{
    TimeSpan.FromSeconds(<span class="hljs-number">1</span>),
    TimeSpan.FromSeconds(<span class="hljs-number">5</span>),
    TimeSpan.FromSeconds(<span class="hljs-number">30</span>),
    TimeSpan.FromMinutes(<span class="hljs-number">5</span>),
    TimeSpan.FromMinutes(<span class="hljs-number">30</span>)
};

<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-built_in">bool</span> <span class="hljs-title">ShouldRetry</span>(<span class="hljs-params">OutboxMessage message</span>)</span>
{
    <span class="hljs-keyword">if</span> (message.AttemptCount &gt;= RetryDelays.Length)
        <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;

    <span class="hljs-keyword">var</span> nextRetryTime = message.LastAttemptUtc?.Add(RetryDelays[message.AttemptCount - <span class="hljs-number">1</span>]);
    <span class="hljs-keyword">return</span> nextRetryTime == <span class="hljs-literal">null</span> || DateTime.UtcNow &gt;= nextRetryTime;
}</code></pre>
<h3 id="5-performance-and-scalability">5. Performance and Scalability</h3>
<p>For high-throughput scenarios:</p>
<ul>
<li><strong>Batch processing</strong>: Process multiple messages in each iteration</li>
<li><strong>Parallel processing</strong>: Use multiple worker threads with proper coordination</li>
<li><strong>Database indexes</strong>: Ensure efficient querying of pending messages</li>
<li><strong>Message archival</strong>: Remove or archive successfully processed messages</li>
</ul>
<h2 id="words-of-caution">Words of Caution</h2>
<h3 id="1-storage-growth-management">1. Storage Growth Management</h3>
<p><strong>Critical Warning</strong>: The outbox table can grow rapidly in high-volume systems. Without proper cleanup, it can:</p>
<ul>
<li>Consume excessive storage space</li>
<li>Degrade query performance</li>
<li>Impact application startup times</li>
</ul>
<p><strong>Solution</strong>: Implement message cleanup after successful publishing:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Remove successful messages immediately</span>
db.Outbox.Remove(message);

<span class="hljs-comment">// Or archive for audit purposes</span>
message.ProcessedAt = DateTime.UtcNow;
message.Status = <span class="hljs-string">&quot;Completed&quot;</span>;</code></pre>
<h3 id="2-message-broker-dependency">2. Message Broker Dependency</h3>
<p>The background publisher creates a dependency on your message broker. Consider:</p>
<ul>
<li><strong>Circuit breaker patterns</strong> for broker outages</li>
<li><strong>Graceful degradation</strong> when messaging is unavailable</li>
<li><strong>Health checks</strong> to monitor broker connectivity</li>
</ul>
<h3 id="3-delayed-message-processing">3. Delayed Message Processing</h3>
<p>Messages aren&#39;t published immediately; there&#39;s a delay based on your polling interval. This means:</p>
<ul>
<li><strong>Near real-time requirements</strong> may not be suitable for this pattern</li>
<li><strong>Dependent services</strong> must handle eventual consistency</li>
<li><strong>User expectations</strong> should account for processing delays</li>
</ul>
<h3 id="4-database-transaction-size">4. Database Transaction Size</h3>
<p>Including outbox messages in business transactions can:</p>
<ul>
<li><strong>Increase transaction size</strong> and duration</li>
<li><strong>Impact database performance</strong> under high load</li>
<li><strong>Create contention</strong> on the outbox table</li>
</ul>
<p><strong>Mitigation</strong>: Consider using separate transactions for outbox writes in extreme high-volume scenarios, accepting the risk of lost messages for performance gains.</p>
<h3 id="5-message-duplication-scenarios">5. Message Duplication Scenarios</h3>
<p>Despite best efforts, duplicate messages can occur due to:</p>
<ul>
<li><strong>Publisher retries</strong> after timeout (message was actually sent)</li>
<li><strong>Database connection issues</strong> during acknowledgment</li>
<li><strong>Process crashes</strong> between publishing and cleanup</li>
</ul>
<p><strong>Solution</strong>: Always implement idempotent message handlers:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">HandleOrderCreated</span>(<span class="hljs-params">OrderCreated orderEvent</span>)</span>
{
    <span class="hljs-comment">// Check if already processed</span>
    <span class="hljs-keyword">var</span> existing = <span class="hljs-keyword">await</span> db.ProcessedEvents
        .FirstOrDefaultAsync(e =&gt; e.EventId == orderEvent.Id);

    <span class="hljs-keyword">if</span> (existing != <span class="hljs-literal">null</span>)
    {
        log.LogInformation(<span class="hljs-string">&quot;Event {EventId} already processed&quot;</span>, orderEvent.Id);
        <span class="hljs-keyword">return</span>; <span class="hljs-comment">// Idempotent exit</span>
    }

    <span class="hljs-comment">// Process the event</span>
    <span class="hljs-keyword">await</span> ProcessOrder(orderEvent);

    <span class="hljs-comment">// Record processing</span>
    db.ProcessedEvents.Add(<span class="hljs-keyword">new</span> ProcessedEvent 
    { 
        EventId = orderEvent.Id, 
        ProcessedAt = DateTime.UtcNow 
    });

    <span class="hljs-keyword">await</span> db.SaveChangesAsync();
}</code></pre>
<h2 id="conclusion">Conclusion</h2>
<p>The Outbox Pattern is a powerful solution for ensuring reliable message delivery in distributed systems. When implemented correctly, it provides:</p>
<ul>
<li><strong>Guaranteed message delivery</strong> with exactly-once semantics</li>
<li><strong>Data consistency</strong> between local and distributed state</li>
<li><strong>Resilience</strong> against partial failures</li>
<li><strong>Auditability</strong> of all system events</li>
</ul>
<p>However, success requires careful attention to transactional boundaries, idempotency, error handling, and performance considerations. The pattern is particularly valuable in mission-critical systems where data consistency and reliability are paramount.</p>
<p>Remember that the Outbox Pattern is one tool in the distributed systems toolkit. Evaluate whether its benefits outweigh the added complexity for your specific use case. For simple scenarios with relaxed consistency requirements, direct message publishing might suffice. For complex, business-critical systems, the Outbox Pattern provides the reliability guarantees that enable robust distributed architectures.</p>
<p>By following this guide and heeding the cautions outlined, you&#39;ll be well-equipped to implement a production-ready Outbox Pattern that serves as a reliable foundation for your microservices communication strategy.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Lessons Learned: Building Cloud-Native Microservices with ASP.NET Core 9 for Our Enterprise Client</title>
            <link>https://www.architect4hire.com/blog/lessons-learned-building-cloud-native-microservices-with-asp-net-core-9-for-our-enterprise-client</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/lessons-learned-building-cloud-native-microservices-with-asp-net-core-9-for-our-enterprise-client</guid>
            <pubDate>Mon, 21 Jul 2025 12:40:44 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>&quot;Our system is slow, hard to scale, and every deployment is a nail-biting experience.&quot; Six months of modernizing a retail client&apos;s monolithic e-commerce platform — and the lessons that only show up in production.</description>
            <content:encoded><![CDATA[<p><em>A Real-World Journey from Requirements to Production</em></p>
<p>Hey fellow developers and architects! 👋</p>
<p>I wanted to share some insights from a recent project we completed for a major retail client who needed to modernize their monolithic e-commerce platform. They came to us with a classic story: &quot;Our system is slow, hard to scale, and every deployment is a nail-biting experience.&quot; Sound familiar?</p>
<p>After six months of intense development, testing, and deployment, we successfully delivered a cloud-native microservices architecture using ASP.NET Core 9, .NET Aspire, and Kubernetes. Here are the key lessons we learned along the way – the good, the challenging, and the &quot;wish we&#39;d known this earlier&quot; moments.</p>
<h2 id="the-client-challenge-why-we-chose-asp-net-core-9">The Client Challenge: Why We Chose ASP.NET Core 9</h2>
<p>Our client, a mid-size retail company, was struggling with their legacy .NET Framework application. Peak traffic during sales events would bring their system to its knees, and adding new features required coordinating deployments across multiple teams. They needed something modern, scalable, and maintainable.</p>
<p>After evaluating several options, we landed on ASP.NET Core 9 for several compelling reasons:</p>
<ul>
<li><strong>Performance</strong>: The client&#39;s existing .NET expertise meant easier team adoption</li>
<li><strong>Container-First Approach</strong>: Perfect fit for their cloud-native aspirations</li>
<li><strong>Microsoft Ecosystem</strong>: They were already invested in Azure</li>
<li><strong>Long-term Support</strong>: .NET 9&#39;s roadmap aligned with their strategic plans</li>
</ul>
<p>But here&#39;s the first lesson: <strong>choose your stack based on team capabilities, not just technical superiority</strong>. We could have gone with Go or Node.js, but the learning curve would have extended our timeline by months.</p>
<h2 id="lesson-1-net-aspire-is-a-game-changer-but-mind-the-learning-curve">Lesson #1: .NET Aspire is a Game-Changer (But Mind the Learning Curve)</h2>
<p>When we first heard about .NET Aspire, I&#39;ll be honest – I was skeptical. &quot;Another framework to learn?&quot; But after our initial spike, it became clear this wasn&#39;t just hype.</p>
<h3 id="what-aspire-got-right">What Aspire Got Right</h3>
<p>Here&#39;s how we structured our Aspire host for the client&#39;s catalog and inventory services:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">var</span> builder = DistributedApplication.CreateBuilder(<span class="hljs-keyword">args</span>);

<span class="hljs-comment">// Infrastructure components</span>
<span class="hljs-keyword">var</span> postgres = builder.AddPostgres(<span class="hljs-string">&quot;postgres&quot;</span>, port: <span class="hljs-number">5432</span>)
    .WithDatabase(<span class="hljs-string">&quot;catalogdb&quot;</span>)
    .WithDatabase(<span class="hljs-string">&quot;inventorydb&quot;</span>);

<span class="hljs-keyword">var</span> redis = builder.AddRedis(<span class="hljs-string">&quot;cache&quot;</span>);

<span class="hljs-keyword">var</span> serviceBus = builder.AddAzureServiceBus(<span class="hljs-string">&quot;messaging&quot;</span>);

<span class="hljs-comment">// Our microservices</span>
<span class="hljs-keyword">var</span> catalogApi = builder.AddProject&lt;Projects.CatalogApi&gt;(<span class="hljs-string">&quot;catalog-api&quot;</span>)
    .WithReference(postgres.GetDatabase(<span class="hljs-string">&quot;catalogdb&quot;</span>))
    .WithReference(redis)
    .WithReference(serviceBus);

<span class="hljs-keyword">var</span> inventoryApi = builder.AddProject&lt;Projects.InventoryApi&gt;(<span class="hljs-string">&quot;inventory-api&quot;</span>)
    .WithReference(postgres.GetDatabase(<span class="hljs-string">&quot;inventorydb&quot;</span>))
    .WithReference(redis)
    .WithReference(serviceBus);

<span class="hljs-keyword">var</span> webApp = builder.AddProject&lt;Projects.WebApp&gt;(<span class="hljs-string">&quot;webapp&quot;</span>)
    .WithReference(catalogApi)
    .WithReference(inventoryApi)
    .WithExternalHttpEndpoints();

builder.Build().Run();</code></pre>
<p><strong>The Magic</strong>: With just this configuration, we got:</p>
<ul>
<li>Automatic service discovery</li>
<li>Centralized configuration management</li>
<li>Built-in observability with OpenTelemetry</li>
<li>A gorgeous dashboard for local development</li>
</ul>
<p><strong>The Reality Check</strong>: Our junior developers initially struggled with the &quot;magic.&quot; When things worked, they worked beautifully. When they didn&#39;t, debugging required understanding several abstraction layers.</p>
<p><strong>Lesson Learned</strong>: Invest time upfront training your team on Aspire&#39;s internals. The productivity gains are massive once everyone understands what&#39;s happening under the hood.</p>
<h2 id="lesson-2-container-optimization-matters-more-than-you-think">Lesson #2: Container Optimization Matters More Than You Think</h2>
<p>One of our early mistakes was treating containerization as an afterthought. &quot;We&#39;ll just add a Dockerfile at the end,&quot; we thought. Wrong approach.</p>
<h3 id="the-problem-we-hit">The Problem We Hit</h3>
<p>Our initial container images were over 1GB, and startup times were exceeding 30 seconds in production. For a retail client expecting sub-second response times, this was unacceptable.</p>
<h3 id="the-solution-multi-stage-builds-and-alpine-images">The Solution: Multi-Stage Builds and Alpine Images</h3>
<p>Here&#39;s the optimized Dockerfile we ended up with:</p>
<pre class="blog-code"><code class="hljs language-dockerfile"><span class="hljs-comment"># Build stage</span>
<span class="hljs-keyword">FROM</span> mcr.microsoft.com/dotnet/sdk:<span class="hljs-number">9.0</span>-alpine AS build
<span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /src</span>

<span class="hljs-comment"># Copy project files and restore dependencies</span>
<span class="hljs-keyword">COPY</span><span class="language-bash"> [<span class="hljs-string">&quot;CatalogApi/CatalogApi.csproj&quot;</span>, <span class="hljs-string">&quot;CatalogApi/&quot;</span>]</span>
<span class="hljs-keyword">COPY</span><span class="language-bash"> [<span class="hljs-string">&quot;CatalogApi.ServiceDefaults/CatalogApi.ServiceDefaults.csproj&quot;</span>, <span class="hljs-string">&quot;CatalogApi.ServiceDefaults/&quot;</span>]</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> dotnet restore <span class="hljs-string">&quot;CatalogApi/CatalogApi.csproj&quot;</span></span>

<span class="hljs-comment"># Copy source and build</span>
<span class="hljs-keyword">COPY</span><span class="language-bash"> . .</span>
<span class="hljs-keyword">WORKDIR</span><span class="language-bash"> <span class="hljs-string">&quot;/src/CatalogApi&quot;</span></span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> dotnet build <span class="hljs-string">&quot;CatalogApi.csproj&quot;</span> -c Release -o /app/build</span>

<span class="hljs-comment"># Publish stage</span>
<span class="hljs-keyword">FROM</span> build AS publish
<span class="hljs-keyword">RUN</span><span class="language-bash"> dotnet publish <span class="hljs-string">&quot;CatalogApi.csproj&quot;</span> -c Release -o /app/publish /p:UseAppHost=<span class="hljs-literal">false</span></span>

<span class="hljs-comment"># Runtime stage</span>
<span class="hljs-keyword">FROM</span> mcr.microsoft.com/dotnet/aspnet:<span class="hljs-number">9.0</span>-alpine AS final
<span class="hljs-keyword">RUN</span><span class="language-bash"> apk add --no-cache icu-libs</span>
<span class="hljs-keyword">ENV</span> DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
<span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /app</span>
<span class="hljs-keyword">COPY</span><span class="language-bash"> --from=publish /app/publish .</span>

<span class="hljs-comment"># Create non-root user</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> adduser -D -s /bin/sh appuser</span>
<span class="hljs-keyword">USER</span> appuser

<span class="hljs-keyword">EXPOSE</span> <span class="hljs-number">8080</span>
<span class="hljs-keyword">ENTRYPOINT</span><span class="language-bash"> [<span class="hljs-string">&quot;dotnet&quot;</span>, <span class="hljs-string">&quot;CatalogApi.dll&quot;</span>]</span></code></pre>
<p><strong>Results</strong>: Image size dropped to 180MB, startup time reduced to under 5 seconds.</p>
<p><strong>Lesson Learned</strong>: Container optimization isn&#39;t just about size – it&#39;s about security, startup time, and resource utilization. Start optimizing from day one, not as an afterthought.</p>
<h2 id="lesson-3-service-communication-strategy-is-critical">Lesson #3: Service Communication Strategy is Critical</h2>
<p>Initially, we went HTTP-everywhere. Every service talked to every other service via HTTP APIs. This seemed clean and RESTful, but we quickly ran into issues during peak loads.</p>
<h3 id="the-http-only-approach-our-first-attempt">The HTTP-Only Approach (Our First Attempt)</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// This seemed fine initially...</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> HttpClient _inventoryClient;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> HttpClient _catalogClient;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> HttpClient _paymentClient;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;OrderResult&gt; <span class="hljs-title">CreateOrderAsync</span>(<span class="hljs-params">CreateOrderRequest request</span>)</span>
    {
        <span class="hljs-comment">// Check inventory</span>
        <span class="hljs-keyword">var</span> inventory = <span class="hljs-keyword">await</span> _inventoryClient.GetAsync(<span class="hljs-string">$&quot;api/inventory/<span class="hljs-subst">{request.ProductId}</span>&quot;</span>);
        
        <span class="hljs-comment">// Get product details</span>
        <span class="hljs-keyword">var</span> product = <span class="hljs-keyword">await</span> _catalogClient.GetAsync(<span class="hljs-string">$&quot;api/products/<span class="hljs-subst">{request.ProductId}</span>&quot;</span>);
        
        <span class="hljs-comment">// Process payment</span>
        <span class="hljs-keyword">var</span> payment = <span class="hljs-keyword">await</span> _paymentClient.PostAsync(<span class="hljs-string">&quot;api/payments&quot;</span>, paymentData);
        
        <span class="hljs-comment">// Create order...</span>
    }
}</code></pre>
<p><strong>The Problem</strong>: During Black Friday testing, we discovered cascade failures. One slow service would cause timeouts across the entire chain.</p>
<h3 id="the-hybrid-approach-our-solution">The Hybrid Approach (Our Solution)</h3>
<p>We implemented a hybrid communication strategy:</p>
<p><strong>Synchronous (HTTP) for</strong>:</p>
<ul>
<li>User-facing operations requiring immediate response</li>
<li>Operations needing strong consistency</li>
</ul>
<p><strong>Asynchronous (Message Bus) for</strong>:</p>
<ul>
<li>Business events and notifications</li>
<li>Operations that can be eventually consistent</li>
</ul>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IInventoryService _inventoryService;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IMessageBus _messageBus;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;OrderResult&gt; <span class="hljs-title">CreateOrderAsync</span>(<span class="hljs-params">CreateOrderRequest request</span>)</span>
    {
        <span class="hljs-comment">// Sync: Check inventory (needs immediate response)</span>
        <span class="hljs-keyword">var</span> inventoryStatus = <span class="hljs-keyword">await</span> _inventoryService.CheckAvailabilityAsync(request.ProductId);
        
        <span class="hljs-keyword">if</span> (!inventoryStatus.IsAvailable)
            <span class="hljs-keyword">return</span> OrderResult.Failed(<span class="hljs-string">&quot;Product not available&quot;</span>);

        <span class="hljs-comment">// Create order first</span>
        <span class="hljs-keyword">var</span> order = <span class="hljs-keyword">await</span> CreateOrderRecordAsync(request);

        <span class="hljs-comment">// Async: Publish events for other services to handle</span>
        <span class="hljs-keyword">await</span> _messageBus.PublishAsync(<span class="hljs-keyword">new</span> OrderCreatedEvent 
        { 
            OrderId = order.Id,
            ProductId = request.ProductId,
            Quantity = request.Quantity,
            CustomerId = request.CustomerId
        });

        <span class="hljs-keyword">return</span> OrderResult.Success(order.Id);
    }
}</code></pre>
<p><strong>Lesson Learned</strong>: Don&#39;t default to all-sync or all-async. Choose the right communication pattern based on business requirements, not technical preferences.</p>
<h2 id="lesson-4-observability-can-and-39-t-be-bolted-on-later">Lesson #4: Observability Can&#39;t Be Bolted On Later</h2>
<p>We initially focused on getting features working, planning to &quot;add monitoring later.&quot; This was a mistake that cost us significant debugging time during UAT.</p>
<h3 id="what-we-should-have-done-from-day-one">What We Should Have Done From Day One</h3>
<p>Aspire gives you observability out of the box, but you need to instrument your business logic properly:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">ProductService</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">readonly</span> ActivitySource ActivitySource = <span class="hljs-keyword">new</span>(<span class="hljs-string">&quot;CatalogApi.Products&quot;</span>);
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">readonly</span> Counter&lt;<span class="hljs-built_in">int</span>&gt; ProductsCreated = 
        Meter.CreateCounter&lt;<span class="hljs-built_in">int</span>&gt;(<span class="hljs-string">&quot;products_created_total&quot;</span>);
    
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;Product&gt; <span class="hljs-title">CreateProductAsync</span>(<span class="hljs-params">CreateProductRequest request</span>)</span>
    {
        <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> activity = ActivitySource.StartActivity(<span class="hljs-string">&quot;CreateProduct&quot;</span>);
        activity?.SetTag(<span class="hljs-string">&quot;product.category&quot;</span>, request.Category);
        activity?.SetTag(<span class="hljs-string">&quot;product.price&quot;</span>, request.Price);

        <span class="hljs-keyword">try</span> 
        {
            <span class="hljs-keyword">var</span> product = <span class="hljs-keyword">new</span> Product 
            {
                Name = request.Name,
                Category = request.Category,
                Price = request.Price
            };

            <span class="hljs-keyword">await</span> _context.Products.AddAsync(product);
            <span class="hljs-keyword">await</span> _context.SaveChangesAsync();

            ProductsCreated.Add(<span class="hljs-number">1</span>, 
                <span class="hljs-keyword">new</span> KeyValuePair&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">object</span>&gt;(<span class="hljs-string">&quot;category&quot;</span>, request.Category));

            _logger.LogInformation(<span class="hljs-string">&quot;Product created: {ProductId} in category {Category}&quot;</span>, 
                product.Id, product.Category);

            activity?.SetStatus(ActivityStatusCode.Ok);
            <span class="hljs-keyword">return</span> product;
        }
        <span class="hljs-keyword">catch</span> (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            _logger.LogError(ex, <span class="hljs-string">&quot;Failed to create product&quot;</span>);
            <span class="hljs-keyword">throw</span>;
        }
    }
}</code></pre>
<p><strong>The Aspire Dashboard Advantage</strong>: During development, we could see all our traces, metrics, and logs in one place. No more switching between multiple tools or guessing which service was causing issues.</p>
<p><strong>Lesson Learned</strong>: Build observability into your code from the first commit. The Aspire dashboard makes local development debugging incredibly efficient, and the same instrumentation works in production.</p>
<h2 id="lesson-5-kubernetes-deployment-complexity-is-real">Lesson #5: Kubernetes Deployment Complexity is Real</h2>
<p>Moving from local Aspire development to Kubernetes production was our biggest challenge. The gap between &quot;works on my machine with Aspire&quot; and &quot;works in Kubernetes&quot; was larger than expected.</p>
<h3 id="the-configuration-management-challenge">The Configuration Management Challenge</h3>
<p>In Aspire, configuration &quot;just works&quot;:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// This works magically in Aspire</span>
builder.AddNpgsqlDbContext&lt;CatalogDbContext&gt;(<span class="hljs-string">&quot;catalogdb&quot;</span>);</code></pre>
<p>In Kubernetes, you need explicit configuration:</p>
<pre class="blog-code"><code class="hljs language-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">ConfigMap</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">catalog-api-config</span>
<span class="hljs-attr">data:</span>
  <span class="hljs-attr">ConnectionStrings__CatalogDb:</span> <span class="hljs-string">&quot;Host=postgres-service;Port=5432;Database=catalogdb;Username=cataloguser&quot;</span>
  <span class="hljs-attr">Redis__ConnectionString:</span> <span class="hljs-string">&quot;redis-service:6379&quot;</span>
  <span class="hljs-attr">ServiceBus__ConnectionString:</span> <span class="hljs-string">&quot;Endpoint=sb://myservicebus.servicebus.windows.net/&quot;</span>
<span class="hljs-meta">---</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">catalog-api</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">replicas:</span> <span class="hljs-number">3</span>
  <span class="hljs-attr">template:</span>
    <span class="hljs-attr">spec:</span>
      <span class="hljs-attr">containers:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">catalog-api</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">myregistry.azurecr.io/catalog-api:latest</span>
        <span class="hljs-attr">envFrom:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">configMapRef:</span>
            <span class="hljs-attr">name:</span> <span class="hljs-string">catalog-api-config</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">secretRef:</span>
            <span class="hljs-attr">name:</span> <span class="hljs-string">catalog-api-secrets</span>
        <span class="hljs-attr">ports:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">8080</span>
        <span class="hljs-attr">livenessProbe:</span>
          <span class="hljs-attr">httpGet:</span>
            <span class="hljs-attr">path:</span> <span class="hljs-string">/health/live</span>
            <span class="hljs-attr">port:</span> <span class="hljs-number">8080</span>
          <span class="hljs-attr">initialDelaySeconds:</span> <span class="hljs-number">30</span>
        <span class="hljs-attr">readinessProbe:</span>
          <span class="hljs-attr">httpGet:</span>
            <span class="hljs-attr">path:</span> <span class="hljs-string">/health/ready</span>
            <span class="hljs-attr">port:</span> <span class="hljs-number">8080</span>
          <span class="hljs-attr">initialDelaySeconds:</span> <span class="hljs-number">10</span></code></pre>
<h3 id="health-checks-saved-our-deployment">Health Checks Saved Our Deployment</h3>
<p>We learned the hard way that proper health checks are essential:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// In Program.cs</span>
builder.Services.AddHealthChecks()
    .AddCheck(<span class="hljs-string">&quot;self&quot;</span>, () =&gt; HealthCheckResult.Healthy())
    .AddNpgSql(connectionString, name: <span class="hljs-string">&quot;database&quot;</span>)
    .AddRedis(redisConnectionString, name: <span class="hljs-string">&quot;redis&quot;</span>)
    .AddCheck&lt;InventoryServiceHealthCheck&gt;(<span class="hljs-string">&quot;inventory-service&quot;</span>);

app.MapHealthChecks(<span class="hljs-string">&quot;/health/live&quot;</span>, <span class="hljs-keyword">new</span> HealthCheckOptions
{
    Predicate = check =&gt; check.Name == <span class="hljs-string">&quot;self&quot;</span>
});

app.MapHealthChecks(<span class="hljs-string">&quot;/health/ready&quot;</span>, <span class="hljs-keyword">new</span> HealthCheckOptions
{
    Predicate = check =&gt; check.Tags.Contains(<span class="hljs-string">&quot;ready&quot;</span>)
});</code></pre>
<p><strong>Lesson Learned</strong>: Aspire generates Kubernetes manifests, but you&#39;ll still need to understand and customize them. Don&#39;t assume the generated manifests are production-ready without review.</p>
<h2 id="lesson-6-performance-testing-early-prevents-late-surprises">Lesson #6: Performance Testing Early Prevents Late Surprises</h2>
<p>We thought our microservices would naturally be more performant than the monolith. We were wrong.</p>
<h3 id="the-latency-discovery">The Latency Discovery</h3>
<p>During load testing, we discovered that our microservices architecture had introduced significant latency:</p>
<ul>
<li><strong>Monolith</strong>: Average response time 200ms</li>
<li><strong>Our Microservices</strong>: Average response time 800ms</li>
</ul>
<p>The culprit? Network calls and serialization overhead.</p>
<h3 id="our-performance-recovery-strategy">Our Performance Recovery Strategy</h3>
<ol>
<li><strong>Caching Strategy</strong>: Implemented Redis caching at multiple levels</li>
</ol>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">CachedProductService</span> : <span class="hljs-title">IProductService</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IProductService _productService;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IDistributedCache _cache;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> TimeSpan _cacheDuration = TimeSpan.FromMinutes(<span class="hljs-number">15</span>);

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;Product&gt; <span class="hljs-title">GetProductAsync</span>(<span class="hljs-params"><span class="hljs-built_in">int</span> productId</span>)</span>
    {
        <span class="hljs-keyword">var</span> cacheKey = <span class="hljs-string">$&quot;product:<span class="hljs-subst">{productId}</span>&quot;</span>;
        <span class="hljs-keyword">var</span> cachedProduct = <span class="hljs-keyword">await</span> _cache.GetStringAsync(cacheKey);
        
        <span class="hljs-keyword">if</span> (cachedProduct != <span class="hljs-literal">null</span>)
            <span class="hljs-keyword">return</span> JsonSerializer.Deserialize&lt;Product&gt;(cachedProduct);

        <span class="hljs-keyword">var</span> product = <span class="hljs-keyword">await</span> _productService.GetProductAsync(productId);
        
        <span class="hljs-keyword">if</span> (product != <span class="hljs-literal">null</span>)
        {
            <span class="hljs-keyword">var</span> serializedProduct = JsonSerializer.Serialize(product);
            <span class="hljs-keyword">await</span> _cache.SetStringAsync(cacheKey, serializedProduct, 
                <span class="hljs-keyword">new</span> DistributedCacheEntryOptions 
                { 
                    AbsoluteExpirationRelativeToNow = _cacheDuration 
                });
        }

        <span class="hljs-keyword">return</span> product;
    }
}</code></pre>
<ol>
<li><strong>Connection Pooling</strong>: Properly configured database connections</li>
</ol>
<pre class="blog-code"><code class="hljs language-csharp">builder.Services.AddNpgsqlDbContext&lt;CatalogDbContext&gt;(connectionString, options =&gt;
{
    options.EnableSensitiveDataLogging(<span class="hljs-literal">false</span>);
    options.EnableServiceProviderCaching();
    options.EnableDetailedErrors(<span class="hljs-literal">false</span>);
});

<span class="hljs-comment">// Configure connection pool</span>
builder.Services.Configure&lt;NpgsqlDbContextOptionsBuilder&gt;(options =&gt;
{
    options.EnableRetryOnFailure(maxRetryCount: <span class="hljs-number">3</span>, maxRetryDelay: TimeSpan.FromSeconds(<span class="hljs-number">5</span>), <span class="hljs-literal">null</span>);
});</code></pre>
<ol>
<li><strong>Async All the Way</strong>: Eliminated blocking calls</li>
</ol>
<p><strong>Final Results</strong>: Brought average response time down to 250ms – better than the original monolith.</p>
<p><strong>Lesson Learned</strong>: Microservices don&#39;t automatically mean better performance. You need to be intentional about latency, caching, and resource utilization.</p>
<h2 id="lesson-7-the-deployment-pipeline-is-as-important-as-the-code">Lesson #7: The Deployment Pipeline is as Important as the Code</h2>
<p>Setting up a reliable CI/CD pipeline took almost as much effort as writing the application code, but it was absolutely worth it.</p>
<h3 id="our-final-pipeline-strategy">Our Final Pipeline Strategy</h3>
<pre class="blog-code"><code class="hljs language-yaml"><span class="hljs-comment"># .github/workflows/deploy-production.yml</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">Production</span> <span class="hljs-string">Deployment</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span> [<span class="hljs-string">main</span>]
    <span class="hljs-attr">tags:</span> [<span class="hljs-string">&#x27;v*&#x27;</span>]

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">test:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">.NET</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-dotnet@v4</span>
      <span class="hljs-attr">with:</span>
        <span class="hljs-attr">dotnet-version:</span> <span class="hljs-string">&#x27;9.0.x&#x27;</span>
    
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">tests</span>
      <span class="hljs-attr">run:</span> <span class="hljs-string">|
        dotnet restore
        dotnet test --configuration Release --logger trx --collect:&quot;XPlat Code Coverage&quot;
</span>    
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Upload</span> <span class="hljs-string">test</span> <span class="hljs-string">results</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/upload-artifact@v3</span>
      <span class="hljs-attr">with:</span>
        <span class="hljs-attr">name:</span> <span class="hljs-string">test-results</span>
        <span class="hljs-attr">path:</span> <span class="hljs-string">&#x27;**/*.trx&#x27;</span>

  <span class="hljs-attr">security-scan:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">security</span> <span class="hljs-string">scan</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">securecodewarrior/github-action-add-sarif@v1</span>
      <span class="hljs-attr">with:</span>
        <span class="hljs-attr">sarif-file:</span> <span class="hljs-string">&#x27;security-scan-results.sarif&#x27;</span>

  <span class="hljs-attr">build-and-deploy:</span>
    <span class="hljs-attr">needs:</span> [<span class="hljs-string">test</span>, <span class="hljs-string">security-scan</span>]
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">and</span> <span class="hljs-string">push</span> <span class="hljs-string">images</span>
      <span class="hljs-attr">run:</span> <span class="hljs-string">|
        docker build -t $REGISTRY/catalog-api:$GITHUB_SHA .
        docker build -t $REGISTRY/inventory-api:$GITHUB_SHA .
        docker push $REGISTRY/catalog-api:$GITHUB_SHA
        docker push $REGISTRY/inventory-api:$GITHUB_SHA
</span>    
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">to</span> <span class="hljs-string">staging</span>
      <span class="hljs-attr">run:</span> <span class="hljs-string">|
        kubectl set image deployment/catalog-api catalog-api=$REGISTRY/catalog-api:$GITHUB_SHA
        kubectl rollout status deployment/catalog-api
</span>    
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">smoke</span> <span class="hljs-string">tests</span>
      <span class="hljs-attr">run:</span> <span class="hljs-string">|
        npm install
        npm run test:smoke
</span>    
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">to</span> <span class="hljs-string">production</span>
      <span class="hljs-attr">if:</span> <span class="hljs-string">startsWith(github.ref,</span> <span class="hljs-string">&#x27;refs/tags/v&#x27;</span><span class="hljs-string">)</span>
      <span class="hljs-attr">run:</span> <span class="hljs-string">|</span>
        <span class="hljs-string">kubectl</span> <span class="hljs-string">set</span> <span class="hljs-string">image</span> <span class="hljs-string">deployment/catalog-api</span> <span class="hljs-string">catalog-api=$REGISTRY/catalog-api:$GITHUB_SHA</span> <span class="hljs-string">--namespace=production</span></code></pre>
<p><strong>Lesson Learned</strong>: Invest in your deployment pipeline early. Automated testing, security scanning, and staged deployments saved us from multiple production incidents.</p>
<h2 id="what-we-and-39-d-do-differently-next-time">What We&#39;d Do Differently Next Time</h2>
<h3 id="1-start-with-aspire-service-defaults-earlier">1. Start with Aspire Service Defaults Earlier</h3>
<p>We initially wrote a lot of boilerplate configuration code that Aspire&#39;s service defaults would have handled for us:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Instead of writing all this manually...</span>
builder.Services.AddOpenTelemetry()
    .WithMetrics(...)
    .WithTracing(...);
    
builder.Services.AddServiceDiscovery();
builder.Services.AddHealthChecks();

<span class="hljs-comment">// Just use this from day one:</span>
builder.AddServiceDefaults();</code></pre>
<h3 id="2-plan-data-consistency-strategy-upfront">2. Plan Data Consistency Strategy Upfront</h3>
<p>We underestimated the complexity of managing data consistency across microservices. Implementing the saga pattern later was painful.</p>
<h3 id="3-invest-in-contract-testing">3. Invest in Contract Testing</h3>
<p>API contract changes between services caused integration issues. Tools like Pact would have caught these earlier.</p>
<h3 id="4-set-resource-limits-from-the-beginning">4. Set Resource Limits from the Beginning</h3>
<p>We experienced resource contention in Kubernetes because we didn&#39;t set proper CPU and memory limits initially.</p>
<h2 id="the-bottom-line-was-it-worth-it">The Bottom Line: Was It Worth It?</h2>
<p>Absolutely. Six months later, our client is seeing:</p>
<ul>
<li><strong>99.9% uptime</strong> (vs. 95% with the monolith)</li>
<li><strong>50% faster feature delivery</strong> (independent service deployments)</li>
<li><strong>3x better performance</strong> during peak traffic</li>
<li><strong>Reduced operational costs</strong> (better resource utilization)</li>
</ul>
<p>But more importantly, their development teams are happier and more productive.</p>
<h2 id="final-thoughts-for-fellow-consultants">Final Thoughts for Fellow Consultants</h2>
<p>Building microservices with ASP.NET Core 9 and .NET Aspire isn&#39;t just about the technology – it&#39;s about the entire ecosystem:</p>
<ol>
<li><strong>Team Readiness</strong>: Ensure your team understands distributed systems concepts</li>
<li><strong>Client Education</strong>: Help clients understand the complexity trade-offs</li>
<li><strong>Incremental Approach</strong>: Don&#39;t try to rebuild everything at once</li>
<li><strong>Observability First</strong>: Build monitoring and logging from day one</li>
<li><strong>Performance Testing</strong>: Load test early and often</li>
</ol>
<p>.NET Aspire genuinely changes the game for building cloud-native applications, but it&#39;s not a silver bullet. Success still requires solid architecture decisions, proper testing, and a team that understands distributed systems.</p>
<p>Would I recommend ASP.NET Core 9 and Aspire for similar projects? Absolutely. The developer experience improvements and production-ready defaults make it a compelling choice for teams building modern, cloud-native applications.</p>
<p>What&#39;s your experience been with ASP.NET Core 9 or .NET Aspire? I&#39;d love to hear about your lessons learned in the comments!</p>
<hr>
<p><em>Want to discuss this project or need help with your own microservices architecture? Feel free to reach out – always happy to chat about .NET, architecture, and lessons learned in the trenches.</em></p>
]]></content:encoded>
        </item>
        <item>
            <title>Mastering Entity Framework Core: A Guide to Efficient Data Access and Common Pitfalls</title>
            <link>https://www.architect4hire.com/blog/mastering-entity-framework-core-a-guide-to-efficient-data-access-and-common-pitfalls</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/mastering-entity-framework-core-a-guide-to-efficient-data-access-and-common-pitfalls</guid>
            <pubDate>Mon, 21 Jul 2025 12:18:06 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>.NET &amp; Azure</category>
            <description>A simple request for a list of blog posts ballooned into hundreds of queries. The CPU was fine. The database was fine. The problem was me — and the classic EF Core traps that catch almost everyone.</description>
            <content:encoded><![CDATA[<p>A few months ago, I found myself staring at a query that was <em>way</em> slower than it should’ve been. What started as a simple request for a list of blog posts ballooned into hundreds of queries hitting the database, one for each related author, category, and comment. My CPU was fine. My database was fine. The problem? I wasn’t.</p>
<p>Like many developers, I had fallen into some classic Entity Framework Core traps without realizing it—lazy loading, improper projections, and unoptimized query patterns. After a long night of debugging and tweaking, I finally got things running smoothly. And I figured: if I hit these issues, then others are likely to as well.</p>
<p>So I decided to write the guide I wish I had when I was pulling my hair out.</p>
<p>Entity Framework Core (EF Core) is Microsoft’s go-to object-relational mapper (ORM) for .NET developers, and for good reason. It makes data access clean, testable, and powerful. But with that power comes a whole set of performance landmines that can cripple your application if you’re not careful.</p>
<p>In this post, I’ll walk you through the most common EF Core performance pitfalls—and show you the wrong and right ways to handle them, with code examples and practical tips you can use immediately.</p>
<h2 id="understanding-ef-core-and-39-s-lazy-loading-trap">Understanding EF Core&#39;s Lazy Loading Trap</h2>
<p>One of the most notorious performance killers in EF Core applications is the N+1 query problem, often caused by lazy loading. This occurs when you retrieve a list of entities and then access related data for each entity individually.</p>
<h3 id="the-wrong-way-triggering-n-1-queries">The Wrong Way: Triggering N+1 Queries</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ❌ BAD: This will execute N+1 queries</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;BlogPostViewModel&gt;&gt; GetBlogPostsAsync()
{
    <span class="hljs-keyword">var</span> posts = <span class="hljs-keyword">await</span> _context.BlogPosts.ToListAsync();
    
    <span class="hljs-keyword">var</span> result = <span class="hljs-keyword">new</span> List&lt;BlogPostViewModel&gt;();
    <span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">var</span> post <span class="hljs-keyword">in</span> posts)
    {
        result.Add(<span class="hljs-keyword">new</span> BlogPostViewModel
        {
            Title = post.Title,
            Content = post.Content,
            AuthorName = post.Author.Name, <span class="hljs-comment">// Lazy loading triggers a query for each post</span>
            CategoryName = post.Category.Name, <span class="hljs-comment">// Another query for each post</span>
            CommentCount = post.Comments.Count() <span class="hljs-comment">// Yet another query for each post</span>
        });
    }
    
    <span class="hljs-keyword">return</span> result;
}</code></pre>
<p>If you have 100 blog posts, this code will execute 301 queries: 1 to get the posts, 100 to get authors, 100 to get categories, and 100 to get comment counts.</p>
<h3 id="the-right-way-using-eager-loading-with-include">The Right Way: Using Eager Loading with Include</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Using Include to load related data in a single query</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;BlogPostViewModel&gt;&gt; GetBlogPostsAsync()
{
    <span class="hljs-keyword">var</span> posts = <span class="hljs-keyword">await</span> _context.BlogPosts
        .Include(p =&gt; p.Author)
        .Include(p =&gt; p.Category)
        .Include(p =&gt; p.Comments)
        .ToListAsync();
    
    <span class="hljs-keyword">return</span> posts.Select(post =&gt; <span class="hljs-keyword">new</span> BlogPostViewModel
    {
        Title = post.Title,
        Content = post.Content,
        AuthorName = post.Author.Name,
        CategoryName = post.Category.Name,
        CommentCount = post.Comments.Count
    }).ToList();
}</code></pre>
<h3 id="even-better-using-projections-to-select-only-what-you-need">Even Better: Using Projections to Select Only What You Need</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ BETTER: Using projections to select only required data</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;BlogPostViewModel&gt;&gt; GetBlogPostsAsync()
{
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _context.BlogPosts
        .Select(p =&gt; <span class="hljs-keyword">new</span> BlogPostViewModel
        {
            Title = p.Title,
            Content = p.Content,
            AuthorName = p.Author.Name,
            CategoryName = p.Category.Name,
            CommentCount = p.Comments.Count()
        })
        .ToListAsync();
}</code></pre>
<p>This approach loads only the data you actually need and executes a single, optimized query.</p>
<h2 id="avoiding-the-and-quot-select-n-1-and-quot-anti-pattern-with-batch-loading">Avoiding the &quot;Select N+1&quot; Anti-Pattern with Batch Loading</h2>
<p>When you need to load related data conditionally or in complex scenarios, consider using batch loading techniques.</p>
<h3 id="the-wrong-way-individual-queries-in-loops">The Wrong Way: Individual Queries in Loops</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ❌ BAD: Loading related data individually</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;OrderSummary&gt;&gt; GetOrderSummariesAsync(List&lt;<span class="hljs-built_in">int</span>&gt; orderIds)
{
    <span class="hljs-keyword">var</span> summaries = <span class="hljs-keyword">new</span> List&lt;OrderSummary&gt;();
    
    <span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">var</span> orderId <span class="hljs-keyword">in</span> orderIds)
    {
        <span class="hljs-keyword">var</span> order = <span class="hljs-keyword">await</span> _context.Orders
            .Include(o =&gt; o.OrderItems)
            .ThenInclude(oi =&gt; oi.Product)
            .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
            
        <span class="hljs-keyword">if</span> (order != <span class="hljs-literal">null</span>)
        {
            summaries.Add(<span class="hljs-keyword">new</span> OrderSummary
            {
                OrderId = order.Id,
                TotalAmount = order.OrderItems.Sum(oi =&gt; oi.Quantity * oi.Product.Price),
                ItemCount = order.OrderItems.Count
            });
        }
    }
    
    <span class="hljs-keyword">return</span> summaries;
}</code></pre>
<h3 id="the-right-way-batch-loading">The Right Way: Batch Loading</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Loading all data in a single query</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;OrderSummary&gt;&gt; GetOrderSummariesAsync(List&lt;<span class="hljs-built_in">int</span>&gt; orderIds)
{
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _context.Orders
        .Where(o =&gt; orderIds.Contains(o.Id))
        .Select(o =&gt; <span class="hljs-keyword">new</span> OrderSummary
        {
            OrderId = o.Id,
            TotalAmount = o.OrderItems.Sum(oi =&gt; oi.Quantity * oi.Product.Price),
            ItemCount = o.OrderItems.Count()
        })
        .ToListAsync();
}</code></pre>
<h2 id="managing-dbcontext-lifecycle-properly">Managing DbContext Lifecycle Properly</h2>
<p>One critical aspect of EF Core that developers often get wrong is DbContext lifecycle management. The DbContext is designed to be short-lived and should not be shared across multiple operations.</p>
<h3 id="the-wrong-way-long-lived-dbcontext">The Wrong Way: Long-lived DbContext</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ❌ BAD: Keeping DbContext alive for too long</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">BadUserService</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> ApplicationDbContext _context;
    
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">BadUserService</span>(<span class="hljs-params">ApplicationDbContext context</span>)</span>
    {
        _context = context; <span class="hljs-comment">// This context might be shared across requests</span>
    }
    
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;User&gt; <span class="hljs-title">GetUserAsync</span>(<span class="hljs-params"><span class="hljs-built_in">int</span> id</span>)</span>
    {
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _context.Users.FindAsync(id);
    }
    
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">UpdateUserAsync</span>(<span class="hljs-params">User user</span>)</span>
    {
        _context.Users.Update(user);
        <span class="hljs-keyword">await</span> _context.SaveChangesAsync();
    }
}</code></pre>
<h3 id="the-right-way-proper-dbcontext-lifecycle">The Right Way: Proper DbContext Lifecycle</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Using scoped DbContext or explicit disposal</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">GoodUserService</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IDbContextFactory&lt;ApplicationDbContext&gt; _contextFactory;
    
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">GoodUserService</span>(<span class="hljs-params">IDbContextFactory&lt;ApplicationDbContext&gt; contextFactory</span>)</span>
    {
        _contextFactory = contextFactory;
    }
    
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;User&gt; <span class="hljs-title">GetUserAsync</span>(<span class="hljs-params"><span class="hljs-built_in">int</span> id</span>)</span>
    {
        <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> context = _contextFactory.CreateDbContext();
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> context.Users.FindAsync(id);
    }
    
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">UpdateUserAsync</span>(<span class="hljs-params">User user</span>)</span>
    {
        <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> context = _contextFactory.CreateDbContext();
        context.Users.Update(user);
        <span class="hljs-keyword">await</span> context.SaveChangesAsync();
    }
}</code></pre>
<p>For dependency injection in ASP.NET Core, use the scoped lifetime:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// In Program.cs or Startup.cs</span>
services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
    options.UseSqlServer(connectionString), ServiceLifetime.Scoped);</code></pre>
<h2 id="optimizing-queries-with-proper-indexing-and-query-structure">Optimizing Queries with Proper Indexing and Query Structure</h2>
<p>EF Core generates SQL queries based on your LINQ expressions, but not all LINQ expressions are created equal in terms of performance.</p>
<h3 id="the-wrong-way-inefficient-query-patterns">The Wrong Way: Inefficient Query Patterns</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ❌ BAD: Using client-side evaluation</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;Product&gt;&gt; GetExpensiveProductsAsync()
{
    <span class="hljs-keyword">var</span> products = <span class="hljs-keyword">await</span> _context.Products.ToListAsync();
    
    <span class="hljs-comment">// This calculation happens in memory, not in the database</span>
    <span class="hljs-keyword">return</span> products
        .Where(p =&gt; CalculateDiscountedPrice(p.Price, p.DiscountPercentage) &gt; <span class="hljs-number">100</span>)
        .ToList();
}

<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-built_in">decimal</span> <span class="hljs-title">CalculateDiscountedPrice</span>(<span class="hljs-params"><span class="hljs-built_in">decimal</span> price, <span class="hljs-built_in">decimal</span> discountPercentage</span>)</span>
{
    <span class="hljs-keyword">return</span> price * (<span class="hljs-number">1</span> - discountPercentage / <span class="hljs-number">100</span>);
}</code></pre>
<h3 id="the-right-way-database-side-evaluation">The Right Way: Database-side Evaluation</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Using database expressions</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;Product&gt;&gt; GetExpensiveProductsAsync()
{
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _context.Products
        .Where(p =&gt; p.Price * (<span class="hljs-number">1</span> - p.DiscountPercentage / <span class="hljs-number">100</span>) &gt; <span class="hljs-number">100</span>)
        .ToListAsync();
}</code></pre>
<h3 id="using-raw-sql-for-complex-queries">Using Raw SQL for Complex Queries</h3>
<p>Sometimes, complex business logic is better expressed in raw SQL:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Using raw SQL for complex aggregations</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;SalesReport&gt;&gt; GetMonthlySalesReportAsync(<span class="hljs-built_in">int</span> year)
{
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _context.SalesReports
        .FromSqlRaw(<span class="hljs-string">@&quot;
            SELECT 
                MONTH(o.OrderDate) as Month,
                COUNT(*) as OrderCount,
                SUM(oi.Quantity * oi.UnitPrice) as TotalRevenue
            FROM Orders o
            INNER JOIN OrderItems oi ON o.Id = oi.OrderId
            WHERE YEAR(o.OrderDate) = {0}
            GROUP BY MONTH(o.OrderDate)
            ORDER BY MONTH(o.OrderDate)&quot;</span>, year)
        .ToListAsync();
}</code></pre>
<h2 id="implementing-effective-change-tracking-strategies">Implementing Effective Change Tracking Strategies</h2>
<p>EF Core&#39;s change tracking can become a performance bottleneck when working with large datasets. Understanding when to disable it can significantly improve performance.</p>
<h3 id="the-wrong-way-always-using-change-tracking">The Wrong Way: Always Using Change Tracking</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ❌ BAD: Using change tracking for read-only operations</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;ProductReport&gt;&gt; GenerateProductReportAsync()
{
    <span class="hljs-keyword">var</span> products = <span class="hljs-keyword">await</span> _context.Products
        .Include(p =&gt; p.Category)
        .Include(p =&gt; p.OrderItems)
        .ToListAsync(); <span class="hljs-comment">// Change tracking is enabled by default</span>
    
    <span class="hljs-keyword">return</span> products.Select(p =&gt; <span class="hljs-keyword">new</span> ProductReport
    {
        ProductName = p.Name,
        CategoryName = p.Category.Name,
        TotalSold = p.OrderItems.Sum(oi =&gt; oi.Quantity),
        Revenue = p.OrderItems.Sum(oi =&gt; oi.Quantity * oi.UnitPrice)
    }).ToList();
}</code></pre>
<h3 id="the-right-way-selective-change-tracking">The Right Way: Selective Change Tracking</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Disabling change tracking for read-only operations</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;List&lt;ProductReport&gt;&gt; GenerateProductReportAsync()
{
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _context.Products
        .AsNoTracking() <span class="hljs-comment">// Disable change tracking</span>
        .Select(p =&gt; <span class="hljs-keyword">new</span> ProductReport
        {
            ProductName = p.Name,
            CategoryName = p.Category.Name,
            TotalSold = p.OrderItems.Sum(oi =&gt; oi.Quantity),
            Revenue = p.OrderItems.Sum(oi =&gt; oi.Quantity * oi.UnitPrice)
        })
        .ToListAsync();
}

<span class="hljs-comment">// For updates, use change tracking selectively</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">UpdateProductPricesAsync</span>(<span class="hljs-params">List&lt;ProductPriceUpdate&gt; updates</span>)</span>
{
    <span class="hljs-keyword">var</span> productIds = updates.Select(u =&gt; u.ProductId).ToList();
    <span class="hljs-keyword">var</span> products = <span class="hljs-keyword">await</span> _context.Products
        .Where(p =&gt; productIds.Contains(p.Id))
        .ToListAsync(); <span class="hljs-comment">// Change tracking enabled for updates</span>
    
    <span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">var</span> update <span class="hljs-keyword">in</span> updates)
    {
        <span class="hljs-keyword">var</span> product = products.First(p =&gt; p.Id == update.ProductId);
        product.Price = update.NewPrice;
        product.LastUpdated = DateTime.UtcNow;
    }
    
    <span class="hljs-keyword">await</span> _context.SaveChangesAsync();
}</code></pre>
<h2 id="implementing-efficient-bulk-operations">Implementing Efficient Bulk Operations</h2>
<p>When dealing with large datasets, individual entity operations can be extremely slow.</p>
<h3 id="the-wrong-way-individual-operations">The Wrong Way: Individual Operations</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ❌ BAD: Inserting records one by one</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">ImportUsersAsync</span>(<span class="hljs-params">List&lt;UserImportModel&gt; users</span>)</span>
{
    <span class="hljs-keyword">foreach</span> (<span class="hljs-keyword">var</span> userModel <span class="hljs-keyword">in</span> users)
    {
        <span class="hljs-keyword">var</span> user = <span class="hljs-keyword">new</span> User
        {
            Name = userModel.Name,
            Email = userModel.Email,
            CreatedDate = DateTime.UtcNow
        };
        
        _context.Users.Add(user);
        <span class="hljs-keyword">await</span> _context.SaveChangesAsync(); <span class="hljs-comment">// Saves after each insert</span>
    }
}</code></pre>
<h3 id="the-right-way-bulk-operations">The Right Way: Bulk Operations</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Bulk insert with batching</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">ImportUsersAsync</span>(<span class="hljs-params">List&lt;UserImportModel&gt; users</span>)</span>
{
    <span class="hljs-keyword">const</span> <span class="hljs-built_in">int</span> batchSize = <span class="hljs-number">1000</span>;
    
    <span class="hljs-keyword">for</span> (<span class="hljs-built_in">int</span> i = <span class="hljs-number">0</span>; i &lt; users.Count; i += batchSize)
    {
        <span class="hljs-keyword">var</span> batch = users.Skip(i).Take(batchSize);
        <span class="hljs-keyword">var</span> userEntities = batch.Select(u =&gt; <span class="hljs-keyword">new</span> User
        {
            Name = u.Name,
            Email = u.Email,
            CreatedDate = DateTime.UtcNow
        }).ToList();
        
        _context.Users.AddRange(userEntities);
        <span class="hljs-keyword">await</span> _context.SaveChangesAsync();
        
        <span class="hljs-comment">// Clear tracked entities to free memory</span>
        _context.ChangeTracker.Clear();
    }
}</code></pre>
<h2 id="best-practices-for-configuration-and-performance">Best Practices for Configuration and Performance</h2>
<h3 id="configure-your-dbcontext-for-performance">Configure Your DbContext for Performance</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Optimized DbContext configuration</span>
<span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnConfiguring</span>(<span class="hljs-params">DbContextOptionsBuilder optionsBuilder</span>)</span>
{
    optionsBuilder
        .UseSqlServer(connectionString, options =&gt;
        {
            options.CommandTimeout(<span class="hljs-number">30</span>); <span class="hljs-comment">// Set appropriate timeout</span>
            options.EnableRetryOnFailure(); <span class="hljs-comment">// Handle transient failures</span>
        })
        .EnableSensitiveDataLogging(<span class="hljs-literal">false</span>) <span class="hljs-comment">// Disable in production</span>
        .EnableDetailedErrors(<span class="hljs-literal">false</span>) <span class="hljs-comment">// Disable in production</span>
        .ConfigureWarnings(warnings =&gt;
            warnings.Throw(RelationalEventId.QueryPossibleUnintendedUseOfEqualsWarning));
}</code></pre>
<h3 id="implement-connection-resiliency">Implement Connection Resiliency</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Connection resiliency configuration</span>
services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
{
    options.UseSqlServer(connectionString, sqlOptions =&gt;
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: <span class="hljs-number">3</span>,
            maxRetryDelay: TimeSpan.FromSeconds(<span class="hljs-number">30</span>),
            errorNumbersToAdd: <span class="hljs-literal">null</span>);
    });
});</code></pre>
<h2 id="monitoring-and-debugging-query-performance">Monitoring and Debugging Query Performance</h2>
<p>Always monitor your EF Core queries in production:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// ✅ GOOD: Logging slow queries</span>
<span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnConfiguring</span>(<span class="hljs-params">DbContextOptionsBuilder optionsBuilder</span>)</span>
{
    optionsBuilder
        .UseSqlServer(connectionString)
        .LogTo(Console.WriteLine, LogLevel.Information)
        .EnableSensitiveDataLogging() <span class="hljs-comment">// Only in development</span>
        .ConfigureWarnings(warnings =&gt;
            warnings.Log(CoreEventId.FirstWithoutOrderByAndFilterWarning,
                        CoreEventId.RowLimitingOperationWithoutOrderByWarning));
}</code></pre>
<h2 id="conclusion">Conclusion</h2>
<p>Efficient use of Entity Framework Core requires understanding its internals and being mindful of the SQL queries it generates. The key principles to remember are:</p>
<ol>
<li><strong>Always think about the SQL being generated</strong> - Use tools like EF Core&#39;s logging to see what queries are executed</li>
<li><strong>Avoid N+1 problems</strong> - Use Include, projections, or batch loading appropriately</li>
<li><strong>Manage DbContext lifecycle properly</strong> - Keep contexts short-lived and dispose them correctly</li>
<li><strong>Use AsNoTracking() for read-only operations</strong> - Don&#39;t track entities you won&#39;t modify</li>
<li><strong>Batch operations when dealing with large datasets</strong> - Avoid individual saves in loops</li>
<li><strong>Configure connection resiliency</strong> - Handle transient failures gracefully</li>
<li><strong>Monitor and profile your queries</strong> - Use logging and profiling tools to identify bottlenecks</li>
</ol>
<p>By following these practices and avoiding the common pitfalls outlined in this guide, you&#39;ll be able to build high-performance applications that scale effectively with Entity Framework Core. Remember that premature optimization can be counterproductive, so profile your application to identify actual bottlenecks before implementing complex optimizations.</p>
<p>The examples provided here represent real-world scenarios that many developers encounter. By understanding both the wrong and right approaches, you&#39;ll be better equipped to write efficient, maintainable code that performs well under load.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Supercharge Your Testing with GitHub Copilot: Generating MSTest DataRows for an Onion Architecture</title>
            <link>https://www.architect4hire.com/blog/supercharge-your-testing-with-github-copilot</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/supercharge-your-testing-with-github-copilot</guid>
            <pubDate>Fri, 11 Jul 2025 19:38:36 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>AI-Assisted Development</category>
            <description>Testing is the backbone of reliable software and the most time-consuming part of building it. Here are the prompts that get Copilot generating genuinely useful MSTest DataRows across an Onion Architecture — controller and business layers both.</description>
            <content:encoded><![CDATA[<h2 id="generating-mstest-datarows-for-an-onion-style-architecture">Generating MSTest DataRows for an Onion Style Architecture</h2>
<p>Testing is the backbone of reliable software development, yet it&#39;s often the most time-consuming aspect of building robust applications. Suppose you&#39;re working with an Onion Architecture (also known as Clean Architecture) service and need to test both your controller and business layers thoroughly. In that case, GitHub Copilot can be your secret weapon for generating comprehensive MSTest suites with DataRows that efficiently cover multiple test scenarios.</p>
<p>In this comprehensive guide, we&#39;ll explore how to leverage GitHub Copilot&#39;s AI-powered code generation to create sophisticated test suites that validate your onion architecture&#39;s controller and business layers. We&#39;ll cover practical techniques, provide real-world examples, and share expert tips to maximize your testing productivity.</p>
<h2 id="understanding-onion-architecture-testing-challenges">Understanding Onion Architecture Testing Challenges</h2>
<p>Before diving into Copilot techniques, let&#39;s briefly review the testing challenges in onion architecture:</p>
<ol>
<li><strong>Controller Layer</strong>: HTTP concerns, request/response mapping, validation, authorization</li>
<li><strong>Business Layer</strong>: Core business logic, domain rules, orchestration between services</li>
<li><strong>Data Layer</strong>: Repository patterns, data access, entity mapping</li>
</ol>
<p>Each layer requires different testing approaches, and DataRows help us test multiple scenarios without code duplication.</p>
<h2 id="setting-up-your-testing-environment">Setting Up Your Testing Environment</h2>
<p>First, ensure your test project has the necessary NuGet packages:</p>
<pre class="blog-code"><code class="hljs language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">PackageReference</span> <span class="hljs-attr">Include</span>=<span class="hljs-string">&quot;Microsoft.NET.Test.Sdk&quot;</span> <span class="hljs-attr">Version</span>=<span class="hljs-string">&quot;17.6.0&quot;</span> /&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">PackageReference</span> <span class="hljs-attr">Include</span>=<span class="hljs-string">&quot;MSTest.TestAdapter&quot;</span> <span class="hljs-attr">Version</span>=<span class="hljs-string">&quot;3.0.4&quot;</span> /&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">PackageReference</span> <span class="hljs-attr">Include</span>=<span class="hljs-string">&quot;MSTest.TestFramework&quot;</span> <span class="hljs-attr">Version</span>=<span class="hljs-string">&quot;3.0.4&quot;</span> /&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">PackageReference</span> <span class="hljs-attr">Include</span>=<span class="hljs-string">&quot;Moq&quot;</span> <span class="hljs-attr">Version</span>=<span class="hljs-string">&quot;4.20.69&quot;</span> /&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">PackageReference</span> <span class="hljs-attr">Include</span>=<span class="hljs-string">&quot;Microsoft.AspNetCore.Mvc.Testing&quot;</span> <span class="hljs-attr">Version</span>=<span class="hljs-string">&quot;7.0.0&quot;</span> /&gt;</span></code></pre>
<h2 id="effective-prompts-for-controller-layer-testing">Effective Prompts for Controller Layer Testing</h2>
<h3 id="basic-controller-test-generation">Basic Controller Test Generation</h3>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Generate MSTest with DataRows for testing a UserController PostUser action that accepts CreateUserRequest and returns UserResponse. Test cases should include valid user creation, invalid email format, missing required fields, and duplicate username scenarios. Use AAA pattern and include proper assertions.</code></pre>
<p><strong>Generated Result:</strong></p>
<pre class="blog-code"><code class="hljs language-csharp">[<span class="hljs-meta">TestClass</span>]
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">UserControllerTests</span>
{
    <span class="hljs-keyword">private</span> UserController _controller;
    <span class="hljs-keyword">private</span> Mock&lt;IUserService&gt; _mockUserService;

    [<span class="hljs-meta">TestInitialize</span>]
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Setup</span>()</span>
    {
        _mockUserService = <span class="hljs-keyword">new</span> Mock&lt;IUserService&gt;();
        _controller = <span class="hljs-keyword">new</span> UserController(_mockUserService.Object);
    }

    [<span class="hljs-meta">TestMethod</span>]
    [<span class="hljs-meta">DataRow(<span class="hljs-string">&quot;john.doe@email.com&quot;</span>, <span class="hljs-string">&quot;John&quot;</span>, <span class="hljs-string">&quot;Doe&quot;</span>, <span class="hljs-string">&quot;johndoe&quot;</span>, true, <span class="hljs-string">&quot;User created successfully&quot;</span>)</span>]
    [<span class="hljs-meta">DataRow(<span class="hljs-string">&quot;invalid-email&quot;</span>, <span class="hljs-string">&quot;John&quot;</span>, <span class="hljs-string">&quot;Doe&quot;</span>, <span class="hljs-string">&quot;johndoe&quot;</span>, false, <span class="hljs-string">&quot;Invalid email format&quot;</span>)</span>]
    [<span class="hljs-meta">DataRow(<span class="hljs-string">&quot;&quot;</span>, <span class="hljs-string">&quot;John&quot;</span>, <span class="hljs-string">&quot;Doe&quot;</span>, <span class="hljs-string">&quot;johndoe&quot;</span>, false, <span class="hljs-string">&quot;Email is required&quot;</span>)</span>]
    [<span class="hljs-meta">DataRow(<span class="hljs-string">&quot;john.doe@email.com&quot;</span>, <span class="hljs-string">&quot;&quot;</span>, <span class="hljs-string">&quot;Doe&quot;</span>, <span class="hljs-string">&quot;johndoe&quot;</span>, false, <span class="hljs-string">&quot;First name is required&quot;</span>)</span>]
    [<span class="hljs-meta">DataRow(<span class="hljs-string">&quot;john.doe@email.com&quot;</span>, <span class="hljs-string">&quot;John&quot;</span>, <span class="hljs-string">&quot;Doe&quot;</span>, <span class="hljs-string">&quot;&quot;</span>, false, <span class="hljs-string">&quot;Username is required&quot;</span>)</span>]
    [<span class="hljs-meta">DataRow(<span class="hljs-string">&quot;existing@email.com&quot;</span>, <span class="hljs-string">&quot;John&quot;</span>, <span class="hljs-string">&quot;Doe&quot;</span>, <span class="hljs-string">&quot;existinguser&quot;</span>, false, <span class="hljs-string">&quot;Username already exists&quot;</span>)</span>]
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">PostUser_WithVariousInputs_ReturnsExpectedResults</span>(<span class="hljs-params">
        <span class="hljs-built_in">string</span> email, <span class="hljs-built_in">string</span> firstName, <span class="hljs-built_in">string</span> lastName, <span class="hljs-built_in">string</span> username, 
        <span class="hljs-built_in">bool</span> shouldSucceed, <span class="hljs-built_in">string</span> expectedMessage</span>)</span>
    {
        <span class="hljs-comment">// Arrange</span>
        <span class="hljs-keyword">var</span> request = <span class="hljs-keyword">new</span> CreateUserRequest
        {
            Email = email,
            FirstName = firstName,
            LastName = lastName,
            Username = username
        };

        <span class="hljs-keyword">if</span> (shouldSucceed)
        {
            _mockUserService.Setup(x =&gt; x.CreateUserAsync(It.IsAny&lt;CreateUserRequest&gt;()))
                .ReturnsAsync(<span class="hljs-keyword">new</span> UserResponse { Id = <span class="hljs-number">1</span>, Message = expectedMessage });
        }
        <span class="hljs-keyword">else</span>
        {
            _mockUserService.Setup(x =&gt; x.CreateUserAsync(It.IsAny&lt;CreateUserRequest&gt;()))
                .ThrowsAsync(<span class="hljs-keyword">new</span> ValidationException(expectedMessage));
        }

        <span class="hljs-comment">// Act</span>
        <span class="hljs-keyword">var</span> result = shouldSucceed 
            ? <span class="hljs-keyword">await</span> _controller.PostUser(request) <span class="hljs-keyword">as</span> OkObjectResult
            : <span class="hljs-keyword">await</span> _controller.PostUser(request) <span class="hljs-keyword">as</span> BadRequestObjectResult;

        <span class="hljs-comment">// Assert</span>
        Assert.IsNotNull(result);
        <span class="hljs-keyword">if</span> (shouldSucceed)
        {
            <span class="hljs-keyword">var</span> response = result.Value <span class="hljs-keyword">as</span> UserResponse;
            Assert.AreEqual(expectedMessage, response.Message);
        }
        <span class="hljs-keyword">else</span>
        {
            Assert.IsTrue(result.Value.ToString().Contains(expectedMessage));
        }
    }
}</code></pre>
<h3 id="advanced-authorization-testing">Advanced Authorization Testing</h3>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Create MSTest DataRows for testing authorization in ProductController GetProducts action. Include test cases for admin role (full access), user role (own products only), guest (no access), expired token, and invalid token scenarios. Mock IAuthorizationService and include proper HTTP status code assertions.</code></pre>
<h2 id="business-layer-testing-strategies">Business Layer Testing Strategies</h2>
<h3 id="domain-logic-validation">Domain Logic Validation</h3>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Generate MSTest with DataRows for OrderService CalculateTotal method in business layer. Test scenarios: normal order with multiple items, order with discounts, order with tax calculations, empty order, order with invalid quantities, and order with expired promotions. Include edge cases and boundary conditions.</code></pre>
<p><strong>Generated Result:</strong></p>
<pre class="blog-code"><code class="hljs language-csharp">[<span class="hljs-meta">TestClass</span>]
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderServiceTests</span>
{
    <span class="hljs-keyword">private</span> OrderService _orderService;
    <span class="hljs-keyword">private</span> Mock&lt;IDiscountRepository&gt; _mockDiscountRepo;
    <span class="hljs-keyword">private</span> Mock&lt;ITaxCalculator&gt; _mockTaxCalculator;

    [<span class="hljs-meta">TestInitialize</span>]
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Setup</span>()</span>
    {
        _mockDiscountRepo = <span class="hljs-keyword">new</span> Mock&lt;IDiscountRepository&gt;();
        _mockTaxCalculator = <span class="hljs-keyword">new</span> Mock&lt;ITaxCalculator&gt;();
        _orderService = <span class="hljs-keyword">new</span> OrderService(_mockDiscountRepo.Object, _mockTaxCalculator.Object);
    }

    [<span class="hljs-meta">TestMethod</span>]
    [<span class="hljs-meta">DataRow(new double[</span>] { <span class="hljs-number">10.00</span>, <span class="hljs-number">20.00</span>, <span class="hljs-number">15.00</span> }, <span class="hljs-keyword">new</span> <span class="hljs-built_in">int</span>[] { <span class="hljs-number">2</span>, <span class="hljs-number">1</span>, <span class="hljs-number">3</span> }, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.10</span>, <span class="hljs-number">96.25</span>)]
    [<span class="hljs-meta">DataRow(new double[</span>] { <span class="hljs-number">100.00</span> }, <span class="hljs-keyword">new</span> <span class="hljs-built_in">int</span>[] { <span class="hljs-number">1</span> }, <span class="hljs-number">10.0</span>, <span class="hljs-number">0.08</span>, <span class="hljs-number">97.20</span>)]
    [<span class="hljs-meta">DataRow(new double[</span>] { <span class="hljs-number">50.00</span>, <span class="hljs-number">30.00</span> }, <span class="hljs-keyword">new</span> <span class="hljs-built_in">int</span>[] { <span class="hljs-number">1</span>, <span class="hljs-number">2</span> }, <span class="hljs-number">15.0</span>, <span class="hljs-number">0.12</span>, <span class="hljs-number">100.80</span>)]
    [<span class="hljs-meta">DataRow(new double[</span>] { }, <span class="hljs-keyword">new</span> <span class="hljs-built_in">int</span>[] { }, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>)]
    [<span class="hljs-meta">DataRow(new double[</span>] { <span class="hljs-number">25.00</span> }, <span class="hljs-keyword">new</span> <span class="hljs-built_in">int</span>[] { <span class="hljs-number">0</span> }, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>, <span class="hljs-number">0.0</span>)]
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">CalculateTotal_WithVariousOrderConfigurations_ReturnsCorrectTotal</span>(<span class="hljs-params">
        <span class="hljs-built_in">double</span>[] itemPrices, <span class="hljs-built_in">int</span>[] quantities, <span class="hljs-built_in">double</span> discountPercent, 
        <span class="hljs-built_in">double</span> taxRate, <span class="hljs-built_in">double</span> expectedTotal</span>)</span>
    {
        <span class="hljs-comment">// Arrange</span>
        <span class="hljs-keyword">var</span> orderItems = <span class="hljs-keyword">new</span> List&lt;OrderItem&gt;();
        <span class="hljs-keyword">for</span> (<span class="hljs-built_in">int</span> i = <span class="hljs-number">0</span>; i &lt; itemPrices.Length; i++)
        {
            <span class="hljs-keyword">if</span> (i &lt; quantities.Length &amp;&amp; quantities[i] &gt; <span class="hljs-number">0</span>)
            {
                orderItems.Add(<span class="hljs-keyword">new</span> OrderItem 
                { 
                    Price = itemPrices[i], 
                    Quantity = quantities[i] 
                });
            }
        }

        <span class="hljs-keyword">var</span> order = <span class="hljs-keyword">new</span> Order { Items = orderItems };

        _mockDiscountRepo.Setup(x =&gt; x.GetApplicableDiscount(It.IsAny&lt;Order&gt;()))
            .Returns(discountPercent);
        _mockTaxCalculator.Setup(x =&gt; x.CalculateTax(It.IsAny&lt;<span class="hljs-built_in">double</span>&gt;()))
            .Returns&lt;<span class="hljs-built_in">double</span>&gt;(amount =&gt; amount * taxRate);

        <span class="hljs-comment">// Act</span>
        <span class="hljs-keyword">var</span> result = _orderService.CalculateTotal(order);

        <span class="hljs-comment">// Assert</span>
        Assert.AreEqual(expectedTotal, result, <span class="hljs-number">0.01</span>);
    }
}</code></pre>
<h3 id="complex-business-rules-testing">Complex Business Rules Testing</h3>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Create comprehensive MSTest DataRows for InventoryService ProcessRestockOrder method. Test scenarios include: sufficient stock levels, insufficient stock with backorder, priority customer handling, seasonal restrictions, vendor availability, quantity limits, and concurrent stock updates. Use realistic business scenarios and include proper exception handling tests.</code></pre>
<h2 id="advanced-copilot-techniques">Advanced Copilot Techniques</h2>
<h3 id="using-context-aware-prompts">Using Context-Aware Prompts</h3>
<p><strong>Effective Strategy:</strong> Start your prompts with existing code context to get more accurate results.</p>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Based on this existing UserService interface:
```csharp
public interface IUserService
{
    Task&lt;UserResponse&gt; CreateUserAsync(CreateUserRequest request);
    Task&lt;UserResponse&gt; GetUserByIdAsync(int id);
    Task&lt;bool&gt; DeleteUserAsync(int id);
}</code></pre>
<p>Generate MSTest with DataRows for UserService implementation testing all methods. Include positive cases, negative cases, async exception handling, and edge cases. Mock dependencies appropriately.</p>
<pre class="blog-code"><code class="hljs language-plaintext">### Chaining Prompts for Complex Scenarios

**Initial Prompt:**</code></pre>
<p>Generate MSTest DataRows for PaymentService ProcessPayment method with basic scenarios: successful payment, insufficient funds, invalid card, expired card.</p>
<pre class="blog-code"><code class="hljs language-plaintext">**Follow-up Prompt:**</code></pre>
<p>Extend the previous PaymentService tests with additional DataRows for: fraud detection triggers, currency conversion scenarios, refund processing, partial payments, and payment gateway timeout handling.</p>
<pre class="blog-code"><code class="hljs language-plaintext">### Integration Testing with Copilot

**Prompt:**</code></pre>
<p>Create MSTest integration tests with DataRows for e-commerce checkout workflow spanning controller and business layers. Test complete user journey: add to cart, apply discount, calculate shipping, process payment, update inventory. Use TestServer and realistic data scenarios.</p>
<pre class="blog-code"><code class="hljs language-plaintext">## Best Practices and Pro Tips

### 1. Structure Your Prompts Effectively

**Good Prompt Structure:**
- Context (what you&#x27;re testing)
- Specific requirements (DataRows, scenarios)
- Technical constraints (frameworks, patterns)
- Expected outcomes (assertions, exceptions)

### 2. Leverage Copilot for Test Data Generation

**Prompt:**</code></pre>
<p>Generate realistic test data objects for e-commerce testing including: valid/invalid credit cards, international addresses, product catalogs with various categories, user profiles with different roles, and order histories with edge cases.</p>
<pre class="blog-code"><code class="hljs language-plaintext">### 3. Use Copilot for Mock Setup

**Prompt:**</code></pre>
<p>Generate Moq setup code for testing OrderController that depends on IOrderService, IPaymentService, and IInventoryService. Include different return scenarios for each dependency and proper verify calls.</p>
<pre class="blog-code"><code class="hljs language-plaintext">### 4. Exception Testing Patterns

**Prompt:**</code></pre>
<p>Create MSTest DataRows for testing exception scenarios in UserRegistrationService. Include: ArgumentNullException for null inputs, ValidationException for invalid data, DuplicateUserException for existing users, and ServiceUnavailableException for external service failures.</p>
<pre class="blog-code"><code class="hljs language-plaintext">## Complex Real-World Example

Let&#x27;s create a comprehensive test suite for an order processing system:

**Prompt:**</code></pre>
<p>Generate a complete MSTest suite with DataRows for OrderProcessingService in an e-commerce onion architecture. Include controller tests for OrderController endpoints and business layer tests for OrderService. Cover scenarios: new order creation, order status updates, inventory validation, payment processing, shipping calculations, order cancellation, refund processing, and concurrent order handling. Use realistic e-commerce data and include proper mocking of dependencies.</p>
<pre class="blog-code"><code class="hljs language-plaintext">**Generated Result Structure:**
```csharp
[TestClass]
public class OrderProcessingIntegrationTests
{
    // Controller Layer Tests
    [TestMethod]
    [DataRow(&quot;valid-order.json&quot;, 201, &quot;Order created successfully&quot;)]
    [DataRow(&quot;invalid-payment.json&quot;, 400, &quot;Payment method invalid&quot;)]
    [DataRow(&quot;insufficient-inventory.json&quot;, 409, &quot;Insufficient stock&quot;)]
    public async Task CreateOrder_WithVariousScenarios_ReturnsExpectedResults(
        string testDataFile, int expectedStatusCode, string expectedMessage)
    {
        // Implementation here
    }

    // Business Layer Tests
    [TestMethod]
    [DataRow(OrderStatus.Pending, OrderStatus.Processing, true)]
    [DataRow(OrderStatus.Processing, OrderStatus.Shipped, true)]
    [DataRow(OrderStatus.Shipped, OrderStatus.Delivered, true)]
    [DataRow(OrderStatus.Delivered, OrderStatus.Processing, false)]
    public void UpdateOrderStatus_WithStatusTransitions_ValidatesBusinessRules(
        OrderStatus currentStatus, OrderStatus newStatus, bool shouldSucceed)
    {
        // Implementation here
    }
}</code></pre>
<h2 id="debugging-and-refinement-tips">Debugging and Refinement Tips</h2>
<h3 id="1-iterative-improvement">1. Iterative Improvement</h3>
<p>Start with basic tests and refine:</p>
<p><strong>Initial Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Generate basic MSTest for UserController Login method</code></pre>
<p><strong>Refinement Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Add DataRows to the previous UserController Login test for: valid credentials, invalid password, non-existent user, locked account, expired password, and rate limiting scenarios</code></pre>
<h3 id="2-copilot-assisted-test-maintenance">2. Copilot-Assisted Test Maintenance</h3>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Update the existing UserService tests to include new DataRows for: two-factor authentication scenarios, OAuth login, password reset workflows, and account verification steps</code></pre>
<h2 id="performance-considerations">Performance Considerations</h2>
<h3 id="1-efficient-test-data">1. Efficient Test Data</h3>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Generate MSTest DataRows using object arrays for complex scenarios in ProductCatalogService. Optimize for test execution speed while maintaining comprehensive coverage of product search, filtering, sorting, and pagination scenarios.</code></pre>
<h3 id="2-parallel-test-execution">2. Parallel Test Execution</h3>
<p><strong>Prompt:</strong></p>
<pre class="blog-code"><code class="hljs language-plaintext">Create thread-safe MSTest DataRows for concurrent testing of OrderService methods. Include proper test isolation and avoid shared state issues.</code></pre>
<h2 id="conclusion">Conclusion</h2>
<p>GitHub Copilot transforms the traditionally tedious task of writing comprehensive test suites into an efficient, AI-assisted process. By crafting effective prompts and leveraging Copilot&#39;s understanding of testing patterns, you can generate robust MSTest suites with DataRows that thoroughly validate both your controller and business layers.</p>
<p>The key to success lies in:</p>
<ol>
<li><strong>Clear, contextual prompting</strong> that provides Copilot with sufficient information</li>
<li><strong>Iterative refinement</strong> of generated tests to match your specific requirements</li>
<li><strong>Strategic use of DataRows</strong> to maximize test coverage with minimal code duplication</li>
<li><strong>Proper separation of concerns</strong> between controller and business layer tests</li>
</ol>
<p>Remember that while Copilot excels at generating boilerplate test code and common patterns, you should always review and customize the generated tests to ensure they align with your specific business requirements and testing standards.</p>
<p>Start implementing these techniques in your next project, and you&#39;ll find that comprehensive testing becomes not just manageable, but genuinely productive and even enjoyable. Your onion architecture will be more robust, your code more reliable, and your development velocity significantly increased.</p>
<p>Happy testing with GitHub Copilot!</p>
]]></content:encoded>
        </item>
        <item>
            <title>Streamlining Microservice Communication: How Refit HTTP Clients Power Our Distributed Architecture</title>
            <link>https://www.architect4hire.com/blog/streamlining-microservice-communication-how-refit-http-clients-power-our-distributed-architecture</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/streamlining-microservice-communication-how-refit-http-clients-power-our-distributed-architecture</guid>
            <pubDate>Fri, 11 Jul 2025 15:30:25 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>Microservices are the easy part. Making them talk to each other without a pile of hand-rolled HttpClient boilerplate is the part that quietly rots. Refit turns inter-service communication into a declarative interface — here is how we wire it.</description>
            <content:encoded><![CDATA[<p>In the world of modern software development, microservices architecture has become the gold standard for building scalable, maintainable applications. However, with this architectural approach comes the challenge of seamless inter-service communication. Today, I want to share how we&#39;ve leveraged Refit, a powerful REST library for .NET, to create a robust and elegant HTTP client infrastructure that forms the backbone of our clients platform.</p>
<h2 id="what-is-refit-and-why-we-chose-it">What is Refit and Why We Chose It</h2>
<p>Refit is a REST library for .NET that transforms REST APIs into live interfaces. Instead of writing boilerplate HTTP client code, Refit generates the implementation for you at runtime using attributes to define the HTTP operations. This approach dramatically reduces code complexity while providing type safety and excellent IntelliSense support.</p>
<p>Our platform consists of multiple specialized services: ProfileService, UserService, AdminService, BuddyService, CheckedOutService, and several others. Each service needs to communicate with others to fulfill complex business requirements. Traditional HttpClient usage would result in hundreds of lines of repetitive code, but Refit transforms this into clean, declarative interfaces.</p>
<h2 id="the-refit-http-client-portfolio">The Refit HTTP Client Portfolio</h2>
<p>In our solution, we&#39;ve implemented a comprehensive suite of HTTP client interfaces, each designed for specific inter-service communication patterns. Let&#39;s explore the key clients that power our ecosystem:</p>
<h3 id="core-service-clients">Core Service Clients</h3>
<p><strong>IProfileHttpClient</strong> serves as the central communication hub for profile-related operations. This client handles everything from profile creation and updates to user status management:</p>
<pre class="blog-code"><code class="hljs language-csharp">[<span class="hljs-meta">Headers(<span class="hljs-string">&quot;Content-Type: application/json&quot;</span>, <span class="hljs-string">&quot;Another-Header: AnotherValue&quot;</span>)</span>]
<span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title">IProfileHttpClient</span>
{
    [<span class="hljs-meta">Post(<span class="hljs-string">&quot;/api/profile/v1/integration/profile/upsert/{userName}/{email}/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;ProfileServiceModel&gt;&gt; UpsertDefaultProfile(<span class="hljs-built_in">string</span> userName, <span class="hljs-built_in">string</span> email, <span class="hljs-built_in">int</span> site);

    [<span class="hljs-meta">Post(<span class="hljs-string">&quot;/api/profile/v1/integration/profile/markloggedoff/{userGuid}/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;NoResponseServiceModel&gt;&gt; MarkUserAsLoggedOff(Guid userGuid, <span class="hljs-built_in">int</span> site);

    [<span class="hljs-meta">Get(<span class="hljs-string">&quot;/api/profile/v1/integration/honeypot/{userGuid}/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;HoneyPotServiceModel&gt;&gt; GetHoneypot(Guid userGuid, <span class="hljs-built_in">int</span> site);
}</code></pre>
<p>Notice how clean and readable this interface is. Each method clearly defines its HTTP verb, route template, and expected response type. The route parameters are automatically bound from method parameters, making the client both intuitive to use and resistant to errors.</p>
<p><strong>ITokenHttpClient</strong> handles authentication token management across services:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title">ITokenHttpClient</span>
{
    [<span class="hljs-meta">Post(<span class="hljs-string">&quot;/api/token/v1/token/&quot;</span>)</span>]
    Task&lt;Result&lt;TokenServiceModel&gt;&gt; FetchUserServiceToken([FromBody] TokenViewModel model);
}</code></pre>
<p>This client is crucial for our authentication flow, providing service-to-service token exchange capabilities that maintain security boundaries while enabling necessary inter-service communication.</p>
<h3 id="specialized-domain-clients">Specialized Domain Clients</h3>
<p><strong>IBuddyHttpClient</strong> manages relationship and social interaction features:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title">IBuddyHttpClient</span>
{
    [<span class="hljs-meta">Get(<span class="hljs-string">&quot;/api/buddy/v1/integration/{userGuid}/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;BuddyListServiceModel&gt;&gt; GetBuddies(Guid userGuid, <span class="hljs-built_in">int</span> site);

    [<span class="hljs-meta">Get(<span class="hljs-string">&quot;/api/buddy/v1/relationships/{userGuid}/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;BuddyListServiceModel&gt;&gt; GetRelationships(Guid userGuid, <span class="hljs-built_in">int</span> site);

    [<span class="hljs-meta">Get(<span class="hljs-string">&quot;/api/buddy/v1/endorsements/{userGuid}/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;EndorsementListServiceModel&gt;&gt; GetEndorsements(Guid userGuid, <span class="hljs-built_in">int</span> site);
}</code></pre>
<p><strong>ICheckedOutHttpClient</strong> handles profile viewing analytics:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title">ICheckedOutHttpClient</span>
{
    [<span class="hljs-meta">Post(<span class="hljs-string">&quot;/api/checkedout/v1/integration/{userGuid}/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;ProfileViewCountServiceModel&gt;&gt; ProfileViewCountAsync(Guid userGuid, <span class="hljs-built_in">int</span> site);

    [<span class="hljs-meta">Post(<span class="hljs-string">&quot;/api/checkedout/v1/integration/{site}&quot;</span>)</span>]
    Task&lt;Result&lt;GuidListServiceModel&gt;&gt; GetMostPopularProfileViews(<span class="hljs-built_in">int</span> site);
}</code></pre>
<p>These specialized clients demonstrate how Refit enables us to create focused, single-responsibility interfaces that align perfectly with our domain boundaries.</p>
<h2 id="architectural-integration-and-service-registration">Architectural Integration and Service Registration</h2>
<p>The magic of our Refit implementation lies not just in the interface definitions, but in how we integrate them into our dependency injection container. Each service registers its required HTTP clients during startup through dedicated <code>AddHttpClients</code> methods:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">AddHttpClients</span>(<span class="hljs-params">WebApplicationBuilder builder</span>)</span>
{
    builder.Services.AddRefitClient&lt;ISocialAdHttpClient&gt;()
           .ConfigureHttpClient(c =&gt; c.BaseAddress = <span class="hljs-keyword">new</span> Uri(builder.Environment.EnvironmentName != <span class="hljs-string">&quot;LocalDevelopment&quot;</span> ? builder.Configuration[<span class="hljs-string">&quot;socialadurl&quot;</span>] : <span class="hljs-string">&quot;https+http://socialadservice&quot;</span>))
           .AddHttpMessageHandler(() =&gt; <span class="hljs-keyword">new</span> AuthHeaderHandler(builder.Services.BuildServiceProvider()));

    builder.Services.AddRefitClient&lt;IImagesHttpClient&gt;()
        .ConfigureHttpClient(c =&gt; c.BaseAddress = <span class="hljs-keyword">new</span> Uri(builder.Environment.EnvironmentName != <span class="hljs-string">&quot;LocalDevelopment&quot;</span> ? builder.Configuration[<span class="hljs-string">&quot;imagesurl&quot;</span>] : <span class="hljs-string">&quot;https+http://imagesservice&quot;</span>))
        .AddHttpMessageHandler(() =&gt; <span class="hljs-keyword">new</span> AuthHeaderHandler(builder.Services.BuildServiceProvider()));
}</code></pre>
<p>This configuration pattern provides several key benefits:</p>
<ol>
<li><strong>Environment-Aware Routing</strong>: Our services automatically switch between external URLs for production and local service discovery URLs for development</li>
<li><strong>Automatic Authentication</strong>: Most clients include our custom <code>AuthHeaderHandler</code> for seamless token management</li>
<li><strong>Centralized Configuration</strong>: All HTTP client settings are managed in one location per service</li>
</ol>
<h2 id="authentication-and-security-through-http-message-handlers">Authentication and Security Through HTTP Message Handlers</h2>
<p>One of the most powerful features of our Refit implementation is the integration with custom HTTP message handlers. Our <code>AuthHeaderHandler</code> demonstrates how cross-cutting concerns like authentication can be elegantly handled:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">AuthHeaderHandler</span> : <span class="hljs-title">DelegatingHandler</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IServiceProvider _serviceProvider;

    <span class="hljs-function"><span class="hljs-keyword">protected</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">async</span> Task&lt;HttpResponseMessage&gt; <span class="hljs-title">SendAsync</span>(<span class="hljs-params">HttpRequestMessage request, CancellationToken cancellationToken</span>)</span>
    {
        <span class="hljs-keyword">var</span> userHttpClient = _serviceProvider.GetRequiredService&lt;ITokenHttpClient&gt;();
        <span class="hljs-keyword">var</span> configuration = _serviceProvider.GetRequiredService&lt;IConfiguration&gt;();

        <span class="hljs-keyword">var</span> tokenViewModel = <span class="hljs-keyword">new</span> TokenViewModel
        {
            Token = configuration[<span class="hljs-string">&quot;UserServiceToken&quot;</span>]
        };
        <span class="hljs-keyword">var</span> tokenResult = <span class="hljs-keyword">await</span> userHttpClient.FetchUserServiceToken(tokenViewModel);
        <span class="hljs-keyword">var</span> token = tokenResult?.Data?.Token;

        <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">string</span>.IsNullOrEmpty(token))
        {
            request.Headers.Authorization = <span class="hljs-keyword">new</span> System.Net.Http.Headers.AuthenticationHeaderValue(<span class="hljs-string">&quot;Bearer&quot;</span>, token);
        }

        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-keyword">base</span>.SendAsync(request, cancellationToken);
    }
}</code></pre>
<p>This handler automatically:</p>
<ul>
<li>Fetches authentication tokens when needed</li>
<li>Adds Bearer token headers to outgoing requests</li>
<li>Handles token refresh scenarios</li>
<li>Provides a consistent authentication experience across all services</li>
</ul>
<h2 id="multi-environment-configuration-strategy">Multi-Environment Configuration Strategy</h2>
<p>Our Refit configuration showcases a sophisticated approach to environment management. Notice how we handle different environments:</p>
<pre class="blog-code"><code class="hljs language-csharp">builder.Environment.EnvironmentName != <span class="hljs-string">&quot;LocalDevelopment&quot;</span> ? 
    builder.Configuration[<span class="hljs-string">&quot;profileurl&quot;</span>] : 
    <span class="hljs-string">&quot;https+http://profileservice&quot;</span></code></pre>
<p>In local development, we use Aspire&#39;s service discovery with the <code>https+http://</code> scheme, which automatically resolves to the correct local service endpoints. In production and staging environments, we use configuration-driven URLs that point to the appropriate service instances.</p>
<p>This approach provides several advantages:</p>
<ul>
<li>Seamless local development experience</li>
<li>Easy deployment across multiple environments</li>
<li>No code changes required between environments</li>
<li>Built-in support for service discovery and load balancing</li>
</ul>
<h2 id="error-handling-and-result-patterns">Error Handling and Result Patterns</h2>
<p>All our Refit clients return a consistent <code>Result</code> pattern that encapsulates both success and failure scenarios:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">Result</span>&lt;<span class="hljs-title">T</span>&gt; <span class="hljs-keyword">where</span> <span class="hljs-title">T</span> : <span class="hljs-title">ServiceBaseModel</span> 
{
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">bool</span> IsSuccess { <span class="hljs-keyword">get</span>; }
    <span class="hljs-keyword">public</span> <span class="hljs-built_in">bool</span> IsFailure { <span class="hljs-keyword">get</span>; }
    <span class="hljs-keyword">public</span> List&lt;<span class="hljs-built_in">string</span>&gt; Messages { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
    <span class="hljs-keyword">public</span> Error Error { <span class="hljs-keyword">get</span>; }
    <span class="hljs-keyword">public</span> T? Data { <span class="hljs-keyword">get</span>; <span class="hljs-keyword">set</span>; }
}</code></pre>
<p>This pattern ensures that:</p>
<ul>
<li>All API responses follow a consistent structure</li>
<li>Error handling is standardized across services</li>
<li>Consumers can easily distinguish between success and failure cases</li>
<li>Rich error information is available for debugging and user feedback</li>
</ul>
<h2 id="performance-and-scalability-considerations">Performance and Scalability Considerations</h2>
<p>Our Refit implementation includes several performance optimizations:</p>
<ol>
<li><strong>HTTP Client Factory Integration</strong>: All Refit clients are registered using the <code>AddRefitClient</code> extension, which leverages the built-in HTTP client factory for connection pooling and lifecycle management</li>
<li><strong>Async/Await Throughout</strong>: Every client method returns <code>Task</code>, ensuring non-blocking I/O operations</li>
<li><strong>Efficient Serialization</strong>: Refit uses System.Text.Json by default, providing excellent performance for our JSON-based APIs</li>
<li><strong>Connection Reuse</strong>: The underlying HttpClientFactory ensures proper connection pooling and DNS refresh cycles</li>
</ol>
<h2 id="integration-with-net-aspire">Integration with .NET Aspire</h2>
<p>Our solution leverages .NET Aspire for cloud-native development, and Refit integrates seamlessly with this ecosystem. The <code>builder.AddServiceDefaults()</code> call in each service includes:</p>
<ul>
<li><strong>Service Discovery</strong>: Automatic resolution of service endpoints</li>
<li><strong>Resilience Patterns</strong>: Built-in retry policies and circuit breakers</li>
<li><strong>Observability</strong>: Automatic OpenTelemetry instrumentation for HTTP calls</li>
<li><strong>Health Checks</strong>: Integrated health monitoring for all HTTP dependencies</li>
</ul>
<h2 id="real-world-usage-patterns">Real-World Usage Patterns</h2>
<p>Let&#39;s examine how these clients are used in practice. In our ProfileService, we inject multiple HTTP clients to gather comprehensive profile information:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">ProfileBusiness</span> : <span class="hljs-title">IProfileBusiness</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IBuddyHttpClient _buddyHttpClient;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> ICheckedOutHttpClient _checkedOutHttpClient;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IImagesHttpClient _imagesHttpClient;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;ProfileViewModel&gt; <span class="hljs-title">GetCompleteProfile</span>(<span class="hljs-params">Guid userGuid, Site site</span>)</span>
    {
        <span class="hljs-keyword">var</span> profileTask = GetBaseProfile(userGuid, site);
        <span class="hljs-keyword">var</span> buddiesTask = _buddyHttpClient.GetBuddies(userGuid, (<span class="hljs-built_in">int</span>)site);
        <span class="hljs-keyword">var</span> viewsTask = _checkedOutHttpClient.ProfileViewCountAsync(userGuid, (<span class="hljs-built_in">int</span>)site);
        <span class="hljs-keyword">var</span> imagesTask = _imagesHttpClient.GetImages(userGuid, (<span class="hljs-built_in">int</span>)site);

        <span class="hljs-keyword">await</span> Task.WhenAll(profileTask, buddiesTask, viewsTask, imagesTask);

        <span class="hljs-keyword">return</span> BuildCompleteProfile(
            profileTask.Result,
            buddiesTask.Result,
            viewsTask.Result,
            imagesTask.Result
        );
    }
}</code></pre>
<p>This pattern allows us to make parallel HTTP calls to multiple services, dramatically improving response times while maintaining clean, readable code.</p>
<h2 id="benefits-and-lessons-learned">Benefits and Lessons Learned</h2>
<p>After implementing Refit across our entire microservices ecosystem, we&#39;ve observed several key benefits:</p>
<p><strong>Developer Productivity</strong>: Refit eliminated hundreds of lines of boilerplate HTTP client code. New developers can quickly understand and extend API integrations without learning complex HTTP client patterns.</p>
<p><strong>Type Safety</strong>: Compile-time checking ensures that API contracts are maintained. When APIs change, the compiler immediately identifies affected clients.</p>
<p><strong>Maintainability</strong>: Changes to API endpoints require updates only to interface definitions. The generated implementation handles all the underlying HTTP communication details.</p>
<p><strong>Testing</strong>: Refit interfaces are easily mockable, enabling comprehensive unit testing of service interactions without complex HTTP mocking frameworks.</p>
<p><strong>Consistency</strong>: All inter-service communication follows identical patterns, reducing cognitive load when working across different parts of the system.</p>
<h2 id="future-enhancements">Future Enhancements</h2>
<p>Looking ahead, we&#39;re exploring several enhancements to our Refit implementation:</p>
<ol>
<li><strong>Automatic Retry Policies</strong>: Integration with Polly for sophisticated retry and circuit breaker patterns</li>
<li><strong>Response Caching</strong>: Adding HTTP-level caching for appropriate GET operations</li>
<li><strong>Request/Response Logging</strong>: Enhanced observability for debugging and performance monitoring</li>
<li><strong>API Versioning</strong>: Support for multiple API versions within the same client interface</li>
</ol>
<h2 id="conclusion">Conclusion</h2>
<p>Refit has transformed how we handle inter-service communication in our platform. By providing a clean, type-safe abstraction over HTTP operations, it has enabled us to build a robust microservices architecture that is both maintainable and performant.</p>
<p>The combination of declarative interfaces, automatic serialization, integrated authentication, and seamless dependency injection makes Refit an invaluable tool for any .NET microservices architecture. Our implementation demonstrates how thoughtful architectural decisions can dramatically improve developer productivity while maintaining the flexibility and scalability that modern applications demand.</p>
<p>Whether you&#39;re building your first microservices system or looking to modernize an existing one, consider how Refit can simplify your HTTP client infrastructure and enable your team to focus on delivering business value rather than managing communication plumbing.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Building Modern, Observable .NET Applications: A Deep Dive into OpenTelemetry, Scalar, and OpenAPI Integration</title>
            <link>https://www.architect4hire.com/blog/building-modern-observable-net-applications-a-deep-dive-into-opentelemetry-scalar-and-openapi-integration</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/building-modern-observable-net-applications-a-deep-dive-into-opentelemetry-scalar-and-openapi-integration</guid>
            <pubDate>Fri, 11 Jul 2025 15:29:36 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>.NET &amp; Azure</category>
            <description>When you are running multiple microservices across environments, &quot;it works on my machine&quot; stops being a joke and starts being an outage. Observability cannot be bolted on later — so here is how we build it in from the start.</description>
            <content:encoded><![CDATA[<p>In today&#39;s distributed application landscape, observability isn&#39;t just a nice-to-have—it&#39;s an absolute necessity. When you&#39;re running multiple microservices across different environments, understanding what&#39;s happening inside your applications becomes critical for maintaining performance, debugging issues, and ensuring reliability. In this article, I&#39;ll walk you through how we&#39;ve integrated OpenTelemetry, Scalar, and OpenAPI technologies into our .NET 9 solution to create a comprehensive observability and documentation strategy that scales with our microservices architecture.</p>
<h2 id="the-foundation-net-aspire-and-service-defaults">The Foundation: .NET Aspire and Service Defaults</h2>
<p>Our journey begins with .NET Aspire, Microsoft&#39;s opinionated stack for building observable, production-ready cloud applications. At the heart of our implementation lies the <code>ServiceDefaults</code> project—a centralized configuration hub that standardizes observability across all our microservices.</p>
<p>The <code>ServiceDefaults</code> project serves as our single source of truth for common configurations. Every service in our solution references this project, ensuring consistent telemetry collection, health checks, and service discovery patterns. This approach eliminates configuration drift and makes it incredibly easy to add new services that automatically inherit our observability standards.</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> TBuilder <span class="hljs-title">AddServiceDefaults</span>&lt;<span class="hljs-title">TBuilder</span>&gt;(<span class="hljs-params"><span class="hljs-keyword">this</span> TBuilder builder</span>) 
    <span class="hljs-keyword">where</span> TBuilder : IHostApplicationBuilder</span>
{
    builder.ConfigureOpenTelemetry();
    builder.AddDefaultHealthChecks();
    builder.Services.AddServiceDiscovery();

    builder.Services.ConfigureHttpClientDefaults(http =&gt;
    {
        http.AddStandardResilienceHandler();
        http.AddServiceDiscovery();
    });

    <span class="hljs-keyword">return</span> builder;
}</code></pre>
<p>This extension method is called in every service&#39;s <code>Program.cs</code>, immediately providing OpenTelemetry instrumentation, health checks, and service discovery capabilities. It&#39;s a perfect example of how .NET Aspire promotes best practices by making the right thing the easy thing.</p>
<h2 id="opentelemetry-the-observability-backbone">OpenTelemetry: The Observability Backbone</h2>
<p>OpenTelemetry forms the cornerstone of our observability strategy. Rather than relying on proprietary monitoring solutions that lock us into specific vendors, we&#39;ve embraced the open standard that provides vendor-neutral instrumentation for metrics, logs, and traces.</p>
<h3 id="comprehensive-instrumentation-strategy">Comprehensive Instrumentation Strategy</h3>
<p>Our OpenTelemetry configuration captures three critical signals:</p>
<p><strong>Tracing</strong> helps us understand request flows across our distributed system. We instrument ASP.NET Core applications to automatically capture incoming HTTP requests, and we track outgoing HTTP client calls to understand inter-service dependencies. Each trace creates a complete picture of how a user request travels through our system, making it invaluable for debugging performance bottlenecks and understanding system behavior.</p>
<p><strong>Metrics</strong> provide the quantitative data we need for monitoring system health. We collect ASP.NET Core metrics for request rates and response times, HTTP client metrics for outbound call performance, and .NET runtime metrics for garbage collection and memory usage patterns. This gives us both application-level and infrastructure-level insights.</p>
<p><strong>Logging</strong> integrates seamlessly with .NET&#39;s built-in logging framework. OpenTelemetry automatically correlates log entries with traces, so when we&#39;re debugging an issue, we can see exactly which log entries belong to a specific request trace.</p>
<pre class="blog-code"><code class="hljs language-csharp">builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =&gt;
    {
        metrics.AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation();
    })
    .WithTracing(tracing =&gt;
    {
        tracing.AddSource(builder.Environment.ApplicationName)
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation();
    });</code></pre>
<h3 id="flexible-export-strategy">Flexible Export Strategy</h3>
<p>One of OpenTelemetry&#39;s greatest strengths is its vendor-neutral approach to data export. Our configuration supports multiple export targets depending on the environment and requirements:</p>
<p>For development and testing environments, we can export to any OTLP-compatible endpoint by setting the <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> environment variable. This might be a local Jaeger instance, a Grafana stack, or any other OpenTelemetry-compatible tool.</p>
<p>For production workloads, we integrate with Azure Monitor, leveraging Microsoft&#39;s OpenTelemetry distro to send telemetry data directly to Application Insights. This provides us with enterprise-grade monitoring capabilities while maintaining the flexibility to switch monitoring backends if needed.</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> TBuilder <span class="hljs-title">AddOpenTelemetryExporters</span>&lt;<span class="hljs-title">TBuilder</span>&gt;(<span class="hljs-params"><span class="hljs-keyword">this</span> TBuilder builder</span>) 
    <span class="hljs-keyword">where</span> TBuilder : IHostApplicationBuilder</span>
{
    <span class="hljs-keyword">var</span> useOtlpExporter = !<span class="hljs-built_in">string</span>.IsNullOrWhiteSpace(
        builder.Configuration[<span class="hljs-string">&quot;OTEL_EXPORTER_OTLP_ENDPOINT&quot;</span>]);

    <span class="hljs-keyword">if</span> (useOtlpExporter)
    {
        builder.Services.AddOpenTelemetry().UseOtlpExporter();
    }

    <span class="hljs-keyword">if</span>(builder.Environment.IsDevelopment() || builder.Environment.IsProduction())
    {
        <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">string</span>.IsNullOrEmpty(
            builder.Configuration[<span class="hljs-string">&quot;APPLICATIONINSIGHTS_CONNECTION_STRING&quot;</span>]))
        {
            builder.Services.AddOpenTelemetry().UseAzureMonitor();
        }
    }

    <span class="hljs-keyword">return</span> builder;
}</code></pre>
<p>This dual-export capability means our development teams can use lightweight, local observability tools during development while production systems benefit from Azure&#39;s robust monitoring infrastructure.</p>
<h3 id="custom-instrumentation-through-action-filters">Custom Instrumentation Through Action Filters</h3>
<p>Beyond automatic instrumentation, we&#39;ve implemented custom telemetry collection through our <code>ActionControllerLogging</code> filter. This filter captures detailed audit information for every API call, including user context, session information, request bodies, and routing data.</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">ActionControllerLogging</span> : <span class="hljs-title">IAsyncActionFilter</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">OnActionExecutionAsync</span>(<span class="hljs-params">ActionExecutingContext context, ActionExecutionDelegate next</span>)</span>
    {
        HttpAuditEntryMessageModel msg = <span class="hljs-keyword">new</span> HttpAuditEntryMessageModel();

        msg.UserName = context.HttpContext.Request.Headers
            .Where(w =&gt; w.Key.ToLower() == <span class="hljs-string">&quot;username&quot;</span>).FirstOrDefault().Value;
        msg.Controller = ((ControllerBase)context.Controller)
            .ControllerContext.ActionDescriptor.ControllerName;
        msg.ActionMethod = ((ControllerBase)context.Controller)
            .ControllerContext.ActionDescriptor.ActionName;

        <span class="hljs-comment">// Additional audit data collection...</span>

        <span class="hljs-keyword">await</span> next();
    }
}</code></pre>
<p>This filter demonstrates how OpenTelemetry&#39;s extensible nature allows us to capture domain-specific telemetry that goes beyond standard web framework metrics. The audit data flows through Azure Service Bus to dedicated audit storage, creating a complete audit trail for compliance and security analysis.</p>
<h2 id="scalar-modern-api-documentation">Scalar: Modern API Documentation</h2>
<p>While OpenTelemetry handles observability, Scalar addresses another critical need: comprehensive, developer-friendly API documentation. Traditional Swagger UI, while functional, often feels outdated and lacks the polish that modern development teams expect. Scalar provides a beautiful, responsive interface that makes API exploration intuitive and enjoyable.</p>
<h3 id="seamless-integration-with-openapi">Seamless Integration with OpenAPI</h3>
<p>Our Scalar integration builds on .NET 9&#39;s native OpenAPI support. The <code>Microsoft.AspNetCore.OpenApi</code> package automatically generates OpenAPI specifications from our controller definitions, and Scalar consumes these specifications to create interactive documentation.</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> WebApplication <span class="hljs-title">ConfigureOpenApiAndScaler</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> WebApplication app</span>)</span>
{
    app.MapOpenApi();
    app.MapScalarApiReference(options =&gt;
    {
        options.WithPreferredScheme(<span class="hljs-string">&quot;Bearer&quot;</span>)
            .WithHttpBearerAuthentication(bearer =&gt;
            {
                bearer.Token = <span class="hljs-string">&quot;your-bearer-token&quot;</span>;
            });
        options.Title = <span class="hljs-string">$&quot;<span class="hljs-subst">{app.Environment.EnvironmentName}</span> - <span class="hljs-subst">{app.Environment.ApplicationName}</span>&quot;</span>;
    });
    <span class="hljs-keyword">return</span> app;
}</code></pre>
<p>This configuration provides several key benefits. The documentation automatically stays in sync with our code—when we add new endpoints or modify existing ones, the documentation updates automatically. Scalar&#39;s interface allows developers to test APIs directly from the documentation, eliminating the need to switch between documentation and testing tools.</p>
<h3 id="security-aware-documentation">Security-Aware Documentation</h3>
<p>Our Scalar configuration includes built-in authentication support. The Bearer token authentication is pre-configured, so developers can easily test secured endpoints. In development environments, this dramatically reduces the friction involved in API testing and exploration.</p>
<p>The environment-aware titles (<code>{app.Environment.EnvironmentName} - {app.Environment.ApplicationName}</code>) help developers immediately understand which environment and service they&#39;re working with, reducing confusion when working across multiple environments.</p>
<h3 id="strategic-documentation-deployment">Strategic Documentation Deployment</h3>
<p>We only expose Scalar documentation in non-production environments, maintaining security best practices while maximizing developer productivity:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-keyword">if</span> (!app.Environment.IsProduction())
{
    app.ConfigureOpenApiAndScaler();
}</code></pre>
<p>This approach ensures that our production endpoints remain secure while development and staging environments provide rich documentation experiences for both internal teams and external API consumers.</p>
<h2 id="openapi-the-contract-first-approach">OpenAPI: The Contract-First Approach</h2>
<p>OpenAPI serves as the foundation for both our documentation and our inter-service communication strategy. By leveraging .NET 9&#39;s built-in OpenAPI support, we ensure that our API contracts are automatically generated, accurate, and versioned.</p>
<h3 id="api-versioning-strategy">API Versioning Strategy</h3>
<p>Our solution implements comprehensive API versioning through the <code>Asp.Versioning</code> libraries. This allows us to evolve our APIs while maintaining backward compatibility for existing consumers.</p>
<pre class="blog-code"><code class="hljs language-csharp">builder.Services.AddApiVersioning(options =&gt;
{
    options.DefaultApiVersion = <span class="hljs-keyword">new</span> ApiVersion(primaryVersion, <span class="hljs-number">0</span>);
    options.ReportApiVersions = <span class="hljs-literal">true</span>;
    options.AssumeDefaultVersionWhenUnspecified = <span class="hljs-literal">true</span>;
    options.ApiVersionReader = ApiVersionReader.Combine(
        <span class="hljs-keyword">new</span> UrlSegmentApiVersionReader(),
        <span class="hljs-keyword">new</span> HeaderApiVersionReader(<span class="hljs-string">&quot;x-api-version&quot;</span>),
        <span class="hljs-keyword">new</span> QueryStringApiVersionReader(<span class="hljs-string">&quot;api-version&quot;</span>)
    );
});</code></pre>
<p>This configuration supports multiple versioning strategies—URL segments, headers, and query strings—giving API consumers flexibility in how they specify version requirements. The <code>ReportApiVersions</code> setting ensures that API responses include version information, helping consumers understand what versions are available.</p>
<h3 id="code-generation-and-type-safety">Code Generation and Type Safety</h3>
<p>While not explicitly shown in our current codebase, our OpenAPI specifications enable powerful code generation scenarios. Teams can generate strongly-typed HTTP clients using tools like Refit (which we include in our shared project) or NSwag, ensuring that inter-service communication is type-safe and automatically stays in sync with API changes.</p>
<p>The presence of Refit in our dependencies suggests we&#39;re using this approach for HTTP client generation:</p>
<pre class="blog-code"><code class="hljs language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">PackageReference</span> <span class="hljs-attr">Include</span>=<span class="hljs-string">&quot;Refit&quot;</span> <span class="hljs-attr">Version</span>=<span class="hljs-string">&quot;8.0.0&quot;</span> /&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">PackageReference</span> <span class="hljs-attr">Include</span>=<span class="hljs-string">&quot;Refit.HttpClientFactory&quot;</span> <span class="hljs-attr">Version</span>=<span class="hljs-string">&quot;8.0.0&quot;</span> /&gt;</span></code></pre>
<p>This enables us to define HTTP APIs as C# interfaces and let Refit handle the HTTP implementation details, creating a more maintainable and less error-prone approach to service communication.</p>
<h2 id="architectural-integration-how-it-all-works-together">Architectural Integration: How It All Works Together</h2>
<p>The true power of our observability and documentation strategy emerges from how these technologies work together to support our microservices architecture.</p>
<h3 id="service-to-service-communication">Service-to-Service Communication</h3>
<p>When a request flows through our system, OpenTelemetry automatic instrumentation creates a distributed trace that spans multiple services. If our User Service calls the Profile Service, which then calls the Image Service, we get a complete trace showing the entire request flow, including timing information for each service call.</p>
<p>The OpenAPI specifications for each service define clear contracts, while Refit-generated clients provide type-safe communication. If the Profile Service needs to call the User Service, it uses a strongly-typed interface generated from the User Service&#39;s OpenAPI specification.</p>
<h3 id="development-workflow">Development Workflow</h3>
<p>During development, our setup provides an exceptional developer experience. Developers can:</p>
<ol>
<li><strong>Explore APIs</strong> using Scalar&#39;s beautiful interface, testing endpoints directly from the documentation</li>
<li><strong>Monitor request flows</strong> using local OpenTelemetry exporters to understand how their changes affect the broader system</li>
<li><strong>Debug issues</strong> using correlated logs and traces that show exactly what happened during a specific request</li>
<li><strong>Ensure compatibility</strong> by testing against versioned APIs that clearly communicate breaking changes</li>
</ol>
<h3 id="production-operations">Production Operations</h3>
<p>In production, our observability stack provides operations teams with the insights they need to maintain system reliability:</p>
<ul>
<li><strong>Azure Monitor integration</strong> provides enterprise-grade alerting, dashboards, and analysis capabilities</li>
<li><strong>Distributed tracing</strong> enables rapid root cause analysis when issues occur across service boundaries</li>
<li><strong>Comprehensive metrics</strong> support proactive monitoring and capacity planning</li>
<li><strong>Structured logging</strong> with trace correlation makes troubleshooting specific customer issues straightforward</li>
</ul>
<h3 id="security-and-compliance">Security and Compliance</h3>
<p>Our <code>ActionControllerLogging</code> filter creates detailed audit trails for compliance requirements, while the audit data flows through Azure Service Bus to ensure reliable delivery and processing. The combination of OpenTelemetry traces and custom audit logs provides both technical observability and business-level audit capabilities.</p>
<h2 id="performance-and-overhead-considerations">Performance and Overhead Considerations</h2>
<p>Implementing comprehensive observability always raises questions about performance impact. OpenTelemetry is designed with performance in mind, using efficient sampling strategies and asynchronous export mechanisms to minimize impact on application performance.</p>
<p>Our instrumentation focuses on high-value, low-overhead data collection. ASP.NET Core instrumentation adds minimal latency to request processing, while HTTP client instrumentation helps us identify slow downstream dependencies. Runtime instrumentation provides valuable insights into garbage collection and memory usage with negligible overhead.</p>
<p>The key is selective instrumentation—we capture the telemetry that provides actionable insights while avoiding over-instrumentation that could impact performance or create overwhelming amounts of data.</p>
<h2 id="future-enhancements-and-roadmap">Future Enhancements and Roadmap</h2>
<p>Looking ahead, our observability and documentation strategy continues to evolve. Some areas we&#39;re exploring include:</p>
<p><strong>Enhanced Custom Metrics</strong>: Adding business-specific metrics that help us understand not just technical performance but business outcomes.</p>
<p><strong>Improved Documentation</strong>: Exploring ways to include more context in our API documentation, such as usage examples and integration guides.</p>
<p><strong>Advanced Tracing</strong>: Investigating custom span creation for critical business processes to provide even more detailed insights into application behavior.</p>
<p><strong>Multi-Environment Correlation</strong>: Developing strategies to correlate telemetry across development, staging, and production environments to better understand how code changes affect system behavior.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The integration of OpenTelemetry, Scalar, and OpenAPI in our .NET 9 solution creates a powerful foundation for building and operating modern microservices. OpenTelemetry provides vendor-neutral observability that scales with our application, Scalar delivers beautiful, developer-friendly API documentation, and OpenAPI ensures our service contracts are clear, versioned, and automatically maintained.</p>
<p>This combination supports both development velocity and operational excellence. Developers get the tools they need to understand and test APIs, while operations teams get the observability data required to maintain reliable, performant systems.</p>
<p>Most importantly, this approach is future-proof. By embracing open standards like OpenTelemetry and OpenAPI, we avoid vendor lock-in while still benefiting from excellent tooling and integrations. As our architecture evolves and new requirements emerge, our observability and documentation strategy can adapt without requiring fundamental changes.</p>
<p>The investment in proper observability and documentation pays dividends throughout the application lifecycle—from initial development through production operations to long-term maintenance and evolution. In today&#39;s fast-paced development environment, these capabilities aren&#39;t just helpful; they&#39;re essential for building systems that teams can confidently develop, deploy, and operate at scale.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Github Copilot in the real world</title>
            <link>https://www.architect4hire.com/blog/github-copilot-in-the-real-world</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/github-copilot-in-the-real-world</guid>
            <pubDate>Thu, 03 Jul 2025 14:58:22 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>AI-Assisted Development</category>
            <description>Copilot demos beautifully and disappoints quietly. The difference between the two is everything you do around it — how you prompt it, where you trust it, and where you absolutely do not.</description>
            <content:encoded><![CDATA[<p>In today&#39;s rapidly evolving tech landscape, developers are constantly seeking tools and approaches to accelerate development cycles while maintaining code quality. GitHub Copilot has emerged as a game-changer, transforming how developers approach cloud-based development, particularly when working with ASP.NET Core 9 and microservices architectures. This comprehensive guide explores how to leverage GitHub Copilot effectively in modern cloud development scenarios, highlighting best practices and potential pitfalls.</p>
<h2 id="understanding-github-copilot-in-2025">Understanding GitHub Copilot in 2025</h2>
<p>GitHub Copilot has evolved significantly since its initial release, becoming an indispensable AI pair programmer that understands context, suggests complete functions, and helps developers navigate complex codebases. In 2025, Copilot offers a suite of powerful features specifically optimized for cloud-native development workflows.</p>
<h3 id="key-features-enhancing-net-development">Key Features Enhancing .NET Development</h3>
<p>GitHub Copilot now offers specialized capabilities for .NET developers, including:</p>
<ol>
<li><strong>Intelligent Code Completion</strong>: Beyond simple autocompletion, Copilot now understands ASP.NET Core 9&#39;s architecture and can suggest entire controller methods, middleware configurations, and service registrations based on your project context.</li>
<li><strong>Copilot in CLI</strong>: Get real-time assistance directly in your terminal for command suggestions and explanations when working with the dotnet CLI, Docker commands, or Kubernetes configurations.</li>
<li><strong>Application Modernization</strong>: The dedicated Visual Studio extension helps upgrade legacy .NET projects to newer versions, update dependencies, and apply code fixes automatically, making migration to ASP.NET Core 9 significantly smoother.</li>
<li><strong>.NET Aspire Integration</strong>: With the release of .NET Aspire 9.3, GitHub Copilot provides specialized assistance for cloud-native application development, including orchestrated container deployments and service discovery configurations.</li>
<li><strong>Real-time Documentation</strong>: Copilot can generate and explain documentation for complex microservice architectures, saving valuable development time while ensuring knowledge transfer across teams.</li>
</ol>
<h2 id="asp-net-core-9-cloud-ready-foundation-for-microservices">ASP.NET Core 9: Cloud-Ready Foundation for Microservices</h2>
<p>ASP.NET Core 9 brings substantial improvements specifically designed for cloud-native and microservices development:</p>
<h3 id="cloud-native-features">Cloud-Native Features</h3>
<ol>
<li><strong>Enhanced Performance</strong>: ASP.NET Core 9 offers improved request processing throughput, making it ideal for high-load microservice scenarios.</li>
<li><strong>Built-in Security Enhancements</strong>: Advanced authorization policies, improved data protection APIs, and better integration with cloud identity providers streamline secure microservices deployment.</li>
<li><strong>Minimal APIs Advancement</strong>: Further refinements to the minimal API approach make creating lightweight microservices even more straightforward and maintainable.</li>
<li><strong>Cloud Integration</strong>: Native integration with major cloud providers simplifies deployment and management of containerized applications.</li>
<li><strong>Cross-Platform Excellence</strong>: True platform independence ensures consistent behavior across Windows, Linux, and cloud environments.</li>
</ol>
<h2 id="leveraging-github-copilot-for-microservices-architecture">Leveraging GitHub Copilot for Microservices Architecture</h2>
<p>Microservices architecture continues to be the preferred approach for building scalable, maintainable cloud applications. Here&#39;s how GitHub Copilot can be leveraged effectively in this context:</p>
<h3 id="do-use-copilot-for-service-interface-design">DO: Use Copilot for Service Interface Design</h3>
<p>When designing service interfaces, Copilot excels at suggesting consistent API patterns based on your existing codebase:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Simply start defining your controller and let Copilot suggest the rest</span>
[<span class="hljs-meta">ApiController</span>]
[<span class="hljs-meta">Route(<span class="hljs-string">&quot;api/[controller]&quot;</span>)</span>]
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderController</span> : <span class="hljs-title">ControllerBase</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IOrderService _orderService;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">OrderController</span>(<span class="hljs-params">IOrderService orderService</span>)</span>
    {
        _orderService = orderService;
    }

    <span class="hljs-comment">// Copilot will suggest CRUD operations with appropriate status codes,</span>
    <span class="hljs-comment">// validation, error handling, and documentation</span>
}</code></pre>
<p>Copilot will understand your domain model and suggest appropriate endpoints, status codes, and error handling approaches consistent with your existing services.</p>
<h3 id="don-and-39-t-rely-on-copilot-for-critical-business-logic-without-review">DON&#39;T: Rely on Copilot for Critical Business Logic Without Review</h3>
<p>While Copilot is excellent at generating boilerplate code and common patterns, critical business logic should always be reviewed carefully:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DON&#x27;T blindly accept complex business logic suggestions</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;<span class="hljs-built_in">decimal</span>&gt; <span class="hljs-title">CalculateOrderTotal</span>(<span class="hljs-params">OrderRequest request</span>)</span>
{
    <span class="hljs-comment">// Copilot might suggest a calculation that looks reasonable</span>
    <span class="hljs-comment">// but misses your specific business rules or edge cases</span>
    <span class="hljs-comment">// ALWAYS review these implementations carefully!</span>
}</code></pre>
<p>Always review business logic suggestions to ensure they align with your specific requirements, especially for financial calculations, security-critical operations, or complex domain rules.</p>
<h3 id="do-use-copilot-for-configuration-and-middleware-setup">DO: Use Copilot for Configuration and Middleware Setup</h3>
<p>ASP.NET Core 9&#39;s middleware pipeline and service configuration can be complex. Copilot excels at suggesting appropriate middleware ordering and configuration:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Start typing your Program.cs setup and Copilot will help</span>
<span class="hljs-keyword">var</span> builder = WebApplication.CreateBuilder(<span class="hljs-keyword">args</span>);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

<span class="hljs-comment">// Copilot can suggest appropriate service registrations</span>
<span class="hljs-comment">// for your microservices, including health checks, monitoring,</span>
<span class="hljs-comment">// and distributed tracing</span>
builder.Services.AddHealthChecks()
    .AddCheck&lt;DatabaseHealthCheck&gt;(<span class="hljs-string">&quot;database&quot;</span>)
    .AddCheck&lt;ExternalApiHealthCheck&gt;(<span class="hljs-string">&quot;external-api&quot;</span>);

<span class="hljs-comment">// Copilot will suggest appropriate middleware ordering</span>
<span class="hljs-keyword">var</span> app = builder.Build();

<span class="hljs-keyword">if</span> (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks(<span class="hljs-string">&quot;/health&quot;</span>);</code></pre>
<h3 id="don-and-39-t-generate-complex-container-orchestration-without-understanding">DON&#39;T: Generate Complex Container Orchestration Without Understanding</h3>
<p>Container orchestration with Docker Compose or Kubernetes manifests is an area where Copilot can help, but blindly accepting suggestions can lead to problems:</p>
<pre class="blog-code"><code class="hljs language-yaml"><span class="hljs-comment"># Copilot can generate Docker Compose files, but ensure you understand</span>
<span class="hljs-comment"># the networking, volume mounts, and resource allocations</span>
<span class="hljs-attr">services:</span>
  <span class="hljs-attr">api:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">yourregistry/api:latest</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;8080:80&quot;</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">ConnectionStrings__DefaultConnection=Server=db;Database=master;User=sa;Password=${DB_PASSWORD}</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">db</span>
  <span class="hljs-comment"># Review configurations thoroughly before deployment</span></code></pre>
<p>Always understand the infrastructure implications of generated configurations, especially regarding security, networking, and resource allocation.</p>
<h2 id="best-practices-for-microservices-development-with-copilot">Best Practices for Microservices Development with Copilot</h2>
<h3 id="1-service-boundary-definition">1. Service Boundary Definition</h3>
<p>Use Copilot to help identify and maintain clear service boundaries:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Let Copilot help with defining clear service contracts</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">OrderService.Contracts</span>
{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title">IOrderService</span>
    {
        <span class="hljs-comment">// Copilot can suggest a well-defined service interface</span>
        <span class="hljs-comment">// with appropriate DTOs and operation contracts</span>
    }
}</code></pre>
<p>Copilot can analyze your domain model and suggest appropriate service boundaries, but always ensure these align with your business domains and team structures.</p>
<h3 id="2-communication-patterns">2. Communication Patterns</h3>
<p>GitHub Copilot excels at implementing various communication patterns required for microservices:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Use Copilot to implement communication patterns</span>
<span class="hljs-comment">// Synchronous HTTP communication</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderClient</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> HttpClient _httpClient;

    <span class="hljs-comment">// Copilot can generate resilient HTTP client implementations</span>
    <span class="hljs-comment">// with appropriate retry policies, circuit breakers, etc.</span>
}

<span class="hljs-comment">// Message-based communication</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderCreatedEventHandler</span> : <span class="hljs-title">IEventHandler</span>&lt;<span class="hljs-title">OrderCreatedEvent</span>&gt;
{
    <span class="hljs-comment">// Copilot can suggest appropriate message handling patterns</span>
    <span class="hljs-comment">// based on your chosen messaging infrastructure</span>
}</code></pre>
<h3 id="3-data-consistency-strategies">3. Data Consistency Strategies</h3>
<p>Microservices often require sophisticated data consistency strategies. Copilot can help implement patterns like:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// Saga pattern implementation</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderProcessingSaga</span> : <span class="hljs-title">ISaga</span>&lt;<span class="hljs-title">OrderProcessingState</span>&gt;
{
    <span class="hljs-comment">// Copilot can suggest appropriate saga steps, compensation actions,</span>
    <span class="hljs-comment">// and state management approaches</span>
}

<span class="hljs-comment">// Outbox pattern for reliable message publishing</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderRepository</span> : <span class="hljs-title">IOrderRepository</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">CreateOrder</span>(<span class="hljs-params">Order order</span>)</span>
    {
        <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> transaction = <span class="hljs-keyword">await</span> _dbContext.Database.BeginTransactionAsync();
        <span class="hljs-comment">// Copilot can help implement the transactional outbox pattern</span>
        <span class="hljs-comment">// to ensure reliable message publishing with database transactions</span>
    }
}</code></pre>
<p>While Copilot provides good starting points, always review these implementations carefully as they&#39;re critical to system reliability.</p>
<h2 id="cloud-deployment-and-devops-integration">Cloud Deployment and DevOps Integration</h2>
<p>GitHub Copilot shines in streamlining cloud deployments and DevOps practices for ASP.NET Core 9 microservices:</p>
<h3 id="do-generate-infrastructure-as-code-templates">DO: Generate Infrastructure as Code Templates</h3>
<p>Copilot can help create comprehensive infrastructure definitions:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Use Copilot to generate Pulumi/Terraform/ARM templates</span>
<span class="hljs-comment">// Example Pulumi C# code for deploying a microservice to Azure</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">InfrastructureStack</span> : <span class="hljs-title">Stack</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">InfrastructureStack</span>()</span>
    {
        <span class="hljs-keyword">var</span> resourceGroup = <span class="hljs-keyword">new</span> ResourceGroup(<span class="hljs-string">&quot;myResourceGroup&quot;</span>, <span class="hljs-keyword">new</span> ResourceGroupArgs
        {
            Location = <span class="hljs-string">&quot;EastUS&quot;</span>
        });

        <span class="hljs-comment">// Copilot can generate appropriate infrastructure definitions</span>
        <span class="hljs-comment">// for your specific cloud provider and requirements</span>
    }
}</code></pre>
<h3 id="don-and-39-t-ignore-security-best-practices-in-generated-configurations">DON&#39;T: Ignore Security Best Practices in Generated Configurations</h3>
<p>Copilot might generate code that works but doesn&#39;t follow security best practices:</p>
<pre class="blog-code"><code class="hljs language-yaml"><span class="hljs-comment"># DON&#x27;T: Accept security-sensitive configurations without review</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Secret</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">database-credentials</span>
<span class="hljs-attr">data:</span>
  <span class="hljs-comment"># Copilot might suggest hardcoded credentials or insecure practices</span>
  <span class="hljs-comment"># ALWAYS review security-sensitive configurations</span>
  <span class="hljs-attr">username:</span> <span class="hljs-string">YWRtaW4=</span>  <span class="hljs-comment"># base64 encoded &quot;admin&quot;</span>
  <span class="hljs-attr">password:</span> <span class="hljs-string">cGFzc3dvcmQxMjM=</span>  <span class="hljs-comment"># base64 encoded &quot;password123&quot;</span></code></pre>
<p>Always ensure generated infrastructure code follows security best practices, especially regarding secrets management, network security, and access controls.</p>
<h2 id="testing-microservices-with-copilot">Testing Microservices with Copilot</h2>
<p>GitHub Copilot can significantly accelerate test creation:</p>
<h3 id="do-use-copilot-for-test-case-generation">DO: Use Copilot for Test Case Generation</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Let Copilot suggest test cases based on your implementation</span>
[<span class="hljs-meta">Fact</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">GetOrder_WithValidId_ReturnsOrder</span>()</span>
{
    <span class="hljs-comment">// Arrange</span>
    <span class="hljs-keyword">var</span> orderId = Guid.NewGuid();
    <span class="hljs-keyword">var</span> expectedOrder = <span class="hljs-keyword">new</span> Order { Id = orderId, Customer = <span class="hljs-string">&quot;Test Customer&quot;</span> };
    _orderRepositoryMock.Setup(repo =&gt; repo.GetOrderByIdAsync(orderId))
        .ReturnsAsync(expectedOrder);

    <span class="hljs-comment">// Act</span>
    <span class="hljs-keyword">var</span> result = <span class="hljs-keyword">await</span> _controller.GetOrder(orderId);

    <span class="hljs-comment">// Assert</span>
    <span class="hljs-keyword">var</span> okResult = Assert.IsType&lt;OkObjectResult&gt;(result.Result);
    <span class="hljs-keyword">var</span> returnedOrder = Assert.IsType&lt;OrderDto&gt;(okResult.Value);
    Assert.Equal(orderId, returnedOrder.Id);
    Assert.Equal(<span class="hljs-string">&quot;Test Customer&quot;</span>, returnedOrder.Customer);
}</code></pre>
<p>Copilot can analyze your implementation and suggest comprehensive test cases covering various scenarios, including edge cases and error conditions.</p>
<h3 id="don-and-39-t-skip-integration-testing-of-microservice-communication">DON&#39;T: Skip Integration Testing of Microservice Communication</h3>
<p>While unit tests are important, microservices require thorough integration testing:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Use Copilot to help write integration tests</span>
[<span class="hljs-meta">Fact</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task <span class="hljs-title">OrderAPI_Integration_CreatesOrderAndNotifiesShipping</span>()</span>
{
    <span class="hljs-comment">// Arrange</span>
    <span class="hljs-keyword">await</span> <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> application = <span class="hljs-keyword">new</span> WebApplicationFactory&lt;Program&gt;();
    <span class="hljs-keyword">var</span> client = application.CreateClient();

    <span class="hljs-comment">// Act &amp; Assert</span>
    <span class="hljs-comment">// Copilot can help generate comprehensive integration test scenarios</span>
    <span class="hljs-comment">// that verify cross-service communication works correctly</span>
}</code></pre>
<p>Copilot can help generate test fixtures and assertions, but ensure your tests cover the critical integration points between services.</p>
<h2 id="performance-optimization-and-monitoring">Performance Optimization and Monitoring</h2>
<p>ASP.NET Core 9 offers advanced performance features that can be leveraged with Copilot:</p>
<h3 id="do-use-copilot-for-implementing-performance-monitoring">DO: Use Copilot for Implementing Performance Monitoring</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Let Copilot help implement monitoring and telemetry</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ConfigureServices</span>(<span class="hljs-params">IServiceCollection services</span>)</span>
{
    <span class="hljs-comment">// Copilot can suggest appropriate monitoring configurations</span>
    services.AddOpenTelemetry()
        .WithTracing(builder =&gt; builder
            .AddSource(<span class="hljs-string">&quot;OrderService&quot;</span>)
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddSqlClientInstrumentation()
            .AddOtlpExporter())
        .WithMetrics(builder =&gt; builder
            .AddMeter(<span class="hljs-string">&quot;Microsoft.AspNetCore.Hosting&quot;</span>)
            .AddMeter(<span class="hljs-string">&quot;Microsoft.AspNetCore.Server.Kestrel&quot;</span>)
            .AddOtlpExporter());
}</code></pre>
<h3 id="don-and-39-t-over-optimize-prematurely">DON&#39;T: Over-Optimize Prematurely</h3>
<p>Copilot might suggest aggressive optimization techniques that aren&#39;t necessary:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DON&#x27;T: Implement complex optimizations without measuring first</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IEnumerable&lt;OrderSummary&gt;&gt; GetRecentOrders()
{
    <span class="hljs-comment">// Copilot might suggest complex caching strategies or query optimizations</span>
    <span class="hljs-comment">// that add unnecessary complexity before you&#x27;ve identified a performance issue</span>

    <span class="hljs-comment">// Start with simple, readable implementations and optimize based on actual metrics</span>
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> _orderRepository.GetRecentOrdersAsync();
}</code></pre>
<p>Always measure performance before applying complex optimizations, even when Copilot suggests them.</p>
<h2 id="error-handling-and-resilience">Error Handling and Resilience</h2>
<p>Modern cloud applications need robust error handling and resilience patterns:</p>
<h3 id="do-use-copilot-for-implementing-resilience-patterns">DO: Use Copilot for Implementing Resilience Patterns</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Let Copilot help implement resilience patterns</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> : <span class="hljs-title">IOrderService</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IHttpClientFactory _clientFactory;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;OrderResult&gt; <span class="hljs-title">CreateOrder</span>(<span class="hljs-params">OrderRequest request</span>)</span>
    {
        <span class="hljs-comment">// Copilot can suggest appropriate resilience patterns</span>
        <span class="hljs-keyword">var</span> client = _clientFactory.CreateClient(<span class="hljs-string">&quot;shipping-service&quot;</span>);

        <span class="hljs-keyword">var</span> policy = Policy
            .Handle&lt;HttpRequestException&gt;()
            .OrResult&lt;HttpResponseMessage&gt;(r =&gt; !r.IsSuccessStatusCode)
            .WaitAndRetryAsync(<span class="hljs-number">3</span>, retryAttempt =&gt; TimeSpan.FromSeconds(Math.Pow(<span class="hljs-number">2</span>, retryAttempt)));

        <span class="hljs-keyword">var</span> response = <span class="hljs-keyword">await</span> policy.ExecuteAsync(() =&gt; client.PostAsJsonAsync(<span class="hljs-string">&quot;/api/shipments&quot;</span>, request));
        <span class="hljs-comment">// Handle response appropriately</span>
    }
}</code></pre>
<h3 id="don-and-39-t-ignore-proper-error-propagation">DON&#39;T: Ignore Proper Error Propagation</h3>
<p>Copilot might not always suggest the most appropriate error handling strategy:</p>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DON&#x27;T: Accept simplistic error handling suggestions</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IActionResult&gt; <span class="hljs-title">ProcessPayment</span>(<span class="hljs-params">PaymentRequest request</span>)</span>
{
    <span class="hljs-keyword">try</span>
    {
        <span class="hljs-comment">// Process payment</span>
        <span class="hljs-keyword">return</span> Ok();
    }
    <span class="hljs-keyword">catch</span> (Exception ex)
    {
        <span class="hljs-comment">// Copilot might suggest overly broad exception handling</span>
        <span class="hljs-comment">// that masks important errors or doesn&#x27;t provide enough context</span>
        <span class="hljs-keyword">return</span> StatusCode(<span class="hljs-number">500</span>, <span class="hljs-string">&quot;An error occurred&quot;</span>);
    }
}

<span class="hljs-comment">// DO: Implement proper error handling with appropriate details and logging</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IActionResult&gt; <span class="hljs-title">ProcessPayment</span>(<span class="hljs-params">PaymentRequest request</span>)</span>
{
    <span class="hljs-keyword">try</span>
    {
        <span class="hljs-comment">// Process payment</span>
        <span class="hljs-keyword">return</span> Ok();
    }
    <span class="hljs-keyword">catch</span> (PaymentProviderException ex)
    {
        _logger.LogWarning(ex, <span class="hljs-string">&quot;Payment provider error for order {OrderId}&quot;</span>, request.OrderId);
        <span class="hljs-keyword">return</span> StatusCode(<span class="hljs-number">503</span>, <span class="hljs-keyword">new</span> ProblemDetails
        {
            Title = <span class="hljs-string">&quot;Payment service temporarily unavailable&quot;</span>,
            Detail = <span class="hljs-string">&quot;The payment couldn&#x27;t be processed due to a temporary issue with the payment provider&quot;</span>,
            Status = <span class="hljs-number">503</span>
        });
    }
    <span class="hljs-keyword">catch</span> (Exception ex)
    {
        _logger.LogError(ex, <span class="hljs-string">&quot;Unexpected error processing payment for order {OrderId}&quot;</span>, request.OrderId);
        <span class="hljs-keyword">return</span> StatusCode(<span class="hljs-number">500</span>, <span class="hljs-keyword">new</span> ProblemDetails
        {
            Title = <span class="hljs-string">&quot;Internal server error&quot;</span>,
            Detail = <span class="hljs-string">&quot;An unexpected error occurred processing your payment&quot;</span>,
            Status = <span class="hljs-number">500</span>
        });
    }
}</code></pre>
<h2 id="security-considerations">Security Considerations</h2>
<p>Security is paramount in cloud-based microservices. GitHub Copilot can both help and hinder:</p>
<h3 id="do-use-copilot-for-security-best-practices-implementation">DO: Use Copilot for Security Best Practices Implementation</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DO: Let Copilot help implement security best practices</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ConfigureServices</span>(<span class="hljs-params">IServiceCollection services</span>)</span>
{
    <span class="hljs-comment">// Copilot can suggest appropriate security configurations</span>
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =&gt;
        {
            options.Authority = Configuration[<span class="hljs-string">&quot;Authentication:Authority&quot;</span>];
            options.Audience = Configuration[<span class="hljs-string">&quot;Authentication:Audience&quot;</span>];
            options.TokenValidationParameters = <span class="hljs-keyword">new</span> TokenValidationParameters
            {
                ValidateIssuer = <span class="hljs-literal">true</span>,
                ValidateAudience = <span class="hljs-literal">true</span>,
                ValidateLifetime = <span class="hljs-literal">true</span>,
                ValidateIssuerSigningKey = <span class="hljs-literal">true</span>
            };
        });

    services.AddAuthorization(options =&gt;
    {
        options.AddPolicy(<span class="hljs-string">&quot;OrdersRead&quot;</span>, policy =&gt;
            policy.RequireClaim(<span class="hljs-string">&quot;scope&quot;</span>, <span class="hljs-string">&quot;orders:read&quot;</span>));
        options.AddPolicy(<span class="hljs-string">&quot;OrdersWrite&quot;</span>, policy =&gt;
            policy.RequireClaim(<span class="hljs-string">&quot;scope&quot;</span>, <span class="hljs-string">&quot;orders:write&quot;</span>));
    });
}</code></pre>
<h3 id="don-and-39-t-accept-generated-security-critical-code-without-review">DON&#39;T: Accept Generated Security-Critical Code Without Review</h3>
<pre class="blog-code"><code class="hljs language-csharp"><span class="hljs-comment">// DON&#x27;T: Blindly accept security-critical code</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">PasswordHasher</span> : <span class="hljs-title">IPasswordHasher</span>
{
    <span class="hljs-comment">// Copilot might suggest insecure practices or outdated algorithms</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-built_in">string</span> <span class="hljs-title">HashPassword</span>(<span class="hljs-params"><span class="hljs-built_in">string</span> password</span>)</span>
    {
        <span class="hljs-comment">// NEVER accept security-critical code without careful review</span>
        <span class="hljs-comment">// and alignment with current best practices</span>
    }
}</code></pre>
<p>Always have security-critical code reviewed, regardless of whether it was generated by Copilot.</p>
<h2 id="conclusion">Conclusion</h2>
<p>GitHub Copilot represents a transformative tool for modern cloud development with ASP.NET Core 9 and microservices. When used judiciously, it can dramatically accelerate development while maintaining code quality. The key to success lies in understanding where Copilot excels (boilerplate, patterns, configuration) and where human expertise remains essential (security, business logic, architecture).</p>
<p>By following the best practices outlined in this guide, developers can harness the power of AI-assisted coding while avoiding common pitfalls. As cloud-native development continues to evolve, the partnership between human developers and tools like GitHub Copilot will become increasingly symbiotic, enabling teams to deliver sophisticated microservice architectures with greater efficiency and quality than ever before.</p>
<p>The future of cloud development is collaborative—between humans and AI—and GitHub Copilot is at the forefront of this revolution, particularly for ASP.NET Core 9 and modern microservices architectures. Embrace this powerful tool, but do so thoughtfully, and you&#39;ll unlock new levels of productivity in your cloud development journey.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Domain-Driven Design with Onion Architecture in .NET 9: Building Scalable Microservices</title>
            <link>https://www.architect4hire.com/blog/domain-driven-design-with-onion-architecture-in-net-9-building-scalable-microservices</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/domain-driven-design-with-onion-architecture-in-net-9-building-scalable-microservices</guid>
            <pubDate>Thu, 03 Jul 2025 14:08:39 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>Architecture still matters, and most systems prove it the hard way. A real-world implementation of Domain-Driven Design on Onion Architecture in .NET 9 — dependencies pointing inward, business reality in the code.</description>
            <content:encoded><![CDATA[<p>In modern software development, creating systems that are maintainable, testable, and adaptable to change is crucial. Our platform showcases a real-world implementation of Domain-Driven Design (DDD) combined with Onion Architecture principles, all built on .NET 9. This architectural approach has allowed us to develop a robust microservices ecosystem that delivers both business agility and technical excellence.</p>
<p>.NET has evolved dramatically over the past decade. With .NET 9 and ASP.NET Core 9, Microsoft’s flagship platform has doubled down on performance, cloud-native microservices, and developer productivity. But as the tools get more powerful, the architectures we choose to build with them matter more than ever.</p>
<p>If you’re a developer or architect trying to keep your codebases clean, maintainable, and adaptable in a world of microservices, buzzwords like <strong>Domain-Driven Design (DDD)</strong>, <strong>Onion Architecture</strong>, and <strong>Clean Architecture</strong> are probably familiar. But how do these ideas actually fit together? How do you put them into practice in a real ASP.NET Core 9 solution? And how do you ensure your system stays robust as it scales?</p>
<p>In this post, we’ll break all that down. We’ll look at how Clean Architecture, Onion Architecture, and Domain-Driven Design complement each other — and how you can implement these principles in an ASP.NET Core 9 microservices solution that’s easy to reason about, test, and evolve.</p>
<p>By the end, you’ll have a practical roadmap to help you structure your next enterprise-ready .NET 9 application.</p>
<h2 id="why-architecture-still-matters"><strong>Why Architecture Still Matters</strong></h2>
<p>The .NET 9 ecosystem is rich with features: minimal APIs, powerful performance optimizations, improved cloud-native tooling, and first-class support for containerized deployments and orchestration.</p>
<p>But even the best framework can’t save a poorly structured application.</p>
<p>Modern business requirements change fast. You might need to pivot your domain logic, adapt to new integrations, split services apart, or rewrite pieces without taking the whole system down.</p>
<p>If your code is tangled up — if it’s just “controller spaghetti” directly tied to your database models — you’ll hit walls fast. That’s where proper architecture comes in.</p>
<p><em>Clean Architecture</em> and <em>Onion Architecture</em> are design patterns that help you organize your solution in a way that:</p>
<ul>
<li>Puts your <strong>business logic at the center</strong>, protected from external concerns.</li>
<li>Makes dependencies flow in one direction — inward.</li>
<li>Keeps infrastructure details (like databases, web APIs, message queues) on the outer layers, easy to swap or replace.</li>
<li>Encourages strong boundaries and separation of concerns.</li>
</ul>
<p>When you pair this with <strong>Domain-Driven Design</strong>, you also ensure that your code reflects the real business domain — the real problems you’re solving — instead of just being a CRUD wrapper around tables.</p>
<h2 id="the-microservices-landscape">The Microservices Landscape</h2>
<p>The platform consists of multiple interdependent yet decoupled microservices, each responsible for a specific business domain:</p>
<pre class="blog-code"><code class="hljs language-plaintext">// From AppHost/Program.cs - Our microservices orchestration
var token = builder.AddProject&lt;Projects.Networks_Token&gt;(&quot;tokenservice&quot;);

var buddy = builder.AddProject&lt;Projects.Networks_BuddyService&gt;(&quot;buddyservice&quot;)
    .WithReference(cache).WaitFor(cache)
    .WithReference(token).WaitFor(token)
    .WithReference(seq).WaitFor(seq);

var profile = builder.AddProject&lt;Projects.Networks_ProfileService&gt;(&quot;profileservice&quot;)
    .WithReference(cache).WaitFor(cache)
    .WithReference(token).WaitFor(token)
    .WithReference(seq).WaitFor(seq);

// Additional services...</code></pre>
<p>This structure allows us to scale, deploy, and maintain each service independently. Let&#39;s explore how DDD and Onion Architecture principles guide the design of each service.</p>
<h2 id="domain-driven-design-aligning-code-with-business-reality">Domain-Driven Design: Aligning Code with Business Reality</h2>
<p>Domain-Driven Design puts the focus on the core domain and domain logic, mapping business concepts to code structures. In the solution, each microservice represents a bounded context - a logical boundary around a specific domain model.</p>
<h3 id="ubiquitous-language">Ubiquitous Language</h3>
<p>A cornerstone of DDD is establishing a common language shared between developers and domain experts. In our codebase, this is evident in how our domain models reflect business concepts:</p>
<pre class="blog-code"><code class="hljs language-plaintext">// Domain models that reflect our business language
public class ProfileDomainModel
{
    public Guid Id { get; set; }
    public Guid Guid { get; set; }
    public string UserName { get; set; }
    public string NormalizedUserName { get; set; }
    public string Email { get; set; }
    public Site Site { get; set; }
    public string Location { get; set; }
    public int? LocationId { get; set; }
    public bool IsOnline { get; set; }
    public DateTime? LastOnline { get; set; }
    // Additional properties...
}</code></pre>
<p>This domain model captures the essence of a user profile in our system, using terms that business stakeholders understand.</p>
<h3 id="bounded-contexts">Bounded Contexts</h3>
<p>Each microservice maintains its own bounded context. For example, the ProfileService manages everything related to user profiles, while the BuddyService handles relationships between users. These services communicate through well-defined interfaces, preserving context boundaries.</p>
<h2 id="onion-architecture-dependencies-point-inward">Onion Architecture: Dependencies Point Inward</h2>
<p>Onion Architecture (also known as Clean Architecture or Hexagonal Architecture) organizes code in concentric layers, with domain models at the center. Dependencies always point inward, ensuring that the core business logic remains independent of infrastructure concerns.</p>
<h3 id="the-domain-layer-at-the-core">The Domain Layer: At the Core</h3>
<p>The domain layer represents the business concepts and rules:</p>
<pre class="blog-code"><code class="hljs language-plaintext">// Domain interfaces define the contract without implementation details
public interface IIntegrationData
{
    Task&lt;ProfileDomainModel&gt; FetchProfileAsync(Guid userGuid, Site site);
    Task&lt;List&lt;ProfileDomainModel&gt;&gt; FetchProfilListAsync(List&lt;Guid&gt; userGuids, Site site);
    Task&lt;List&lt;HoneyPotDomainModel&gt;&gt; GetHoneyPotAsync(Guid userGuid, Site site);
    Task MarkUserAsLoggedOffAsync(Guid userGuid, Site site);
    Task&lt;ProfileDomainModel&gt; UpsertDefaultProfileAsync(string userName, string email, Site site);
}</code></pre>
<p>These interfaces define what the system needs from a business perspective, without specifying how these operations are implemented.</p>
<h3 id="the-data-layer-infrastructure-concerns">The Data Layer: Infrastructure Concerns</h3>
<p>The data layer implements the domain interfaces, connecting to databases and other external systems:</p>
<pre class="blog-code"><code class="hljs language-plaintext">public class IntegrationData : IIntegrationData
{
    private readonly ProfileDbContext _context;

    public IntegrationData(ProfileDbContext context)
    {
        _context = context;
    }

    public Task&lt;ProfileDomainModel&gt; FetchProfileAsync(Guid userGuid, Site site)
    {
        return _context.Profiles
            .Where(up =&gt; up.Guid == userGuid &amp;&amp; up.Site == site)
            .FirstOrDefaultAsync();
    }

    // Other implementation methods...
}</code></pre>
<p>Here, the data layer depends on domain interfaces but implements them using Entity Framework Core. The domain layer remains completely unaware of these implementation details.</p>
<h3 id="the-business-layer-application-services">The Business Layer: Application Services</h3>
<p>The business layer orchestrates operations and enforces business rules:</p>
<pre class="blog-code"><code class="hljs language-plaintext">public class IntegrationBusiness : IIntegrationBusiness
{
    private readonly IIntegrationData _data;

    public IntegrationBusiness(IIntegrationData data)
    {
        _data = data;
    }

    public async Task&lt;ProfileServiceModel&gt; FetchProfileAsync(Guid userGuid, Site site)
    {
        var domain = await _data.FetchProfileAsync(userGuid, site);
        if (domain == null)
        {
            throw new ProfileNotFoundException($&quot;Profile not found for userGuid: {userGuid} on site: {site}&quot;);
        }
        return domain.MapToServiceModel();
    }

    // Other business methods...
}</code></pre>
<p>The business layer depends on abstractions from both the domain and data layers, but never on concrete implementations. This ensures flexibility and testability.</p>
<h2 id="the-facade-pattern-simplifying-interactions">The Facade Pattern: Simplifying Interactions</h2>
<p>One key architectural choice in solution is the extensive use of the Facade pattern. The Facade sits between the business layer and the API layer, providing a simplified interface for clients and handling cross-cutting concerns:</p>
<pre class="blog-code"><code class="hljs language-plaintext">public class IntegrationFacade : IIntegrationFacade
{
    private readonly IIntegrationBusiness _business;
    private readonly CacheAside _cache;

    public IntegrationFacade(IIntegrationBusiness business, IDistributedCache cache)
    {
        _business = business;
        _cache = new CacheAside(cache);
    }

    public async Task&lt;Result&lt;ProfileServiceModel&gt;&gt; FetchProfileAsync(Guid userGuid, Site site)
    {
        try
        {
            if (userGuid == Guid.Empty || !Enum.IsDefined(typeof(Site), site))
                return Result&lt;ProfileServiceModel&gt;.Failure(Error.InvalidInput, 
                    new List&lt;string&gt; { &quot;Invalid userGuid or site.&quot; });

            string cacheKey = $&quot;profile_{userGuid}_{site}&quot;;
            var data = await _cache.FetchFromCache&lt;ProfileServiceModel&gt;(cacheKey, async () =&gt;
            {
                return await _business.FetchProfileAsync(userGuid, site);
            }, new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTime.UtcNow.AddHours(24) });

            return Result&lt;ProfileServiceModel&gt;.Success(data);
        }
        catch (ProfileNotFoundException)
        {
            return Result&lt;ProfileServiceModel&gt;.Failure(Error.NotFound, 
                new List&lt;string&gt; { &quot;Profile not found.&quot; });
        }
    }

    // Other facade methods...
}</code></pre>
<p>The Facade pattern offers several benefits:</p>
<ol>
<li><strong>Centralized Input Validation</strong>: The Facade layer validates inputs before passing them to the business layer.</li>
<li><strong>Standardized Error Handling</strong>: Exceptions are caught and converted into consistent response formats.</li>
<li><strong>Cross-Cutting Concerns</strong>: Caching, logging, and other aspects are managed here.</li>
<li><strong>Simplified API</strong>: Controllers interact with a clean, consistent interface.</li>
</ol>
<h2 id="dependency-injection-wiring-it-all-together">Dependency Injection: Wiring It All Together</h2>
<p>.NET 9&#39;s built-in dependency injection container manages the instantiation and lifetime of our components:</p>
<pre class="blog-code"><code class="hljs language-plaintext">void AddScopes(WebApplicationBuilder builder)
{
    builder.Services.AddScoped&lt;IIntegrationFacade, IntegrationFacade&gt;();
    builder.Services.AddScoped&lt;IIntegrationBusiness, IntegrationBusiness&gt;();
    builder.Services.AddScoped&lt;IIntegrationData, IntegrationData&gt;();
    builder.Services.AddScoped&lt;IProfileFacade, ProfileFacade&gt;();
    builder.Services.AddScoped&lt;IProfileBusiness, ProfileBusiness&gt;();
    builder.Services.AddScoped&lt;IProfileData, ProfileData&gt;();
    builder.Services.AddScoped&lt;IHoneyPotFacade, HoneyPotFacade&gt;();
    builder.Services.AddScoped&lt;IHoneyPotBusiness, HoneyPotBusiness&gt;();
    builder.Services.AddScoped&lt;IHoneyPotData, HoneyPotData&gt;();
    builder.Services.AddScoped&lt;IServiceBusSender, ServiceBusSender&gt;();
    // Additional registrations...
}</code></pre>
<p>This configuration allows for flexible composition while maintaining the dependency inversion principle. Higher-level components depend on abstractions, not concrete implementations.</p>
<h2 id="api-layer-thin-controllers">API Layer: Thin Controllers</h2>
<p>With our layered architecture in place, controllers remain remarkably thin:</p>
<pre class="blog-code"><code class="hljs language-plaintext">[ApiController]
[Route(&quot;api/buddy/v{v:apiVersion}/[controller]&quot;)]
[ApiVersion(1)]
public class IntegrationController : ControllerBase
{
    private readonly IIntegrationFacade _facade;

    public IntegrationController(IIntegrationFacade facade)
    {
        _facade = facade;
    }

    [HttpGet(&quot;{userGuid:guid}/{site:int}&quot;)]
    public async Task&lt;IActionResult&gt; GetBuddies(Guid userGuid, int site)
    {
        var result = await _facade.GetBuddies(userGuid, (Site)site);
        return result.Match();
    }

    // Other endpoint methods...
}</code></pre>
<p>The controllers simply map HTTP requests to facade calls and convert the results to HTTP responses, without any business logic.</p>
<h2 id="inter-service-communication">Inter-Service Communication</h2>
<p>In a microservices architecture, services need to communicate. We implement two primary methods:</p>
<h3 id="http-clients-with-refit">HTTP Clients with Refit</h3>
<p>For synchronous communication, we use typed HTTP clients with Refit:</p>
<pre class="blog-code"><code class="hljs language-plaintext">void AddHttpClients(WebApplicationBuilder builder)
{
    builder.Services.AddRefitClient&lt;IProfileHttpClient&gt;()
        .ConfigureHttpClient(c =&gt; c.BaseAddress = new Uri(
            builder.Environment.EnvironmentName != &quot;LocalDevelopment&quot; 
                ? builder.Configuration[&quot;profileurl&quot;] 
                : &quot;https+http://profileservice&quot;))
        .AddHttpMessageHandler(() =&gt; new AuthHeaderHandler(builder.Services.BuildServiceProvider()));

    // Additional HTTP clients...
}</code></pre>
<p>These strongly-typed clients ensure type safety when communicating between services.</p>
<h3 id="service-bus-for-asynchronous-communication">Service Bus for Asynchronous Communication</h3>
<p>For event-based communication, we leverage Azure Service Bus:</p>
<pre class="blog-code"><code class="hljs language-plaintext">builder.Services.AddSingleton&lt;ServiceBusClient&gt;(sp =&gt;
    new ServiceBusClient(builder.Configuration.GetConnectionString(&quot;servicebus&quot;)));</code></pre>
<p>This facilitates loosely coupled, asynchronous interactions between services.</p>
<h2 id="infrastructure-as-code-with-net-aspire">Infrastructure as Code with .NET Aspire</h2>
<p>Our solution leverages .NET Aspire to define and orchestrate the entire microservices ecosystem:</p>
<pre class="blog-code"><code class="hljs language-plaintext">var builder = DistributedApplication.CreateBuilder(args);

// Infrastructure components
var cache = builder.AddRedis(&quot;cache&quot;)
    .WithLifetime(ContainerLifetime.Persistent)
    .WithHostPort(6379)
    .WithRedisInsight()
    .WithRedisCommander();

// Service definitions with dependencies
var profile = builder.AddProject&lt;Projects.Networks_ProfileService&gt;(&quot;profileservice&quot;)
    .WithReference(cache).WaitFor(cache)
    .WithReference(token).WaitFor(token)
    .WithReference(seq).WaitFor(seq);</code></pre>
<p>This declarative approach ensures consistent deployment and proper dependency management.</p>
<h2 id="cross-cutting-concerns">Cross-Cutting Concerns</h2>
<p>Several aspects span multiple services and layers:</p>
<h3 id="caching">Caching</h3>
<p>The Facade layer implements a cache-aside pattern to improve performance:</p>
<pre class="blog-code"><code class="hljs language-plaintext">var data = await _cache.FetchFromCache&lt;BuddyListServiceModel&gt;(cacheKey, async () =&gt;
{
    return await _business.GetBuddies(userGuid, site);
}, new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTime.UtcNow.AddHours(24) });</code></pre>
<h3 id="authentication-and-authorization">Authentication and Authorization</h3>
<p>Security is managed consistently across services:</p>
<pre class="blog-code"><code class="hljs language-plaintext">void AddAuthentication(WebApplicationBuilder builder)
{
    builder.Services.AddAuthentication(options =&gt;
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =&gt;
    {
        var key = Encoding.UTF8.GetBytes(
            builder.Configuration[&quot;Jwt:Key&quot;];
            
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(key),
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration[&quot;Jwt:Issuer&quot;],
            ValidateAudience = true,
            ValidAudience = builder.Configuration[&quot;Jwt:Audience&quot;],
            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero
        };
    });
}</code></pre>
<h3 id="logging-and-monitoring">Logging and Monitoring</h3>
<p>OpenTelemetry integration provides comprehensive observability:</p>
<pre class="blog-code"><code class="hljs language-plaintext">public static TBuilder ConfigureOpenTelemetry&lt;TBuilder&gt;(this TBuilder builder) 
    where TBuilder : IHostApplicationBuilder
{
    builder.Logging.AddOpenTelemetry(logging =&gt;
    {
        logging.IncludeFormattedMessage = true;
        logging.IncludeScopes = true;
    });

    builder.Services.AddOpenTelemetry()
        .WithMetrics(metrics =&gt;
        {
            metrics.AddAspNetCoreInstrumentation()
                .AddHttpClientInstrumentation()
                .AddRuntimeInstrumentation();
        })
        .WithTracing(tracing =&gt;
        {
            tracing.AddSource(builder.Environment.ApplicationName)
                .AddAspNetCoreInstrumentation()
                .AddHttpClientInstrumentation();
        });

    // Configure exporters...
    return builder;
}</code></pre>
<h2 id="benefits-of-our-architectural-approach">Benefits of Our Architectural Approach</h2>
<p>This DDD + Onion Architecture approach delivers numerous advantages:</p>
<h3 id="1-testability">1. Testability</h3>
<p>Since business logic depends only on abstractions, unit testing becomes straightforward:</p>
<pre class="blog-code"><code class="hljs language-plaintext">[TestMethod]
public async Task AssociateUserAsync_Valid_CallsDataAndProfile()
{
    // Arrange
    var model = new AssociateViewModel { UserName = userName, Level = level };
    _userDataMock.Setup(d =&gt; d.AssociateUserAsync(userName, level, site))
        .ReturnsAsync(email);
    _profileMock.Setup(p =&gt; p.UpsertDefaultProfile(userName, email, (int)site))
        .ReturnsAsync(Result&lt;ProfileServiceModel&gt;.Success(
            new ProfileServiceModel { Guid = Guid.NewGuid() }));

    // Act
    await _business.AssociateUserAsync(model, site);

    // Assert
    _userDataMock.Verify(d =&gt; d.AssociateUserAsync(userName, level, site), Times.Once);
    _profileMock.Verify(p =&gt; p.UpsertDefaultProfile(userName, email, (int)site), Times.Once);
}</code></pre>
<h3 id="2-maintainability">2. Maintainability</h3>
<p>The clear separation of concerns makes the codebase more maintainable. Developers can understand and modify specific parts of the system without needing to comprehend the entire system.</p>
<h3 id="3-flexibility">3. Flexibility</h3>
<p>Implementation details can change without affecting the core business logic:</p>
<ul>
<li>Switching from Cosmos DB to SQL Server would only affect the Data layer</li>
<li>Changing from REST APIs to gRPC would only impact the API layer</li>
<li>Updating caching strategies would be isolated to the Facade layer</li>
</ul>
<h3 id="4-scalability">4. Scalability</h3>
<p>Each microservice can scale independently based on its specific load characteristics, optimizing resource usage.</p>
<h2 id="challenges-and-solutions">Challenges and Solutions</h2>
<p>While the benefits are significant, this architecture isn&#39;t without challenges:</p>
<h3 id="1-development-complexity">1. Development Complexity</h3>
<p><strong>Challenge</strong>: More layers and abstractions mean more code to write initially.</p>
<p><strong>Solution</strong>: We established clear templates and conventions to streamline development and maintain consistency.</p>
<h3 id="2-distributed-data-management">2. Distributed Data Management</h3>
<p><strong>Challenge</strong>: With each service owning its data, ensuring consistency across services is complex.</p>
<p><strong>Solution</strong>: We employ event-driven patterns for eventual consistency and carefully design service boundaries to minimize cross-service transactions.</p>
<h3 id="3-performance-overhead">3. Performance Overhead</h3>
<p><strong>Challenge</strong>: Additional layers and service boundaries can introduce performance overhead.</p>
<p><strong>Solution</strong>: Strategic caching in the Facade layer, connection pooling, and efficient serialization mitigate these concerns.</p>
<h2 id="conclusion-enabling-business-agility-through-technical-excellence">Conclusion: Enabling Business Agility Through Technical Excellence</h2>
<p>Our DDD + Onion Architecture approach has enabled our client to grow and evolve rapidly while maintaining code quality and developer productivity. By ensuring that our code structure reflects business realities and maintains clean separation of concerns, we&#39;ve created a system that&#39;s both technically sound and business-aligned.</p>
<p>The combination of Domain-Driven Design, Onion Architecture, and the Facade pattern has proven particularly powerful. It allows domain experts to contribute meaningfully to the design, helps developers understand the business context, and maintains clean boundaries between different aspects of the system.</p>
<p>As the platform continues to grow, this architectural foundation will enable us to adapt to changing requirements, scale individual components as needed, and incorporate new technologies without destabilizing the entire system. The result is a microservices ecosystem that delivers business value consistently while remaining technically maintainable and evolvable.</p>
<p>In an era where software must continuously adapt to changing business needs, investing in solid architectural principles isn&#39;t just a technical concern—it&#39;s a strategic business advantage. Our experience with this project demonstrates that Domain-Driven Design, Onion Architecture, and clean separation of concerns provide a robust foundation for building complex, scalable, and maintainable microservices systems in .NET 9.</p>
]]></content:encoded>
        </item>
        <item>
            <title>How the Architect4Hire SOA Framework Powers Real .NET Aspire Applications in the Wild</title>
            <link>https://www.architect4hire.com/blog/how-the-architect4hire-soa-framework-powers-real-net-aspire-applications-in-the-wild</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/how-the-architect4hire-soa-framework-powers-real-net-aspire-applications-in-the-wild</guid>
            <pubDate>Sat, 14 Jun 2025 11:11:07 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>Architecture &amp; Patterns</category>
            <description>Cutting through the marketing gloss on .NET Aspire: how it is actually used in production, by real teams, for real clients, on an architecture that scales.</description>
            <content:encoded><![CDATA[<p>If you’ve been paying attention to .NET’s evolution into a full-fledged cloud-native powerhouse, you’ve likely heard about <a href="https://learn.microsoft.com/en-us/dotnet/aspire/" target="_blank" rel="noopener noreferrer">.NET Aspire</a>. But let’s cut through the marketing gloss and talk about how it&#39;s actually used in production by real teams, for real clients with a solid architecture that scales.</p>
<p>At Architect4Hire, we’ve been pushing the limits of what ASP.NET Core 9 and .NET Aspire can do, and our latest framework implementation for BuddyNetworks is a showcase of modern software architecture done right.</p>
<p>From microservice orchestration to clean separation of concerns, it’s the kind of stuff that makes devs sleep better at night and apps hum in production.</p>
<hr>
<h2 id="building-blocks-multi-layer-architecture-the-real-way">🧱 Building Blocks: Multi-Layer Architecture, the Real Way</h2>
<p>At the heart of the Architect4Hire framework is a well-structured multi-layer architecture that enforces a strict separation of concerns. Each layer is responsible for a focused set of responsibilities. The Presentation Layer handles HTTP requests, routing, and authentication. The Domain Layer is subdivided into facade, business, and data sublayers—each encapsulating orchestration, core logic, and persistence, respectively. The Shared Layer houses reusable utilities and enums, and implements a <code>Result</code> pattern to standardize error handling. This clear architectural design improves maintainability, enhances testability, and keeps teams productive even as the codebase scales. This structure is the foundation for building reliable and adaptable systems.</p>
<p>Example controller:</p>
<pre class="blog-code"><code class="hljs language-plaintext">[Authorize]
[ApiController]
[Route(&quot;api/v{v:apiVersion}/[controller]&quot;)]
[ApiVersion(1)]
public class UserController : ControllerBase
{
    private readonly IUserFacade _userFacade;

    public UserController(IUserFacade userFacade)
    {
        _userFacade = userFacade;
    }

    [HttpPost(&quot;register&quot;)]
    public async Task&lt;IActionResult&gt; RegisterUser([FromBody] RegisterViewModel model, [FromHeader] int site)
    {
        var result = await _userFacade.RegisterUserAsync(model, site);
        return result.Match();
    }
}</code></pre>
<h2 id="soa-aspire-clean-scalable-microservices">🔁 SOA + Aspire = Clean, Scalable Microservices</h2>
<p>The framework is built around a service-oriented architecture (SOA) where each domain is represented by its own microservice. These services, such as <code>UserService</code>, <code>AdminService</code>, and <code>ProfileService</code>, communicate via REST and Azure Service Bus. The .NET Aspire project plays a critical role in service discovery, observability, and orchestration. Aspire allows each service to be discovered dynamically and monitored with health checks and distributed tracing. It makes the deployment and scaling of services in cloud-native environments seamless and secure. By leveraging Aspire, we&#39;re able to provide a modern, production-grade microservice ecosystem with very little friction and excellent visibility into system health.</p>
<p>AppHost and service discovery:</p>
<pre class="blog-code"><code class="hljs language-plaintext">builder.Services.AddRefitClient&lt;IAdminHttpClient&gt;()
    .ConfigureHttpClient(c =&gt; c.BaseAddress = new Uri(
        builder.Environment.IsProduction()
            ? builder.Configuration[&quot;adminurl&quot;]
            : &quot;https+http://adminservice&quot;));

builder.AddAzureServiceBusClient(&quot;servicebus&quot;);</code></pre>
<p><img src="/img/blog/how-the-architect4hire-soa-framework-powers-real-net-aspire-applications-in-the-wild/img-1.png" alt="How the Architect4Hire SOA Framework Powers Real .NET Aspire Applications in the Wild" loading="lazy" decoding="async"></p>
<h2 id="aspire-empowers-local-development-too">🧪 Aspire Empowers Local Development Too</h2>
<p>One of the lesser-sung advantages of .NET Aspire is how much it improves the local development experience. Aspire supports virtualized cloud resources, allowing developers to simulate complex distributed systems without requiring full Azure infrastructure locally. Developers can run all microservices in tandem, inspect traffic between them, and leverage local emulators for Azure services, such as Service Bus or Cosmos DB. The Aspire AppHost automatically wires up service dependencies, enabling consistent startup across environments. This means devs spend less time fiddling with Docker or local secrets and more time writing and debugging actual features. It’s local-first cloud-native development, done right.</p>
<p>Example Aspire app configuration:</p>
<pre class="blog-code"><code class="hljs language-plaintext">var builder = DistributedApplication.CreateBuilder(args);

var cosmos = builder.AddAzureCosmosDB(&quot;cosmos&quot;)
    .WithDatabase(&quot;BuddyNetworks&quot;);

var userService = builder.AddProject&lt;Projects.UserService&gt;(&quot;userservice&quot;)
    .WithReference(cosmos);

builder.Build().Run();</code></pre>
<p><img src="/img/blog/how-the-architect4hire-soa-framework-powers-real-net-aspire-applications-in-the-wild/img-2.png" alt="How the Architect4Hire SOA Framework Powers Real .NET Aspire Applications in the Wild" loading="lazy" decoding="async"></p>
<h2 id="asp-net-core-9-in-action">✨ ASP.NET Core 9 in Action</h2>
<p>ASP.NET Core 9 provides a modern, high-performance foundation for building cloud-native APIs. With support for minimal APIs, developers can write faster, leaner endpoints that are easier to test and maintain. Versioned routing, detailed response annotations, and OpenAPI integration make documentation and client development straightforward. In the Architect4Hire framework, we implement consistent response types using <code>Result.Match()</code>, which converts domain results into HTTP responses cleanly. We also integrate FluentValidation for input validation and use claims-based JWT authentication to secure endpoints. ASP.NET Core 9 gives us the flexibility, performance, and security features needed to meet enterprise-grade software requirements.</p>
<p>Using <code>Match()</code> for clean responses:</p>
<pre class="blog-code"><code class="hljs language-plaintext">public static IActionResult Match&lt;TModel&gt;(this Result&lt;TModel&gt; result) where TModel : ServiceBaseModel
{
    if (result.IsSuccess)
    {
        return new OkObjectResult(result);
    }

    return result.Error switch
    {
        Error.NotFound =&gt; new NotFoundObjectResult(result),
        Error.InvalidInput =&gt; new BadRequestObjectResult(result),
        Error.Unauthorized =&gt; new UnauthorizedObjectResult(result),
        Error.Forbidden =&gt; new ForbidResult(),
        Error.InternalServerError =&gt; new ObjectResult(result) { StatusCode = 500 },
        Error.DuplicateEmail =&gt; new ObjectResult(result) { StatusCode = 432 },
        _ =&gt; new ObjectResult(result) { StatusCode = 500 }
    };
}</code></pre>
<h2 id="the-facade-pattern-our-secret-weapon">🧐 The Facade Pattern: Our Secret Weapon</h2>
<p>The Facade Layer is one of the standout features of the Architect4Hire framework. It acts as an intermediary between the HTTP-facing Presentation Layer and the logic-rich Business Layer. This pattern abstracts away validation, exception translation, and cross-service orchestration, keeping the controllers thin and focused. Facades validate input using FluentValidation, convert domain exceptions into typed errors, and return standardized <code>Result</code> objects to maintain consistency across the API. The pattern ensures that changing business rules doesn&#39;t require controller refactoring, making the application more resilient to change. It also enhances testability and reusability by isolating orchestration from routing logic.</p>
<p>Example facade method:</p>
<pre class="blog-code"><code class="hljs language-plaintext">public async Task&lt;Result&lt;NoResponseServiceModel&gt;&gt; RegisterUserAsync(RegisterViewModel model, int site)
{
    var validationResult = _registerValidator.Validate(model);
    if (!validationResult.IsValid)
    {
        var errorMessages = validationResult.Errors.Select(e =&gt; e.ErrorMessage).ToList();
        return Result&lt;NoResponseServiceModel&gt;.Failure(Error.InvalidInput, errorMessages);
    }

    try
    {
        await _userBusiness.RegisterUserAsync(model, site);
        return Result&lt;NoResponseServiceModel&gt;.Success();
    }
    catch (DuplicateEmailException)
    {
        return Result&lt;NoResponseServiceModel&gt;.Failure(Error.DuplicateEmail, new List&lt;string&gt;());
    }
    catch (Exception ex)
    {
        return Result&lt;NoResponseServiceModel&gt;.Failure(Error.InternalServerError, new List&lt;string&gt; { ex.Message });
    }
}</code></pre>
<h2 id="data-done-right">📃 Data Done Right</h2>
<p>Data persistence in the Architect4Hire SOA Framework is handled through clean repository-style interfaces and EF Core-backed implementations. SQL Server is used for relational storage, especially for identity and transactional data, while Cosmos DB supports high-throughput document storage for services like profiles. The <code>UserData</code> class encapsulates all Identity-related operations, throwing custom domain exceptions for known failure conditions (e.g., duplicate username or email). This allows business logic to handle failures in a domain-aware fashion. Every data layer implementation is unit-testable, and our framework encourages clear boundaries, preventing business logic from leaking into persistence. It’s a clean, robust approach to modern data management.</p>
<p>Sample data operation:</p>
<pre class="blog-code"><code class="hljs language-plaintext">public async Task&lt;string&gt; RegisterUserAsync(string email, string userName, string password, MemberLevel level, int site)
{
    var user = new ApplicationUser { Email = email, UserName = userName, EmailConfirmed = false };
    var result = await _userManager.CreateAsync(user, password);

    if (!result.Succeeded)
    {
        switch (result.Errors.FirstOrDefault()?.Code.ToLower())
        {
            case &quot;duplicateusername&quot;: throw new DuplicateUserNameException(&quot;Username already exists.&quot;);
            case &quot;duplicateemail&quot;: throw new DuplicateEmailException(&quot;Email already exists.&quot;);
            default: throw new Exception(&quot;User registration failed: &quot; + string.Join(&quot;, &quot;, result.Errors.Select(e =&gt; e.Description)));
        }
    }

    await _userManager.AddClaimAsync(user, new Claim(&quot;MemberBB4N&quot;, &quot;true&quot;));
    await _userManager.AddClaimAsync(user, new Claim(&quot;BB4NLevel&quot;, level.ToString()));

    return user.Id;
}</code></pre>
<p><img src="/img/blog/how-the-architect4hire-soa-framework-powers-real-net-aspire-applications-in-the-wild/img-3.png" alt="How the Architect4Hire SOA Framework Powers Real .NET Aspire Applications in the Wild" loading="lazy" decoding="async"></p>
<h2 id="testing-because-production-is-not-qa">🧪 Testing, Because Production is Not QA</h2>
<p>Robust testing practices are baked into every layer of our framework. We employ unit tests for business logic and validation, integration tests for verifying component interactions, and full-stack API tests for endpoint coverage. For example, <code>RegisterUserAsync</code> is tested from input validation all the way through to Cosmos DB writes and email orchestration. We use in-memory test doubles and mocking to simulate external services. Testing is integrated into our CI/CD pipeline using GitHub Actions and Azure DevOps, ensuring that no code reaches production without proper coverage. Testing isn&#39;t an afterthought—it&#39;s foundational to delivering stable, predictable software in real-world deployments.</p>
<p>Unit test strategy example:</p>
<pre class="blog-code"><code class="hljs language-plaintext">[Fact]
public async Task RegisterUserAsync_ShouldReturnSuccess_WhenValidInput()
{
    var model = new RegisterViewModel { UserName = &quot;testuser&quot;, Email = &quot;test@example.com&quot;, Password = &quot;Password123&quot;, Level = MemberLevel.Standard };
    var result = await _facade.RegisterUserAsync(model, site: 1);

    Assert.True(result.IsSuccess);
}</code></pre>
<h2 id="want-a-template-like-this">Want a Template Like This?</h2>
<p>This architecture isn’t locked away behind corporate doors. The Architect4Hire SOA Framework is available as a reusable solution template that you can adopt or adapt to your own business needs. Whether you&#39;re building a dating app, a logistics platform, or a social media service, this architecture provides a head start with proven patterns, a working service mesh, and all the boilerplate wiring done correctly. Want a copy? Reach out to us directly.</p>
<p>📍 <strong>Visit</strong><a href="https://architect4hire.com/" target="_blank" rel="noopener noreferrer">Architect4Hire.com</a> and let’s talk about how we can help you build better software, faster.</p>
]]></content:encoded>
        </item>
        <item>
            <title>Aspire Cafe - A hands-on template for Aspire and ASP.NET Core</title>
            <link>https://www.architect4hire.com/blog/aspire-cafe-a-hands-on-template-for-aspire-and-asp-net-core</link>
            <guid isPermaLink="true">https://www.architect4hire.com/blog/aspire-cafe-a-hands-on-template-for-aspire-and-asp-net-core</guid>
            <pubDate>Sun, 11 May 2025 14:49:56 GMT</pubDate>
            <dc:creator>Robert Felkins</dc:creator>
            <category>.NET &amp; Azure</category>
            <description>Building cloud-native apps can feel like juggling flaming swords while riding a unicycle. Aspire Café is a hands-on, working template — microservices, orchestration, infrastructure and patterns you can actually read.</description>
            <content:encoded><![CDATA[<p>Let’s face it—building modern cloud-native apps can feel like juggling flaming swords while riding a unicycle. That’s where <a href="https://architect4hire.com/" target="_blank" rel="noopener noreferrer">architect4hire.com</a> comes in. Whether knee-deep in code or mapping out the next big system architecture, Architect4Hire offers guidance, patterns, and real-world examples to help you get there faster (with fewer headaches).</p>
<p>Our Aspire Café project, built with .NET Aspire and ASP.NET Core, is like a hands-on blueprint for developers who want to learn by doing. It takes all those best practices we hear about—like clean architecture, microservices, and containerized deployments—and shows them in action. So if you want to level up your cloud-native game, Architect4Hire’s got your back.</p>
<p>Make sure to bookmark the git repo at <a href="https://github.com/architect4hire/aspirecafe" target="_blank" rel="noopener noreferrer">https://github.com/architect4hire/aspirecafe</a></p>
<h2 id="microservices-the-caf-and-39-s-backbone">☕ Microservices: The Café&#39;s Backbone</h2>
<p>The Aspire Café solution exemplifies a microservices architecture, where each service is dedicated to a specific business function:</p>
<ul>
<li><strong>Product API</strong>: Manages the product catalog and inventory.</li>
<li><strong>Counter API</strong>: Handles order processing and payments.</li>
<li><strong>Kitchen API</strong>: Manages food preparation workflows.</li>
<li><strong>Barista API</strong>: Handles beverage preparation workflows.</li>
<li><strong>Order Summary API</strong>: Aggregates order information across services.</li>
</ul>
<p>Each service adheres to the Single Responsibility Principle, allowing for independent development, deployment, and scaling. This modularity enhances fault isolation and facilitates targeted updates.</p>
<hr>
<h2 id="net-aspire-application-host-orchestrating-the-services">🔧 .NET Aspire Application Host: Orchestrating the Services</h2>
<p>At the heart of the solution is the <strong>Aspire Application Host</strong>, which orchestrates the various services and infrastructure components. It simplifies the management of distributed applications by handling:</p>
<ul>
<li><strong>Service registration and dependencies</strong>: Automatically wires up services and their dependencies.</li>
<li><strong>Infrastructure provisioning</strong>: Manages resources like databases, caches, and message brokers.</li>
<li><strong>Service discovery and configuration</strong>: Facilitates service communication through automatic discovery and configuration.</li>
</ul>
<p>This orchestration enhances the local development experience by simplifying the management of the application&#39;s configuration and interconnections.</p>
<hr>
<h2 id="infrastructure-components-building-blocks-of-the-caf">🏗️ Infrastructure Components: Building Blocks of the Café</h2>
<p>The solution leverages several cloud-native services, managed through Aspire:</p>
<ul>
<li><strong>Azure Cosmos DB</strong>: A NoSQL database for storing product and order information. The .NET Aspire integration enables seamless connection to existing Cosmos DB instances or creation of new ones.</li>
<li><strong>Redis Cache</strong>: Provides distributed caching for improved performance. Aspire&#39;s integration allows for easy setup and management of Redis instances.</li>
<li><strong>Azure Service Bus</strong>: Acts as a message broker for asynchronous communication between services. Aspire simplifies the integration with Azure Service Bus.</li>
<li><strong>Azure Key Vault</strong>: Securely stores credentials and secrets. The solution uses an emulator for development purposes.</li>
</ul>
<p>Each component is containerized for local development, ensuring consistency across environments.</p>
<hr>
<h2 id="design-patterns-and-practices-structuring-the-codebase">🧱 Design Patterns and Practices: Structuring the Codebase</h2>
<p>The solution employs several design patterns to enhance maintainability and testability:</p>
<h3 id="layered-onion-style-architecture">Layered (Onion Style) Architecture</h3>
<p>Each microservice implements a clean layered architecture:</p>
<ul>
<li><strong>API Layer</strong>: Controllers handling HTTP requests.</li>
<li><strong>Facade Layer</strong>: Orchestrates business logic and validation.</li>
<li><strong>Business Layer</strong>: Implements core business rules.</li>
<li><strong>Data Layer</strong>: Manages data access and persistence.</li>
</ul>
<p>This separation of concerns improves maintainability and testability.</p>
<h3 id="facade-pattern">Facade Pattern</h3>
<p>The facade pattern provides a simplified interface to complex subsystems, decoupling the client (controller) from the implementation details of the business logic. This pattern enhances code readability and maintainability.</p>
<h3 id="result-pattern">Result Pattern</h3>
<p>A custom <code>Result</code> type is used to handle operation outcomes consistently, avoiding exception-based flow control and providing clear error information.</p>
<h3 id="validation-pattern">Validation Pattern</h3>
<p>FluentValidation is employed for model validation, separating validation rules from the models. This ensures robust input validation before business operations.</p>
<hr>
<h2 id="api-documentation-clear-and-comprehensive">📚 API Documentation: Clear and Comprehensive</h2>
<p>The controllers utilize XML documentation and <code>ProducesResponseType</code> attributes to generate comprehensive API documentation. This approach integrates well with OpenAPI and tools like Scalar.net, providing developers with explicit and interactive API references.</p>
<hr>
<h2 id="modern-c-and-net-features-leveraging-the-latest">🚀 Modern C# and .NET Features: Leveraging the Latest</h2>
<p>The solution takes advantage of the latest features in C# 13 and .NET 9:</p>
<ul>
<li><strong>Async/await patterns</strong>: Facilitate non-blocking operations.</li>
<li><strong>Pattern matching</strong>: Enhances code clarity and conciseness.</li>
<li><strong>Extension methods</strong>: Promote cleaner code organization.</li>
<li><strong>Middleware</strong>: Handles cross-cutting concerns like exception handling.</li>
</ul>
<p>These features contribute to a modern, efficient, and maintainable codebase.</p>
<hr>
<h2 id="simply-put-a-robust-foundation-for-cloud-native-applications">☁️ Simply Put A Robust Foundation for Cloud-Native Applications</h2>
<p>The Aspire Café solution demonstrates how modern .NET applications can be built using a microservices architecture with the .NET Aspire stack. The application achieves scalability and maintainability by leveraging clean design patterns, cloud-native infrastructure, and the latest language features.</p>
<p>This architecture provides a solid foundation for building complex, distributed systems that can evolve independently while working together seamlessly.</p>
]]></content:encoded>
        </item>
    </channel>
</rss>
