ASP.NET Core

From a route that works to an ASP.NET Core API you can maintain

A practical architecture guide to API contracts, validation, dependency lifetimes, asynchronous data access, authorization, error responses, tests, and production deployment.

Editorial illustration for From a route that works to an ASP.NET Core API you can maintain

Define the promise before writing the route

An HTTP route becomes useful when a caller can predict its behavior. Returning a JSON object once is a good first experiment, but a maintainable API needs a stable promise about input, output, failure, ownership, and state changes. Start with one concrete operation, such as creating a reading-list entry. Describe which fields the caller supplies, which fields the server decides, and what the caller should do when the operation cannot complete.

For that example, the caller might supply a title and a URL. The server assigns an identifier, records the owner, and stores the creation time. An empty title is invalid input. A missing identity is an authentication problem. An attempt to modify another person’s entry is an authorization problem. A database outage is an operational failure. Treating every case as a generic unsuccessful request discards information the client needs to behave correctly.

Write a few example requests and responses before choosing an abstraction. Include the ordinary case, an invalid value, a missing resource, and a repeated submission. These examples serve as design material and later become integration checks. They also expose vague requirements early, while changing a field name is inexpensive and no deployed client depends on it.

Keep learning previews and production hosts distinct

An interactive playground is a useful place to understand route syntax, JSON shapes, and basic control flow. SharpRune provides a focused in-browser web preview through its own request-dispatch runtime. That environment can shorten the feedback loop while you learn C#. It does not establish that the same project has been tested with a real network listener, a production database, authentication middleware, or the complete ASP.NET Core framework.

The production guidance in this article applies to an application built and run with the normal ASP.NET Core toolchain on a supported server environment. Check the target framework and documentation version for your actual project. Do not infer support for a middleware, hosting option, or package merely because a similarly named API appears in a browser subset. A preview and a production host answer different questions.

A good progression is to prototype a small contract in the playground, move the project into the .NET SDK, and add the operational pieces deliberately. Re-run the behavior through real HTTP and the intended persistence provider. Keep the preview as a teaching aid or fast experiment, while treating server verification as the evidence required for publishing a public API.

Separate transport objects from stored entities

A request body is a public input surface. A database entity is an internal persistence model. Reusing the same class for both can expose fields callers should never control, such as owner identifiers, moderation state, or internal timestamps. It can also make a database refactor an accidental API change. Define a small request type containing exactly the values the operation accepts, then map those values into the model you persist.

Response types deserve the same care. Return the fields a caller needs rather than serializing an entire tracked entity graph. A list page might require an identifier, title, and creation time, while a detail endpoint includes additional information. This makes payload size predictable and prevents relationship navigation properties from quietly expanding the response when the persistence model grows.

Records work well for many simple transport shapes because they make the intended data concise. Their syntax is less important than the boundary. A regular class can express the same contract. Be explicit about optional values and the meaning of omission. For updates, decide whether an absent field means leave the value unchanged or replace it with null; those are different operations and need different representations.

C# example

public sealed record CreateEntryRequest(string Title, string Url);
public sealed record EntryResponse(Guid Id, string Title, string Url, DateTimeOffset CreatedAt);

References: Microsoft: Minimal API parameter binding

Validate both shape and business meaning

Binding converts transport data into parameters and objects. It does not automatically establish every business rule your application cares about. A title can be a valid string and still be empty after trimming. A decimal can parse correctly and still exceed an allowed amount. A URL can be syntactically valid and point to a scheme your application must not fetch. Separate these questions so each failure receives a useful explanation.

Validate at the boundary before starting expensive work, then enforce essential invariants again where the state change is owned. Boundary validation helps the client repair input. Domain validation protects correctness when the same operation is called from a job, an administrative tool, or a future transport. Database constraints provide another layer for rules that must hold under concurrency, such as uniqueness.

Avoid turning validation into a collection of arbitrary limits. Choose constraints from the product requirement and storage behavior, explain them in the contract, and test the boundaries. Check just below, exactly at, and just above a limit. Normalize only when the transformation is intentional. Silently rewriting a meaningful identifier can be more surprising than rejecting it with a clear field-level error.

References: Microsoft: Minimal API parameter binding

Make dependency lifetimes match ownership

Dependency injection is a way to make object creation and ownership explicit. A transient service is created when resolved. A scoped service belongs to a scope, commonly an HTTP request in a server application. A singleton belongs to the application lifetime. Choosing among them changes how state is shared, when resources are disposed, and whether concurrent requests can safely use the same object.

A database context is commonly scoped because it represents a unit of work and is not designed for concurrent operations on the same instance. A singleton that captures a scoped context can accidentally retain request state and misuse its lifetime. A singleton cache or client wrapper may be appropriate when it is designed for shared concurrent use. The label singleton does not itself make an object thread-safe.

