Web APIs and RESTful services form the connective tissue of modern cloud applications, mobile platforms, and distributed microservices. Beyond writing simple CRUD controllers, engineering candidates are rigorously evaluated on their mastery of HTTP protocol semantics, API security boundaries, idempotency under network failures, and high-throughput architectural resilience.
Whether you are interviewing for a junior backend developer role or architecting a mission-critical financial API gateway capable of 100,000 requests per second, interviewers look for deep operational intuition: how the HTTP pipeline delegates requests, how Idempotency Keys prevent duplicate credit card charges, how mTLS secures inter-service communication, and how Circuit Breakers stop cascading outages.
Who Should Use This Guide?
- College Freshers & Junior Developers (0–2 Years): Master core HTTP protocols, REST architectural constraints, HTTP status codes, safe vs idempotent methods, and clean URI design.
- Mid-Level Backend Engineers (3–5 Years): Master ASP.NET Core Minimal APIs vs Controllers, Model Binding & Validation, Content Negotiation, RFC 7807 ProblemDetails, API versioning, and Keyset cursor pagination.
- Senior API Developers (6–10 Years): Dive deep into JWT token revocation, OAuth 2.0 PKCE flows, ASP.NET Core middleware ordering, Rate Limiting algorithms, ETags, and OWASP API Top 10 (BOLA) defenses.
- Principal Solutions Architects & Leads (10+ Years): Review Zero Trust mTLS, YARP reverse proxy gateways, gRPC vs REST benchmarks, Transactional Outbox pattern, and live production outage triage playbooks.
- 24-Hour Final Interview Revision: Rapidly review high-frequency architectural trade-offs, HTTP status codes, and copy-pasteable resilience snippets.
HTTP Status Codes Architectural Cheat Sheet
Keep this quick reference matrix in mind during technical interview discussions on REST semantics, error handling, and client SDK contracts:
| Status Code | Standard Name | Classification | When to Return | Body Payload Expectation |
|---|---|---|---|---|
| 200 | OK | Success | Standard response for successful GET, PUT, or PATCH. | Required representation of resource. |
| 201 | Created | Success | Resource created via POST. Must include Location header. | Newly created resource object. |
| 202 | Accepted | Success | Long-running asynchronous job queued (polling / webhook pattern). | Job metadata with status URI and Retry-After. |
| 204 | No Content | Success | Successful action with zero response payload (common for DELETE/PUT). | Strictly EMPTY body (zero bytes). |
| 304 | Not Modified | Redirection | Conditional GET matches client If-None-Match ETag. | Strictly EMPTY body (saves 100% bandwidth). |
| 400 | Bad Request | Client Error | Malformed JSON syntax, unparseable parameters, schema failure. | RFC 7807 ProblemDetails JSON. |
| 401 | Unauthorized | Client Error | Unauthenticated: Missing, malformed, or expired JWT/OAuth token. | Challenge header + error message. |
| 403 | Forbidden | Client Error | Unauthorized: Authenticated user lacks permission/scope for resource. | Explanation of missing permission. |
| 404 | Not Found | Client Error | Target URI does not map to any existing database entity. | Resource not found notification. |
| 409 | Conflict | Client Error | State conflict (e.g. duplicate unique key, concurrency version mismatch). | Conflict details & current state. |
| 422 | Unprocessable Entity | Client Error | JSON is syntactically valid, but business validation rules failed. | Field-by-field validation error list. |
| 429 | Too Many Requests | Client Error | Rate limit / quota exceeded. Must include Retry-After. | Throttling explanation and reset time. |
| 502 | Bad Gateway | Server Error | Reverse proxy received an invalid response from upstream microservice. | Proxy gateway error details. |
| 503 | Service Unavailable | Server Error | Server overloaded, undergoing maintenance, or circuit breaker tripped. | Temporary unavailability notice. |
| 504 | Gateway Timeout | Server Error | Upstream microservice or database failed to respond within timeout. | Gateway timeout error. |
What is an API, and what are the 6 architectural constraints of a RESTful Web API?
In web and distributed systems development, REST (Representational State Transfer) is not a protocol or a library; it is an architectural style formulated by Dr. Roy Fielding in his 2000 doctoral dissertation. To be genuinely RESTful, an API must adhere to six core constraints:
- Client-Server Architecture: The user interface concerns (client) are separated from data storage and business logic (server). This enables independent evolution of web, iOS, and Android clients without touching backend services.
- Statelessness: Every HTTP request from client to server must contain all the information necessary to understand and process the request. The server stores no client session context in memory between requests.
- Cacheability: Responses must explicitly define themselves as cacheable or non-cacheable (via
Cache-ControlandETagheaders) to eliminate redundant network roundtrips. - Uniform Interface: Simplifies and decouples architecture through four sub-constraints:
- Resource Identification: Unique URIs (e.g.,
/api/v1/orders/102). - Manipulation through Representations: Modifying a resource via JSON or XML payloads.
- Self-descriptive Messages: Headers like
Content-Typeexplaining how to process data. - HATEOAS (Hypermedia As The Engine Of Application State): Including navigable hypermedia links in responses.
- Resource Identification: Unique URIs (e.g.,
- Layered System: The client cannot tell whether it is connected directly to the end server, or through an intermediary (reverse proxy, load balancer, CDN, API gateway).
- Code on Demand (Optional): Servers can temporarily extend client functionality by transferring executable scripts (e.g., compiled WebAssembly or JavaScript widgets).
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/[controller]")] // Uniform Resource Identification
public class CustomersController : ControllerBase
{
private readonly ICustomerRepository _repository;
public CustomersController(ICustomerRepository repository)
{
_repository = repository;
}
// GET api/v1/customers/42
[HttpGet("{id:int}", Name = "GetCustomerById")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ResponseCache(Duration = 60)] // Cacheability constraint
public async Task<ActionResult<CustomerResponseDto>> GetById(int id, CancellationToken ct)
{
var customer = await _repository.GetByIdAsync(id, ct);
if (customer == null)
return NotFound(new { message = $"Customer with ID {id} not found." });
return Ok(new CustomerResponseDto(customer.Id, customer.FullName, customer.Email));
}
}What is the difference between GET, POST, PUT, PATCH, and DELETE? Which are Safe and Idempotent?
Two fundamental properties govern HTTP method semantics according to RFC 9110:
- Safe Methods: A method is safe if it does not alter server state. Read-only operations (GET, HEAD, OPTIONS) are safe. Safe methods can be pre-fetched, cached, and crawled without side effects.
- Idempotent Methods: A method is idempotent if executing the identical request multiple times produces the exact same server side-effect as executing it once (
f(f(x)) = f(x)). GET, PUT, and DELETE are idempotent. POST is non-idempotent because multiple requests create multiple database records.
| Method | Primary Purpose | Safe? | Idempotent? | Expected Status Code |
|---|---|---|---|---|
| GET | Retrieve representation | ✅ Yes | ✅ Yes | 200 OK / 404 Not Found |
| POST | Create subordinate resource / process batch | ❌ No | ❌ No | 201 Created + Location header |
| PUT | Full replacement of target resource | ❌ No | ✅ Yes | 200 OK / 204 No Content |
| PATCH | Partial update (delta changes) | ❌ No | ⚠️ Conditional | 200 OK / 204 No Content |
| DELETE | Remove target resource | ❌ No | ✅ Yes | 204 No Content / 200 OK |
// PUT api/v1/users/5 (Full Replacement - Idempotent)
[HttpPut("{id:int}")]
public async Task<IActionResult> ReplaceUser(int id, [FromBody] UpdateUserDto dto)
{
var existing = await _db.Users.FindAsync(id);
if (existing == null) return NotFound();
// Replaces ALL fields; missing fields are reset or overwritten
existing.FirstName = dto.FirstName;
existing.LastName = dto.LastName;
existing.PhoneNumber = dto.PhoneNumber;
await _db.SaveChangesAsync();
return NoContent(); // 204 No Content
}
// PATCH api/v1/users/5 (Partial Update - JSON Merge Patch RFC 7396)
[HttpPatch("{id:int}")]
public async Task<IActionResult> PatchUser(int id, [FromBody] JsonPatchUserDto patchDto)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
// Only update fields explicitly supplied in the payload
if (patchDto.PhoneNumber != null)
user.PhoneNumber = patchDto.PhoneNumber;
await _db.SaveChangesAsync();
return Ok(user);
}Explain HTTP status code classifications (2xx, 3xx, 4xx, 5xx) with high-frequency interview examples.
In REST API design, choosing the correct status code informs client SDKs, browsers, and API gateways how to react automatically without parsing human error strings:
- 2xx Success:
200 OK: Request succeeded; payload returned in body.201 Created: Resource created; must include aLocationresponse header with the URI of the newly created item.202 Accepted: Request accepted for asynchronous processing, but not yet complete.204 No Content: Succeeded, but the response body is intentionally empty (common for PUT and DELETE).
- 3xx Redirection:
301 Moved Permanently: Resource permanently relocated; search engines transfer SEO link equity.304 Not Modified: Client’s cached copy is fresh (returned whenIf-None-Matchmatches serverETag). Saves bandwidth.
- 4xx Client Errors:
400 Bad Request: Malformed JSON syntax or schema validation failure.401 Unauthorized: Missing or invalid authentication token (misnamed; actually means Unauthenticated).403 Forbidden: Authenticated, but user lacks permissions (role/scope) to access the resource.404 Not Found: URI does not map to any existing server resource.409 Conflict: State conflict (e.g., trying to register an email that already exists).422 Unprocessable Entity: Syntactically valid JSON, but violates business validation rules.429 Too Many Requests: Rate limit exceeded; response should includeRetry-Afterheader.
- 5xx Server Errors:
500 Internal Server Error: Unhandled backend exception or database crash.502 Bad Gateway: Reverse proxy / API gateway received an invalid response from upstream microservice.503 Service Unavailable: Server is overloaded or undergoing maintenance.504 Gateway Timeout: Downstream microservice or database failed to respond within gateway timeout limits.
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductDto dto)
{
if (await _db.Products.AnyAsync(p => p.Sku == dto.Sku))
{
// 409 Conflict: Business constraint collision
return Conflict(new { message = $"SKU '{dto.Sku}' already exists." });
}
var product = new Product { Name = dto.Name, Sku = dto.Sku, Price = dto.Price };
_db.Products.Add(product);
await _db.SaveChangesAsync();
// 201 Created with Location Header: /api/v1/products/{id}
return CreatedAtAction(
actionName: nameof(GetProductById),
routeValues: new { id = product.Id },
value: product
);
}What is the difference between REST and SOAP APIs? When would you choose one over the other?
Comparing REST and SOAP requires understanding the distinction between an architectural style (REST) and an opinionated protocol (SOAP):
| Feature | REST (Representational State Transfer) | SOAP (Simple Object Access Protocol) |
|---|---|---|
| Protocol vs Style | Architectural style, uses underlying HTTP | Strict protocol, transport-agnostic (HTTP, SMTP, TCP) |
| Data Format | Flexible (JSON, XML, HTML, Protobuf) | Strictly XML enveloped payloads |
| Contract Definition | OpenAPI / Swagger (optional, flexible) | WSDL (Web Services Description Language) required |
| Security Standards | HTTPS, OAuth 2.0, OpenID Connect, JWT | WS-Security (message-level encryption, XML Signatures) |
| Caching | Directly leverages HTTP cache headers | Cannot be natively cached by HTTP proxies (uses POST) |
| Bandwidth & Speed | Extremely lightweight, minimal JSON overhead | Heavy XML envelopes increase network transmission |
<!-- SOAP Request (Heavy XML Envelope, Headers, and Body) -->
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cust="https://rtsall.com/customers">
<soapenv:Header>
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>admin</wsse:Username>
<wsse:Password>SecretPass</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
</soapenv:Header>
<soapenv:Body>
<cust:GetBalance>
<cust:AccountId>987654</cust:AccountId>
</cust:GetBalance>
</soapenv:Body>
</soapenv:Envelope>
<!-- REST Request & Response (Clean HTTP Header & Compact JSON Payload) -->
<!-- GET /api/v1/accounts/987654/balance -->
<!-- Authorization: Bearer eyJhbGciOi... -->
{
"accountId": 987654,
"balance": 15420.50,
"currency": "USD"
}What are the industry best practices for designing clean, intuitive, and RESTful URIs?
Professional RESTful URI design treats URIs as identifiers for resources (nouns), while the HTTP method supplies the action (verb):
- Use Plural Nouns: Prefer
/api/v1/customersover/api/v1/customeror/api/v1/getAllCustomers. - Let HTTP Verbs Do the Work:
GET /api/v1/invoices(List invoices)POST /api/v1/invoices(Create invoice)GET /api/v1/invoices/101(Retrieve invoice 101)DELETE /api/v1/invoices/101(Delete invoice 101)
- Sub-resources for Relationships: Represent natural parent-child relationships through URL paths:
/api/v1/authors/4/books(All books written by author 4). - Limit Nesting Depth: Avoid deep chains like
/authors/4/books/12/chapters/3/paragraphs. If nesting exceeds 2 levels, elevate the resource:/api/v1/chapters/3/paragraphs. - Query Parameters for Filtering, Sorting, and Paging: Never create separate endpoints for sorting. Use
/api/v1/products?category=laptops&sort=price_desc&page=2&pageSize=20. - Kebab-Case Naming: Use lowercase hyphen-separated words (e.g.,
/user-profilesinstead of/userProfilesor/user_profiles).
[ApiController]
[Route("api/v1/departments/{departmentId:int}/employees")]
public class DepartmentEmployeesController : ControllerBase
{
// GET api/v1/departments/5/employees?status=active&page=1
[HttpGet]
public async Task<IActionResult> GetEmployees(
int departmentId,
[FromQuery] string? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
// Fetches employees scoped to the specific parent department
return Ok(new { departmentId, status, page });
}
}What are HTTP Headers? Explain the difference between Content-Type, Accept, and Authorization.
Headers allow clients and servers to negotiate transmission details, security policies, and payload representations independently of the message body:
Content-Type: Describes the media type of the payload in the current message body.application/json; charset=utf-8(JSON payload)application/x-www-form-urlencoded(Standard web form post)multipart/form-data; boundary=...(File uploads)
Accept: Drives Content Negotiation. Sent by the client to tell the server what format it can understand:Accept: application/json(Client wants JSON back)Accept: application/xml(Client wants XML back)Accept: text/csv(Client requests a CSV export)
406 Not Acceptable.Authorization: Transmits client credentials to verify identity:Authorization: Bearer <JWT_Token>(OAuth 2.0 / OpenID Connect token)Authorization: Basic <Base64(user:pass)>(Basic HTTP auth)Authorization: ApiKey <Key_String>(Custom API key)
- Custom Headers: Standardized with descriptive names (e.g.,
X-Request-ID,X-Correlation-ID,X-RateLimit-Remaining). RFC 6648 deprecated the mandatory `X-` prefix for new custom headers.
/* --- Outgoing Client HTTP Request --- */
GET /api/v1/reports/monthly HTTP/1.1
Host: api.rtsall.com
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
User-Agent: iOS-MobileClient/3.4.1
X-Correlation-ID: 7a8f9c2d-5b3e-4d2a-89a1-7c9e0f3b4a2e
/* --- Incoming Server HTTP Response --- */
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 482
ETag: W/"5a3b2c1d"
Cache-Control: public, max-age=300
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 984
X-Correlation-ID: 7a8f9c2d-5b3e-4d2a-89a1-7c9e0f3b4a2e
{"reportId": 1042, "totalRevenue": 48500.00}Why has JSON replaced XML as the dominant data format for Web APIs? What are the performance trade-offs?
The transition from XML to JSON over the last decade was driven by web browsers, mobile networks, and serialization efficiency:
- Compact Wire Size: JSON does not require redundant closing tags (e.g.,
</customerName>). A dataset serialized in JSON is typically 30% to 50% smaller than the equivalent XML representation, reducing cellular data usage and latency. - Native Data Types: JSON natively supports numbers, strings, booleans, null, arrays
[], and objects{}. In XML, all elements and attributes are fundamentally strings until parsed against an XSD schema. - Memory & CPU Benchmarks: Modern JSON serializers (like .NET’s
System.Text.JsonusingUtf8JsonReader) parse directly from UTF-8 byte buffers without allocating intermediate strings, achieving up to 5x higher throughput than legacy DOM-based XML parsers. - When XML is Still Used: Regulated healthcare (HL7/FHIR), banking protocols (SWIFT/ISO 20022), and enterprise systems requiring formal W3C XML Schema Definition (XSD) validation.
var builder = WebApplication.CreateBuilder(args);
// Configure System.Text.Json options for speed and standards compliance
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.DefaultIgnoreCondition =
System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
options.JsonSerializerOptions.WriteIndented = false; // Never indent in production!
});When should you pass parameters via Route, Query String, or Request Body?
Selecting the right transmission mechanism preserves REST semantics, security, and cacheability:
- Route Parameters (Path Variables):
- Purpose: Uniquely identify a specific resource or entity hierarchy.
- Example:
GET /api/v1/warehouses/3/items/88 - Rule: If removing the parameter changes the identity of what resource you are querying, it belongs in the route.
- Query String Parameters:
- Purpose: Modifiers that filter, sort, search, or paginate the requested resource collection.
- Example:
GET /api/v1/items?inStock=true&category=electronics&page=1 - Security Warning: Never place sensitive credentials (passwords, API tokens, PII) in query strings! Query parameters appear in web server access logs, browser history, and proxy logs.
- Request Body:
- Purpose: Structured payloads (JSON/XML) for state creation or modification.
- Applicable Verbs: POST, PUT, PATCH. (RFC 9110 allows GET with body, but many proxies and firewalls strip or reject GET bodies).
[HttpGet("categories/{categoryId:int}/products")]
public async Task<IActionResult> SearchProducts(
[FromRoute] int categoryId, // From Route: /categories/12/products
[FromQuery] string? searchTerm, // From Query: ?searchTerm=monitor
[FromQuery] decimal? maxPrice, // From Query: &maxPrice=300
[FromHeader(Name = "X-Tenant-ID")] string tenant // From Header: X-Tenant-ID: corp_42
)
{
var products = await _productService.SearchAsync(categoryId, searchTerm, maxPrice, tenant);
return Ok(products);
}What is CORS (Cross-Origin Resource Sharing)? What is a Preflight OPTIONS request?
CORS is enforced strictly by web browsers (not curl, mobile apps, or Postman) to prevent malicious websites from executing unauthorized cross-origin requests using a user’s stored session cookies:
- Same-Origin Policy (SOP): A browser allows scripts on
https://example.com:443to only access resources on that exact same origin (Protocol + Domain + Port). Callinghttps://api.example.comis a cross-origin request. - Simple Requests: Requests using GET, HEAD, or POST with standard headers (
Accept,Content-Type: text/plainorapplication/x-www-form-urlencoded) skip preflight. - Preflight Request (HTTP OPTIONS): When a request uses custom headers (e.g.,
Authorization) or methods like PUT, DELETE, orContent-Type: application/json, the browser automatically sends an HTTPOPTIONSrequest first:Access-Control-Request-Method: PUTAccess-Control-Request-Headers: authorization, content-type
204 No Contentor200 OKand matchingAccess-Control-Allow-*headers before the browser sends the actual payload.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("FrontendClients", policy =>
{
policy.WithOrigins("https://rtsall.com", "https://app.rtsall.com") // Specific origins ONLY
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type", "X-Correlation-ID")
.AllowCredentials(); // Allows cookies/tokens
});
});
var app = builder.Build();
// Must be placed between UseRouting() and UseEndpoints() / MapControllers()
app.UseCors("FrontendClients");
app.MapControllers();
app.Run();What does Statelessness mean in REST, and why is it crucial for horizontal cloud scaling?
In stateful architectures (like traditional ASP.NET Web Forms with Session["User"] stored in server RAM), client requests must be routed back to the exact same physical server via sticky sessions:
- The Failure of Sticky Sessions: If Server 3 crashes or restarts, all users whose sessions resided in Server 3’s memory are forcibly logged out and lose unsaved progress. Furthermore, load balancers cannot evenly distribute traffic because heavy users stay pinned to one box.
- The REST Stateless Model:
- The client authenticates once and receives a cryptographically signed token (e.g., JWT).
- Every subsequent request passes this token in the
Authorization: Bearerheader. - Any server in a cluster of 50 Docker containers can independently validate the signature, extract the claims (User ID, Roles), and execute the query.
- Servers can be autoscaled up or terminated down instantly without disrupting a single user.
// Stateless Controller: No HttpContext.Session used!
[Authorize]
[HttpGet("profile")]
public IActionResult GetUserProfile()
{
// Extracts user identity directly from the cryptographically verified JWT Claims
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var userRole = User.FindFirst(ClaimTypes.Role)?.Value;
if (string.IsNullOrEmpty(userId))
return Unauthorized();
// Query database or Redis cache statelessly using the extracted ID
return Ok(new { UserId = userId, Role = userRole });
}What is the difference between Minimal APIs and Controller-based APIs in ASP.NET Core? When should you use each?
Modern ASP.NET Core (.NET 8/9) provides two primary ways to expose REST endpoints:
- Minimal APIs:
- Structure: Routes mapped directly via
app.MapGet(),app.MapPost()using lambdas or static methods. - Performance: Eliminates reflection-heavy controller activation, MVC filter pipelines, and action descriptor caches. Reduces memory allocations and cold-start latency significantly.
- Ideal Use Case: Microservices, serverless functions (AWS Lambda / Azure Functions), high-throughput gateway endpoints, and simple CRUD services.
- Structure: Routes mapped directly via
- Controller-based APIs:
- Structure: Classes inheriting from
ControllerBasedecorated with[ApiController]. - Features: Native support for MVC Action Filters (
IActionFilter,IAsyncActionFilter), automatic model state validation, and clean segregation of hundreds of endpoints in large codebases. - Ideal Use Case: Monoliths, complex enterprise apps requiring shared cross-cutting filter logic, and legacy system migrations.
- Structure: Classes inheriting from
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Route Group with common prefix and tags
var orders = app.MapGroup("/api/v1/orders")
.WithTags("Orders")
.RequireAuthorization();
orders.MapGet("/{id:int}", async Task<Results<Ok<OrderDto>, NotFound>> (int id, IOrderService service, CancellationToken ct) =>
{
var order = await service.GetOrderAsync(id, ct);
return order != null
? TypedResults.Ok(order)
: TypedResults.NotFound();
});
orders.MapPost("/", async (CreateOrderDto dto, IOrderService service) =>
{
var created = await service.CreateAsync(dto);
return TypedResults.CreatedAtRoute(created, "GetOrderById", new { id = created.Id });
});
app.Run();How does Model Binding and Validation work in ASP.NET Core? Why use FluentValidation over DataAnnotations?
In ASP.NET Core Web API, when a controller is marked with [ApiController]:
- Automatic Binding Inference: Complex types default to
[FromBody], primitives default to[FromQuery]or[FromRoute], andIFormFiledefaults to[FromForm]. - Automatic 400 Bad Request: If
ModelState.IsValid == false, the runtime automatically short-circuits the pipeline and returns an RFC 7807ProblemDetailsresponse before your action code ever executes. - DataAnnotations vs FluentValidation:
- DataAnnotations (
[Required],[StringLength]): Simple, built-in, but clutters DTO classes with UI/validation logic and cannot execute asynchronous database queries (e.g., checking if email is already taken). - FluentValidation: Keeps DTOs clean POCOs, provides fluent chaining, supports conditional validation (
When(...)), unit tests cleanly, and injects services to query databases during validation.
- DataAnnotations (
using FluentValidation;
public record RegisterUserDto(string Email, string Password, int Age);
public class RegisterUserValidator : AbstractValidator<RegisterUserDto>
{
private readonly IUserRepository _userRepo;
public RegisterUserValidator(IUserRepository userRepo)
{
_userRepo = userRepo;
RuleFor(x => x.Email)
.NotEmpty().EmailAddress()
.MustAsync(async (email, ct) => !await _userRepo.EmailExistsAsync(email, ct))
.WithMessage("This email address is already registered.");
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
.Matches(@"[0-9]").WithMessage("Password must contain at least one digit.");
RuleFor(x => x.Age)
.InclusiveBetween(18, 120).WithMessage("User must be at least 18 years old.");
}
}What is Content Negotiation in REST APIs, and how does ASP.NET Core handle it via Formatters?
The HTTP protocol defines Content Negotiation via the Accept request header:
GET /api/v1/reports/12 HTTP/1.1
Accept: application/xml, application/json;q=0.8Here, the client prefers XML, but will accept JSON if XML is unavailable (q=0.8 is quality factor/weight).
- Default ASP.NET Core Behavior: By default, ASP.NET Core enables
SystemTextJsonOutputFormatter. If the client asks for XML, the server ignores it and falls back to JSON unless XML formatters are explicitly configured. - Enforcing Strict ConNeg: Setting
ReturnHttpNotAcceptable = truecauses ASP.NET Core to return406 Not Acceptableif the client requests a format the server does not support. - Custom Formatters: You can inherit from
TextOutputFormatterto generate CSV, vCard, or custom Excel exports directly from the API pipeline.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options =>
{
// Return 406 Not Acceptable if client requests unsupported media type
options.ReturnHttpNotAcceptable = true;
// Respect browser Accept headers
options.RespectBrowserAcceptHeader = true;
})
.AddXmlSerializerFormatters(); // Enables XML Content Negotiation
var app = builder.Build();
app.MapControllers();
app.Run();Compare IActionResult, ActionResult<T>, and Results<T> (TypedResults) in ASP.NET Core.
The evolution of return types in ASP.NET Core reflects a shift toward compile-time safety and self-documenting APIs:
IActionResult:Returns any status code (
Ok(data),NotFound()). However, because the return type is interface-only, Swagger/OpenAPI cannot inspect what C# model is returned unless you decorate the method with[ProducesResponseType(typeof(CustomerDto), 200)].ActionResult<T>:Introduced in ASP.NET Core 2.1 for controllers. You can either return an instance of
Tdirectly (automatically wrapped in 200 OK) or anyIActionResult. Swagger automatically knows the 200 response type isT.TypedResults(Minimal APIs in .NET 7+):Uses generic union types (e.g.,
Results<Ok<UserDto>, NotFound, BadRequest>). This guarantees compile-time exhaustiveness: the method can only return those three exact status codes, and unit tests can inspectresult.Result.Valuewithout type casting.
// 1. Controller Action using ActionResult<T>
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> GetProduct(int id)
{
var product = await _repo.GetByIdAsync(id);
if (product == null) return NotFound(); // Returns 404
return new ProductDto(product.Id, product.Name); // Implicitly returns 200 OK with ProductDto
}
// 2. Minimal API using TypedResults (C# .NET 8)
app.MapGet("/api/products/{id}", async Task<Results<Ok<ProductDto>, NotFound>> (int id, IProductRepo repo) =>
{
var product = await repo.GetByIdAsync(id);
return product is not null
? TypedResults.Ok(new ProductDto(product.Id, product.Name))
: TypedResults.NotFound();
});What is an Over-Posting (Mass Assignment) attack, and why must you never expose EF Core entities in Web APIs?
Directly binding HTTP request payloads to ORM entities (like EF Core classes) introduces severe security vulnerabilities and architectural coupling:
- The Over-Posting Vulnerability:
Imagine an API endpoint:
public IActionResult UpdateUser([FromBody] User user). An attacker sends:{ "firstName": "Alice", "isAdmin": true, "accountBalance": 999999 }Because the ASP.NET Core model binder matches all JSON keys to entity properties,
user.IsAdminis set totrueand saved directly to the database. - Serialization Cycles & Leaks: EF Core navigation properties (e.g.,
Order.CustomerandCustomer.Orders) cause circular reference exceptions during JSON serialization unless broken. Furthermore, internal sensitive fields (likePasswordHashorRowVersion) get leaked to the frontend. - The Solution — DTO Pattern: Define separate, immutable request/response records (e.g.,
CreateUserRequest,UserSummaryResponse). Map between DTOs and entities using explicit constructors, AutoMapper, or Mapster.
// Vulnerable Entity (Contains sensitive internal fields)
public class User
{
public int Id { get; set; }
public string Username { get; set; } = default!;
public string PasswordHash { get; set; } = default!; // MUST NEVER LEAK!
public bool IsAdmin { get; set; } // MUST NEVER BIND FROM CLIENT!
}
// Secure Input DTO (Only accepts fields client is allowed to set)
public record UpdateUserProfileRequest(string DisplayName, string Bio);
// Secure Output DTO (Only returns fields client is allowed to see)
public record UserProfileResponse(int Id, string Username, string DisplayName, string Bio);
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateUserProfileRequest request)
{
var user = await _db.Users.FindAsync(CurrentUserId);
if (user == null) return NotFound();
// Explicit field mapping prevents mass assignment
user.DisplayName = request.DisplayName;
user.Bio = request.Bio;
await _db.SaveChangesAsync();
return Ok(new UserProfileResponse(user.Id, user.Username, user.DisplayName, user.Bio));
}How do you implement Global Exception Handling in ASP.NET Core using IExceptionHandler and ProblemDetails (RFC 7807)?
Prior to .NET 8, developers wrote custom error handling middleware using try-catch blocks. .NET 8 introduced the standardized IExceptionHandler pattern:
- RFC 7807 ProblemDetails Standard: Defines a uniform JSON schema for HTTP API errors:
type: URI identifier for the error category.title: Short, human-readable summary.status: The HTTP status code.detail: Human-readable explanation specific to this occurrence.instance: The URI that generated the error.extensions: Custom metadata (e.g.,traceId,errorCode).
- Security Imperative: In production, never leak raw exception messages or stack traces! They reveal server paths, database schema details, and third-party library versions to attackers.
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken ct)
{
_logger.LogError(exception, "Unhandled exception occurred: {Message}", exception.Message);
var (statusCode, title) = exception switch
{
KeyNotFoundException => (StatusCodes.Status404NotFound, "Resource Not Found"),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "Forbidden Access"),
ValidationException => (StatusCodes.Status400BadRequest, "Validation Failure"),
_ => (StatusCodes.Status500InternalServerError, "Server Error")
};
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = statusCode == 500 ? "An unexpected error occurred. Please contact support." : exception.Message,
Instance = context.Request.Path
};
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
context.Response.StatusCode = statusCode;
await context.Response.WriteAsJsonAsync(problemDetails, cancellationToken: ct);
return true; // Exception has been fully handled
}
}
// In Program.cs:
// builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// builder.Services.AddProblemDetails();
// ...
// app.UseExceptionHandler();What are the common strategies for API Versioning? How do you implement versioning in ASP.NET Core?
APIs evolve continuously. When a change breaks existing clients (e.g., removing a field, changing data types), versioning allows existing consumers to function while new consumers adopt the update:
- 1. URI Path Versioning (Most Popular):
- Format:
https://api.rtsall.com/v1/customersvs/v2/customers - Pros: Highly explicit, easy to route at API gateway level, clean browser navigation and caching.
- Cons: Strictly speaking violates pure REST principle that a resource should have a single canonical URI.
- Format:
- 2. Query Parameter Versioning:
- Format:
/customers?api-version=2.0 - Pros: Simple to default to latest version if omitted.
- Format:
- 3. Custom HTTP Header Versioning:
- Format:
X-API-Version: 2.0 - Pros: Keeps URIs clean.
- Cons: Difficult to test directly in browser address bar without developer tools.
- Format:
- 4. Media Type (Accept Header) Versioning:
- Format:
Accept: application/vnd.company.v2+json - Pros: Purest REST implementation (Content Negotiation).
- Format:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // Adds 'api-supported-versions' response header
// Combines URL Path versioning with optional Header / Query string fallback
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-API-Version"),
new QueryStringApiVersionReader("api-version")
);
}).AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});Why must you pass CancellationToken to asynchronous methods in Web API controllers and EF Core?
Consider an expensive search endpoint that executes a 5-second SQL query:
[HttpGet("search")]
public async Task Search(string query)
{
var results = await _db.Products.Where(...).ToListAsync(); // No CancellationToken!
return Ok(results);
} - The Ghost Work Problem: If an impatient user clicks ‘Search’ 5 times in 3 seconds, the browser aborts the first 4 connections. Without
CancellationToken, your web server and database continue executing all 5 heavy SQL queries to 100% completion, only to throw a socket write exception when attempting to return the payload to the closed client! - The Solution: In ASP.NET Core, the controller automatically exposes
HttpContext.RequestAborted. By addingCancellationToken ctas an action parameter, model binding automatically injects this token. - When cancelled, EF Core cancels the active database command at the SQL Server TDS protocol level (sending an
ATTNpacket) and throws anOperationCanceledException, which ASP.NET Core catches and exits cleanly with499 Client Closed Request.
[HttpGet("analytics")]
public async Task<ActionResult<AnalyticsReportDto>> GetAnalytics(
[FromQuery] DateTime startDate,
CancellationToken ct) // Automatically binds to HttpContext.RequestAborted
{
// 1. Pass token to EF Core query
var data = await _dbContext.Transactions
.Where(t => t.CreatedAt >= startDate)
.ToListAsync(ct); // Aborts SQL execution if user disconnects
// 2. Pass token to external microservice HttpClient
var exchangeRates = await _httpClient.GetFromJsonAsync<ExchangeRatesDto>(
"https://api.rates.com/latest", ct); // Aborts outgoing HTTP socket
return Ok(new AnalyticsReportDto(data, exchangeRates));
}What is the difference between Offset-based pagination and Keyset (Cursor-based) pagination? Why does Offset pagination degrade at scale?
When designing collection endpoints (e.g., GET /api/v1/transactions), choosing between Offset and Keyset pagination dictates database scalability:
- 1. Offset-based Pagination:
Query:
SELECT * FROM Transactions ORDER BY Id OFFSET 1000000 ROWS FETCH NEXT 20 ROWS ONLY;- How it works: SQL Server must read all 1,000,000 preceding rows into memory, count them, discard them, and return only the last 20 rows!
- The Drift Problem: If new rows are inserted while a user is paging, items shift between pages, causing duplicate items or skipped records.
- 2. Keyset (Cursor-based) Pagination:
Query:
SELECT TOP 20 * FROM Transactions WHERE Id > 1000000 ORDER BY Id ASC;- How it works: SQL Server performs an instant Index Seek on the clustered index to find
Id = 1000000in $OO(log N)time, and reads exactly the next 20 leaf pages. Execution time is identical whether querying page 1 or page 50,000! - Trade-off: Cannot jump directly to an arbitrary page (e.g., ‘Go to Page 47’). Ideal for infinite scroll feeds (Twitter, Instagram, Slack).
- How it works: SQL Server performs an instant Index Seek on the clustered index to find
public record PagedResult<T>(IReadOnlyList<T> Items, int? NextCursor, bool HasMore);
[HttpGet("feed")]
public async Task<ActionResult<PagedResult<FeedItemDto>>> GetFeed(
[FromQuery] int? cursor, // The last seen Item ID from previous batch
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var query = _db.FeedItems.AsNoTracking().OrderBy(x => x.Id).AsQueryable();
// Fast Index Seek: No OFFSET skipping!
if (cursor.HasValue)
{
query = query.Where(x => x.Id > cursor.Value);
}
var items = await query
.Take(pageSize + 1) // Fetch 1 extra to check if next page exists
.Select(x => new FeedItemDto(x.Id, x.Title, x.CreatedAt))
.ToListAsync(ct);
bool hasMore = items.Count > pageSize;
var resultItems = items.Take(pageSize).ToList();
int? nextCursor = hasMore ? resultItems.Last().Id : null;
return Ok(new PagedResult<FeedItemDto>(resultItems, nextCursor, hasMore));
}What is the OpenAPI Specification (Swagger)? How has OpenAPI tooling evolved in .NET 8 and .NET 9?
OpenAPI (formerly Swagger) serves as the contract boundary between backend engineering and frontend/client developers:
- Client SDK Generation: Tools like Kiota, NSwag, and OpenAPI Generator read OpenAPI specs to automatically compile strongly typed client libraries in TypeScript, Swift, Java, and Python.
- The .NET 8/9 Transition:
- In .NET 9, Microsoft officially removed the default Swashbuckle template dependency because the third-party project was unmaintained for long stretches.
- Microsoft introduced
Microsoft.AspNetCore.OpenApi, which leverages compile-time source generation to produce OpenAPI specs without reflection, supporting Native AOT compilation. - For interactive UI documentation, developers now pair native OpenAPI with modern UI renderers like Scalar or Swagger UI.
var builder = WebApplication.CreateBuilder(args);
// Native .NET 9 OpenAPI document generation
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, ct) =>
{
document.Info.Title = "RTSALL Enterprise Core API";
document.Info.Version = "v1";
document.Info.Description = "Production High-Performance REST API Platform";
return Task.CompletedTask;
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
// Maps raw OpenAPI JSON at /openapi/v1.json
app.MapOpenApi();
// Integrates modern Scalar Interactive API Reference
// app.MapScalarApiReference();
}
app.Run();What is the difference between Authentication and Authorization? How do you implement Policy-Based Authorization in ASP.NET Core?
In distributed enterprise systems, conflating Authentication (401) and Authorization (403) causes subtle security bugs and confusing UX:
- 401 Unauthorized (Unauthenticated): The request lacks valid credentials. The client must authenticate (e.g., login, refresh expired JWT token).
- 403 Forbidden (Unauthorized): The server knows exactly who the user is, but the user’s role or permissions do not allow accessing this resource. Retrying with the same token will produce the same error.
- Why Role-Based Authorization Fails at Scale: Hardcoding
[Authorize(Roles = "Admin, SuperUser")]across hundreds of controllers creates maintenance nightmares when permissions change. - Policy-Based Authorization:
- Define a Requirement: An object implementing
IAuthorizationRequirement(e.g.,MinimumAgeRequirement(21)orTenantAccessRequirement). - Define a Handler: A class implementing
AuthorizationHandler<TRequirement>where business logic evaluates the user’s claims or queries a permission service. - Bind the policy in
Program.cs:options.AddPolicy("RequireVipCustomer", policy => policy.Requirements.Add(...));.
- Define a Requirement: An object implementing
// 1. Requirement Contract
public record SubscriptionRequirement(string RequiredTier) : IAuthorizationRequirement;
// 2. Authorization Handler
public class SubscriptionHandler : AuthorizationHandler<SubscriptionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
SubscriptionRequirement requirement)
{
var tierClaim = context.User.FindFirst("subscription_tier")?.Value;
if (tierClaim != null && (tierClaim == requirement.RequiredTier || tierClaim == "Enterprise"))
{
context.Succeed(requirement); // Access Granted
}
else
{
// context.Fail() is called implicitly if not succeeded
}
return Task.CompletedTask;
}
}
// 3. Applying in Controller
[Authorize(Policy = "ProOrEnterpriseOnly")]
[HttpGet("premium-reports")]
public IActionResult GetPremiumReports() => Ok(new { data = "Executive Analytics" });Explain JWT (JSON Web Token) architecture. How do you handle token revocation and Refresh Token rotation?
JWTs enable stateless authentication, but their self-contained nature creates a fundamental architectural dilemma: Once signed and issued, a JWT is valid until it expires! If an employee is fired or a laptop is stolen, the token remains valid.
- Structure:
Header.Payload.Signature- Header:
{"alg": "RS256", "typ": "JWT"} - Payload: Claims (
sub,exp,iss,roles). Warning: The payload is only base64 encoded, not encrypted! Never store passwords, PII, or secret keys in JWT claims. - Signature: Computed hash verifying that the header and payload were not tampered with.
- Header:
- Production Token Lifecycle Strategy:
- Short-Lived Access Tokens: Set lifespan to 10 minutes. If compromised, the exposure window is narrow.
- Refresh Token Rotation: Store long-lived refresh tokens (7 days) in a database/Redis paired with a client device fingerprint.
- Every time the client exchanges a refresh token for a new access token, the used refresh token is invalidated immediately and a brand-new refresh token is issued.
- Compromise Detection: If an attacker attempts to use an already-consumed refresh token, the server detects token reuse, immediately invalidates the entire refresh token family, and forces all active sessions to log out.
public async Task<AuthResult> RefreshTokenAsync(string tokenStr, string refreshTokenStr, string ipAddress)
{
var principal = GetPrincipalFromExpiredToken(tokenStr);
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var storedToken = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.Token == refreshTokenStr);
if (storedToken == null)
throw new SecurityException("Invalid refresh token.");
// REUSE DETECTION: If token was already revoked, someone is attacking!
if (storedToken.IsRevoked)
{
// Invalidate all tokens for this user family immediately
await RevokeAllTokensForUserAsync(userId, "Refresh token reuse attempt detected!");
throw new SecurityException("Security breach: Token reuse detected. Please log in again.");
}
if (storedToken.IsExpired)
throw new SecurityException("Refresh token expired.");
// Revoke old token and issue fresh rotated pair
storedToken.IsRevoked = true;
storedToken.RevokedByIp = ipAddress;
var newAccessToken = GenerateJwtAccessToken(principal.Claims);
var newRefreshToken = GenerateSecureRefreshToken(userId, ipAddress);
await _db.SaveChangesAsync();
return new AuthResult(newAccessToken, newRefreshToken.Token);
}What is the difference between OAuth 2.0 and OpenID Connect (OIDC)? Explain the Authorization Code Flow with PKCE.
OAuth 2.0 alone does not know who the user is; it only proves that the client has been granted access scopes. OIDC extends OAuth 2.0 to provide user identity:
- Core Tokens:
ID Token (OIDC):Proves Authentication (who the user is: name, email, avatar). Consumed by the client app.Access Token (OAuth 2.0):Grants Authorization (scopes:read:orders,write:invoices). Consumed by the backend API.
- Authorization Code Flow with PKCE (RFC 7636):
Public clients (React, Angular, iOS apps) cannot safely store a client secret. PKCE (Proof Key for Code Exchange) eliminates the need for a client secret:
- Client generates a random cryptographic secret:
code_verifier. - Client hashes it with SHA-256 to create:
code_challenge = BASE64URL(SHA256(code_verifier)). - Client redirects user to Auth Server passing
code_challengeandcode_challenge_method=S256. - After login, Auth Server redirects back with a temporary
authorization_code. - Client exchanges the
authorization_codefor tokens, sending the original plaintextcode_verifier. - Auth Server hashes the verifier; if it matches the stored challenge, it issues the tokens. Even if an attacker intercepted the authorization code from browser history, they cannot redeem it without the verifier!
- Client generates a random cryptographic secret:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.rtsall.com"; // OIDC Discovery (.well-known/openid-configuration)
options.Audience = "rtsall-api"; // Verifies 'aud' claim
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://identity.rtsall.com",
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30) // Mitigates clock drift between servers
};
});Explain the ASP.NET Core Middleware Pipeline. Why does the exact registration order matter?
In ASP.NET Core, the pipeline is constructed in Program.cs via app.Use(...) calls. Each middleware has access to HttpContext and invokes the next() delegate:
Incoming Request ──► [ExceptionHandler] ──► [HSTS] ──► [Routing] ──► [CORS] ──► [AuthN] ──► [AuthZ] ──► [Endpoint]
│
Outgoing Response ◄── [ExceptionHandler] ◄── [HSTS] ◄── [Routing] ◄── [CORS] ◄── [AuthN] ◄── [AuthZ] ◄────────┘
- Critical Ordering Rules:
UseExceptionHandlermust be registered first so it wraps all downstream middleware in an outertry-catch.UseRoutingmust execute beforeUseCorsandUseAuthenticationso the pipeline knows which endpoint was matched and what metadata attributes it has.UseCorsmust precedeUseAuthenticationandUseAuthorizationso browser preflightOPTIONSrequests are approved before hitting authentication challenges.UseAuthenticationmust precedeUseAuthorizationbecause you cannot authorize permissions for a user whose identity has not yet been established!
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// 1. Pre-execution logic: inject Correlation ID
if (!context.Request.Headers.TryGetValue("X-Correlation-ID", out var correlationId))
{
correlationId = Guid.NewGuid().ToString("N");
context.Request.Headers["X-Correlation-ID"] = correlationId;
}
context.Response.Headers["X-Correlation-ID"] = correlationId;
var stopwatch = Stopwatch.StartNew();
// 2. Invoke downstream pipeline
await _next(context);
// 3. Post-execution logic: log latency
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > 500)
{
_logger.LogWarning("SLOW API ALERT: {Method} {Path} took {Elapsed}ms (Trace: {TraceId})",
context.Request.Method, context.Request.Path, stopwatch.ElapsedMilliseconds, correlationId);
}
}
}Compare API Rate Limiting algorithms: Fixed Window, Sliding Window, Token Bucket, and Concurrency Limiter. How is it implemented natively in .NET 7/8?
Rate limiting protects APIs from DDoS attacks, brute-force login attempts, scraping bots, and noisy neighbor tenant starvation:
| Algorithm | Mechanism | Pros | Cons / Boundary Vulnerability |
|---|---|---|---|
| Fixed Window | Counter resets every $T$ seconds (e.g. 100 req/min) | Lowest memory usage | Burst hazard: 100 calls at 0:59 and 100 at 1:01 allows 200 calls in 2 seconds! |
| Sliding Window | Window divided into segments; computes weighted moving sum | Eliminates boundary burst problem | Slightly higher memory to track segment timestamps |
| Token Bucket | Tokens added at constant rate up to bucket capacity; each request consumes 1 token | Allows legitimate short bursts while enforcing long-term average | Must tune burst capacity and replenishment rate |
| Concurrency Limiter | Limits concurrent executing requests (semaphore) | Protects server thread pool & CPU | Doesn’t limit total requests over time |
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Custom response: Add Retry-After header
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.Headers.RetryAfter = "30";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "Rate limit exceeded. Please retry after 30 seconds."
}, cancellationToken: token);
};
// Partition by Client IP using Token Bucket algorithm
options.AddPolicy("IpThrottling", httpContext =>
RateLimitPartition.GetTokenBucketLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: partition => new TokenBucketRateLimiterOptions
{
TokenLimit = 60, // Maximum burst capacity
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0, // Do not queue; reject immediately
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
TokensPerPeriod = 10, // Sustained rate: 10 req/sec
AutoReplenishment = true
}));
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/api/search", () => Results.Ok("Search results"))
.RequireRateLimiting("IpThrottling");How does HTTP Caching work? Explain ETags, Conditional Requests (If-None-Match), and Redis Output Caching in ASP.NET Core.
Modern API performance relies on a layered caching strategy across three tiers:
- Client / Browser Cache (Cache-Control):
The server returns
Cache-Control: public, max-age=300. For 5 minutes, the client never contacts the server; it serves the response directly from browser disk/memory cache. - Conditional Validation via ETags (RFC 9110):
- The server computes a hash of the resource (e.g.,
ETag: "7c8d9e") and returns it with the initial200 OK. - When the client cache expires, it sends:
GET /items/42withIf-None-Match: "7c8d9e". - If the hash on the server still matches, the server returns
304 Not Modifiedwithout any body! The client continues using its existing cache, saving 100% of payload bandwidth.
- The server computes a hash of the resource (e.g.,
- Server-Side Redis Output Caching (.NET 7/8+):
Unlike legacy response caching, Output Caching allows you to lock cache keys, buffer responses, and evict related cache entries across your entire API cluster via Cache Tags:
await _outputCacheStore.EvictByTagAsync("products", ct);
var builder = WebApplication.CreateBuilder(args);
// Configure Redis Output Caching
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = "redis.internal:6379";
});
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Cache());
// Custom cache policy with Tagging
options.AddPolicy("ProductsCache", policy =>
policy.Expire(TimeSpan.FromMinutes(10))
.Tag("tag-products")
.SetVaryByQuery("category", "page"));
});
var app = builder.Build();
app.UseOutputCache();
// Cached for 10 minutes, partitioned by query string
app.MapGet("/api/products", async (IProductService svc) => await svc.GetAllAsync())
.CacheOutput("ProductsCache");
// Evicts all cached product pages instantly upon updates
app.MapPost("/api/products", async (ProductDto dto, IOutputCacheStore cache) =>
{
await SaveProductAsync(dto);
await cache.EvictByTagAsync("tag-products", default); // Purges cache cluster-wide
return Results.Created($"/api/products/{dto.Id}", dto);
});What is BOLA (Broken Object Level Authorization), and how do you protect Web APIs from it?
BOLA accounts for the majority of massive data breaches in modern cloud applications:
- The Attack Scenario:
- User Alice logs into the banking app and views her statement at
GET /api/v1/statements/1001. - Alice notices the integer ID in the URL and changes it to
GET /api/v1/statements/1002(User Bob’s statement). - Because the API only checked
[Authorize](verifying Alice is logged in), but failed to check ifstatement.UserId == currentUserId, Bob’s private financial data is returned to Alice!
- User Alice logs into the banking app and views her statement at
- Why BOLA is Pervasive: Automated web application scanners cannot easily detect BOLA because it requires understanding specific business authorization rules rather than syntax flaws like SQL Injection.
- Defense-in-Depth Remediation:
- Always enforce tenancy in the database query: Never do
_db.Statements.FindAsync(id). Always do_db.Statements.FirstOrDefaultAsync(s => s.Id == id && s.UserId == currentUserId). - Use Non-Sequential Identifiers: Replace auto-incrementing integers (1, 2, 3) with UUIDv7 or Cryptographically Secure GUIDs to prevent trivial enumeration attacks.
- EF Core Global Query Filters: Apply tenant filters automatically on DbContext initialization.
- Always enforce tenancy in the database query: Never do
[HttpGet("invoices/{invoiceId:guid}")]
public async Task<IActionResult> GetInvoice(Guid invoiceId)
{
// Extract current authenticated user ID from JWT token claims
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!Guid.TryParse(userIdClaim, out var currentUserId))
return Unauthorized();
// SECURE: Enforce ownership directly in SQL query!
var invoice = await _dbContext.Invoices
.Where(i => i.Id == invoiceId && i.TenantUserId == currentUserId)
.Select(i => new InvoiceDto(i.Id, i.Amount, i.CreatedAt))
.FirstOrDefaultAsync();
// Return 404 rather than 403 to prevent resource existence enumeration!
if (invoice == null)
return NotFound(new { message = "Invoice not found." });
return Ok(invoice);
}How does Response Compression (Gzip vs Brotli) work in Web APIs? What are the security risks (BREACH attack)?
Transferring large JSON collections consumes network bandwidth and increases mobile battery consumption. Web APIs compress payloads on the fly:
- Gzip vs Brotli:
- Gzip: The ubiquitous standard. Fast compression and decompression with low CPU utilization.
- Brotli (
br): Developed by Google specifically for web payloads. Features a built-in static dictionary of common web words. Produces ~20% smaller files than Gzip at similar compression levels.
- When NOT to Compress:
- Small Payloads (< 1KB): The compressed header overhead can make the payload larger than the original uncompressed text!
- Already Compressed Formats: Never compress images (PNG, JPEG), PDFs, or ZIP files; doing so wastes 100% of CPU cycles with zero size reduction.
- The BREACH Attack: An attacker sniffing encrypted HTTPS traffic can deduce CSRF tokens or secrets by observing subtle changes in compressed payload lengths when injecting reflected text. ASP.NET Core disables compression over HTTPS by default for this reason unless explicitly configured.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true; // Review BREACH risk before enabling!
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"application/json",
"application/problem+json"
});
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
// Optimal: Excellent compression without burning CPU in live HTTP pipeline
options.Level = CompressionLevel.Fastest;
});
var app = builder.Build();
app.UseResponseCompression(); // Must be near top of pipeline
app.MapControllers();
app.Run();How do you design a REST API for long-running operations (e.g., generating a 10-minute report) without timing out?
Holding an HTTP request open for minutes causes reverse proxies (Nginx, AWS ALB, Cloudflare) to terminate the socket with 504 Gateway Timeout (typically at 30 to 60 seconds). It also locks server threads and memory:
- Initiation:
Client sends:
POST /api/v1/reports/generate - Immediate 202 Accepted Response:
HTTP/1.1 202 Accepted Location: /api/v1/reports/jobs/b8c4d2e1-4567 Retry-After: 30 {"jobId": "b8c4d2e1-4567", "status": "Queued", "progress": 0} - Polling the Job Status:
Client polls
GET /api/v1/reports/jobs/b8c4d2e1-4567periodically:HTTP/1.1 200 OK {"jobId": "b8c4d2e1-4567", "status": "Processing", "progress": 65} - Completion:
Once finished, the status endpoint returns
200 OKor303 See Otherdirecting the client to the permanent resource URI (/api/v1/reports/9982) or a pre-signed S3 download URL.
[HttpPost("generate-payroll")]
public async Task<IActionResult> StartPayrollGeneration(
[FromBody] PayrollJobRequest request,
IBackgroundJobQueue jobQueue)
{
var jobId = Guid.NewGuid();
// Enqueue job to background worker service / Redis / RabbitMQ
await jobQueue.EnqueueAsync(new PayrollTask(jobId, request.CompanyId, request.Month));
// Return 202 Accepted immediately with status URI
return Accepted(
uri: Url.Action(nameof(GetJobStatus), new { jobId }),
value: new { jobId, status = "Queued", estimatedSeconds = 120 }
);
}
[HttpGet("jobs/{jobId:guid}")]
public async Task<IActionResult> GetJobStatus(Guid jobId, IJobStore jobStore)
{
var job = await jobStore.GetAsync(jobId);
if (job == null) return NotFound();
if (job.Status == "Completed")
{
return Ok(new { status = "Completed", downloadUrl = $"/api/v1/reports/{job.ResultReportId}" });
}
return Ok(new { status = job.Status, progressPercentage = job.Progress });
}How do you implement Health Checks and Distributed Tracing in modern Web APIs using OpenTelemetry?
Modern containerized microservices require automated telemetry for both container orchestrators and operational reliability engineers:
- Liveness vs Readiness Probes (Kubernetes):
/health/live (Liveness):Checks if the internal web server process is alive. If this returns 500 or times out, Kubernetes immediately kills and restarts the pod. It must NOT check external dependencies (database/Redis)!/health/ready (Readiness):Checks if the app is ready to accept user traffic (can connect to database, cache, message broker). If this fails, Kubernetes stops routing ingress traffic to this pod until it recovers.
- OpenTelemetry & W3C TraceContext:
The standard
traceparentheader formats distributed traces:traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01Contains:
version-traceId-parentId-traceFlags. Every microservice forwards this header when calling downstream APIs or database queries.
var builder = WebApplication.CreateBuilder(args);
// 1. Health Checks
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddSqlServer(builder.Configuration.GetConnectionString("Default")!, tags: new[] { "ready" })
.AddRedis(builder.Configuration.GetConnectionString("Redis")!, tags: new[] { "ready" });
// 2. OpenTelemetry Tracing
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation()
.AddOtlpExporter()); // Exports to Jaeger, Honeycomb, or Datadog
var app = builder.Build();
// Liveness Probe (Instant, internal only)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
// Readiness Probe (Deep dependency validation)
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
app.Run();How do you implement Idempotency Keys in financial/payment APIs to prevent double charges during network retries?
In distributed payment processing (like Stripe, Adyen, PayPal), network connections frequently drop after the bank has charged the customer’s card but before the client receives the 200 OK response. If the client or mobile app retries the POST request, the customer will be charged twice unless the API guarantees Idempotency:
- Client Generates Key: The mobile or web client generates a UUIDv4 (e.g.,
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d) and attaches it to the HTTP POST header. - Server Verification Flow:
- Step 1: Inspect Redis for the idempotency key.
- Step 2: If found with status
COMPLETED, immediately return the cached HTTP status code and body. Zero duplicate payment processing! - Step 3: If found with status
PROCESSING, another thread/request is currently executing it; return409 Conflictor wait briefly. - Step 4: If not found, acquire a distributed lock in Redis with a short TTL (e.g., 30s) and status
PROCESSING. - Step 5: Execute the payment transaction against the bank and database.
- Step 6: Update the Redis record with status
COMPLETED, response body, and status code with a 24-hour TTL. Release lock.
public class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly IConnectionMultiplexer _redis;
public IdempotencyMiddleware(RequestDelegate next, IConnectionMultiplexer redis)
{
_next = next;
_redis = redis;
}
public async Task InvokeAsync(HttpContext context)
{
// Only enforce on state-mutating POST/PATCH calls with the header
if (context.Request.Method != HttpMethods.Post ||
!context.Request.Headers.TryGetValue("Idempotency-Key", out var rawKey))
{
await _next(context);
return;
}
var db = _redis.GetDatabase();
string redisKey = $"idempotency:{rawKey}";
// 1. Check if response is already cached from previous execution
var cachedResponse = await db.StringGetAsync(redisKey);
if (cachedResponse.HasValue)
{
var saved = JsonSerializer.Deserialize<CachedApiResponse>(cachedResponse.ToString())!;
context.Response.StatusCode = saved.StatusCode;
context.Response.ContentType = "application/json";
context.Response.Headers["X-Cache-Lookup"] = "HIT-IDEMPOTENT";
await context.Response.WriteAsync(saved.Body);
return; // Short-circuit execution!
}
// 2. Intercept response stream to capture and cache output
var originalBodyStream = context.Response.Body;
using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
await _next(context); // Execute action
// 3. Cache successful response in Redis for 24 hours
if (context.Response.StatusCode is >= 200 and < 300)
{
memoryStream.Seek(0, SeekOrigin.Begin);
string responseBody = await new StreamReader(memoryStream).ReadToEndAsync();
memoryStream.Seek(0, SeekOrigin.Begin);
var toCache = new CachedApiResponse(context.Response.StatusCode, responseBody);
await db.StringSetAsync(redisKey, JsonSerializer.Serialize(toCache), TimeSpan.FromHours(24));
}
await memoryStream.CopyToAsync(originalBodyStream);
}
}What is the API Gateway Pattern? Compare YARP (Yet Another Reverse Proxy) with Ocelot and Cloud Gateways.
Without an API Gateway, frontend applications must communicate with dozens of distinct microservice endpoints directly, exposing internal network topologies, requiring CORS on every service, and multiplying token verification overhead:
- Core Gateway Capabilities:
- Routing & Load Balancing: Dynamically maps
/api/v1/billing/*to internal cluster IPs with round-robin or least-connection balancing. - Cross-Cutting Offloading: Verifies JWT signatures once at the perimeter so internal downstream microservices trust the forwarded identity headers (e.g.,
X-User-Id). - BFF (Backend For Frontend): Aggregates multiple internal microservice responses (e.g., User Profile + Orders + Recommendations) into a single optimized payload tailored for mobile devices.
- Routing & Load Balancing: Dynamically maps
- YARP vs Ocelot:
YARP was created by Microsoft in 2021 as a high-performance reverse proxy toolkit built on modern .NET
SocketsHttpHandler. It handles millions of concurrent connections with minimal memory allocations, significantly outperforming legacy Ocelot.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();
/* --- appsettings.json Configuration --- */
/*
{
"ReverseProxy": {
"Routes": {
"orders-route": {
"ClusterId": "orders-cluster",
"Match": { "Path": "/api/v1/orders/{**catch-all}" }
}
},
"Clusters": {
"orders-cluster": {
"Destinations": {
"pod1": { "Address": "http://orders-svc-1:8080" },
"pod2": { "Address": "http://orders-svc-2:8080" }
}
}
}
}
}
*/Synchronous (REST/gRPC) vs Asynchronous (Kafka/RabbitMQ) Microservice communication: How do you prevent temporal coupling?
The biggest architectural trap in microservices is building a Distributed Monolith where synchronous REST calls are chained across services:
Client ──► [Order API] ──(REST)──► [Inventory API] ──(REST)──► [Payment API] ──(REST)──► [Email API]
- The Cascade of Death: If the Email API slows down from 50ms to 2,000ms, the Payment API runs out of threads, which starves the Inventory API, which crashes the Order API. The overall availability of the system becomes the mathematical product of every service’s individual uptime (99.9% * 99.9% * 99.9% = 99.7%).
- The Event-Driven Solution:
- Use synchronous REST/gRPC strictly for Queries that require instant data (e.g., checking item availability).
- Use asynchronous messaging for Commands and State Changes: The Order API persists the order, publishes an
OrderPlacedEventto Kafka/RabbitMQ, and returns202 Acceptedor201 Createdimmediately. - Inventory, Payment, and Notification services consume the event independently and asynchronously at their own pace.
[HttpPost]
public async Task<IActionResult> PlaceOrder(
[FromBody] CreateOrderRequest request,
IPublishEndpoint publishEndpoint,
AppDbContext db)
{
var order = new Order { Id = Guid.NewGuid(), Total = request.Total, Status = OrderStatus.Pending };
db.Orders.Add(order);
await db.SaveChangesAsync();
// Publishes event to RabbitMQ/Kafka topic asynchronously
await publishEndpoint.Publish<IOrderSubmittedEvent>(new
{
OrderId = order.Id,
TotalAmount = order.Total,
CustomerId = request.CustomerId,
CreatedAt = DateTime.UtcNow
});
return Accepted($"/api/v1/orders/{order.Id}", new { order.Id, status = "Pending" });
}gRPC vs REST: Architectural differences, HTTP/2 multiplexing, and Protocol Buffer performance benchmarks.
For internal server-to-server communication where human readability is not required, gRPC dramatically outperforms REST:
| Feature | gRPC | REST (JSON over HTTP) |
|---|---|---|
| Protocol Transport | Strictly HTTP/2 (or HTTP/3) with header compression (HPACK) | Primarily HTTP/1.1 or HTTP/2 |
| Payload Format | Binary Protocol Buffers (strongly typed schema) | Text JSON / XML |
| Multiplexing | Hundreds of concurrent bidirectional requests over a single TCP connection | Often requires opening multiple TCP sockets (connection pooling overhead) |
| Streaming Modes | Unary, Server Streaming, Client Streaming, Bi-directional Streaming | Primarily Request-Reply (SSE / WebSockets needed for streaming) |
| Browser Support | Limited (requires gRPC-Web proxy due to browser HTTP/2 framing restrictions) | Universal native browser Fetch/AJAX support |
// 1. orders.proto (Strict Binary Schema)
/*
syntax = "proto3";
package orders;
service OrderGrpcService {
rpc GetOrderDetails (OrderRequest) returns (OrderReply);
}
message OrderRequest {
int32 order_id = 1;
}
message OrderReply {
int32 order_id = 1;
string customer_name = 2;
double total_amount = 3;
}
*/
// 2. C# ASP.NET Core gRPC Service Implementation
public class OrderGrpcServiceImpl : OrderGrpcService.OrderGrpcServiceBase
{
private readonly IOrderRepository _repository;
public OrderGrpcServiceImpl(IOrderRepository repository)
{
_repository = repository;
}
public override async Task<OrderReply> GetOrderDetails(
OrderRequest request,
ServerCallContext context)
{
var order = await _repository.GetByIdAsync(request.OrderId, context.CancellationToken);
if (order == null)
throw new RpcException(new Status(StatusCode.NotFound, "Order not found."));
return new OrderReply
{
OrderId = order.Id,
CustomerName = order.CustomerName,
TotalAmount = (double)order.Total
};
}
}How do you design a secure and reliable Webhook delivery engine? How do you sign payloads using HMAC-SHA256?
Webhooks invert the standard HTTP client-server model: when an event occurs, your server initiates an outgoing HTTP POST to a client’s callback URL:
- Reliability Guarantees:
- Persistent Outbox: Store webhook events in an
OutgoingWebhooksdatabase table. Never fire-and-forget directly in memory! - Retry Schedule: If the client endpoint fails (5xx, timeout, network error), retry with exponential backoff: immediately, 5 min, 30 min, 2 hrs, 8 hrs, 24 hrs.
- Dead Letter Queue (DLQ): If 10 retries fail, move to DLQ and alert the user to check their callback server.
- Persistent Outbox: Store webhook events in an
- Security — HMAC-SHA256 Payload Signing:
- When the customer registers a webhook URL, issue them a secret key (e.g.,
whsec_...). - When dispatching a webhook, compute:
HMAC_SHA256(timestamp + "." + rawJsonBody, secretKey). - Pass the timestamp and signature in headers:
X-RTSALL-Timestamp: 1727435000 X-RTSALL-Signature: sha256=4f2c9e8b7a... - The receiver recomputes the HMAC using their shared secret. If the signature matches and the timestamp is within 5 minutes (preventing replay attacks), the payload is verified as genuine and untampered.
- When the customer registers a webhook URL, issue them a secret key (e.g.,
public static class WebhookSecurity
{
// 1. Dispatcher: Generate HMAC-SHA256 Signature
public static string ComputeSignature(string secret, long timestamp, string payload)
{
string signedPayload = $"{timestamp}.{payload}";
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
byte[] payloadBytes = Encoding.UTF8.GetBytes(signedPayload);
using var hmac = new HMACSHA256(keyBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
// 2. Receiver: Constant-Time Signature Verification (Prevents Timing Attacks)
public static bool VerifySignature(string secret, long timestamp, string payload, string expectedSignature)
{
// Prevent Replay Attacks: Reject payloads older than 5 minutes
long currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(currentTimestamp - timestamp) > 300) return false;
string computed = ComputeSignature(secret, timestamp, payload);
// CryptographicOperations.FixedTimeEquals prevents timing analysis attacks
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(expectedSignature));
}
}Compare GraphQL and REST APIs. What are the trade-offs regarding over-fetching, caching, and the N+1 problem?
GraphQL was developed by Meta to solve mobile network bandwidth and endpoint proliferation challenges in complex relational apps:
- The Benefits of GraphQL:
- Zero Over-Fetching: Client queries only
{ user { name, email } }instead of receiving a 50-field JSON model. - Zero Under-Fetching: Fetches user, their recent orders, and shipping status in a single roundtrip instead of 3 separate REST calls.
- Zero Over-Fetching: Client queries only
- The Critical Trade-Offs:
- Broken HTTP Caching: REST leverages native HTTP methods (GET) and status codes (304), allowing CDNs and browser caches to work automatically. GraphQL routes all queries through
POST /graphqlwith status 200 OK, requiring complex normalized client caches (Apollo / Relay). - The N+1 Query Trap: If a query requests 50 authors and each author’s books, a naive GraphQL resolver executes 1 query for the authors and 50 separate SQL queries for their books (N+1 queries)!
- The Fix: In .NET, libraries like Hot Chocolate use the DataLoader pattern to batch and cache sub-queries into a single SQL
WHERE AuthorId IN (...)operation.
- Broken HTTP Caching: REST leverages native HTTP methods (GET) and status codes (304), allowing CDNs and browser caches to work automatically. GraphQL routes all queries through
public class BookBatchDataLoader : BatchDataLoader<int, List<Book>>
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public BookBatchDataLoader(
IDbContextFactory<AppDbContext> dbFactory,
IBatchScheduler batchScheduler) : base(batchScheduler)
{
_dbFactory = dbFactory;
}
protected override async Task<IReadOnlyDictionary<int, List<Book>>> LoadBatchAsync(
IReadOnlyList<int> authorIds,
CancellationToken ct)
{
await using var db = _dbFactory.CreateDbContext();
// Single batch SQL query using IN (...) eliminates N+1 query explosion!
var books = await db.Books
.Where(b => authorIds.Contains(b.AuthorId))
.ToListAsync(ct);
return books.GroupBy(b => b.AuthorId)
.ToDictionary(g => g.Key, g => g.ToList());
}
}How do you implement Resiliency with Polly in Web APIs? Explain Circuit Breaker states, Exponential Backoff, and Jitter.
When calling external APIs or microservices over a network, transient glitches (packet loss, momentary restarts) are inevitable:
- Why Simple Retries Are Dangerous:
If a downstream payment service slows down and 1,000 clients retry immediately every 1 second, you create a Thundering Herd Problem that guarantees the service will never recover.
- Exponential Backoff + Full Jitter:
Calculate delay:
Delay = Min(MaxDelay, BaseDelay * 2^attempt), then randomize:RandomBetween(0, Delay). Spreading retries across time smooths out traffic spikes. - The 3 Circuit Breaker States:
Closed (Normal):Requests flow through unimpeded. Failure rate is tracked.Open (Tripped):Failure rate exceeds threshold (e.g., 50% errors over 30s). The circuit trips. All calls immediately throw an exception (or return fallback) without attempting to call the downstream service, giving it time to heal.Half-Open (Testing):After a break duration (e.g., 60s), the circuit allows a small number of trial requests through. If they succeed, it resets to Closed; if they fail, it immediately trips back to Open.
var builder = WebApplication.CreateBuilder(args);
// Modern Polly v8 Resilience Pipeline
builder.Services.AddHttpClient("ExternalPaymentApi", client =>
{
client.BaseAddress = new Uri("https://api.paymentgateway.com");
})
.AddResilienceHandler("CustomResilience", pipelineBuilder =>
{
// 1. Retry with Exponential Backoff + Jitter
pipelineBuilder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromSeconds(2)
});
// 2. Circuit Breaker
pipelineBuilder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5, // Trip if 50% of requests fail
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 20,
BreakDuration = TimeSpan.FromSeconds(45)
});
// 3. Timeout
pipelineBuilder.AddTimeout(TimeSpan.FromSeconds(10));
});How do System.Text.Json Source Generators eliminate reflection and enable Native AOT in high-performance APIs?
At high request volumes (50,000+ RPS), JSON serialization becomes one of the largest CPU and memory allocation bottlenecks in a .NET Web API:
- Runtime Reflection Overhead:
Legacy serializers (Newtonsoft.Json) and default System.Text.Json build internal expression trees and reflection caches at runtime. This causes cold-start latency, consumes megabytes of heap memory, and violates Native AOT constraints (which prohibit runtime JIT code generation).
- Compile-Time Source Generation:
By declaring a
partial class AppJsonSerializerContext : JsonSerializerContextdecorated with[JsonSerializable(typeof(MyDto))], the Roslyn compiler writes exact C# code that callsUtf8JsonWriter.WriteString()andUtf8JsonReader.GetString()directly. - Benefits: Zero reflection, ~30% higher throughput, immediate startup with zero cold-start warmup, and 100% compatibility with Native AOT compilation.
using System.Text.Json.Serialization;
public record OrderSummaryDto(int Id, string CustomerName, decimal Total);
// 1. Declare Source Generator Context
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(OrderSummaryDto))]
[JsonSerializable(typeof(List<OrderSummaryDto>))]
public partial class AppJsonSerializerContext : JsonSerializerContext
{
}
// 2. Register in WebApplication (Program.cs)
var builder = WebApplication.CreateSlimBuilder(args); // Native AOT optimized builder
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
var app = builder.Build();
app.MapGet("/api/orders", () => Results.Ok(new List<OrderSummaryDto>
{
new(1, "Acme Corp", 500.00m),
new(2, "Stark Ind", 1250.00m)
}));
app.Run();What causes Thread Pool Starvation in Web APIs, and how does the Bulkhead Isolation Pattern protect services?
The term Bulkhead originates from naval architecture, where ship hulls are divided into watertight compartments. If one compartment fills with water, the ship remains afloat:
- The Sync-over-Async Death Trap:
When an incoming HTTP request blocks on
var data = GetDataAsync().Result;, the current thread pool worker is put to sleep waiting for the task. The task needs another thread pool worker to complete. Under load, all worker threads become blocked waiting for tasks that cannot obtain a thread to finish! Response times spike to infinity, and the server stops responding entirely. - Bulkhead Partitioning:
If an API serves both fast core checkout requests and slow analytical report queries, a surge in slow reporting queries can consume all 500 thread pool threads. A Bulkhead limits the slow reporting subsystem to a maximum of 20 concurrent threads. Even if the reporting system is saturated, 480 threads remain available to process checkouts.
// Configure isolated concurrency limiter (Bulkhead) for slow third-party service
builder.Services.AddHttpClient("SlowReportingService")
.AddResilienceHandler("ReportingBulkhead", builder =>
{
// Enforce maximum of 15 simultaneous active executions
builder.AddConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 15,
QueueLimit = 5,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
});What is Consumer-Driven Contract Testing (Pact)? How does it prevent breaking changes in distributed APIs?
In distributed enterprise architectures, teams encounter two flawed testing extremes:
- End-to-End (E2E) Integration Tests: Spinning up 20 microservices in a staging environment is slow, brittle, prone to network flakiness, and provides late feedback.
- Isolated Unit Tests: Fast, but fail to detect when a provider team modifies an API response schema, breaking a consumer team’s mobile app in production.
- The Pact Workflow:
- The Consumer (e.g., React frontend) writes a test specifying: ‘When I GET
/users/5, I expect{ id: 5, fullName: "Alice" }‘. - Pact generates a
.jsoncontract file (the Pact) and publishes it to a shared Pact Broker. - The Provider (ASP.NET Core API) runs a CI/CD build that downloads the contract and replays it against the live controller code.
- If the provider renamed
fullNametodisplayName, the build fails immediately in CI before any code can be merged or deployed!
- The Consumer (e.g., React frontend) writes a test specifying: ‘When I GET
public class ApiProviderContractTests
{
[Fact]
public void VerifyPactWithConsumer()
{
var config = new PactVerifierConfig { Outputters = new[] { new ConsoleOutput() } };
IPactVerifier verifier = new PactVerifier("RTSALL-Backend-API", config);
verifier
.WithHttpEndpoint(new Uri("http://localhost:5000")) // Live test server
.WithPactBrokerSource(new Uri("https://pact-broker.internal"), options =>
{
options.ConsumerVersionSelectors(new ConsumerVersionSelector { MainBranch = true });
options.PublishResults(providerVersion: "1.4.2");
})
.Verify(); // Fails CI build if any consumer contract is broken!
}
}What is Zero Trust Architecture in Web APIs? How do you implement Mutual TLS (mTLS) for inter-service communication?
Traditional network security relied on a Castle-and-Moat approach: once traffic passed through the corporate VPN or perimeter firewall, all internal microservices communicated in unencrypted, unauthenticated plaintext:
- The Perimeter Failure: If an attacker breaches one minor pod or server, they can sniff unencrypted traffic, forge HTTP headers, and access internal financial APIs across the entire cluster.
- Zero Trust Principles:
- Every request must be authenticated and authorized, regardless of where it originates.
- Least privilege access control enforced at every service boundary.
- All network communication is strictly encrypted in transit using mTLS.
- mTLS Handshake:
- Client connects to Server and requests its certificate.
- Server presents its certificate; Client validates the Certificate Authority (CA) chain and hostname.
- The Mutual Step: Server also requests a certificate from the Client!
- Client presents its X.509 certificate; Server validates the CA and inspects Subject Alternative Names (SANs) or thumbprints to verify service identity.
- A symmetric session key is negotiated; all subsequent traffic is encrypted and authenticated.
var builder = WebApplication.CreateBuilder(args);
// Configure Kestrel to require Client Certificate on HTTPS
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.AllowedCertificateTypes = CertificateTypes.All;
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
var cert = context.ClientCertificate;
// Validate Certificate Thumbprint or Issuer against trusted internal CA
string expectedThumbprint = builder.Configuration["Security:TrustedServiceThumbprint"]!;
if (!string.Equals(cert.Thumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
context.Fail("Unauthorized client certificate thumbprint.");
return Task.CompletedTask;
}
// Inject service claims
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, cert.Subject),
new Claim(ClaimTypes.Role, "InternalMicroservice")
};
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name));
context.Success();
return Task.CompletedTask;
}
};
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();How do you architect a global REST API to handle 100,000+ Requests Per Second (QPS) with sub-50ms latency?
At 100,000 QPS, traditional architectures collapse under database connection limits, socket exhaustion, and serialization latency. The traffic must be shed in layers:
- Tier 1 — Edge Caching (Anycast CDN):
Route user requests via BGP Anycast to the nearest Edge Point of Presence (PoP). CDNs (Cloudflare, Fastly) serve static assets, catalog data, and authenticated queries using
stale-while-revalidateand weak ETags. Result: 70–85% of requests never touch your cloud datacenter. - Tier 2 — Ingress & Reverse Proxy:
AWS Network Load Balancers (Layer 4) distribute raw TCP packets across multiple availability zones. Reverse Proxies (YARP/Envoy) terminate TLS using hardware crypto acceleration and route requests over persistent HTTP/2 keep-alive connection pools.
- Tier 3 — Application Compute:
Stateless ASP.NET Core Minimal APIs compiled with Native AOT and
System.Text.Jsonsource generators. Memory per container is ~30MB, eliminating GC pause spikes and starting in milliseconds. - Tier 4 — Distributed Caching & Sharding:
Redis Cluster with read replicas and client-side caching (Redis RESP3 tracking). Read queries hit local in-memory RAM cache first (0.2ms latency).
- Tier 5 — Database Layer:
CQRS pattern: Relational SQL Master handles state-mutating writes. Read replicas (SQL / PostgreSQL) or document stores (Elasticsearch / DynamoDB) serve complex queries without locking tables.
// Ultra-fast endpoint returning pre-computed JSON UTF-8 byte stream directly
app.MapGet("/api/v1/catalog/featured", async (IConnectionMultiplexer redis, HttpResponse response) =>
{
var db = redis.GetDatabase();
// Fetch raw pre-serialized UTF-8 JSON bytes directly from Redis
RedisValue cachedJsonBytes = await db.StringGetAsync("cache:catalog:featured:bytes");
if (!cachedJsonBytes.IsNullOrEmpty)
{
response.ContentType = "application/json; charset=utf-8";
response.Headers["X-Cache"] = "HIT-EDGE-RAM";
// Zero JSON deserialization and re-serialization overhead!
await response.Body.WriteAsync((byte[])cachedJsonBytes!);
return;
}
// Fallback: Query read replica database and cache UTF8 bytes
var freshData = await QueryReadReplicaAsync();
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(freshData, CatalogJsonContext.Default.FeaturedCatalogDto);
await db.StringSetAsync("cache:catalog:featured:bytes", jsonBytes, TimeSpan.FromMinutes(5));
response.ContentType = "application/json; charset=utf-8";
await response.Body.WriteAsync(jsonBytes);
});How does the Transactional Outbox Pattern solve the Dual-Write Problem in Microservice APIs?
Consider an e-commerce API endpoint: POST /api/orders:
// NAIVE DUAL-WRITE IMPLEMENTATION:
await _db.Orders.AddAsync(order);
await _db.SaveChangesAsync(); // Step 1: Saves to SQL Database
// NETWORK GLITCH OCCURS HERE!
await _bus.Publish(new OrderCreatedEvent(order.Id)); // Step 2: Fails!- The Failure Mode: The order was successfully saved to SQL Server, but the RabbitMQ event failed. The customer’s credit card is charged, but the warehouse never ships the order because the inventory service never heard about it!
- Why 2-Phase Commit (2PC) is NOT the Answer: Distributed transactions (MSDTC) are notoriously slow, lock resources across services, and are unsupported by modern cloud message brokers like Kafka and AWS SQS.
- The Transactional Outbox Solution:
- Add an
OutboxMessagestable in the same database as your business entities. - In a single local ACID database transaction, insert the Order record AND insert a JSON event record into
OutboxMessages. Both succeed or both rollback atomically! - A background worker process (e.g., MassTransit Outbox or a Debezium Change Data Capture [CDC] engine reading the database transaction log) reads unpublished rows from
OutboxMessages, publishes them to the message broker, and marks them as published.
- Add an
var builder = WebApplication.CreateBuilder(args);
// Configure MassTransit with Entity Framework Core Outbox
builder.Services.AddMassTransit(x =>
{
x.AddEntityFrameworkOutbox<AppDbContext>(o =>
{
o.UseSqlServer();
o.UseBusOutbox(); // Integrates outbox directly with IPublishEndpoint!
});
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("rabbitmq://localhost");
cfg.ConfigureEndpoints(context);
});
});
// Inside your API Controller:
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderDto dto, IPublishEndpoint publishEndpoint, AppDbContext db)
{
var order = new Order { Id = Guid.NewGuid(), Total = dto.Amount };
db.Orders.Add(order);
// This publish does NOT immediately hit RabbitMQ!
// It writes the event into the OutboxMessage table inside the DB transaction!
await publishEndpoint.Publish(new OrderSubmittedEvent(order.Id, order.Total));
// Commits BOTH the Order and the Outbox event in ONE atomic SQL transaction!
await db.SaveChangesAsync();
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}Scenario 1: Production API suddenly returns widespread 504 Gateway Timeouts across all endpoints. How do you triage and resolve it?
When an outage alerts you at 2:00 AM with widespread 504s, follow a disciplined elimination methodology:
- Step 1: Isolate the Fault Domain:
Query your APM (Datadog / New Relic) or CloudWatch: Are requests timing out at the Edge Proxy, the Web App Container, or the Database? If ALB target group health checks are failing, Kestrel has stopped responding.
- Step 2: Check Database Connection Pool Exhaustion:
In 70% of 504 incidents, a slow database query has saturated the SQL connection pool (default: 100 connections). Run
sp_WhoIsActiveor querysys.dm_exec_requeststo find the root blocker holding exclusive locks. Terminate the blocking PID (KILL <spid>). - Step 3: Check Third-Party Dependency Hangs:
Did an external fraud detection or shipping API stop responding? If outgoing HTTP clients lack strict timeouts, hundreds of incoming threads hang waiting for socket reads, exhausting the server thread pool.
- Step 4: Check Thread Pool Starvation:
Inspect
ThreadPool.ThreadCount. If thread count has spiked to hundreds and CPU is near 100%, someone deployed sync-over-async code (.Resultor.Wait()). Roll back the latest deployment immediately. - Step 5: Immediate Stabilization:
Trip circuit breakers on optional features (recommendations, analytics), enable rate limiting on abusive client IPs, and execute a rolling restart of the application pods.
// Configure resilient HttpClient with strict socket timeouts
builder.Services.AddHttpClient("ExternalService", client =>
{
// Mandatory: Never allow an HTTP call to hang indefinitely!
client.Timeout = TimeSpan.FromSeconds(5);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
ConnectTimeout = TimeSpan.FromSeconds(2), // TCP connection timeout
PooledConnectionLifetime = TimeSpan.FromMinutes(5), // DNS refresh
KeepAlivePingTimeout = TimeSpan.FromSeconds(5),
MaxConnectionsPerServer = 200 // Protects against socket exhaustion
});Scenario 2: Your API memory climbs monotonically until OutOfMemoryException (OOM) kills the container every 6 hours. How do you find and fix the leak?
The Garbage Collector cannot collect objects that are still reachable from a GC Root (static variables, active thread stacks, event subscriptions):
- Step 1: Capture Production Memory Dump:
# Capture dump inside running Linux Docker container without stopping process dotnet-dump collect -p 1 --type Full - Step 2: Analyze Heap Statistics:
dotnet-dump analyze core_dump.dmp > dumpheap -statInspect the output: Are there 2,000,000 instances of
byte[]orSystem.Stringconsuming 3.8 GB of RAM? - Step 3: Trace the GC Root:
> dumpheap -type System.Byte[] -min 85000 > gcroot <AddressOfLargestByteArray>The root path reveals the culprit: e.g., a static singleton
InvoiceServicesubscribed to an event on transient user objects without unsubscribing (-=), preventing the entire user graph from ever being collected! - Step 4: Large Object Heap (LOH) Fragmentation:
Any object >= 85,000 bytes is allocated directly on the LOH. Because LOH is not compacted by default during Gen 2 collections, repeated allocations of 100KB memory streams create swiss-cheese memory fragmentation that triggers OOM crashes.
// ANTI-PATTERN: Allocates 250KB buffer directly on LOH (Leads to OOM crashes)
// byte[] buffer = new byte[250 * 1024];
// BEST PRACTICE: Rent reusable buffer from shared memory pool
byte[] rentedBuffer = ArrayPool<byte>.Shared.Rent(250 * 1024);
try
{
int bytesRead = await stream.ReadAsync(rentedBuffer.AsMemory(0, 250 * 1024));
await ProcessDataAsync(rentedBuffer, bytesRead);
}
finally
{
// MUST always return buffer in finally block!
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
// FOR MEMORY STREAMS: Use Microsoft.IO.RecyclableMemoryStreamManager
// Eliminates Gen 2 GC sweeps for PDF/Excel file exports!
var memoryStream = _streamManager.GetStream();Scenario 3: An automated botnet executes a Layer 7 DDoS attack, bypassing Cloudflare WAF by cycling through thousands of residential proxy IPs. How do you defend your API?
Modern botnets use millions of compromised residential devices (smart TVs, home routers) with legitimate consumer ISP IP addresses, rendering traditional Geo-blocking and IP blacklists ineffective:
- JA3 / JA4 TLS Fingerprinting:
Even though the botnet cycles residential IP addresses, the underlying bot software (e.g., Python Requests, Go HTTP, or headless Chromium) produces an identical TLS Client Hello Fingerprint (cipher suites, extensions, elliptic curves). Filtering on the JA4 fingerprint at Cloudflare or Envoy blocks 100% of the botnet regardless of what IP address it uses.
- Autonomous Socket Dropping (Kestrel):
If abusive requests penetrate the edge, reject them immediately in Kestrel before body deserialization or database connection pooling:
context.Abort(); // Instantly drops TCP RST packet, terminating the socket - Cryptographic Proof-of-Work (PoW):
Require suspicious clients to solve a mathematical cryptographic puzzle (e.g., finding a SHA-256 hash collision with 4 leading zeros) before granting an API session. This imposes zero noticeable cost on a human mobile user (takes 5ms), but bankrupts the botnet’s CPU capacity when attempting 50,000 requests per second.
public class BotDefenseMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _blocklistCache;
public BotDefenseMiddleware(RequestDelegate next, IMemoryCache blocklistCache)
{
_next = next;
_blocklistCache = blocklistCache;
}
public async Task InvokeAsync(HttpContext context)
{
string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// 1. Instant O(1) in-memory blocklist check
if (_blocklistCache.TryGetValue($"blocked:{clientIp}", out _))
{
// Aborts TCP socket immediately (sends TCP RST) without sending HTTP headers!
// Consumes zero server bandwidth or memory!
context.Abort();
return;
}
// 2. Validate mandatory client signature or API key
if (!context.Request.Headers.TryGetValue("X-Client-Signature", out var signature))
{
// Mark IP as suspicious and drop
_blocklistCache.Set($"blocked:{clientIp}", true, TimeSpan.FromMinutes(15));
context.Abort();
return;
}
await _next(context);
}
}Scenario 4: How do you safely deprecate and sunset a legacy v1 API consumed by 500+ enterprise customers without breaking their business?
Shutting down a public enterprise API without notice causes immediate contractual disputes, broken partner integrations, and lost enterprise revenue:
- Phase 1: Formal Notification & RFC 8594 Headers:
Add standardized HTTP deprecation headers to all v1 responses:
Deprecation: @1727435000 Sunset: Wed, 30 Sep 2027 00:00:00 GMT Link: <https://rtsall.com/docs/v2-migration>; rel="deprecation"; type="text/html"Developer tooling and automated API linting systems parse these headers to flag technical debt automatically.
- Phase 2: Customer Telemetry & Direct Outreach:
Query API access logs grouped by customer API Key or OAuth Client ID. Have customer success managers reach out directly to the top 20 enterprise clients still hitting v1.
- Phase 3: The ‘Scream Test’ — Graduated Brownouts:
Three months prior to sunset, introduce controlled synthetic brownouts during off-peak windows (e.g., returning
503 Service Unavailablefor 30 minutes on Tuesday at 3 AM, expanding to 2 hours the following month). This forces sleeping development teams to notice and prioritize the migration before the hard cutoff. - Phase 4: Final Sunset with HTTP 410 Gone:
When the sunset date arrives, replace the v1 endpoints with a lightweight static handler that returns
410 Gonewith a migration link.410 Goneexplicitly tells search engines and API clients that the resource is permanently deleted and will never return.
public class ApiSunsetMiddleware
{
private readonly RequestDelegate _next;
private static readonly DateTimeOffset SunsetDate = new(2027, 9, 30, 0, 0, 0, TimeSpan.Zero);
public ApiSunsetMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
// Check if client is calling legacy v1 endpoint
if (context.Request.Path.StartsWithSegments("/api/v1"))
{
// If sunset date has passed, return 410 Gone permanently
if (DateTimeOffset.UtcNow >= SunsetDate)
{
context.Response.StatusCode = StatusCodes.Status410Gone;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = "API version 1.0 has been retired. Please migrate to v2.",
migrationGuide = "https://rtsall.com/docs/v2-migration"
});
return;
}
// Inject RFC 8594 Sunset Headers
context.Response.Headers["Deprecation"] = "true";
context.Response.Headers["Sunset"] = SunsetDate.ToString("R"); // RFC 1123 HTTP-date format
context.Response.Headers["Link"] = "<https://rtsall.com/docs/v2-migration>; rel="deprecation"";
}
await _next(context);
}
}How do you architect an Event-Driven CQRS REST API? How do you manage Eventual Consistency in client UIs?
In high-scale systems, the database schema optimized for writes (3rd Normal Form with constraints) is the exact opposite of what is optimal for complex queries (flat, denormalized documents):
- The CQRS Architecture:
- Command Side:
POST /api/v1/ordersroutes to a Write Service that executes validation, appends an event to the Event Store, and returns202 Accepted. - Event Projection: Background event consumers project the event into read-optimized datastores: Elasticsearch for text search, Redis for caching, PostgreSQL read models for reporting.
- Query Side:
GET /api/v1/orders/recentqueries the flat denormalized read model with 1ms latency and zero joins.
- Command Side:
- Handling Eventual Consistency in the Frontend:
Because projections take 50ms–500ms to propagate, an immediate
GETrequest right after aPOSTmight not show the new order yet!- Optimistic UI Updates: The frontend JavaScript/React app immediately displays the new order in the local state tree before the server projection finishes.
- Version Token / Read-Your-Own-Writes: The Command response returns the
SequenceNumber: 1045. When the client executes subsequent GET queries, it includesX-Min-Version: 1045. The Query API waits until the read model reaches that version before replying.
// 1. COMMAND: State Mutation (No return data except ID/Status)
public record CreateOrderCommand(Guid CustomerId, decimal Amount) : IRequest<Guid>;
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _repository;
private readonly IPublishEndpoint _bus;
public async Task<Guid> Handle(CreateOrderCommand command, CancellationToken ct)
{
var order = new Order(Guid.NewGuid(), command.CustomerId, command.Amount);
await _repository.SaveAsync(order, ct);
// Publishes event to update read projections asynchronously
await _bus.Publish(new OrderCreatedEvent(order.Id, order.Amount), ct);
return order.Id;
}
}
// 2. QUERY: Read Model Query (Fast, Denormalized, Zero Locks)
public record GetCustomerOrdersQuery(Guid CustomerId) : IRequest<IReadOnlyList<OrderSummaryView>>;
public class GetCustomerOrdersQueryHandler : IRequestHandler<GetCustomerOrdersQuery, IReadOnlyList<OrderSummaryView>>
{
private readonly IDocumentStore _documentStore; // Elasticsearch / MongoDB / Read DB
public async Task<IReadOnlyList<OrderSummaryView>> Handle(GetCustomerOrdersQuery query, CancellationToken ct)
{
return await _documentStore.QueryAsync<OrderSummaryView>(o => o.CustomerId == query.CustomerId, ct);
}
}How do you design a Multi-Tenant API with dynamic tier-based rate limits and usage monetization (Stripe)?
SaaS APIs monetize access by enforcing strict rate limits and consumption quotas across subscription tiers:
- Tenant Identification & Isolation:
- Every client authenticates with an API Key (e.g.,
rts_live_98a7b...). - API Key resolves to a
TenantContext(TenantId, SubscriptionTier: "Enterprise")cached in Redis. - EF Core automatically applies a global query filter:
modelBuilder.Entity<T>().HasQueryFilter(e => e.TenantId == _tenantContext.TenantId).
- Every client authenticates with an API Key (e.g.,
- Tier-Based Rate Limits (Redis Token Bucket):
- Free Tier: 5 requests/sec, burst 10.
- Pro Tier: 50 requests/sec, burst 100.
- Enterprise Tier: 1,000 requests/sec, dedicated cluster.
- Usage Metering (Stripe Invoicing):
Instead of hitting Stripe’s API on every incoming request (which would double your latency), increment an atomic Redis counter:
INCRBY billing:tenant_102:usage 1. A background cron worker flushes these usage metrics to the Stripe Metered Billing API once every 15 minutes.
public class MultiTenantRateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly IConnectionMultiplexer _redis;
public MultiTenantRateLimitingMiddleware(RequestDelegate next, IConnectionMultiplexer redis)
{
_next = next;
_redis = redis;
}
public async Task InvokeAsync(HttpContext context, ITenantResolver tenantResolver)
{
var tenant = await tenantResolver.ResolveTenantAsync(context);
if (tenant == null)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var db = _redis.GetDatabase();
string rateKey = $"ratelimit:{tenant.TenantId}:{DateTime.UtcNow:yyyyMMddHHmm}";
// Atomic Increment in Redis
long currentCount = await db.StringIncrementAsync(rateKey);
if (currentCount == 1)
{
await db.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(2));
}
int maxLimit = tenant.SubscriptionTier switch
{
"Enterprise" => 50000,
"Pro" => 5000,
_ => 100 // Free Tier
};
if (currentCount > maxLimit)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers["Retry-After"] = "60";
await context.Response.WriteAsJsonAsync(new { error = "Monthly or minute quota exceeded for your tier." });
return;
}
// Increment background billing meter asynchronously
_ = db.StringIncrementAsync($"billing:meter:{tenant.TenantId}", 1);
await _next(context);
}
}Scenario 5: Post-Mortem Analysis: A slow downstream payment gateway crashed all customer-facing APIs across the company. How do you re-architect to guarantee isolation?
This classic distributed systems post-mortem illustrates the fragility of synchronous request-response chains:
- The Anatomy of the Disaster:
- At 11:00 AM, the external Payment Gateway began experiencing internal database locks, causing their response times to spike from 200ms to 45 seconds (without throwing errors).
- The internal
PaymentMicroservicehad an infinite HTTP timeout. Every incoming checkout request held a thread open for 45 seconds. - Within 60 seconds, all 500 thread pool threads on the Payment service were occupied waiting for sockets.
- Upstream
OrderServiceandCartServicealso had 30-second timeouts. Their threads quickly starved waiting on the Payment service. - The entire customer website and mobile app crashed with 504 Gateway Timeouts — users couldn’t even browse product catalogs or view help pages!
- The Architectural Remediation Blueprint:
- Rule 1 — Never Block on Third Parties Synchronously: Switch checkout to an asynchronous queue. Accept the order, enqueue payment processing, and return
202 Accepted. - Rule 2 — Strict 3-Second Timeout: Mandate hard timeouts on all outgoing HTTP clients via
SocketsHttpHandler.ConnectTimeout. - Rule 3 — Circuit Breaker: Trip the circuit breaker if payment failure rate exceeds 20% over 15 seconds, failing fast in 0.1ms.
- Rule 4 — Bulkhead Isolation: Limit the payment subsystem to 20 threads maximum. Even if the payment gateway catches fire, the other 480 threads continue serving product searches, catalog views, and login requests without interruption.
- Rule 1 — Never Block on Third Parties Synchronously: Switch checkout to an asynchronous queue. Accept the order, enqueue payment processing, and return
// Complete enterprise resilience stack for external payment dependencies
builder.Services.AddHttpClient("ResilientPaymentGateway", client =>
{
client.BaseAddress = new Uri("https://api.paymentpartner.com");
client.Timeout = TimeSpan.FromSeconds(3); // Rule 1: Strict 3-second hard timeout
})
.AddResilienceHandler("PaymentCircuitBreaker", builder =>
{
// Rule 2: Bulkhead Isolation (Max 15 concurrent threads)
builder.AddConcurrencyLimiter(15);
// Rule 3: Circuit Breaker (Trips after 50% errors, breaks for 30s)
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(15),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(30)
});
// Rule 4: Graceful Fallback
builder.AddFallback(new FallbackStrategyOptions<HttpResponseMessage>
{
FallbackAction = _ => Outcome.FromResultAsValueTask(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("{"error":"Payment processor temporarily unavailable. Queued for retry."}")
})
});
});Top 6 Mistakes Candidates Make in Web API & REST Interviews
- Returning 200 OK with an Error in the Body: Returning
HTTP 200 OKwith{ "success": false, "error": "Invalid password" }breaks monitoring alerts, load balancer health checks, and CDN edge caching. Always use standard 4xx and 5xx status codes. - Confusing Safe vs Idempotent Methods: Believing DELETE is not idempotent because a second call returns 404. Idempotency measures server state, not status code. The resource remains deleted after both calls.
- Putting Sensitive Data in Query Parameters: Transmitting API keys, passwords, or PII in query strings (
?token=...) exposes secrets to proxy logs, CDN analytics, and browser histories. Always use theAuthorization: Bearerheader. - Failing to Account for Network Retries (Lack of Idempotency): Writing payment or order POST endpoints without an
Idempotency-Keyheader, causing customers to be charged twice when their mobile connection drops. - Exposing Database Entities Directly in API Controllers: Returning raw EF Core entities instead of DTOs, introducing severe mass-assignment (over-posting) vulnerabilities and circular reference serialization crashes.
- Synchronous Blocking in API Controllers (Sync-over-Async): Calling
.Resultor.Wait()on asynchronous tasks, causing massive thread pool starvation and 504 Gateway Timeouts under production traffic.
The 4-Step Technical Interview Framework for API Candidates
When asked an open-ended API design or troubleshooting question, structure your answer using this battle-tested 4-step framework:
Ask about expected QPS, payload sizes, read-to-write ratios, and client types (web, mobile, or internal microservices).
Define clean, plural noun URIs. Select proper HTTP methods (GET, POST, PUT, PATCH, DELETE) and justify idempotency.
Specify authentication (JWT/OAuth), authorization policies, input validation (FluentValidation), and BOLA ownership checks.
Explain rate limiting, ETags caching, circuit breakers (Polly), distributed tracing (OpenTelemetry), and health probes.
24-Hour Final Revision Checklist
Quickly verify you have these high-frequency concepts locked down before entering your interview:
- [ ] Know the 6 REST architectural constraints by heart (Statelessness, Client-Server, Cacheable, Uniform Interface, Layered System, Code on Demand).
- [ ] Can explain why PUT is idempotent while POST is not.
- [ ] Differentiate 401 Unauthorized (unauthenticated) vs 403 Forbidden (authenticated, but lack permissions).
- [ ] Understand RFC 7807
ProblemDetailsschema fields (type, title, status, detail, instance). - [ ] Can write a clean JWT Bearer token validation handler and explain Refresh Token Rotation with reuse detection.
- [ ] Know why BOLA is the #1 vulnerability on the OWASP API Top 10 and how to enforce tenancy in database queries.
- [ ] Explain how Idempotency Keys prevent duplicate charges in payment APIs using Redis distributed locks.
- [ ] Differentiate gRPC (HTTP/2 binary Protobuf multiplexing) vs REST (JSON) for microservices.
- [ ] Know the 3 states of a Circuit Breaker (Closed, Open, Half-Open) and why Full Jitter prevents Thundering Herds.
- [ ] Can troubleshoot a production 504 Gateway Timeout across reverse proxies, thread pools, and databases.
Related Developer Tools & Career Roadmaps
.NET to AI Engineer Roadmap
Complete 24-week curriculum transitioning backend .NET engineers to production AI and LLM agents.
C# Interview Questions & Answers
42 Master questions covering .NET 8/9, CLR internals, Garbage Collection, async/await, and Span<T>.
SQL Server Interview Questions
50 In-depth database questions covering B-Tree indexes, execution plans, RCSI, and live DBA scenarios.
ASP.NET MVC & Core Interview Guide
100 Questions covering MVC architecture, middleware pipelines, EF Core, and enterprise patterns.
DSA Coding Interview Questions
38 Master data structures and algorithm problems with code solutions, two-pointers, sliding window, and DP.
JWT Decoder Online Tool
Inspect, decode, and verify JSON Web Token headers, payload claims, and expiry dates instantly.
JSON Formatter & Beautifier
Validate, format, and minify complex REST API JSON payloads with real-time syntax checking.
System Design Calculator
Interactive back-of-the-envelope estimator for API QPS, network bandwidth, and Redis cache sizing.
Frequently Asked Questions (Candidate FAQ)
1. What is the single most tested topic in senior REST API interviews?
Idempotency and API Security (BOLA/OWASP). Senior candidates are rarely asked basic CRUD syntax. Interviewers want to see how you design state-mutating endpoints that safely handle network drops via Idempotency-Key headers and how you prevent Broken Object Level Authorization vulnerabilities by enforcing user tenancy directly in database queries.
2. Is an API truly RESTful if it does not implement HATEOAS?
According to Roy Fielding’s strict academic definition, an API must implement HATEOAS (Hypermedia As The Engine Of Application State) to be fully RESTful (Richardson Maturity Model Level 3). However, in practical enterprise software development, over 95% of production APIs operate at Level 2 (HTTP Verbs + URIs + Status Codes) because modern single-page apps and mobile clients maintain their own client-side routing logic.
3. When should I choose gRPC over REST?
Choose gRPC for internal, high-throughput, microservice-to-microservice communication where CPU serialization overhead and network latency are critical bottlenecks. Choose REST for public APIs, browser frontends, mobile client integrations, and third-party partner portals where human readability and standard tooling (OpenAPI, Postman, curl) are essential.
4. What is the difference between Keyset and Offset pagination?
Offset pagination uses OFFSET/FETCH (Skip/Take). It is simple to implement but degrades drastically at deep pages because the database must read and discard all preceding rows. Keyset (cursor-based) pagination queries WHERE Id > lastSeenId ORDER BY Id ASC LIMIT 20, leveraging clustered index seeks in $OO(log N) time regardless of whether you are querying page 1 or page 50,000.
5. How do I prevent thread pool starvation in ASP.NET Core Web API?
Never block asynchronously executing code synchronously using .Result, .Wait(), or GetAwaiter().GetResult() (sync-over-async). Always use async/await all the way down the call stack, pass CancellationToken to all database and HTTP calls, and isolate slow third-party dependencies using Bulkheads.
Leave a comment