Contents
- .NET 8 platform essentials
- Modern C#: records, nullable reference types, pattern matching
- Multi-layered architecture: API, application, domain, infrastructure
- Dependency injection and object lifetimes
- REST APIs: minimal endpoints, controllers and contracts
- EF Core: modelling, tracking and query behaviour
- Dapper and raw SQL: when the ORM is the wrong tool
- Repository and Unit of Work — and when they are noise
- Relational databases: SQL Server and PostgreSQL
- NoSQL with MongoDB
- Authentication and authorization: JWT, OAuth 2.0 and OIDC
- Validation and error handling
- Configuration, secrets and options
- Logging and observability
- Background work and the outbox pattern
- Testing: unit, integration and contract
- Docker and Kubernetes for .NET services
- Microservices and resilience
- Performance and caching
- Git and Azure DevOps in the lifecycle
- AI-assisted development, done responsibly
- The Angular-to-.NET contract
- Security checklist before any release
- Mapping the must-haves to evidence
01.NET 8 platform essentials
The role asks for .NET 8 or later. The practical differences against older versions are the unified SDK, minimal APIs as a first-class model, performance work on JSON and LINQ, and a few features that change day-to-day code.
Keyed services remove the need for a factory when two implementations of the same interface must coexist; TimeProvider makes time injectable and therefore testable.
- .NET 8 is LTS (support to November 2026); .NET 9 is STS. Say which one you ship and why.
- Minimal APIs,
TimeProvider, keyed DI and NativeAOT are the features worth naming in an interview. CancellationTokenon every async boundary is part of the platform contract, not an extra.
// Program.cs - .NET 8 host wiring
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedScoped<IPaymentGateway, AdyenGateway>("adyen");
var app = builder.Build();
app.MapGet("/orders/{id:guid}", async (Guid id, IOrderService svc, CancellationToken ct)
=> await svc.GetAsync(id, ct) is { } order ? Results.Ok(order) : Results.NotFound());
app.Run();02Modern C#: records, nullable reference types, pattern matching
Most of what an interviewer hears as 'modern C#' is three things: immutable data with records, compile-time null safety with nullable reference types, and pattern matching instead of chains of if.
They matter because they remove whole categories of bug before runtime — a null that cannot reach production, an object that cannot be mutated by accident.
recordgives value equality andwithexpressions;sealedstops inheritance surprises.<Nullable>enable</Nullable>plus warnings-as-errors is what makes it a guarantee, not a suggestion.- Switch expressions over property patterns read better than nested
ifblocks and are exhaustiveness-checked for enums.
public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice)
{
public decimal Total => Quantity * UnitPrice;
}
public static decimal Discount(Order order) => order switch
{
{ Lines.Count: 0 } => 0m,
{ Customer.Tier: Tier.Gold, Total: > 1000m } => order.Total * 0.10m,
{ Customer.Tier: Tier.Silver } => order.Total * 0.05m,
_ => 0m
};
// nullable reference types: the compiler tracks the null state
public Order? Find(Guid id) => _orders.TryGetValue(id, out var o) ? o : null;03Multi-layered architecture: API, application, domain, infrastructure
The posting is explicit about separation between API/presentation, business/application and data access. That is the classic layered model, and the interview question behind it is dependency direction: the domain knows nothing, the application depends on the domain, infrastructure implements interfaces declared inside.
One project per layer is the simplest honest structure; Vertical Slice is a defensible alternative if you can argue the trade-off.
- Dependency inversion: interfaces live where they are consumed, implementations live outside.
- The API project references Application only; Infrastructure is referenced at composition time (Program.cs).
- Loose coupling and high cohesion are the criteria — say which layer owns validation and which owns persistence.
src/
Orders.Api/ // controllers or endpoints, DI wiring, filters
Orders.Application/ // use cases, DTOs, validation, interfaces
Orders.Domain/ // entities, value objects, domain rules
Orders.Infrastructure/ // EF Core, Dapper, HTTP clients, messaging
// Application declares the contract
public interface IOrderRepository
{
Task<Order?> GetAsync(Guid id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
}
// Infrastructure implements it; Api never sees the implementation
public sealed class OrderRepository(OrdersDbContext db) : IOrderRepository
{
public Task<Order?> GetAsync(Guid id, CancellationToken ct) =>
db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);
}04Dependency injection and object lifetimes
Built-in DI is the default expectation. The trap interviewers look for is the captive dependency: a scoped service captured by a singleton, which then lives forever and holds a disposed DbContext.
Know the three lifetimes cold, plus when a scope must be created by hand (IServiceScopeFactory in background workers).
- Singleton must be thread-safe and stateless; scoped is the default for anything touching the DbContext.
- Injecting a scoped service into a singleton is a runtime
InvalidOperationException, not a compile error. - Validate on startup (
ValidateOnBuild,ValidateScopes) so misconfiguration fails in development, not in production.
builder.Services.AddSingleton<IClock, SystemClock>(); // one instance, app lifetime
builder.Services.AddScoped<IOrderRepository, OrderRepository>(); // per HTTP request
builder.Services.AddTransient<IOrderMapper, OrderMapper>(); // new on every resolution
// Background work has no request scope: create one explicitly
public sealed class OutboxWorker(IServiceScopeFactory scopes) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var scope = scopes.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IOutboxRepository>();
await repo.DrainAsync(ct);
}
}05REST APIs: minimal endpoints, controllers and contracts
Both models ship in .NET 8; minimal APIs are the default for new services, controllers remain fine for large surfaces with filters and conventions.
What is graded is the contract discipline: resource naming, correct status codes, no entity types leaking out of the API, and error payloads that clients can parse.
201with aLocationheader for creation,204for delete,409for conflicts,422/400for validation.- Never return EF entities: DTOs decouple the wire format from the schema.
- Version explicitly (
/v1/...orAsp.Versioning) before the first external consumer exists.
public sealed record CreateOrderRequest(string CustomerId, IReadOnlyList<CreateOrderLine> Lines);
public sealed record OrderResponse(Guid Id, string Status, decimal Total);
app.MapPost("/api/orders", async (CreateOrderRequest req, IOrderService svc, CancellationToken ct) =>
{
var result = await svc.CreateAsync(req, ct);
return result.IsSuccess
? Results.Created($"/api/orders/{result.Value.Id}", result.Value)
: Results.ValidationProblem(result.Errors);
})
.WithName("CreateOrder")
.Produces<OrderResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem();06EF Core: modelling, tracking and query behaviour
EF Core is an ORM with a query pipeline, not a database. The questions that separate levels are about tracking, projections and the N+1 problem.
AsNoTracking for reads, projection into DTOs at the database level, and Include used deliberately rather than sprinkled.
SelectbeforeToListmeans the filtering happens in SQL;ToListthenSelectmeans it happens in memory.- Lazy loading is off by default in EF Core — keep it off and load explicitly, or the N+1 problem comes back.
- Migrations belong in source control;
dotnet ef migrations addin the pipeline,database updatein deploy.
// Read model: no tracking, only the columns needed
var rows = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderSummary(o.Id, o.Status, o.Lines.Sum(l => l.Quantity * l.UnitPrice)))
.Take(50)
.ToListAsync(ct);
// Write model: tracking on, explicit transaction boundary
var order = await db.Orders.Include(o => o.Lines).FirstAsync(o => o.Id == id, ct);
order.AddLine(sku, quantity, price);
await db.SaveChangesAsync(ct);07Dapper and raw SQL: when the ORM is the wrong tool
Dapper maps rows to objects with no change tracking and no query translation. It wins for reporting queries, bulk reads and anything where the generated SQL is wrong or slow.
A healthy codebase mixes both: EF Core for writes and aggregates, Dapper for read models.
- Always parameterise; string concatenation in SQL is the vulnerability reviewers look for first.
CommandDefinitioncarries the cancellation token and command timeout.- Explain the trade-off: Dapper is faster and explicit, EF Core is safer and cheaper to maintain.
const string sql = @"
SELECT o.Id, o.Status, SUM(l.Quantity * l.UnitPrice) AS Total
FROM Orders o
JOIN OrderLines l ON l.OrderId = o.Id
WHERE o.CustomerId = @customerId
GROUP BY o.Id, o.Status";
var rows = await connection.QueryAsync<OrderSummary>(
new CommandDefinition(sql, new { customerId }, cancellationToken: ct));08Repository and Unit of Work — and when they are noise
A repository abstracts persistence behind a collection-like interface; Unit of Work groups changes into one transaction. With EF Core, DbContext already is a Unit of Work and DbSet already is a repository.
The defensible position: use repositories at the application boundary to keep the domain persistence-ignorant, and skip a hand-written UoW on top of EF Core.
- Do not wrap
DbSetin a repository that only forwards calls — that is abstraction theatre. - Repositories earn their place when the storage may change or when queries must be reused and tested.
- Say out loud that EF Core's
DbContextis scoped, so the UoW must be scoped too.
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct);
}
public sealed class EfUnitOfWork(OrdersDbContext db) : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct) => db.SaveChangesAsync(ct);
}
// Application layer: one transaction per use case
await _orders.AddAsync(order, ct);
await _unitOfWork.SaveChangesAsync(ct);09Relational databases: SQL Server and PostgreSQL
Two years of relational experience is a must-have, and the questions are practical: indexes, execution plans, transactions and isolation.
Know how to read a plan, what a covering index is, and why a SELECT * on a wide table hurts.
@@ROWCOUNT = 0is how you detect a lost update without a lock hint.- Read committed is the default; say when you would use snapshot isolation and what it costs.
- PostgreSQL equivalents:
EXPLAIN (ANALYZE, BUFFERS), partial indexes,ON CONFLICT DO NOTHING.
-- Covering index for the most frequent read path
CREATE INDEX IX_Orders_Customer_Created
ON Orders (CustomerId, CreatedAt DESC)
INCLUDE (Status, Total);
-- Check what the engine actually did
SET STATISTICS IO ON;
SELECT Id, Status FROM Orders WHERE CustomerId = @id ORDER BY CreatedAt DESC;
-- Explicit transaction with a safe isolation level
BEGIN TRANSACTION;
UPDATE Inventory SET Available = Available - @qty WHERE Sku = @sku AND Available >= @qty;
IF @@ROWCOUNT = 0 ROLLBACK; ELSE COMMIT;10NoSQL with MongoDB
One year of NoSQL is required. The point is not 'schemaless' but embedded versus referenced documents, and which one matches the read pattern.
Indexes, $lookup sparingly, and transactions only when a single-document write is not enough.
- Embed what you read together; reference what changes independently or grows without bound.
- Every query needs an index or it becomes a collection scan — check with
explain(). - Multi-document transactions exist in replica sets but cost more; design so you rarely need them.
public sealed class OrderDocument
{
[BsonId] public Guid Id { get; set; }
public string CustomerId { get; set; } = "";
public List<OrderLineDocument> Lines { get; set; } = [];
public DateTime CreatedAt { get; set; }
}
var filter = Builders<OrderDocument>.Filter.Eq(o => o.CustomerId, customerId);
var docs = await collection.Find(filter)
.SortByDescending(o => o.CreatedAt)
.Limit(50)
.ToListAsync(ct);
await collection.Indexes.CreateOneAsync(
new CreateIndexModel<OrderDocument>(
Builders<OrderDocument>.IndexKeys.Descending(o => o.CreatedAt)));11Authentication and authorization: JWT, OAuth 2.0 and OIDC
The must-have is implementing authn/authz with OAuth 2.0, OpenID Connect or comparable. In .NET that means JWT bearer validation plus policy-based authorization.
Explain the flow, not just the attribute: the API validates tokens issued by an identity provider; it does not issue them.
- Authentication answers who; authorization answers what. Two registrations, two attributes.
- Scopes for machine-to-machine, roles or policies for user permissions; keep the check server-side always.
- Never validate a token by hand — signature, issuer, audience and lifetime are the library's job.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = "https://login.example.com"; // OIDC discovery
o.Audience = "orders-api";
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true
};
});
builder.Services.AddAuthorization(o =>
o.AddPolicy("orders:write", p => p.RequireClaim("scope", "orders.write")));
app.MapPost("/api/orders", CreateOrder).RequireAuthorization("orders:write");
app.MapGet("/api/orders/{id}", GetOrder).RequireAuthorization();12Validation and error handling
Two layers: input shape (required fields, formats) and business rules (uniqueness, state transitions). The first belongs in the request pipeline, the second in the domain.
One error format for the whole API, based on ProblemDetails, so clients parse a single shape.
400for malformed input,422for semantic violations,409for state conflicts — pick and document.- Return the trace id in the payload; it is what makes production support possible.
- Validate at the boundary and do not trust the client twice.
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Lines).NotEmpty().WithMessage("An order needs at least one line.");
RuleForEach(x => x.Lines).ChildRules(l =>
l.RuleFor(x => x.Quantity).GreaterThan(0));
}
}
app.UseExceptionHandler(err => err.Run(async ctx =>
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Unexpected error",
Status = 500,
Extensions = { ["traceId"] = Activity.Current?.Id }
});
}));13Configuration, secrets and options
appsettings.json per environment, environment variables in containers, and the Options pattern for typed access.
Secrets never live in the repository: user-secrets locally, Key Vault or the platform's secret store in production.
IOptionsfor fixed values,IOptionsSnapshotper request,IOptionsMonitorfor live reload.ValidateOnStartturns a missing setting into a boot failure instead of a null reference at 3 a.m.- Precedence to remember: appsettings < appsettings.{Env} < environment variables < command line.
public sealed class StorageOptions
{
public const string SectionName = "Storage";
[Required] public string ConnectionString { get; init; } = "";
public int RetryCount { get; init; } = 3;
}
builder.Services.AddOptions<StorageOptions>()
.Bind(builder.Configuration.GetSection(StorageOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Consumer
public sealed class BlobStore(IOptionsMonitor<StorageOptions> options)
{
private StorageOptions Current => options.CurrentValue;
}14Logging and observability
ILogger<T> is the abstraction; structured logging and tracing are what make production debuggable. Log a correlation id once per request and every downstream message carries it.
OpenTelemetry is now the portable answer: one exporter, traces and metrics, no vendor lock-in.
- Message templates, not interpolation:
$"..."loses structure and allocates even when the level is off. - Log once at the boundary; do not log the same exception in three layers.
- Traces + a trace id in the error payload turn a support ticket into a query.
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(o => o.IncludeScopes = true);
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter());
// Structured, searchable, no string concatenation
logger.LogInformation("Order {OrderId} created for {CustomerId} with {LineCount} lines",
order.Id, order.CustomerId, order.Lines.Count);15Background work and the outbox pattern
BackgroundService is the built-in host for long-running work. The dangerous part is scope and idempotency: a message consumer will see the same message twice.
The outbox pattern is the standard answer: write the domain change and the outgoing message in one transaction, then publish from a worker.
- Never inject a scoped service into a singleton worker — create a scope per iteration.
- Idempotency key on the consumer side is what makes at-least-once delivery safe.
Task.Delaywith the token, and catchOperationCanceledExceptionon shutdown.
public sealed class OutboxPublisher(
IServiceScopeFactory scopes, ILogger<OutboxPublisher> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OutboxDbContext>();
var batch = await db.Outbox.Where(m => m.PublishedAt == null)
.OrderBy(m => m.CreatedAt).Take(50).ToListAsync(ct);
foreach (var message in batch)
{
await Publish(message, ct); // idempotent consumer side
message.PublishedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}16Testing: unit, integration and contract
The split that matters: unit tests for domain rules, integration tests for the API plus a real database, contract tests for anything consumed by another team.
WebApplicationFactory boots the whole pipeline in memory; Testcontainers gives a throwaway SQL Server or PostgreSQL with no mocks.
- Integration tests catch DI, routing, serialization and SQL problems that unit tests cannot.
- Mocks assert interactions; use them for external services, not for your own repositories.
- Name tests after the behaviour, not the method:
Rejects_order_without_linesexplains the requirement.
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersApiTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();
[Fact]
public async Task Rejects_order_without_lines()
{
var response = await _client.PostAsJsonAsync("/api/orders",
new { customerId = "c1", lines = Array.Empty<object>() });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(400, problem!.Status);
}
}
// Domain level: no infrastructure, no mocks
[Theory]
[InlineData(0, 0)]
[InlineData(3, 30)]
public void Total_multiplies_quantity_by_price(int qty, decimal expected)
=> Assert.Equal(expected, new OrderLine("SKU", qty, 10m).Total);17Docker and Kubernetes for .NET services
A multi-stage Dockerfile keeps the SDK out of the runtime image; a non-root user and health endpoints are what make it deployable.
In Kubernetes, what breaks first is configuration and readiness: the pod must report healthy only when it can serve.
- Copy the
.csprojand restore before copying the source: the restore layer stays cached. /health/livefor liveness,/health/readyfor readiness — different questions, different probes.- Pin the image tag;
latestmakes rollbacks impossible to reason about.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Orders.Api.dll"]18Microservices and resilience
Microservices are an organisational answer to independent deployment, not a performance trick. What gets discussed in interviews is failure handling: timeouts, retries with jitter, circuit breakers and idempotency.
IHttpClientFactory plus a resilience handler gives all of it in configuration.
- Retry only idempotent operations; a retried POST without a key duplicates the order.
- A timeout shorter than the caller's timeout, or the retry never happens before the caller gives up.
- Service discovery, gateway and BFF are the other three words to be able to place on a diagram.
builder.Services.AddHttpClient<IPricingClient, PricingClient>(c =>
{
c.BaseAddress = new Uri("https+http://pricing-api");
c.Timeout = TimeSpan.FromSeconds(5);
})
.AddStandardResilienceHandler(o =>
{
o.Retry.MaxRetryAttempts = 3;
o.Retry.UseJitter = true;
o.CircuitBreaker.FailureRatio = 0.5;
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
});19Performance and caching
Most .NET performance problems are not micro-optimisations: they are sync-over-async, unbounded queries, missing indexes and chatty calls between services.
Caching is the cheapest win when the read pattern allows it — and the most dangerous when invalidation is unclear.
- Async all the way:
.Resultand.Wait()burn threads and deadlock under load. - Measure with
dotnet-counters,dotnet-traceor Application Insights before changing anything. - Name the invalidation strategy before adding a cache; a stale cache is a bug with a delay.
// Distributed cache with an explicit key and expiry
var key = $"catalog:{categoryId}:v2";
var cached = await cache.GetStringAsync(key, ct);
if (cached is null)
{
var data = await db.Products.AsNoTracking()
.Where(p => p.CategoryId == categoryId)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(ct);
await cache.SetStringAsync(key, JsonSerializer.Serialize(data),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct);
return data;
}
return JsonSerializer.Deserialize<List<ProductDto>>(cached)!;20Git and Azure DevOps in the lifecycle
The role lists Git and Azure DevOps explicitly. Beyond git commit, that means branch policies, pull requests with review, and a YAML pipeline that builds, tests and publishes.
Build once, deploy the same artifact to every environment.
- Pull request + required reviewers + build validation is the branch policy trio.
- Secrets come from variable groups or a key vault link, never from the YAML.
- Tag the artifact with the commit hash so a deployment can be traced back to code.
trigger:
branches: { include: [ main ] }
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UseDotNet@2
inputs: { packageType: 'sdk', version: '8.0.x' }
- script: dotnet restore
- script: dotnet build --configuration Release --no-restore
- script: dotnet test --no-build --configuration Release --logger trx
- task: DotNetCoreCLI@2
inputs:
command: publish
publishWebProjects: false
arguments: '--configuration Release --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: true21AI-assisted development, done responsibly
The posting asks for hands-on experience with Copilot, Cursor or similar — and for independently reviewing what they generate. That second half is the part interviewers probe.
The workflow that holds up: generate, read, test, and be able to explain every line you keep.
- State the constraints in the prompt: framework version, layer, DTO shapes, error contract.
- Never merge generated code you cannot debug.
- Licensing and data exposure: do not paste customer data or secrets into a prompt.
// Prompt shape that produces reviewable code:
// "Add a POST /api/orders endpoint that validates the request, calls
// IOrderService.CreateAsync, returns 201 with Location, and 400 with
// ProblemDetails on validation failure. Use the existing DTOs."
// Review checklist applied to generated code:
// 1. Async all the way, CancellationToken passed through
// 2. No secrets, no hard-coded URLs
// 3. Validation at the boundary, not inside the domain
// 4. Tests written for the happy path and one failure
// 5. Nothing the team's conventions forbid (naming, layering)22The Angular-to-.NET contract
The role is full stack, so the seam matters: DTO shape, error format, CORS and the generated client. Angular interceptors and .NET middleware must agree on both success and failure.
OpenAPI generation removes the hand-written client and the drift that follows it.
- Money as
decimalin .NET,numberin JSON — decide early and document the rounding rule. - Dates in UTC (ISO 8601) on the wire; format for the user in Angular, not in the API.
WithOriginswithAllowCredentialsforbids*— the browser rejects the combination.
// .NET: stable error shape for every failure
public sealed record ApiError(string Code, string Message, string? TraceId);
// Angular: one interceptor for auth + error mapping
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
const problem = err.error as { title?: string; status?: number };
return throwError(() => new AppError(problem?.title ?? 'Unexpected error', err.status));
})
);
};
// CORS must name the origin; credentials need an explicit policy
builder.Services.AddCors(o => o.AddPolicy("spa", p => p
.WithOrigins("https://app.example.com")
.AllowAnyHeader().AllowAnyMethod().AllowCredentials()));23Security checklist before any release
A short list that catches most findings in a review of an enterprise API.
Say which of these you have actually implemented, with an example — that is what a technical interviewer listens for.
- Parameterised SQL, least-privilege database users, and secrets outside the repository.
- Authorization checked server-side on every endpoint, including the ones the UI hides.
- Rate limiting and request size limits are part of the API, not an infrastructure afterthought.
builder.Services.AddRateLimiter(o =>
o.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
}));
app.UseHsts();
app.UseHttpsRedirection();
app.Use(async (ctx, next) =>
{
ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
ctx.Response.Headers["X-Frame-Options"] = "DENY";
ctx.Response.Headers["Referrer-Policy"] = "no-referrer";
await next();
});
app.UseRateLimiter();24Mapping the must-haves to evidence
What the interview actually tests is whether each requirement has a story behind it. This is the table to keep in mind — one concrete example each, with a number when possible.
If a line has no evidence yet, that is the gap to close before applying.
| Requirement | Evidence to prepare |
|---|---|
| 4+ years full stack | Two systems you owned end to end, with the architecture you chose and why |
| .NET 8 hands-on | A service on .NET 8 using minimal APIs, DI lifetimes and TimeProvider |
| Angular 13+ from scratch | A project you architected: modules, routing, guards, interceptors, state |
| Layered .NET | One repository with Api/Application/Domain/Infrastructure and interfaces at the boundary |
| EF Core or Dapper | A query you optimised, with before/after timings |
| OAuth 2.0 / OIDC | Token validation plus policies in an API you deployed |
| SQL 2+ years, NoSQL 1+ | An index or schema decision with the read pattern behind it |
| Docker, Kubernetes, microservices | A multi-stage image, probes, and one resilience policy you configured |
| Git and Azure DevOps | A YAML pipeline you wrote: build, test, publish, deploy |
| AI-assisted tools | A feature you shipped with Copilot and the review you applied to it |
- One concrete story per line beats ten vague claims.
- Quantify: latency, defect rate, deployment frequency.
- Be ready for the follow-up 'what would you do differently'.