Web API
Minimal APIs versus MVC controllers, model binding, action results, JWT authentication, authorization policies, and API versioning.
11 questions
JuniorTheoryVery commonWhat does AddJwtBearer need in order to validate a token, and where do those values come from?
What does AddJwtBearer need in order to validate a token, and where do those values come from?
It needs TokenValidationParameters: the signing key, the valid issuer and the valid audience. These are bound at startup from builder.Configuration, not hardcoded — the key is a secret, so it comes from an environment variable, read after appsettings.json and therefore winning.
Common mistakes
- ✗Hardcoding the signing key in source instead of binding it from configuration
- ✗Trusting the issuer and audience printed inside the token rather than the configured ones
- ✗Thinking a
JWTpayload is encrypted rather than merely signed
Follow-up questions
- →What does the handler do when the token's
expclaim has already passed? - →Why can anyone holding a signed token read its payload?
MiddleTheoryVery commonHow does authentication differ from authorization, and what does a policy add over a role check?
How does authentication differ from authorization, and what does a policy add over a role check?
Authentication establishes who the caller is and yields a ClaimsPrincipal; authorization decides whether that principal may reach the endpoint. A policy is a named set of requirements — claims, roles, or a custom requirement with its handler — so [Authorize] names an intent such as CanRefund instead of hardcoding a group name.
Common mistakes
- ✗Using roles for everything and encoding business rules in group names
- ✗Expecting a policy to be evaluated before routing, without any endpoint metadata
- ✗Confusing a 401 (not authenticated) with a 403 (authenticated but not permitted)
Follow-up questions
- →When do you need an authorization handler instead of a plain claims requirement?
- →Which status code does a failing policy produce for an already authenticated caller?
MiddleTheoryVery commonWhere in the pipeline does bearer authentication with the signed-token format JWT run, and what does it validate?
Where in the pipeline does bearer authentication with the signed-token format JWT run, and what does it validate?
UseAuthentication runs after UseRouting and before UseAuthorization. The handler reads the bearer token from the Authorization header, validates its signature against the configured key, and checks issuer, audience and expiry; on success it puts a ClaimsPrincipal on HttpContext.User. It decides no access — UseAuthorization does that.
Common mistakes
- ✗Expecting
UseAuthenticationto reject an anonymous request — it only builds the principal - ✗Registering
UseAuthorizationbeforeUseAuthentication, soHttpContext.Useris still empty - ✗Assuming a valid signature already means the caller is authorized for the endpoint
Follow-up questions
- →How do you revoke a token before its expiry, given the handler is stateless?
- →Where do the signing key, the issuer and the audience come from at startup?
JuniorTheoryCommonWhat does the [ApiController] attribute turn on automatically for a controller?
What does the [ApiController] attribute turn on automatically for a controller?
It applies the API conventions: an automatic 400 with a ProblemDetails body when ModelState is invalid, binding-source inference (complex types come from the body, simple types from the route values and then the query string), and required attribute routing on the controller.
Common mistakes
- ✗Still hand-writing an invalid-
ModelStatecheck in every action - ✗Expecting conventional routing to work on an
[ApiController], where attribute routing is required - ✗Assuming a complex parameter is bound from the query string rather than from the body
Follow-up questions
- →How do you replace the automatic 400 response with your own error shape?
- →Which parameter types does binding-source inference send to the request body?
JuniorTheoryCommonWhen do you return IActionResult from a controller action instead of a concrete type?
When do you return IActionResult from a controller action instead of a concrete type?
Return a concrete type when the action always produces one shape — it is serialized and sent with 200. Return IActionResult when the action itself must pick the status code, like NotFound() or BadRequest(). ActionResult<T> gives you both and keeps the payload type visible to the API description.
Common mistakes
- ✗Thinking a concrete return type cannot express a 404 or a 400
- ✗Using
IActionResulteverywhere and losing the response type in the API description - ✗Forgetting
ActionResult<T>can return both a typed payload and a status result
Follow-up questions
- →How does
ProducesResponseTypechange the API-descriptionOpenAPIdocument for an action? - →Which status code does an action under
[ApiController]produce when it returnsnull?
MiddleTheoryCommonWhat does the endpoint model Minimal APIs gain and give up against model-view-controller MVC controllers?
What does the endpoint model Minimal APIs gain and give up against model-view-controller MVC controllers?
Minimal APIs map a delegate straight to a route: no controller class, no action-filter pipeline, TypedResults for the response. Controllers bring the conventions that scale to a large surface — action filters, [ApiController] model-state validation, custom model binders. Both run on the same routing, container and middleware pipeline.
Common mistakes
- ✗Expecting the automatic 400 of
[ApiController]on invalid model state in a Minimal API endpoint - ✗Believing Minimal APIs skip the middleware pipeline or the container
- ✗Thinking action filters apply to a Minimal API endpoint, which uses endpoint filters instead
Follow-up questions
- →How do you validate the request body in a Minimal API endpoint?
- →What is an endpoint filter, and how does it differ from an action filter?
MiddleTheoryCommonHow does model binding decide which part of the request an action parameter comes from?
How does model binding decide which part of the request an action parameter comes from?
An explicit attribute wins — [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromServices]. Without one, [ApiController] infers the source: a complex type comes from the body, a simple type from the route values and then the query string. At most one parameter may come from the body, because the body is read once.
Common mistakes
- ✗Putting two complex parameters on one action and expecting both to bind from the body
- ✗Forgetting that a simple type falls back to the route values and the query, not the body
- ✗Assuming an explicit
[FromQuery]is overridden by binding-source inference
Follow-up questions
- →Why can only one parameter bind from the request body?
- →What does binding do when a value is present but cannot be converted to the target type?
MiddleDesignOccasionalYour team owns a public REST API used by third-party clients you cannot force to upgrade. The next release must add a required field to the create-order request body and reshape the order response — both are breaking changes. Existing clients must keep working unchanged for at least a year, new clients should get the new contract by default, and you must be able to see which clients still call the old contract so you can plan its removal. Everything runs behind one deployment and one database. Design the versioning strategy: how a request selects its version, how the two versions are represented in the code, what you answer a request that asks for a version you do not support, and how deprecation is communicated and measured.
Your team owns a public REST API used by third-party clients you cannot force to upgrade. The next release must add a required field to the create-order request body and reshape the order response — both are breaking changes. Existing clients must keep working unchanged for at least a year, new clients should get the new contract by default, and you must be able to see which clients still call the old contract so you can plan its removal. Everything runs behind one deployment and one database. Design the versioning strategy: how a request selects its version, how the two versions are represented in the code, what you answer a request that asks for a version you do not support, and how deprecation is communicated and measured.
Version the contract, not the database. Pick one explicit selector — a URL segment is the most discoverable, a header keeps URLs stable. Freeze the v1 handlers and let v2 evolve, mapping both onto one domain model. An unsupported version answers 400 and names the versions you do support. Announce deprecation with a Sunset header and count calls per version, so removal is driven by data rather than by hope.
Common mistakes
- ✗Introducing a breaking change in place and relying on clients to adapt in time
- ✗Selecting the version implicitly (by API key, address or default) instead of from the request
- ✗Shipping a new version with no deprecation signal and no per-version usage metrics
Follow-up questions
- →How do you keep one domain model while two contract versions map onto it?
- →What do you answer a client that asks for a version you have already removed?
SeniorDesignOccasionalA read-heavy catalog API serves product pages: 95% of the traffic is reads, and a handful of products take most of it. The service runs on several instances. Product data changes rarely, but when an editor updates a product the change must be visible on every instance within a few seconds. Reads currently go straight to EF Core and the database is the bottleneck. You also see repeated lookups for product identifiers that do not exist, and when a popular product's cached entry expires, dozens of concurrent requests all hit the database at once. Memory on each instance is limited, so the whole catalog cannot be held locally. Design the caching layer: which tiers you use, how a read is served, how an update becomes visible everywhere, and how you protect the database from those two failure modes.
A read-heavy catalog API serves product pages: 95% of the traffic is reads, and a handful of products take most of it. The service runs on several instances. Product data changes rarely, but when an editor updates a product the change must be visible on every instance within a few seconds. Reads currently go straight to EF Core and the database is the bottleneck. You also see repeated lookups for product identifiers that do not exist, and when a popular product's cached entry expires, dozens of concurrent requests all hit the database at once. Memory on each instance is limited, so the whole catalog cannot be held locally. Design the caching layer: which tiers you use, how a read is served, how an update becomes visible everywhere, and how you protect the database from those two failure modes.
Two tiers: a small per-instance memory cache in front of a shared distributed cache, both read cache-aside over an AsNoTracking query. On an update, invalidate the key in both tiers and keep the lifetimes short, so a missed invalidation self-heals in seconds. Cache the misses briefly, so unknown identifiers stop reaching the database. Coalesce concurrent rebuilds of one key so an expiry costs one query, not dozens.
Common mistakes
- ✗Relying on expiry alone, so an editor's update stays invisible for the whole cache lifetime
- ✗Not caching the misses, so lookups for identifiers that do not exist keep reaching the database
- ✗Letting a hot key's expiry send every concurrent request to the database at once
Follow-up questions
- →How do you invalidate the per-instance tier on the other instances after an update?
- →How do you keep the two tiers from serving two different versions of one product?
SeniorDesignRareYou are building an API for a business product on ASP.NET Core and EF Core. Each customer is a tenant, and a tenant's data must never leak into another tenant's response — that is the hard requirement. Tenants range from tiny ones to a handful of very large ones; one enterprise tenant contractually requires its data to sit in its own database, and every tenant may ask for its data to be exported or deleted. Tenants are onboarded weekly, so provisioning must be cheap, and schema migrations must stay manageable as the tenant count grows into the thousands. The tenant is known from a claim on the caller's token. Compare a shared database with a tenant discriminator column against a database per tenant, and design the resolution and isolation strategy you would ship, including how the DbContext learns which tenant it serves.
You are building an API for a business product on ASP.NET Core and EF Core. Each customer is a tenant, and a tenant's data must never leak into another tenant's response — that is the hard requirement. Tenants range from tiny ones to a handful of very large ones; one enterprise tenant contractually requires its data to sit in its own database, and every tenant may ask for its data to be exported or deleted. Tenants are onboarded weekly, so provisioning must be cheap, and schema migrations must stay manageable as the tenant count grows into the thousands. The tenant is known from a claim on the caller's token. Compare a shared database with a tenant discriminator column against a database per tenant, and design the resolution and isolation strategy you would ship, including how the DbContext learns which tenant it serves.
Ship both behind one abstraction. Resolve the tenant once per request from the token claim into a scoped tenant context, which the scoped DbContext takes to pick its connection string. Shared-database tenants get a global query filter on the discriminator plus the tenant identifier on every write: cheap to onboard and migrate, but one missed filter leaks data. The enterprise tenant gets its own database — isolation by construction, at the cost of migrating many.
Common mistakes
- ✗Passing the tenant identifier by hand into every query, so one forgotten filter leaks data
- ✗Trusting a client-supplied header for tenant identity instead of an authenticated claim
- ✗Holding the current tenant in a
Singletonrather than in a scoped service
Follow-up questions
- →How do you stop a global query filter from being bypassed by an explicit ignore or by raw SQL?
- →How do you run one migration across thousands of tenant databases safely?
SeniorDesignRareA public REST API is served by four instances behind a load balancer. Anonymous callers are identified by address, authenticated ones by the subject claim of their token, and a paid tier must get a larger allowance than the free tier. Traffic is bursty: a client may legitimately fire twenty calls in one second and then idle, while a naive fixed-window counter both lets a client spend two quotas across a window boundary and rejects that legitimate burst. Abusive clients must not be able to cost you a database query per rejected request, and a throttled client has to be told when to retry. Design the rate limiter: where it sits in the request pipeline, how a request is attributed to a client and a tier, how one budget is enforced consistently across the four instances, and what a rejected caller sees.
A public REST API is served by four instances behind a load balancer. Anonymous callers are identified by address, authenticated ones by the subject claim of their token, and a paid tier must get a larger allowance than the free tier. Traffic is bursty: a client may legitimately fire twenty calls in one second and then idle, while a naive fixed-window counter both lets a client spend two quotas across a window boundary and rejects that legitimate burst. Abusive clients must not be able to cost you a database query per rejected request, and a throttled client has to be told when to retry. Design the rate limiter: where it sits in the request pipeline, how a request is attributed to a client and a tier, how one budget is enforced consistently across the four instances, and what a rejected caller sees.
Enforce it in the pipeline before any real work — the limiter sits above authorization, with the health endpoint exempt. Attribute a request to the token's subject when authenticated and to the address otherwise, and choose the policy from that caller's tier. Smooth the boundary with a sliding window or a token bucket so a short burst passes while the average holds. Keep the counters in a store all four instances share, and reject with 429 plus Retry-After.
Common mistakes
- ✗Keeping counters in each instance's memory, so the real limit is multiplied by the instance count
- ✗Using a fixed window, letting a client spend two quotas across the boundary
- ✗Authenticating or hitting the database before the limiter, so rejected traffic still costs work
Follow-up questions
- →How do you keep the shared counter store from becoming the bottleneck or a single point of failure?
- →What do you return to a client that keeps ignoring the
Retry-Afteryou send it?