Keep constructors honest about dependencies. If an entry service needs a repository and a clock, receive those dependencies directly rather than looking them up through a global container. This makes tests easier and exposes coupling during review. When a background worker needs scoped services, create a deliberate scope for the operation and dispose it after the work completes. Do not retain a request’s service graph after the request has ended.

References: Microsoft: Dependency injection in ASP.NET Core · Microsoft: Dependency injection guidelines

Keep the HTTP layer readable

A route handler should make the HTTP behavior easy to assess. It binds input, applies the appropriate access requirement, invokes the application operation, and translates the result into a response. If it also performs parsing, business policy, database queries, notification delivery, and retry logic, reviewers have to reconstruct too many responsibilities before deciding whether the endpoint is correct.

Small applications do not need an elaborate architecture to avoid that problem. Extract a method or service when a coherent operation has meaningful rules or dependencies. Group related endpoints and use consistent names. Keep the path from request to state change visible. Additional interfaces are useful when they express an actual boundary, not when they merely repeat every public method of another class.

Choose Minimal APIs or controllers according to the project’s requirements and the team’s conventions. Both can support clear contracts. Focus on behavior that callers can observe: status codes, headers, payloads, and side effects. A short route is not automatically simple if it delegates to an opaque chain. Readability means a maintainer can follow the operation without discovering hidden state or surprising global behavior.

References: Microsoft: Minimal API parameter binding

Use async for waiting and carry cancellation

A server handles many requests over time. Waiting synchronously for a database or remote HTTP operation occupies a thread that could otherwise handle work. Use asynchronous APIs for I/O and await them through the call chain. Avoid calling Result or Wait on tasks in the request path. Wrapping a blocking operation in Task.Run does not remove its underlying cost or make the dependency asynchronous.

Accept a CancellationToken at the endpoint boundary and pass it to operations that support cancellation. In a normal ASP.NET Core request, that token can represent the client disconnecting. Your service and persistence code should carry it rather than replacing it with an unrelated token at every layer. Cancellation is cooperative, and a provider may have limits, so it is a way to avoid unnecessary work rather than a guarantee of instant interruption.

Decide carefully what cancellation means after a state change. A disconnected caller may not know whether a transaction committed. The server cannot undo reality merely because the response was never read. For important mutations, combine clear transaction boundaries with an idempotency design or a way to query operation state. That makes recovery predictable when timeouts occur between saving data and delivering the response.

References: Microsoft: ASP.NET Core best practices · Microsoft: EF Core asynchronous programming

Shape database queries for the response

An API list endpoint rarely needs every column and every related row from the database. Build a query that filters by the authorized scope, projects the response fields, applies a stable order, and limits the number of results. Let the database perform the work it can express efficiently. Materializing a large table before filtering transfers unnecessary data and hides a scaling problem behind a tidy LINQ expression.

Inspect the generated SQL and actual query plan for important paths. A query that reads naturally in C# can still need an index, produce a large join, or make repeated round trips. Pay attention to relationship loading and the N plus one pattern, where a list operation issues another query for each item. The number of rows in a development fixture is often too small to reveal the cost.

For read-only results, consider no-tracking queries when entity tracking is not needed. Project directly into the response shape where practical. Keep provider translation in mind: not every arbitrary C# method can run in SQL. This article’s query fragment illustrates shaping and cancellation; it belongs in a real EF Core application with the appropriate context, entity model, and package references.

C# example

var entries = await db.Entries
    .AsNoTracking()
    .Where(entry => entry.OwnerId == ownerId)
    .OrderByDescending(entry => entry.CreatedAt)
    .ThenBy(entry => entry.Id)
    .Select(entry => new EntryResponse(entry.Id, entry.Title, entry.Url, entry.CreatedAt))
    .Take(50)
    .ToListAsync(cancellationToken);

References: Microsoft: Efficient querying with EF Core · Microsoft: EF Core asynchronous programming

Pagination needs a stable order

An unbounded collection response is easy to implement and difficult to operate. The dataset grows, serialization takes longer, and a request can allocate far more memory than intended. Set a maximum page size and make the chosen limit visible in the contract. A client should know how to request the next portion and how to recognize the end of the sequence.

Always define an order that is stable enough for pagination. Ordering only by a timestamp can leave ties, so include a unique secondary key. Offset pagination is straightforward for small datasets and administrative views, but changes between requests can shift rows across pages. Cursor or keyset pagination can provide more predictable traversal for suitable access patterns when designed around that stable ordering.

