The Same Stack Answers the Door Twice: Composing Onion, Outbox, Projection, and CQRS in JobBoard
Or: why a controller and a Service Bus consumer are two front doors onto one pipeline — and why the "onion" in this repo isn't the onion you think it is
TL;DR — the code lives at github.com/architect4hire/jobboard. JobBoard is an event-driven, multi-service .NET reference build, and this post is about four patterns inside it — the layered "onion" stack, a hand-rolled transactional outbox/inbox, an event-fed read projection, and the lightweight CQRS shape they add up to. The angle isn't "here are four patterns." Everybody's got four patterns. The angle is that these four aren't independent choices — they compose, 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.
Let me open with the sentence I want you to hold onto for the whole post, because it's the thing most pattern write-ups get exactly backwards.
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 one skeleton reused for both the write path and the projection-maintenance path.
Read a hundred blog posts and you'll get the outbox pattern in isolation, the read-model pattern in isolation, a CQRS diagram with a lightning bolt in the middle labeled "events (magic happens here)." What you rarely get is the load-bearing truth that these things only earn their complexity together. So I'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.
The one feature that demonstrates all four at once
GET /applications/mine — a candidate's applications, enriched with each job's title and the employer's company name, with zero synchronous cross-service calls on the read path.
Nobody writes to applicationsdb except Applications' own code. Jobs and Profiles publish facts to the bus and never learn who's listening. Here's the wiring that makes it real — and note the detail that makes it cheap (src/JobBoard.AppHost/AppHost.cs:60-88):
// Jobs already published JobPosted (Notifications and Audit consume it). Applications is a THIRD
// subscriber bolted onto a pre-existing topic — it mirrors Title/EmployerId into a local projection
// so "my applications" can show a job title without ever calling back into Jobs (ADR-0012 option B).
var jobPostedTopic = serviceBus.AddServiceBusTopic("JobPosted");
jobPostedTopic.AddServiceBusSubscription("notifications-jobposted");
jobPostedTopic.AddServiceBusSubscription("audit-jobposted");
jobPostedTopic.AddServiceBusSubscription("applications-jobposted"); // bolted onto an existing topic
// State-transfer twin of ProfileUpdated: this one carries the company name itself, so it is
// Applications-only — ProfileUpdated stays the PII-free fact Audit sees.
var employerProfileChangedTopic = serviceBus.AddServiceBusTopic("EmployerProfileChanged"); // genuinely new
employerProfileChangedTopic.AddServiceBusSubscription("applications-employerprofilechanged");
That comment is doing more than documenting a subscription — it's the whole thesis in eight lines. JobPosted costs one new subscription on a topic that already existed. Only EmployerProfileChanged 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's what "decoupled" is supposed to feel like, and almost never does.
Now let's take the four apart.
Pattern 1 — The layered stack (and the onion everyone copies)
Every service in JobBoard is a thin host (Controllers/Consumers + a Program.cs composition root) plus a .Core class library holding the whole pipeline: Controller/Consumer → Facade → Business → Data layer → Repository, with three model types at the boundary — ViewModel in, Domain internal, ServiceModel out. Only the Repository touches EF.
Here's one complete write, all five layers, top to bottom. The controller binds and delegates. Nothing else (ApplicationsController.cs:43-50):
[HttpPost]
public async Task<ActionResult<ApplicationDetailServiceModel>> Submit(
[FromBody] SubmitApplicationViewModel viewModel,
CancellationToken cancellationToken)
{
var application = await _facade.SubmitAsync(viewModel, cancellationToken);
return CreatedAtAction(nameof(Get), new { id = application.Id }, application);
}
The facade validates, then delegates (ApplicationFacade.cs:38-44):
public async Task<ApplicationDetailServiceModel> SubmitAsync(
SubmitApplicationViewModel viewModel, CancellationToken cancellationToken = default)
{
await _submitValidator.ValidateAndThrowAsync(viewModel, cancellationToken);
return await _business.SubmitAsync(viewModel, cancellationToken);
}
The business layer does the shape translation and builds the integration event — no EF, no bus, no validation (ApplicationBusiness.cs:56-65):
public async Task<ApplicationDetailServiceModel> SubmitAsync(
SubmitApplicationViewModel viewModel, CancellationToken cancellationToken = default)
{
var application = viewModel.ToEntity();
var @event = application.ToApplicationSubmitted(_requestContext.RootThread());
var saved = await _dataLayer.SubmitAsync(application, @event, cancellationToken);
return saved.ToDetailServiceModel();
}
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 (ApplicationDataLayer.cs:39-63):
public async Task<Application> SubmitAsync(
Application application, ApplicationSubmitted @event, CancellationToken cancellationToken = default)
{
try
{
return await _repository.ExecuteInTransactionAsync(
async token =>
{
var saved = await _repository.AddAsync(application, token);
// Same DbContext, same transaction: the event ships iff this row commits.
await _outbox.EnqueueAsync(@event, token);
return saved;
},
cancellationToken);
}
catch (DbUpdateException ex) when (ApplicationRepository.IsDuplicateApplicationViolation(ex))
{
throw new DomainException(
"application.duplicate",
$"Candidate '{application.CandidateId}' has already applied to job '{application.JobId}'.",
StatusCodes.Status409Conflict);
}
}
And the repository is pure EF, nothing else — it doesn't even call SaveChanges (ApplicationRepository.cs:53-57):
public async Task<Application> AddAsync(Application application, CancellationToken cancellationToken = default)
{
await Context.Applications.AddAsync(application, cancellationToken);
return application;
}
The three boundary types are a structural defense, not decoration. Look at what the client can and can't set:
public sealed record SubmitApplicationViewModel // in
{
public Guid CandidateId { get; init; }
public Guid JobId { get; init; }
public string? ResumeReference { get; init; }
}
public class Application // internal / EF entity
{
public Guid Id { get; set; }
public ApplicationStatus Status { get; set; }
public DateTime SubmittedOnUtc { get; set; }
// ...
}
SubmitApplicationViewModel has no Id, no Status, no SubmittedOnUtc. A client can't set "status": "Offered" because the type it binds to doesn't expose the property. If the controller bound straight to the Application entity, the model binder would happily walk a request body right past ApplicationStatus.Submitted — the classic ASP.NET Core over-posting hole. The distinct ViewModel closes it, and it closes it structurally. There's no attribute to forget.
Here's the contrarian bit
I've been calling this the "onion." The repo's own docs call it "the facade → business → data layer → repository stack." And if you're picturing Jason Taylor's Clean Architecture template — concentric projects, Domain.csproj at the dead center depending on nothing, Dependency Inversion doing compiler-enforced work — you're picturing the wrong thing. Let me be precise, because "half of these diagrams get the arrows backwards" is a hill I'll die on.
The five-layer stack in JobBoard is one project, five folders, not five projects. Controller → Facade → Business → Data → Repository all live inside a single .Core library. Nothing stops ApplicationBusiness from using a Data/ type directly — and it does. The dependency direction is straight-line, not inverted: Business depends on IApplicationDataLayer, an interface declared in the same folder 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's a top-down call chain with DI-for-testability at each hop. Architecturally, that's N-tier, not ports-and-adapters — and calling it "onion" without saying so is exactly the kind of copied-diagram imprecision that gets teams building a monolith with extra folders and congratulating themselves.
So where's the real onion? One level up, at the microservice boundary, and there it's compiler-enforced:
Contracts ← Shared ← <Service>.Core ← <Service> (host) ← AppHost
JobBoard.Contracts is a leaf that references nothing (the Domain-project role). JobBoard.Shared depends only on Contracts (the cross-cutting-mechanism role). Each service's .Core depends on Shared but never on another service's .Core — and that's not a convention, it's enforced by the absence of a project reference between, say, JobBoard.Applications.Core.csproj and JobBoard.Jobs.Core.csproj. Cross-service coupling isn't banned by a review rule you can forget. It's banned because there's no reference path for it to travel down. The only legal channel between services is an event flowing through Contracts and the bus.
So the onion in this repo isn't the five-layer pipeline — that's an N-tier pipeline inside one project. The onion is Contracts at the center, Shared around it, each service as an outer, non-overlapping petal. A shape you could fairly call microservices-of-layered-monoliths.
Why keep even the loose N-tier split, then, if it's not buying swappability? Because one deployable unit with one database wouldn't get much from a compiler wall between Business and Data — there's only ever one Repository implementation; nothing is swappable independently. The split earns its keep for testability (mock IApplicationDataLayer, unit-test ApplicationBusiness with no database) and for keeping each layer legible. It is not buying the thing Clean Architecture's stricter separation buys. Knowing which of those you're paying for is the difference between an architect and someone who saw a nice diagram.
Pattern 2 — The transactional outbox/inbox, hand-rolled
No MassTransit. No third-party outbox. The whole mechanism lives once, in JobBoard.Shared, and it'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't guarantee.
The outbox table's identity is the trick. The event's own Id is reused as the row key and the Service Bus MessageId:
public sealed class OutboxMessage
{
/// The event's own identity, reused as the row key and the Service Bus MessageId.
/// Because it is deterministic, a retried write cannot enqueue the same event twice.
public Guid Id { get; set; }
public string Type { get; set; } = default!;
public string Destination { get; set; } = default!; // the topic
public string Payload { get; set; } = default!; // JSON
public DateTime OccurredOnUtc { get; set; }
public DateTime? ProcessedOnUtc { get; set; } // null until relayed
}
builder.Property(m => m.Id).ValueGeneratedNever(); // load-bearing: Id is ALWAYS the caller's event.Id
builder.HasIndex(m => new { m.ProcessedOnUtc, m.OccurredOnUtc }); // the dispatcher's poll query
Enqueue only stages — it never calls SaveChanges, and it guards against double-staging with a FindAsync:
public async Task EnqueueAsync(IIntegrationEvent @event, CancellationToken cancellationToken = default)
{
// Deterministic id → replay-safe. FindAsync checks the change tracker first, so a retry that
// re-runs the operation finds the row it already staged and does not add a second one.
var existing = await _context.OutboxMessages.FindAsync([@event.Id], cancellationToken);
if (existing is not null) return;
var eventType = @event.GetType();
await _context.OutboxMessages.AddAsync(new OutboxMessage
{
Id = @event.Id,
Type = eventType.Name,
Destination = eventType.Name, // topic-per-event-type
Payload = JsonSerializer.Serialize(@event, eventType, SerializerOptions),
OccurredOnUtc = DateTime.UtcNow,
ProcessedOnUtc = null,
}, cancellationToken);
}
Why a FindAsync guard on an insert? Because of the shape of the transaction wrapper every write goes through:
public async Task<T> ExecuteInTransactionAsync<T>(
Func<CancellationToken, Task<T>> operation, CancellationToken cancellationToken = default)
{
var strategy = Context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async token =>
{
await using var transaction = await Context.Database.BeginTransactionAsync(token);
var result = await operation(token);
// Flush domain rows AND the outbox row inside the transaction, then commit together.
// A throw on any leg skips the commit and rolls every staged change back.
await Context.SaveChangesAsync(token);
await transaction.CommitAsync(token);
return result;
}, cancellationToken);
}
That's a callback, not a using var tx = BeginTransaction(), for a specific reason: Aspire's Npgsql integration turns on retry-on-failure, and EF's execution strategy refuses to run inside a transaction it didn't open itself. Handing the whole unit of work in as a delegate lets the strategy own the boundary and replay the callback on a transient fault. The consequence that ripples through the entire codebase: the callback may run more than once. 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't sprinkled around defensively; it's a direct, traceable consequence of one framework decision.
Dispatch is split deliberately: a BackgroundService timer loop (OutboxDispatcher) and the actual query-send-stamp logic (OutboxRelay), separated so the send logic is unit-testable against a real DbContext and a fake ServiceBusClient without booting a hosted service. The relay's failure handling is the part worth reading closely:
foreach (var row in pending) // pending = unprocessed, OrderBy(OccurredOnUtc), Take(BatchSize)
{
try
{
var sender = _senders.GetOrAdd(row.Destination, _client.CreateSender);
await sender.SendMessageAsync(new ServiceBusMessage(row.Payload)
{
MessageId = row.Id.ToString(),
Subject = row.Type,
}, cancellationToken);
row.ProcessedOnUtc = DateTime.UtcNow;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to relay outbox message {MessageId}; will retry.", row.Id);
break; // preserves ordering — a later row never ships ahead of a stuck earlier one
}
}
await context.SaveChangesAsync(cancellationToken); // persists whatever stamped successfully
It sends, then stamps, then saves once per batch. Crash between send and stamp? The row resends next poll with the same MessageId, and Service Bus genuinely redelivers. Which brings us to the receive side, and the single most important line in the whole mechanism:
var processor = _client.CreateProcessor(topic, subscription, new ServiceBusProcessorOptions
{
AutoCompleteMessages = false, // ← the key line
});
processor.ProcessMessageAsync += async args =>
{
await _processor.ProcessAsync(args.Message, args.CancellationToken);
await args.CompleteMessageAsync(args.Message, args.CancellationToken); // only on success
};
AutoCompleteMessages = false means any throw inside processing — bad payload, missing DI registration, the consumer's own bug — leaves the message unsettled. The lock expires, Service Bus redelivers. And that redelivery is absorbed by the inbox, a dedupe ledger so thin it's almost nothing:
public async Task MarkProcessedAsync(Guid messageId, CancellationToken cancellationToken = default)
{
var existing = await _context.InboxMessages.FindAsync([messageId], cancellationToken);
if (existing is not null) return; // guards the same execution-strategy replay as the outbox
await _context.InboxMessages.AddAsync(
new InboxMessage { MessageId = messageId, ProcessedOnUtc = DateTime.UtcNow }, cancellationToken);
}
The honest version of what this guarantees
Here's the part I want to say plainly, because it's the part that separates a pattern you understand from one you copied.
The whole system is at-least-once, everywhere, never exactly-once. Three separate places dedupe on a deterministic id rather than trying to prevent 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 Guid equality along a single unbroken identity chain: IIntegrationEvent.Id → OutboxMessage.Id → ServiceBusMessage.MessageId → InboxMessage.MessageId. No correlation logic, no payload hashing.
The dispatcher has zero idempotency logic of its own, and doesn't need any — "might send twice" is an accepted outcome, not a bug to prevent. All the correctness burden for "did we already do this?" sits on the consumer's inbox check. That's the sentence to build the whole pattern around: the outbox guarantees at-least-once send; the inbox is what turns that into effectively-once effect. Neither half carries the guarantee alone.
And the caveats, labeled as caveats: ordering is best-effort (the break avoids shipping a later row ahead of a stuck earlier one, but there's no session-partitioned ordering configured, so it's a caveat, not a promise). There's no backoff and no dead-lettering — 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, "no dead-lettering" might be the line item that makes MassTransit worth it. Name the trade out loud; don't inherit it by accident.
Business translation, for the person signing off on this: at-least-once-plus-idempotency is what lets a service go down, come back, and catch up instead of losing writes or double-applying them. The outbox is why "Payments was down for ten minutes" is an availability blip and not a reconciliation nightmare.
Pattern 3 — The event-fed projection
The rule the projection obeys, verbatim from CLAUDE.md: "Duplicate the little reference data you need; don't couple." Applications needs a job's title and an employer'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 keep its own local, denormalized copies, kept current by consuming events. It picked the third.
The projection tables are plain mirrors, keyed by the id they mirror, never a cross-service FK:
public class JobReference
{
public Guid JobId { get; set; }
public string Title { get; set; } = default!;
public Guid EmployerId { get; set; }
}
And here's the part that ties Pattern 3 back to Pattern 1 — the consumer that maintains this projection re-enters the exact same five-layer stack a controller does. Consumer → Facade → Business → Data → Repository, top to bottom, except the "write" is a projection upsert gated by an inbox check:
// Consumer — one-line delegation, same as a controller
public Task ConsumeAsync(JobPosted @event, CancellationToken cancellationToken = default) =>
_facade.HandleJobPostedAsync(@event, cancellationToken);
// Data layer — inbox check + repository upsert, one transaction
public Task UpsertJobReferenceAsync(Guid jobId, Guid messageId, string title, Guid employerId, CancellationToken ct) =>
_repository.ExecuteInTransactionAsync(
async token =>
{
if (await _inbox.HasProcessedAsync(messageId, token)) return;
await _repository.UpsertJobReferenceAsync(jobId, title, employerId, token);
await _inbox.MarkProcessedAsync(messageId, token);
},
ct);
This is the clearest evidence that "onion layering" and "event consumption" aren'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. Same skeleton, two front doors. That's the compose story made concrete.
A real gotcha, worth the anecdote
Because the projection is entirely 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 DbContext — db.Jobs.AddRange(...) — bypassing the business layer and the outbox entirely. No JobPosted fired for seeded jobs. No EmployerProfileChanged for the seeded employer. So in a fresh environment, JobReference and EmployerReference were permanently empty, and every application against a seeded job rendered "Unknown job / Unknown employer."
The fix was to route the seeders through IOutbox.EnqueueAsync too, using the same mappers production code uses, so seed data exercises the real event path.
The lesson is bigger than seed data: a projection's correctness is only as good as every write path to its sources going through the same outbox — including the ones that don't look like "real" writes: seed data, admin scripts, one-off data fixes. There's no compiler and no migration that catches a write that skips the outbox. The projection just silently starves. If you've read my other stuff, you'll recognize the shape of this — it's the same move as debugging the specification instead of the output. The bug wasn't in the read code. It was in a write path nobody thought of as a write path.
The read itself is deliberately three separate queries stitched in memory, not one SQL JOIN, so a row whose reference data hasn't caught up yet still shows up instead of vanishing:
return applications.Select(a =>
{
jobs.TryGetValue(a.JobId, out var job);
var employerId = job?.EmployerId ?? Guid.Empty;
employers.TryGetValue(employerId, out var employer);
return new ApplicationHistoryServiceModel(
a.Id, a.JobId, job?.Title ?? "Unknown job",
employerId, employer?.CompanyName ?? "Unknown employer",
a.Status, a.SubmittedOnUtc, a.StatusChangedOnUtc);
}).ToList();
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 jobs dictionary's values, so a missing JobReference cascades into a missing employer lookup even when the EmployerReference row exists. A narrow, real consequence of joining in application code instead of SQL. Worth knowing before it surprises you in a log.
Pattern 4 — Lightweight CQRS (and where the pattern actually ends)
Two things in this codebase get called "CQRS-ish," and only one of them is the real thing.
The minor sense: every write takes a ViewModel, every read returns a ServiceModel — command/query separation at the type level (that's the over-posting argument from Pattern 1). That's real and useful, but it's the same store, same schema, same code path, typed narrowly at each edge. One sentence, not a pattern.
The real sense is JobReference/EmployerReference themselves, and they qualify on four counts. They're a different shape than the write model (two aggregates in two databases, flattened into one row purpose-built for one query). They're physically separate storage — living in applicationsdb, 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're asynchronously, eventually consistent — 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're independently query-optimized — the three-query stitch is shaped around one screen, with no obligation to serve any other access pattern.
Now the part most CQRS posts skip: where the pattern ends. There is no command/query bus here — no MediatR IRequest/IRequestHandler. There's no event sourcing — the write side is plain CRUD over Job/EmployerProfile, not an append-only log replayed to build state. There's no separate read-side database technology — still Postgres, not a search index. And the read model isn't rebuildable from history — it's upserted incrementally by consumers, so recovering from data loss means re-seeding from the source services' current state, not replaying old events, because the outbox doesn't retain processed rows forever.
So call it CQRS in spirit, not the full pattern: 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't is the entire value of naming the pattern. "We do CQRS" with a hand-wave tells your team nothing about whether they can replay events (they can't) or whether the read lags (it does).
The read path even tightens security while it's at it. The candidate id comes from the JWT via the ambient request context the gateway projects — never a client-supplied parameter:
public Task<IReadOnlyList<ApplicationHistoryServiceModel>> ListMineAsync(CancellationToken ct = default)
{
var candidateId = _requestContext.ActorId
?? throw new DomainException("application.unauthenticated",
"No authenticated candidate on the request.", StatusCodes.Status401Unauthorized);
return _dataLayer.ListMineAsync(candidateId, ct);
}
And the gateway needed zero changes for this whole feature — the catch-all route already proxied the path and already required the authenticated policy. That'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.
How the four compose — the whole story in six steps
Walk it front to back and every pattern shows up in order:
- An employer posts a job. Pattern 1's five-layer write flow. Business builds
JobPostedwithTitle/EmployerIdalready denormalized onto it. The data layer enqueues it intojobsdb.OutboxMessagesin the same transaction as theJobrow — durable before anything hits a broker. - Jobs'
OutboxDispatchernotices within ~5s (Pattern 2), sends withMessageId = the row's own Id, stamps it processed. - Three subscribers wake up on the pre-existing
JobPostedtopic — Notifications, Audit, and now Applications. JobPostedConsumerre-enters the same five-layer stack (Pattern 1 again), except this "write" maintains a projection (Pattern 3), gated by an inbox check so redelivery is a no-op (Pattern 2).- A candidate applies — another instance of the Pattern 1 write flow, publishing
ApplicationSubmittedthe same way. - The candidate opens "my applications."
GET /applications/minenever touches Jobs or Profiles — it readsApplicationjoined in application code against the projection built asynchronously in steps 1–4, scoped to the JWT-derived actor (Pattern 4).
The one sentence I'd tattoo on this build: the outbox is what makes the projection safe to build — durable, at-least-once delivery instead of "hope the HTTP call succeeds"; 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 one 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.
Four patterns. One skeleton. Zero of them worth the complexity alone.
The Practical Version
Stealing this for your own build:
Name your onion honestly. If it's five folders in one project with straight-line dependencies, it's N-tier — say so, and reserve "onion" for the boundary where the compiler actually enforces the arrows. Half the "clean architecture" 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.
Make the correctness live where the duplicates land, not where they're born. At-least-once send plus idempotent-on-a-deterministic-id receive beats chasing exactly-once you'll never actually get. One identity chain, Guid equality at every hop, no payload hashing.
Reuse one stack for both front doors. A consumer is an entry point, not a second architecture. If maintaining a projection looks nothing like handling a request, you've built two things where one would do.
Audit every write path to a projection's sources — including the ones that don't look like writes. Seed data, admin scripts, data fixes. Anything that skips the outbox silently starves the read model, and nothing in the toolchain will warn you.
Say where the pattern ends. "CQRS in spirit" that can'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.
The repo is at github.com/architect4hire/jobboard, and the SCRUB framework is how the specification for all of this stayed honest while it was generated. Read the four patterns as one system, not four — that's the part the diagrams never show you.
Robert Felkins is the principal architect at Architect4Hire and the author of the upcoming book Practical AI Assisted Development. 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.
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't show up until production — that'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 robert@architect4hire.com.





