Ce guide condense ce que je mets en place sur des applis .NET 8 + Angular 17 en prod : architecture claire, API rapides, Angular modulaire (lazy routes), cache & ETag, JWT + refresh en cookie HttpOnly, observabilité (OTel), et CI/CD. Tous les extraits sont « copier/coller ».
Architecture globale
- Backend .NET : Minimal API, EF Core, validations, JWT, endpoints fins.
- Front Angular : structure par features, routing paresseux, interceptors, standalone components.
- Infra : Reverse proxy + CDN pour assets, logs corrélés (traceId), metrics.
repo/ api/ # ASP.NET Core 8 Domain/ # Entités, Value Objects Application/ # Use cases, DTOs, Validations Infrastructure/ # EF Core, Repos, Cache, Email Web/ # Program.cs, Endpoints, Middlewares web/ # Angular 17 (standalone) src/app/ core/ # services transverses (auth, http, config) shared/ # composants UI réutilisables features/ catalog/ # feature module (routes lazy) orders/ # feature module (routes lazy) app.routes.ts 1) API .NET — endpoints fins & rapides
On garde des endpoints très courts, la logique vit dans les handlers. Compression et pagination intégrées.
Program.cs : compression, EF, validation, auth
// Program.cs var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCompression(o => {
o.EnableForHttps = true;
o.Providers.Add();
o.Providers.Add();
});
builder.Services.Configure(o => o.Level = System.IO.Compression.CompressionLevel.Optimal);
builder.Services.Configure(o => o.Level = System.IO.Compression.CompressionLevel.Optimal);
builder.Services.AddDbContext(o => o.UseSqlServer(builder.Configuration.GetConnectionString("sql")));
builder.Services.AddScoped();
builder.Services.AddAuthentication("Bearer").AddJwtBearer("Bearer", o => {
o.TokenValidationParameters = new() {
ValidateIssuer = false, ValidateAudience = false,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
builder.Services.AddAuthorization();
builder.Services.AddCors(o => o.AddPolicy("spa", p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod().AllowCredentials(
)));
var app = builder.Build();
app.UseResponseCompression();
app.UseCors("spa");
app.UseAuthentication();
app.UseAuthorization();
app.MapCatalogEndpoints();
app.Run();
Endpoints (Minimal API) + pagination + ETag
// Web/CatalogEndpoints.cs static class CatalogEndpoints { public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app) { var g = app.MapGroup("/api/catalog").WithTags("Catalog");
g.MapGet("", async (int page = 1, int pageSize = 20, ICatalogService svc, HttpContext ctx) => {
var result = await svc.GetProductsAsync(page, pageSize, ctx.RequestAborted);
var json = JsonSerializer.Serialize(result);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
var etag = $"W/\\"{Convert.ToHexString(hash.AsSpan(0, 8))}\\"";
if (ctx.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(304);
ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "public, max-age=60";
return Results.Text(json, "application/json");
});
g.MapGet("/{id:int}", async (int id, ICatalogService svc, CancellationToken ct) =>
await svc.GetProductAsync(id, ct) is { } p ? Results.Ok(p) : Results.NotFound());
return app;
}
}
Service EF Core (sélectifs & indexés)
// Infrastructure/CatalogService.cs public sealed class CatalogService(AppDb db) : ICatalogService { public async Task<Paged<ProductDto>> GetProductsAsync(int page, int size, CancellationToken ct) { page = Math.Max(1, page); size = Math.Clamp(size, 10, 100); var q = db.Products.AsNoTracking().OrderBy(p => p.Id); var total = await q.CountAsync(ct); var items = await q.Skip((page-1)*size).Take(size) .Select(p => new ProductDto(p.Id, p.Sku, p.Name, p.Price)) .ToListAsync(ct); return new(total, page, size, items); }
public Task GetProductAsync(int id, CancellationToken ct) =>
db.Products.AsNoTracking().Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Sku, p.Name, p.Price))
.FirstOrDefaultAsync(ct);
}
2) Auth : JWT court + Refresh en cookie HttpOnly
Le front porte l’access token (mémoire), le refresh token reste côté navigateur en cookie HttpOnly Secure/Lax.
Endpoint /auth/refresh (rotation + reuse-detection simple)
// Web/AuthEndpoints.cs (extrait) app.MapPost("/auth/refresh", (HttpContext ctx, ITokenService tokens) => { var refresh = ctx.Request.Cookies[".fb.r"]; if (!tokens.Validate(refresh, out var sub)) return Results.Unauthorized(); var (access, newRefresh) = tokens.Rotate(sub!); ctx.Response.Cookies.Append(".fb.r", newRefresh, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddDays(7) }); return Results.Ok(new { access }); }); 3) Angular 17 — structure, routes « lazy », interceptors
Routes paresseuses (standalone)
// src/app/app.routes.ts import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', loadComponent: () => import('./features/home/home.page').then(m => m.HomePage) },
{ path: 'catalog', loadChildren: () => import('./features/catalog/catalog.routes')
.then(m => m.CATALOG_ROUTES) },
{ path: 'orders', canActivate: [() => import('./core/auth.guard').then(m => m.authGuard)],
loadChildren: () => import('./features/orders/orders.routes').then(m => m.ORDERS_ROUTES) },
{ path: '**', redirectTo: '' }
];
HTTP client + interceptor (Bearer + refresh auto)
// src/app/core/http.interceptor.ts import { HttpInterceptorFn } from '@angular/common/http';
let accessToken = '';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const withAuth = accessToken ? req.clone({ setHeaders: { Authorization: Bearer ${accessToken} }}) : req;
return next(withAuth).pipe(
// @ts-ignore: rxjs import
catchError(async (err) => {
if (err.status === 401 && !req.headers.has('X-Retry')) {
const r = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
if (r.ok) {
const { access } = await r.json();
accessToken = access;
const retried = withAuth.clone({ setHeaders: { Authorization: Bearer ${access} , 'X-Retry': '1'}});
return await firstValueFrom(next(retried));
}
}
throw err;
})
);
};
Service Catalog + annulation (AbortController)
// src/app/features/catalog/catalog.service.ts import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class CatalogService {
private http = inject(HttpClient);
list(page=1, size=20, signal?: AbortSignal) {
return this.http.get<{ total:number; items:any }>(/api/catalog?page=${page}&pageSize=${size}, { signal });
}
}
Composant liste (standalone) + skeleton
// src/app/features/catalog/list.page.ts import { Component, effect, signal } from '@angular/core'; import { NgFor, NgIf } from '@angular/common'; import { CatalogService } from './catalog.service';
@Component({
standalone: true,
selector: 'app-catalog-list',
imports: [NgFor, NgIf],
template: `
Chargement…
{{p.name}} — {{p.price | number:'1.2-2'}} €
`
})
export class CatalogListPage {
items = signal([]);
loading = signal(true);
constructor(private svc: CatalogService) {
const ctrl = new AbortController();
this.svc.list(1, 20, ctrl.signal).subscribe({
next: r => { this.items.set(r.items); this.loading.set(false); },
error: _ => this.loading.set(false)
});
}
}
4) Images & assets : WebP + tailles fixes
Servez le front via CDN (cache long) ; versionnez les bundles et fixez la taille pour éviter le CLS.
<img src="/assets/hero-960.webp" srcset="/assets/hero-640.webp 640w, /assets/hero-960.webp 960w, /assets/hero-1440.webp 1440w" sizes="(max-width: 900px) 90vw, 640px" alt="Dashboard .NET + Angular" width="960" height="540" loading="lazy" decoding="async" /> 5) Observabilité : OpenTelemetry + Web Vitals
.NET
// Program.cs (suite) builder.Services.AddOpenTelemetry() .WithTracing(t => t.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation().AddSqlClientInstrumentation()) .WithMetrics(m => m.AddAspNetCoreInstrumentation()); Angular (Web Vitals min)
// src/main.ts import { onCLS, onLCP, onTTFB } from 'web-vitals'; onTTFB(console.log); onLCP(console.log); onCLS(console.log); 6) Sécurité : headers, CORS, validation stricte
Headers durcis
// Program.cs app.Use((ctx, next) => { var h = ctx.Response.Headers; h["X-Content-Type-Options"] = "nosniff"; h["X-Frame-Options"] = "DENY"; h["Referrer-Policy"] = "strict-origin-when-cross-origin"; h["Permissions-Policy"] = "geolocation=()"; return next(); }); Validation (FluentValidation) côté API
public sealed class CreateOrderValidator : AbstractValidator<CreateOrder> { public CreateOrderValidator(){ RuleFor(x => x.CustomerId).NotEmpty(); RuleForEach(x => x.Items).ChildRules(i => { i.RuleFor(x => x.Sku).NotEmpty(); i.RuleFor(x => x.Qty).GreaterThan(0); }); } } 7) CI/CD minimal GitHub Actions
name: ci on: [push, pull_request] jobs: api: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: { dotnet-version: "8.0.x" } - run: dotnet restore api - run: dotnet build api -c Release --no-restore - run: dotnet test api -c Release --no-build web: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: "20" } - run: cd web && npm ci && npm run build Checklist rapide
- Endpoints fins, EF en AsNoTracking, pagination + ETag.
- JWT court en mémoire + Refresh en cookie HttpOnly (rotation).
- Angular : routes lazy, interceptors, skeletons, annulation requêtes.
- CDN + WebP + tailles fixes, cache long, fichiers versionnés.
- OTel + Web Vitals, headers de sécurité, validations strictes.
- CI/CD : build/test front+back, artefacts prêts au déploiement.
Objectif : un socle robuste, rapide et maintenable pour faire évoluer sereinement votre appli .NET + Angular.