Think about user expectations under concurrent writes. Does the list represent a snapshot, or may newly created entries appear during traversal? Is a deleted item simply absent? Document the behavior that matters rather than promising impossible perfect consistency by accident. Test multiple items with the same timestamp and insert a new item between page requests. These examples reveal ordering mistakes much earlier than a performance incident does.

References: Microsoft: Efficient querying with EF Core

Treat concurrent updates as a normal case

Two callers can read the same record and submit different updates. If the application always writes the second request over the first without checking, one person’s change may disappear. A database transaction alone does not automatically solve every lost-update scenario. Decide whether the operation needs optimistic concurrency, a stronger transactional approach, or a domain-specific merge rule.

Optimistic concurrency commonly associates a version with the record and checks that version when saving. If the stored record changed after the caller read it, the update fails in a recognizable way. The application can then return a conflict and enough safe information for the client to refresh or ask the user to reconcile changes. Blindly retrying the same stale values can defeat the purpose of detecting the conflict.

Uniqueness belongs in database constraints when concurrent requests must not create duplicates. An application-level existence check can improve an error message, but another request can insert the same value between the check and the save. Handle the authoritative persistence result and map it to the contract. Tests should exercise competing operations, not only two sequential calls that never overlap.

References: Microsoft: EF Core concurrency conflicts

Return errors that help callers recover

Error responses are part of the API, so design them with the same care as successful responses. ProblemDetails provides a standard shape for describing HTTP failures. Use a stable problem type or application code when clients need to distinguish cases, and keep human-readable text useful without making it the only machine-readable signal. A validation error should identify fields the caller can repair.

Use exception-handling middleware to handle unexpected failures consistently. Log the internal exception with a correlation identifier, then return a safe public response. Do not expose stack traces, connection strings, or raw database errors to an arbitrary caller. At the same time, avoid swallowing failures into a successful status code: that makes monitoring and client behavior misleading.

Keep expected outcomes out of broad exception-driven control flow where a normal result is clearer. A missing record can be represented as not found. Invalid input can be represented as a validation result. Reserve unexpected exceptions for conditions the current operation did not anticipate or cannot resolve. This distinction makes logs quieter and makes genuine operational failures easier to investigate.

References: Microsoft: API error handling and ProblemDetails

Authentication and authorization answer different questions

Authentication establishes who is calling, according to the configured identity system. Authorization decides what that caller may do. A valid identity does not mean access to every record. Apply endpoint requirements and resource ownership checks deliberately. For a reading list, filtering by owner on a list route and checking ownership on an update route are both part of the access model.

Derive ownership from trusted server-side identity information rather than accepting an arbitrary owner identifier from the request body. A caller can change a JSON field even if the normal interface never displays it. Likewise, hiding an action button in the browser improves the interface but does not secure the endpoint. The server must enforce the same rule for a direct HTTP request.

Test access with at least two distinct users and an unauthenticated caller. Verify that one user cannot read, modify, or delete another user’s resource by changing the path identifier. Decide whether a forbidden resource should be reported as forbidden or not found based on your disclosure policy, then apply that policy consistently. The important property is that the request cannot cross the ownership boundary.

References: Microsoft: Introduction to authorization

Keep credentials outside the artifact

Published source and container images should not contain production credentials. Use the platform’s protected configuration mechanism for connection strings, signing material, and service tokens. Development secret storage can make local work more convenient, but it does not replace a production secret-management strategy. Keep test credentials separate from production identities and grant the application only the access it needs.

Validate required configuration at startup so a missing setting produces a clear deployment failure instead of a confusing error on the first customer request. Log the name of the missing setting, not its value. When troubleshooting, avoid dumping the entire environment or configuration tree. A useful diagnostic can confirm that a binding exists and indicate which provider supplied it without exposing the credential.

Plan rotation as an ordinary operation. A credential eventually changes, expires, or needs revocation. Know whether the process must restart, whether connections need refreshing, and how to confirm the new value is active. Include that operational knowledge near the deployment instructions. A system that works only while one forgotten token remains valid has an avoidable maintenance risk.

References: Microsoft: Safe storage of development secrets

CORS does not replace access control

Cross-Origin Resource Sharing determines which browser origins may read certain responses. It is not an authentication system and does not prevent non-browser clients from sending requests. Configure a narrow policy when a browser application on another origin needs access, but continue to enforce identity, authorization, and request validation in the API. A successful CORS preflight says nothing about ownership of the requested record.

If the application uses cookies for authentication, assess cross-site request forgery protection for state-changing operations. If it uses bearer tokens, protect those tokens and understand where they are stored and transmitted. Avoid combining broad origin permissions and credentials without a deliberate design. The correct choice depends on the deployment and identity architecture, so copy a policy only after understanding its assumptions.

