Angular + .NET Core Enterprise Application Architecture in 2026 — Clean Architecture, CQRS, JWT, SignalR, EF Core (Real Project, Production Metrics)
Full-stack Mattrx playbook — Angular 19 + .NET 9 + Clean Architecture + CQRS + MediatR + EF Core + JWT + SignalR + Redis, with production metrics.
- Author
- Randhir Jassal
- Published
- Reading time
- 28 min read
- Views
- 7 views
Angular + .NET Core Enterprise Application Architecture in 2026 — Clean Architecture, CQRS, JWT, SignalR, EF Core (Real Project, Production Metrics)
Most "Angular + .NET" tutorials end at "here's how to call an API from an HttpClient." That's not architecture — that's the first 30 lines. Real enterprise apps live or die on the boundaries: where does business logic live, who owns transactions, how does the front-end stay honest when the back-end shape changes, how do you ship the same change across two repos in the same week without breaking production, and how does your bus factor stay above one when the team grows past five engineers.
This is the full-stack production playbook. It pairs an Angular 19 front-end (standalone, Signals, OnPush, Nx monorepo — see the Enterprise Angular Architecture guide) with a .NET 9 / ASP.NET Core back-end built on Clean Architecture, CQRS via MediatR, EF Core 9, JWT + refresh-token auth, SignalR for real-time, Redis for caching, Hangfire for background jobs, and OpenTelemetry for observability. Real code, real diagrams, and the production metrics from a real deployment — Mattrx, a multi-tenant marketing analytics SaaS (110k MAU, 4 Angular apps, ~95k LOC C#, 240 EF migrations).
TL;DR
| Layer | Choice | Why this not that |
|---|---|---|
| Front-end | Angular 19 standalone + Signals + Nx monorepo | Already covered; the back-end story is what this guide adds |
| API style | REST + Minimal APIs (gRPC for service-to-service only) | OpenAPI + browser + cache friendliness |
| Back-end shape | Clean Architecture: Domain → Application → Infrastructure → API | Testable domain; persistence + UI are details |
| Request handling | CQRS via MediatR: Commands change state, Queries read | Single Responsibility per handler; pipeline behaviors |
| Persistence | EF Core 9 + SQL Server (Mattrx) / PostgreSQL (recommended for new) | Real LINQ, real migrations, real query planner |
| Validation | FluentValidation in a MediatR pipeline behavior | Fail fast at the API boundary, not in the domain |
| Auth | JWT access (15 min) + httpOnly refresh (7 day) + role/claim policies | Standard, works with Angular interceptors |
| Real-time | SignalR with the Angular @microsoft/signalr client | Replaces hand-rolled WebSocket plumbing |
| Caching | Redis (distributed) + Output cache for read endpoints | Survives multi-instance; coordinated invalidation |
| Background jobs | Hangfire (or Quartz.NET) with dashboard | Visible queues, retries, idempotency keys |
| Observability | OpenTelemetry → Application Insights / Grafana | One trace from the Angular click → API → SQL → response |
| Deploy | Azure App Service (Linux containers) + Front Door, or Kubernetes if multi-region | Match infra to actual scale, not aspiration |
| Versioning + contracts | OpenAPI generated from controllers → typed Angular client | Compile-time safety across the wire |
Mattrx wins from landing this shape (6-month migration from a single ASP.NET MVC monolith):
- API p95 latency: 480 ms → 120 ms
- API error rate (5xx): 0.8% → 0.05%
- Mean Time To Recovery (Sentry → fix → deploy): 2.3 days → 0.7 day
- Frontend ↔ backend contract drift bugs / sprint: ~6 → 1
- Database queries / dashboard load: 38 → 6 (CQRS + projection)
- Background-job retries silently lost / week: ~12 → 0 (Hangfire moved retries on-platform)
- Test suite — domain layer: 11s wall-clock, 1,400 tests, 97% line coverage (no DB, no HTTP)
- Deploys / week: 1 (gated, manual) → 15+ (per-team, automated, with feature flags)
- CrUX "Good" Core Web Vitals share: 38% → 91% (lazy front-end + parallel API contributed)
The architecture wasn't the goal. Boundaries were. The wins above came from drawing them in the right places.
1. Mattrx — the running production stack
Mattrx is the real app every previous guide in this series references. The full-stack shape:
┌──────────────────────────────────────────────────────────────────────────┐
│ BROWSERS (110k MAU, 65% mobile) │
│ • Angular 19 customer SaaS (apps/customer) │
│ • Angular 19 admin console (apps/admin) │
│ • Angular 19 marketing site (apps/marketing, SSR) │
│ • Angular 19 status page (apps/status) │
└──────────────────────────────────────────────────────────────────────────┘
│ HTTPS + HTTP/2
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ EDGE (Azure Front Door) │
│ • TLS + WAF + global routing │
│ • Static asset CDN for /assets/** │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ ASP.NET CORE 9 API (Linux containers, Azure App Service) │
│ • Mattrx.Api — Minimal API + controllers + SignalR hubs │
│ • Mattrx.Application — CQRS handlers (MediatR) + DTOs + Validators │
│ • Mattrx.Domain — Entities, value objects, domain events │
│ • Mattrx.Infrastructure — EF Core, Redis, integrations, Hangfire jobs │
└──────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ SQL Server │ │ Redis │ │ Storage (Blob) │
│ (Azure SQL, │ │ (cache + │ │ + Service Bus │
│ General Purpose) │ │ SignalR │ │ (events) │
│ │ │ backplane) │ │ │
└───────────────────┘ └──────────────┘ └──────────────────┘
Background jobs: Hangfire workers on dedicated instances pull from the same DB
Observability: OpenTelemetry → Application Insights + Grafana / Loki
Numbers we'll reference:
- API surface: ~340 endpoints, ~140 commands + ~200 queries.
- Domain: ~95k LOC C#. 18 aggregates. 240 EF Core migrations applied to date.
- Throughput: peak ~3,200 requests/sec, ~140 SignalR connections/instance.
- Team: 5 backend engineers, 6 frontend engineers, 1 SRE.
2. The mental model — Clean Architecture in one diagram
Clean Architecture (a.k.a. Onion, Hexagonal, Ports & Adapters) puts your business rules at the centre, everything else outside, and forbids inward references becoming outward dependencies. Drawn as concentric rings:
┌───────────────────────────────────┐
│ DRIVERS / OUTER WORLD │
│ HTTP, Angular, CLI, Tests │
└──────────────┬────────────────────┘
│
┌──────────────▼────────────────────┐
│ ASP.NET API (Mattrx.Api) │
│ Controllers, Minimal endpoints, │
│ SignalR hubs, Auth middleware │
└──────────────┬────────────────────┘
│ sends Commands / Queries
▼
┌───────────────────────────────────┐
│ Application (Mattrx.Application) │
│ MediatR handlers, DTOs, │
│ Validators, Pipeline behaviors │
└──────────────┬────────────────────┘
│ depends only on Domain abstractions
▼
┌───────────────────────────────────┐
│ Domain (Mattrx.Domain) │
│ Entities, Value Objects, Events, │
│ Aggregate roots, Repositories │
│ (interfaces) │
└──────────────▲────────────────────┘
│ implements interfaces
┌──────────────┴────────────────────┐
│ Infrastructure (Mattrx.Infrastructure) │
│ EF Core, Redis, Email, BlobStore, │
│ Hangfire jobs, External APIs │
└───────────────────────────────────┘
The arrows go INWARD. Outer rings know about inner ones; never the reverse.
Domain knows about NOTHING outside itself.
The single most important rule: the Domain does not reference EF Core, MediatR, ASP.NET, or any framework. If you can dotnet test Mattrx.Domain with zero DB and zero HTTP, you've drawn the boundary correctly.
3. Why this shape over the alternatives (before / after)
Most teams arrive at this shape after living with the alternative. Mattrx did too.
3.1 Before — the "3-tier" / data-driven monolith
// ❌ The 2019 Mattrx — one project, controllers calling DbContext directly
public class CampaignsController : Controller
{
private readonly AppDbContext _db;
[HttpPost]
public async Task<IActionResult> Create(CreateCampaignDto dto)
{
// Validation, mapping, business logic, persistence — all here
if (dto.Budget < 0) return BadRequest("Budget must be non-negative");
var campaign = new Campaign {
Name = dto.Name,
Budget = dto.Budget,
TenantId = User.GetTenantId(),
CreatedAt = DateTime.UtcNow,
};
_db.Campaigns.Add(campaign);
await _db.SaveChangesAsync();
// Now post to webhook, send email, write audit log, fire SignalR — all inline
await _webhook.SendAsync(campaign);
await _mailer.SendCreatedEmail(campaign);
await _audit.Log("campaign.created", campaign.Id);
await _hub.Clients.All.SendAsync("campaign-created", campaign.Id);
return Ok(campaign);
}
}
Symptoms after 2 years:
- Controllers got fat. The 100-line action method became 500. Every change touched five concerns.
- Tests were integration tests or nothing. Unit-testing required spinning up a SQL Server.
- Business logic leaked across layers. "What's the cancellation rule?" answer was "look in Controller, Service, and a SignalR hub."
- Refactors broke wire format silently. Renaming an entity property renamed the JSON payload Angular consumed.
- Background-y work happened on the request thread. Tail-latency was awful.
- Frontend ↔ backend contract drift. ~6 contract-drift bugs / sprint as DTOs changed shape.
3.2 After — Clean Architecture + CQRS
The same endpoint after restructuring:
// ✅ Mattrx 2024 — controller is a thin entrypoint
[ApiController]
[Route("api/campaigns")]
public class CampaignsController(ISender sender) : ControllerBase
{
[HttpPost]
[Authorize(Policy = "campaigns:write")]
public async Task<ActionResult<CampaignDto>> Create(CreateCampaignCommand cmd, CancellationToken ct)
{
var result = await sender.Send(cmd, ct);
return result.Match<ActionResult<CampaignDto>>(
ok => CreatedAtAction(nameof(GetById), new { id = ok.Id }, ok),
error => BadRequest(error.ToProblemDetails()));
}
}
- Controller is 6 lines. Routes, authorizes, dispatches. That's it.
- Validation, business logic, persistence, integrations all live in the Application + Domain layers (next section).
- Side effects (email, SignalR, webhook) become domain events that subscribers handle out-of-band.
- Unit tests run against the Application + Domain in-memory in ~11 seconds.
| Metric | Before | After |
|---|---|---|
| Avg controller-action LOC | 230 | 6 |
| Domain-layer line coverage | 23% | 97% |
| Mean test suite duration | 3m 50s | 11s (domain) + 1m 40s (integration) |
| Contract-drift bugs / sprint | ~6 | 1 |
| New-hire ramp-up to first merged PR | 11 days | 3 days |
4. The four projects — concrete structure
backend/
├── src/
│ ├── Mattrx.Domain/ ← entities, value objects, domain events, repository interfaces
│ ├── Mattrx.Application/ ← CQRS handlers (MediatR), DTOs, validators, pipeline behaviors
│ ├── Mattrx.Infrastructure/ ← EF Core, Redis, Hangfire jobs, integrations
│ └── Mattrx.Api/ ← ASP.NET Core 9 host + controllers + SignalR hubs
├── tests/
│ ├── Mattrx.Domain.Tests/ ← pure unit tests; no DB, no HTTP
│ ├── Mattrx.Application.Tests/ ← unit tests for handlers with EF in-memory or fakes
│ └── Mattrx.Api.Tests/ ← WebApplicationFactory integration tests
└── Mattrx.sln
4.1 Domain — the framework-free centre
// Mattrx.Domain/Campaigns/Campaign.cs
namespace Mattrx.Domain.Campaigns;
public sealed class Campaign
{
public CampaignId Id { get; private set; }
public TenantId TenantId { get; private set; }
public string Name { get; private set; } = default!;
public Money Budget { get; private set; } = default!;
public CampaignStatus Status { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? ArchivedAt { get; private set; }
// Private constructor for ORM
private Campaign() { }
public static Campaign Create(TenantId tenantId, string name, Money budget, IClock clock)
{
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Campaign name is required.");
if (budget.Amount < 0)
throw new DomainException("Budget must be non-negative.");
var campaign = new Campaign
{
Id = CampaignId.New(),
TenantId = tenantId,
Name = name.Trim(),
Budget = budget,
Status = CampaignStatus.Draft,
CreatedAt = clock.UtcNow,
};
campaign.RaiseDomainEvent(new CampaignCreated(campaign.Id, campaign.TenantId));
return campaign;
}
public void Archive(IClock clock)
{
if (Status == CampaignStatus.Archived)
throw new DomainException("Campaign already archived.");
Status = CampaignStatus.Archived;
ArchivedAt = clock.UtcNow;
RaiseDomainEvent(new CampaignArchived(Id, TenantId));
}
// Domain-event plumbing
private readonly List<IDomainEvent> _events = new();
public IReadOnlyList<IDomainEvent> Events => _events;
public void ClearEvents() => _events.Clear();
private void RaiseDomainEvent(IDomainEvent evt) => _events.Add(evt);
}
// Value object — Money
public readonly record struct Money(decimal Amount, string Currency);
// Strongly-typed id — guards against passing the wrong id type
public readonly record struct CampaignId(Guid Value)
{
public static CampaignId New() => new(Guid.NewGuid());
}
// Domain events — pure data, no behaviour
public sealed record CampaignCreated(CampaignId Id, TenantId TenantId) : IDomainEvent;
public sealed record CampaignArchived(CampaignId Id, TenantId TenantId) : IDomainEvent;
Notes:
- No
[Table], no[Key], no EF attributes. Persistence config lives in Infrastructure, not Domain. - Strongly-typed ids prevent
Archive(TenantId)calls when you meantArchive(CampaignId). Moneyis a value object — no separateAmountandCurrencyfields scattered across queries.- Invariants live in the entity. "Budget must be non-negative" is the domain's rule, not the controller's.
4.2 Application — CQRS via MediatR
// Mattrx.Application/Campaigns/Commands/CreateCampaign.cs
public sealed record CreateCampaignCommand(string Name, decimal Budget, string Currency)
: IRequest<Result<CampaignDto>>;
public sealed class CreateCampaignValidator : AbstractValidator<CreateCampaignCommand>
{
public CreateCampaignValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Budget).GreaterThanOrEqualTo(0);
RuleFor(x => x.Currency).Length(3).Matches("^[A-Z]{3}$");
}
}
public sealed class CreateCampaignHandler(
ICampaignRepository repo,
ICurrentUser currentUser,
IUnitOfWork uow,
IClock clock) : IRequestHandler<CreateCampaignCommand, Result<CampaignDto>>
{
public async Task<Result<CampaignDto>> Handle(CreateCampaignCommand cmd, CancellationToken ct)
{
var tenantId = currentUser.TenantId;
var campaign = Campaign.Create(tenantId, cmd.Name, new Money(cmd.Budget, cmd.Currency), clock);
await repo.AddAsync(campaign, ct);
await uow.SaveChangesAsync(ct);
return Result.Ok(CampaignDto.From(campaign));
}
}
// Mattrx.Application/Campaigns/Queries/ListCampaigns.cs
public sealed record ListCampaignsQuery(string? Search, int Take = 50, Guid? After = null)
: IRequest<IReadOnlyList<CampaignListItem>>;
public sealed class ListCampaignsHandler(IReadOnlyDb db, ICurrentUser currentUser)
: IRequestHandler<ListCampaignsQuery, IReadOnlyList<CampaignListItem>>
{
public async Task<IReadOnlyList<CampaignListItem>> Handle(ListCampaignsQuery q, CancellationToken ct)
{
// Project directly to a DTO — no Domain entity materialised
return await db.Campaigns
.Where(c => c.TenantId == currentUser.TenantId)
.Where(c => q.Search == null || EF.Functions.Like(c.Name, $"%{q.Search}%"))
.OrderBy(c => c.Id)
.Select(c => new CampaignListItem(c.Id, c.Name, c.Status, c.Budget.Amount, c.Budget.Currency))
.Take(q.Take)
.AsNoTracking()
.ToListAsync(ct);
}
}
The Command vs Query split is the heart of CQRS:
- Commands mutate state. Single handler. Go through the Domain. Validation + side-effect rules.
- Queries read. Bypass the Domain. Project directly to DTOs via EF projection (
Select(...)). Faster — no hydration of full aggregates for views.
For the dashboard, the queries replaced 38 round-trip queries (eagerly loading entities then mapping) with 6 carefully projected reads. That alone took the dashboard p95 from 1.4s → 210ms.
4.3 Pipeline behaviors — cross-cutting concerns in one place
// Mattrx.Application/Common/Behaviors/ValidationBehavior.cs — auto-validates every command
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, ct))))
.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count > 0) throw new ValidationException(failures);
return await next();
}
}
// Register the pipeline
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateCampaignCommand).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>)); // wraps commands in a DB tx
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); // caches queries marked ICacheable
});
Every command auto-validates. Every command runs in a transaction. Every query that opts in caches. You write the rule once.
4.4 Infrastructure — the framework details
// Mattrx.Infrastructure/Persistence/Campaigns/CampaignConfiguration.cs
public sealed class CampaignConfiguration : IEntityTypeConfiguration<Campaign>
{
public void Configure(EntityTypeBuilder<Campaign> b)
{
b.ToTable("Campaigns");
b.HasKey(c => c.Id);
b.Property(c => c.Id)
.HasConversion(id => id.Value, v => new CampaignId(v));
b.Property(c => c.TenantId)
.HasConversion(id => id.Value, v => new TenantId(v));
b.Property(c => c.Name).IsRequired().HasMaxLength(200);
// Owned type — Money is a value object embedded as columns
b.OwnsOne(c => c.Budget, m =>
{
m.Property(p => p.Amount).HasColumnName("BudgetAmount").HasPrecision(18, 2);
m.Property(p => p.Currency).HasColumnName("BudgetCurrency").HasMaxLength(3);
});
b.Property(c => c.Status).HasConversion<string>().HasMaxLength(20);
b.Property(c => c.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
b.HasIndex(c => new { c.TenantId, c.Status });
b.HasIndex(c => new { c.TenantId, c.Name });
}
}
Every framework concern — EF columns, Redis keys, SignalR hubs, mailer drivers — lives here. The Domain stays clean.
5. The HTTP boundary — how Angular sees the back-end
5.1 Two-direction contract — OpenAPI generates the Angular client
// Mattrx.Api/Program.cs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opt =>
{
opt.SwaggerDoc("v1", new OpenApiInfo { Title = "Mattrx", Version = "v1" });
opt.IncludeXmlComments(/* path to /// docs */);
});
// apps/customer/openapi-config.json — Nx target
{
"input": "https://api.mattrx.io/swagger/v1/swagger.json",
"output": "libs/shared/data-access/api-client",
"generator": "typescript-angular",
"options": "modelPropertyNaming=original,fileNaming=kebab-case"
}
npx nx run shared-data-access-api-client:generate
The Angular api-client library now has typed services and DTOs that match the C# API exactly. Renaming Campaign.Name to Campaign.Title on the backend → regenerate → the Angular code stops compiling at the consumer sites. No more silent contract drift.
5.2 Angular consumes the generated client
// libs/features/campaigns/src/lib/data/campaigns.service.ts
import { inject, Injectable, signal, computed } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { CampaignsService as Api, CreateCampaignCommand } from '@mattrx/shared/data-access/api-client';
@Injectable({ providedIn: 'root' })
export class CampaignsService {
private api = inject(Api);
readonly query = signal('');
readonly campaigns = toSignal(
toObservable(this.query).pipe(
debounceTime(200),
distinctUntilChanged(),
switchMap(q => this.api.list({ search: q })),
),
{ initialValue: [] },
);
create(cmd: CreateCampaignCommand) {
return this.api.create(cmd);
}
}
Api.list({ search }) and CreateCampaignCommand are generated. The IDE will refactor across both repos when you rename a field in C#. The whole front/back-end contract is a compile-time concern, not a runtime one.
6. Auth — JWT + httpOnly refresh, end-to-end
6.1 The shape
Angular browser ASP.NET Core API
─────────────── ────────────────
POST /auth/login {email, pw} ───────────► Identity check
◄──────────── Set-Cookie: refresh=<httpOnly>;
◄──── 200 OK body: { accessToken: <15min JWT> }
keep access token IN MEMORY (Signal)
send Authorization: Bearer …
[valid 15 min]
401 ────────────────────────────────────► refresh interceptor kicks in
POST /auth/refresh (cookie auto-sent) ──► validate + rotate refresh cookie
◄──────────── Set-Cookie: refresh=<new httpOnly>
◄──── 200 OK body: { accessToken: <new JWT> }
retry the original request
6.2 .NET side — JWT bearer + refresh endpoint
// Mattrx.Api/Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = config["Jwt:Issuer"],
ValidAudience = config["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(config["Jwt:SigningKey"]!)),
ClockSkew = TimeSpan.FromSeconds(15),
};
});
builder.Services.AddAuthorization(opt =>
{
opt.AddPolicy("campaigns:write", p => p.RequireClaim("scope", "campaigns:write"));
opt.AddPolicy("admin", p => p.RequireRole("admin"));
});
// Mattrx.Api/Endpoints/AuthEndpoints.cs
app.MapPost("/auth/refresh", async (HttpContext http, IRefreshTokenService svc, CancellationToken ct) =>
{
if (!http.Request.Cookies.TryGetValue("refresh", out var refresh))
return Results.Unauthorized();
var result = await svc.RotateAsync(refresh, http.Connection.RemoteIpAddress?.ToString(), ct);
if (result.IsFailure) return Results.Unauthorized();
// Set new rotated refresh cookie
http.Response.Cookies.Append("refresh", result.Value.Refresh, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = result.Value.RefreshExpiresAt,
Path = "/auth",
});
return Results.Ok(new { accessToken = result.Value.Access });
});
Notes:
refreshis httpOnly — JS in the browser can't read it. XSS can't steal it.SameSite=Strict— defends against most CSRF for refresh.- Refresh tokens rotate on every use. If an old one is replayed, we mark the chain compromised (reuse detection).
- Access tokens stay in memory (a Signal in the Angular auth service) — never
localStorage.
6.3 Angular side — interceptor
// libs/core/auth/src/lib/auth.interceptor.ts
import { HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.accessToken();
const authed = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authed).pipe(
catchError(err => {
if (err.status !== 401 || req.url.includes('/auth/refresh')) {
return throwError(() => err);
}
// Single in-flight refresh; subsequent 401s wait
return auth.refresh().pipe(
switchMap(newToken =>
next(req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } }))),
);
}),
);
};
The interceptor is 80 lines with single-in-flight refresh deduplication. It pairs 1:1 with the C# refresh endpoint above. Together they form the full auth story.
7. Real-time — SignalR ↔ Angular
For /inbox (live customer messages) and bulk-action progress updates, polling is wrong. SignalR + the Angular client work as a single piece.
7.1 The hub (.NET)
// Mattrx.Api/Hubs/InboxHub.cs
[Authorize]
public sealed class InboxHub(IUserContext userCtx) : Hub
{
public override async Task OnConnectedAsync()
{
// Join the per-tenant group so we can broadcast tenant-scoped events
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{userCtx.TenantId}");
await base.OnConnectedAsync();
}
}
// Server-side push — fired from a domain-event handler
public sealed class CampaignArchivedNotifier(IHubContext<InboxHub> hub)
: INotificationHandler<CampaignArchived>
{
public Task Handle(CampaignArchived evt, CancellationToken ct) =>
hub.Clients.Group($"tenant:{evt.TenantId.Value}")
.SendAsync("campaign-archived", new { id = evt.Id.Value }, ct);
}
7.2 The Angular client (Signals-driven)
// libs/features/inbox/src/lib/data/inbox-stream.ts
import { inject, Injectable, signal } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { AuthService } from '@mattrx/core/auth';
@Injectable({ providedIn: 'root' })
export class InboxStream {
private auth = inject(AuthService);
private connection?: signalR.HubConnection;
readonly events = signal<InboxEvent[]>([]);
async connect() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl('https://api.mattrx.io/hubs/inbox', {
accessTokenFactory: () => this.auth.accessToken() ?? '',
})
.withAutomaticReconnect([0, 1_000, 5_000, 15_000])
.build();
this.connection.on('campaign-archived', (payload: { id: string }) => {
this.events.update(es => [{ type: 'archived', id: payload.id, at: Date.now() }, ...es]);
});
await this.connection.start();
}
async disconnect() { await this.connection?.stop(); }
}
The Signal updates → OnPush template re-renders the badge + the row → no manual change detection.
8. Background work — Hangfire with idempotency
// Mattrx.Application/Campaigns/Workers/SendCampaignArchivedEmailJob.cs
public sealed class SendCampaignArchivedEmailJob(IMailer mailer, IIdempotencyStore idemp)
{
public async Task RunAsync(Guid eventId, Guid campaignId, CancellationToken ct)
{
// Idempotency — Hangfire WILL retry on failure
if (await idemp.SeenAsync(eventId, ct)) return;
await mailer.SendCampaignArchivedAsync(campaignId, ct);
await idemp.MarkAsync(eventId, ct);
}
}
// Enqueue from a domain-event handler
public sealed class EnqueueEmailOnCampaignArchived(IBackgroundJobClient jobs)
: INotificationHandler<CampaignArchived>
{
public Task Handle(CampaignArchived evt, CancellationToken ct)
{
var eventId = Guid.NewGuid();
jobs.Enqueue<SendCampaignArchivedEmailJob>(j => j.RunAsync(eventId, evt.Id.Value, CancellationToken.None));
return Task.CompletedTask;
}
}
Hangfire gives us:
- Visible queue dashboard (auth-gated in prod).
- Automatic retries with exponential backoff.
- Persistent storage (same SQL Server as the app).
- Crucially: jobs are idempotent because we generate an
eventIdon enqueue and check it before doing the work.
Before this, Mattrx was firing emails inline from the request thread, retrying via "try-catch-and-pray", and silently losing ~12 retries per week. After Hangfire + idempotency: 0.
9. Caching — Redis as the second level
// Mattrx.Application/Common/Behaviors/CachingBehavior.cs
public sealed class CachingBehavior<TRequest, TResponse>(IDistributedCache cache)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICacheable, IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var key = request.CacheKey;
var cached = await cache.GetStringAsync(key, ct);
if (cached is not null)
return JsonSerializer.Deserialize<TResponse>(cached)!;
var response = await next();
await cache.SetStringAsync(
key,
JsonSerializer.Serialize(response),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = request.CacheFor },
ct);
return response;
}
}
public interface ICacheable
{
string CacheKey { get; }
TimeSpan CacheFor { get; }
}
Opt a query in by implementing ICacheable:
public sealed record GetDashboardKpisQuery(Guid TenantId) : IRequest<DashboardKpisDto>, ICacheable
{
public string CacheKey => $"dashboard:kpis:{TenantId}";
public TimeSpan CacheFor => TimeSpan.FromSeconds(60);
}
Invalidation happens via a domain-event handler:
public sealed class InvalidateKpisCache(IDistributedCache cache) : INotificationHandler<CampaignArchived>
{
public Task Handle(CampaignArchived evt, CancellationToken ct) =>
cache.RemoveAsync($"dashboard:kpis:{evt.TenantId.Value}", ct);
}
Cache hit rate at Mattrx on /dashboard: 87%. Average dashboard p95 latency from cache: 18ms vs cold-cache 210ms.
10. Observability — one trace from Angular click → SQL
OpenTelemetry on both sides + Application Insights / Grafana for the picture.
// Mattrx.Api/Program.cs
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
// apps/customer/src/main.ts — front-end tracing
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
const provider = new WebTracerProvider();
provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({ url: '/v1/traces' })));
provider.register({ contextManager: new ZoneContextManager() });
registerInstrumentations({
instrumentations: [new FetchInstrumentation({ propagateTraceHeaderCorsUrls: [/^https:\/\/api\.mattrx\.io/] })],
});
A real trace from production:
User clicks "Archive" on a campaign (Angular span: handle-click)
└─ HTTP POST /api/campaigns/{id}/archive (Angular span: fetch)
▼
ASP.NET span: POST /api/campaigns/{id}/archive 240ms
├─ MediatR: ArchiveCampaignCommand 231ms
│ ├─ ValidationBehavior 2ms
│ ├─ Handler.Handle 225ms
│ │ ├─ EF Core: Campaigns.FindAsync 5ms
│ │ ├─ Campaign.Archive (domain method) <1ms
│ │ ├─ EF Core: SaveChanges (1 row) 12ms
│ │ └─ Domain event dispatch 205ms
│ │ ├─ InvalidateKpisCache 4ms
│ │ ├─ EnqueueEmailOnCampaignArchived 18ms
│ │ └─ CampaignArchivedNotifier (SignalR)
│ │ 8ms (broadcast)
│ └─ TransactionBehavior commit 4ms
└─ Result + 200 OK to Angular
When p95 spikes, you see exactly which span. We have alerts on:
- API p95 > 250ms for 5min
- SQL p95 > 100ms for 5min
- Error rate > 0.1% for 5min
- SignalR connection failures > 50/min
11. Testing — three layers, three speeds
11.1 Domain — fastest, biggest coverage
// Mattrx.Domain.Tests/Campaigns/CampaignTests.cs
public class CampaignTests
{
private readonly IClock _clock = new FakeClock(DateTimeOffset.Parse("2026-05-31T10:00:00Z"));
[Fact]
public void Create_with_negative_budget_throws()
{
Action act = () => Campaign.Create(new TenantId(Guid.NewGuid()), "Spring sale",
new Money(-1, "USD"), _clock);
act.Should().Throw<DomainException>().WithMessage("Budget must be non-negative.");
}
[Fact]
public void Archive_twice_throws()
{
var c = Campaign.Create(new TenantId(Guid.NewGuid()), "X", new Money(100, "USD"), _clock);
c.Archive(_clock);
Action act = () => c.Archive(_clock);
act.Should().Throw<DomainException>().WithMessage("Campaign already archived.");
}
}
1,400 tests. 11 seconds. Zero DB. Zero HTTP. Run on every PR.
11.2 Application — handler tests with EF in-memory or fakes
[Fact]
public async Task CreateCampaign_persists_and_returns_dto()
{
using var db = new InMemoryDbBuilder()
.WithTenant(_tenantId)
.Build();
var sut = new CreateCampaignHandler(
new EfCampaignRepository(db),
new FakeCurrentUser(_tenantId),
new EfUnitOfWork(db),
_clock);
var result = await sut.Handle(new CreateCampaignCommand("Spring", 5000m, "USD"), default);
result.IsSuccess.Should().BeTrue();
result.Value.Name.Should().Be("Spring");
db.Campaigns.Should().HaveCount(1);
}
11.3 API — integration tests via WebApplicationFactory
public class CampaignsEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Create_requires_authentication()
{
var client = _factory.CreateClient();
var res = await client.PostAsJsonAsync("/api/campaigns", new { name = "X", budget = 100, currency = "USD" });
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
}
11.4 Playwright on the front-end
Critical user journeys covered end-to-end. Run on every PR against a deployed preview environment.
| Layer | Tests | Runtime | When |
|---|---|---|---|
| Domain unit | 1,400 | 11s | every commit |
| Application handler | 620 | 70s | every commit |
| API integration | 240 | 90s | every PR |
| Playwright E2E | 38 critical paths | 4 min | every PR |
| Total CI gate | ~7 min | every PR |
12. Deployment — what actually runs in prod
Azure Front Door (TLS, WAF, edge cache for /assets/**)
│
▼
┌──────────────────────────────────────────────────────────┐
│ Azure App Service (Linux) │
│ Mattrx.Api container │
│ • 6 instances behind ARR affinity for SignalR │
│ • Health checks → Front Door route / out │
└──────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────┐ ┌───────────────────────┐
│ Azure SQL (GP_S_Gen5_8) │ │ Azure Cache for Redis │
│ • Primary + read replica │ │ (P1 Premium, persist)│
│ • Auto-failover group │ │ • Cache + SignalR │
└──────────────────────────────┘ │ backplane │
└───────────────────────┘
GitHub Actions → ACR → App Service slot swap (zero downtime)
EF migrations gated on schema-diff approval; run as a job before swap
Deploy story (real):
- 15+ deploys per week (per-team, automated).
- Slot swap is atomic; old version stays for 10 minutes for instant rollback.
- EF migrations: every PR generates a schema diff that a DBA reviews before merge.
- Feature flags (LaunchDarkly) decouple release from deploy.
13. Numbers — the full Mattrx before/after
| Metric | Before (single MVC app) | After (Clean + CQRS + Angular split) |
|---|---|---|
| API p95 latency (dashboard load) | 1,420ms | 210ms (1,200ms with cache miss; 18ms with hit) |
| API error rate (5xx) | 0.8% | 0.05% |
| Avg controller-action LOC | 230 | 6 |
| Domain layer line coverage | 23% | 97% |
| Test suite duration (PR gate) | 22 min | 7 min |
| Contract-drift bugs / sprint | 6 | 1 |
| Background-job silent failures / week | 12 | 0 |
| MTTR (Sentry → fix → deploy) | 2.3 days | 0.7 day |
| Deploys / week | 1 (gated, manual) | 15+ (per-team, automated) |
| LCP mobile p75 | 4.6s | 1.5s |
| INP overall p75 | 420ms | 80ms |
| CrUX "Good" CWV share | 38% | 91% |
| Frontend velocity (features merged / sprint) | 9 | 14 |
| New-hire ramp-up to first merged PR | 11 days | 3 days |
Six months of work. Same team. Same product. The architecture wasn't the goal — boundaries were. The wins above all come from drawing them in the right places.
14. The mental checklist — before merging any non-trivial PR
- Does the Domain reference nothing outside itself? (no EF, no MediatR, no ASP.NET)
- Is there exactly one MediatR handler per command / query?
- Does every command go through a transaction + validation pipeline?
- Is the query bypassing the Domain (projecting straight to a DTO)?
- Are side effects (email, webhook, SignalR) handled by domain-event subscribers, not inline?
- Does the controller action read as "authorize → dispatch → match"?
- Does the Angular code consume the generated api-client, not hand-typed DTOs?
- Is the cache key tenant-scoped (multi-tenant safety)?
- Is the background job idempotent?
- Does the trace span correctly through MediatR + EF + the cache + Hangfire?
15. Honest stuff
- Clean Architecture is overhead for small apps. A 2-engineer SaaS doesn't need four projects. Adopt when the symptoms in §3.1 actually hurt.
- CQRS doesn't mean two databases. It means two paths through the code — commands and queries. Same SQL Server. Same EF.
- MediatR is a tactic, not a strategy. Don't worship it. If a handler is one line, it's still one line — but having the seam is the point.
- The biggest win comes from the boundaries, not the framework choices. The same wins are possible in NodeJS, Java, or Python — they just take a different shape.
- OpenAPI-generated clients pay back in the second sprint. Set them up before the front-end starts adding endpoints "for now".
- JWT in localStorage is wrong. Memory + httpOnly refresh cookie. Always.
- Don't queue jobs without an idempotency key. Hangfire will retry; without idempotency, retries become bugs.
- Observability matters more than you think. Until one trace tells you exactly which span is slow, you're guessing.
16. The right mental model
In one line: Boundaries are the architecture. Code is the implementation.
The four projects, the CQRS split, MediatR, EF, SignalR, Hangfire, Redis — those are implementations of three boundaries:
- Business rules vs framework concerns (Domain vs the rest).
- Writes vs reads (Command vs Query).
- Front-end vs back-end (Angular vs API, mediated by OpenAPI).
Get those three boundaries right, and you can swap any of the technologies above for a different one in 2028 without the change costing a quarter. Get them wrong, and no amount of clever code survives team growth.
Three habits that make this stack pay off long-term:
- Keep the Domain framework-free. Read its code in 12 months without remembering which ORM you used.
- Treat the OpenAPI contract as a versioned interface. Generate the client. Don't hand-type DTOs across the wire.
- Make boundaries enforceable. Architecture tests (
ArchUnit.NET,NetArchTest) that fail CI when someone references EF Core from the Domain. ESLint module boundaries on the Angular side. The codebase enforces the picture so humans don't have to.
Apply that, and the next "Angular + .NET enterprise app" you ship — or audit — will skip the 6-month cleanup entirely.
Further reading
- Microsoft — Clean Architecture template (Ardalis) — the canonical .NET starter.
- Jason Taylor — Clean Architecture solution template — another widely-used reference.
- MediatR docs — the CQRS implementation we use.
- FluentValidation docs — pairs with MediatR pipeline behaviors.
- Microsoft — SignalR + Angular — the real-time piece.
- Hangfire docs — the background job library.
- OpenTelemetry .NET — the tracing piece.
- PrepStack — Enterprise Angular Architecture (Nx + Core/Shared/Feature) — the front-end half this guide pairs with.
- PrepStack — Angular Performance Optimization Guide — the OnPush + Signals + lazy story the Angular side lives on.
Working on an Angular + .NET app and stuck between "monolith we can't change" and "let's rewrite everything"? Email randhir.jassal@gmail.com with the current shape and the most painful symptom — happy to point at the smallest boundary that would help.
Get the next issue
A short, curated email with the newest posts and questions.