Use realistic origins in a staging test: scheme, host, and port all matter. Exercise both an allowed browser origin and a disallowed one, then call the endpoint with a non-browser client to verify that authorization still works independently. Separating these tests prevents a common mistake in which a browser policy appears to secure an endpoint that actually accepts unauthorized direct requests.

References: Microsoft: CORS in ASP.NET Core

Make observability answer operational questions

Useful telemetry starts with questions. Is the API available? Which operation is slow? Did a database call fail? How many requests were rejected because of invalid input? Structured logs, metrics, and traces answer different parts of that picture. Use consistent operation names and correlation so a maintainer can connect one failed request to the relevant internal work without searching unrelated text.

Prefer low-cardinality metric labels such as route templates and outcome categories. A metric label containing every user identifier or full URL can create a large, expensive series set. Keep personal data and credentials out of logs. A trace identifier and a safe internal record identifier are often enough to connect evidence while limiting unnecessary disclosure. Define retention according to the actual operational need.

Observe the complete request path, including the reverse proxy and database, when diagnosing latency. An endpoint can spend little CPU time while waiting a long time for a dependency. Record duration distributions and error rates instead of only averages. An average can look healthy while a small group of users experiences repeated timeouts. Telemetry should help decide what to change, not merely demonstrate that a logging library was installed.

References: Microsoft: .NET observability with OpenTelemetry

Test the contract through real HTTP boundaries

Unit tests are useful for pure validation and business rules, but they do not establish that routing, binding, middleware, serialization, and dependency registration work together. Integration tests using the ASP.NET Core test host can exercise that combined path. Send actual HTTP requests and inspect the status, headers, response body, and persisted state relevant to the operation.

Choose tests that protect meaningful behavior: valid creation, invalid input, missing identity, cross-user access, missing resource, and a concurrency conflict. Avoid writing dozens of assertions that merely repeat the implementation’s private structure. A refactor that preserves the public contract should not require rewriting every test. When persistence semantics matter, use a database setup that represents the production provider closely enough to exercise its constraints and query behavior.

Also run a small deployed smoke test through the public ingress. An in-memory test host cannot prove TLS configuration, reverse-proxy headers, container startup, or network access to the database. Keep destructive test data isolated and use a recognizable test identity. The combination of focused automated checks and a minimal deployed check gives stronger evidence than either one alone.

References: Microsoft: ASP.NET Core integration tests

Deploy an identifiable artifact with a recovery path

A deployment should identify exactly what is running. Build an artifact from reviewed source, associate it with a revision or digest, and keep deployment configuration separate from the artifact’s code. Promote that artifact through the intended environments instead of rebuilding an ambiguous latest version at every step. The goal is to connect a production observation to the code and dependencies that produced it.

Behind a reverse proxy, configure trusted forwarded headers deliberately so the application interprets the original scheme and client information correctly. Confirm request size limits, timeouts, health endpoints, and startup behavior. A liveness check can establish that a process responds, while a readiness check may need to establish that the instance can accept useful work. Avoid making every health probe an expensive full dependency test.

Keep the previous release and a documented rollback procedure, but remember that data changes can make rollback more complex than replacing a container. Plan schema migrations for compatibility across the transition. Verify the serving artifact after deployment, then send a representative request through the public URL. An accepted deployment job is a starting signal; successful serving behavior is the evidence that the release is complete.

References: Microsoft: Proxy and load-balancer configuration · Microsoft: Health checks in ASP.NET Core

Grow the architecture from observed pressure

A maintainable API does not begin with the largest possible collection of frameworks and patterns. It begins with clear boundaries and grows when the application supplies evidence that another boundary is needed. A small service can have one deployment, one database, and a straightforward set of endpoints while still handling validation, ownership, cancellation, and errors carefully.

Add caching when measurements show a repeatable expensive read and the product can tolerate the chosen freshness behavior. Add a queue when work must outlive the request or needs independent retry and throughput control. Split services when ownership and operational needs justify the distributed failure modes. Each addition should solve a concrete problem and come with tests, observability, and a recovery story.

For your next exercise, choose one endpoint and write its full contract on a page. Implement the ordinary case, then add the most consequential failure paths. Test the access boundary with two users, inspect the generated query, and deploy an identifiable artifact. That sequence builds practical judgment. The result is an API whose behavior remains understandable as the project gains features, users, and maintainers.

Sources & further reading

Technical references checked against primary documentation. Browser playground examples target the SharpRune .NET 9 profile; production server guidance describes a separate hosting environment.

Illustration created with ImageGen for SharpRune. Article and examples are educational; validate behavior in your target runtime.
Put an idea into practice