← Blog

ASP.NET Core Null Responses and Model Binding: Build a Testable API Contract

ASP.NET Core Null Responses and Model Binding: Build a Testable API Contract

An ASP.NET Core action signature is not merely a C# implementation choice. It participates in selecting an HTTP status, a response body, and a media type. A raw null, an empty collection, and a JsonResult containing null can therefore produce observably different responses. Input has a similar trap: JSON members that do not match a request model may be ignored while the action still succeeds. This guide turns those defaults into an explicit, versioned, and reproducible API contract.

Why null changes more than the response value

An ASP.NET Core null response can change both status and body because MVC selects an output formatter before JSON serialization. With the default no-content formatter enabled, a null value from a concrete return type can become a 204 response rather than a 200 response whose JSON body is null.

That distinction matters to every caller. A browser client that parses JSON after every successful request can fail when a 204 response has no body. A client that branches only on status === 200 may skip valid success handling. Monitoring that groups every 2xx response together may hide the difference altogether. The resulting symptom is often described as an empty screen or a missing update, even though the server did exactly what its formatter was configured to do.

The important lesson is not that 204 is inherently wrong. A no-content response is useful when the endpoint deliberately promises no representation. The problem is allowing a nullable C# value to define that promise accidentally. Readers of a method returning Payload? cannot reliably infer whether the deployed endpoint returns 204, 200 with JSON null, 404, or a framework-specific result. Configuration and result type are part of the observable contract.

Collection endpoints demonstrate why shape is usually more valuable than brevity. A successful query with no records can return an empty JSON array. The caller keeps one stable type, iteration remains safe, and “not loaded,” “failed,” and “loaded with zero items” do not collapse into the same null state. A single-resource lookup has different semantics: an absent resource normally deserves an explicit not-found result, while a present resource may still contain nullable properties.

Before implementing an endpoint, write a small result table. Include success with data, success with no items, missing resource, invalid input, and an unexpected failure. For each row, specify status, media type, and body shape. That table gives controller code, OpenAPI output, client logic, and integration tests one shared target. Framework defaults can implement the target, but they should not be the only place where the decision exists.

Choose among raw null, an empty array, and explicit JSON

Raw null, an empty collection, and JsonResult(null) are three different contracts, not interchangeable spellings. In the fixed .NET 9 sandbox used for this article, they produced 204 with no body, 200 with an empty array, and 200 with a JSON null body respectively.

A raw null from an action with a concrete nullable return type enters MVC output formatting. HttpNoContentOutputFormatter has a TreatNullValueAsNoContent setting, documented with a default value of true. That explains the observed 204 in the sandbox without pretending every application must behave identically. An application can change MVC options, select a different result path, or move the endpoint to Minimal APIs. Those changes must trigger the same black-box tests again.

An empty array is generally the strongest contract for a successful list query. It preserves the declared JSON shape and lets callers process zero results without a special null branch. It also makes schema documentation less ambiguous. The endpoint is not saying that the collection itself is unknown; it is saying that the query completed and the collection currently has no members. Error outcomes should use error statuses rather than overloading null.

An explicit JsonResult(null) used the JSON result executor in the sandbox and returned 200 with a body containing null. That can be a legitimate choice when the API truly defines a nullable JSON document. It should be uncommon and documented because many clients still need a special branch for it. If the resource does not exist, NotFound() usually communicates the domain state more clearly. If an operation succeeded and intentionally returns nothing, NoContent() states that intention more clearly than a formatter interpreting raw null.

The return-type decision also affects maintenance. ActionResult allows an action to return a typed success representation and explicit alternatives such as not found. IActionResult offers flexibility but can make the success schema less obvious unless documentation and tests compensate. A concrete type is concise for an invariant success response, yet nullable concrete types can conceal no-content conversion. Choose the type after the status-and-shape matrix is agreed, not before.

Model binding can succeed while discarding intent

Model binding success does not prove that a request matched the intended contract. If a JSON member is misspelled, renamed, or unknown to the request model, the configured JSON input path may ignore it and leave the corresponding known property null while the action continues with a successful status.

Consider a request model with ResourceTypeList and Enabled. If a caller sends resourceType instead of resourceTypeList, the names describe similar business ideas but they are not the same contract member. In the local reproduction, the unknown member was ignored and ResourceTypeList remained null. An extra field was ignored as well. The action returned 200 because the remaining model was valid enough to execute. A status-only test would have reported success while missing the lost filter.

This behavior is especially risky for optional filters. A dropped filter can broaden a query instead of producing an obvious error. The response may look plausible, so a unit test focused on service logic will not reveal that the HTTP payload never populated the service input. End-to-end or integration tests must submit the exact JSON used by a caller and assert the resulting values or behavior. Merely calling the controller method with an already-constructed C# object bypasses the failure point.

Required members should have explicit validation rules appropriate to the application. Optional members still need contract tests proving that the documented JSON name reaches the intended property. If a team chooses to reject unknown JSON members, that choice belongs in fixed serializer configuration and must have a failing-request test. If unknown members remain tolerated for forward compatibility, tests should ensure that a renamed required concept cannot silently become optional.

Separate request and response DTOs help constrain this surface. An input DTO should expose only accepted members, independent of persistence navigation and internal state. A response DTO should specify public naming, nullability, and collection shape. This separation prevents an attribute added for storage or internal serialization from unexpectedly changing the HTTP interface. It also makes reviews concrete: a DTO diff can be evaluated as an API contract change rather than an incidental entity refactor.

Design rules for status codes and JSON shapes

A stable ASP.NET Core API defines observable outcomes first and selects a return type second. Treat formatter behavior as an implementation mechanism, then protect the intended status, media type, raw body, and deserialized shape with integration tests at the HTTP boundary.

For a single-item GET, ActionResult is a practical choice when the endpoint can explicitly return NotFound() and otherwise return a DTO. For a list GET, return an array for every successful query, including zero matches. For a command that promises no representation, return NoContent() directly. If a special endpoint really promises a JSON null document, choose an explicit JSON result, describe it in the API definition, and test client handling.

The following pattern makes absence explicit and keeps list shape stable:

[HttpGet("{id:int}")]
public ActionResult GetById(int id)
{
    ItemDto? item = repository.Find(id);
    return item is null ? NotFound() : Ok(item);
}

[HttpGet]
public ActionResult> List()
{
    IReadOnlyList items = repository.List();
    return Ok(items); // Returns [] when the successful result has no items.
}

Tests should not stop at a deserialized C# value. A 204 response has no JSON document to deserialize, while a 200 response containing null does. Conversely, checking only that a response is successful merges 200 and 204. At minimum, assert the numeric status, Content-Type when a body exists, the exact body for null or empty shapes, and the resulting object after parsing. A browser-facing endpoint should also have a client test that avoids JSON parsing when no body is promised.

Keep the test isolated from external data. A fake repository or fixed endpoint result is enough to exercise MVC result execution and JSON input. Deterministic inputs make a framework upgrade failure actionable: if status changes, the change came from code, configuration, or runtime rather than mutable records. This article makes no throughput or payload-cost claim; semantics are the reason to choose among these outcomes. Performance needs a separate benchmark with controlled payloads, protocol, compression, warm-up, and repeated measurements.

Verification and local reproduction

This reproduction was executed in the Windows scheduler with .NET SDK 9.0.305, target framework net9.0, and ASP.NET Core Runtime 9.0.9. It used only a local sandbox and observed 204 for raw null, 200 plus [] for an empty list, 200 plus null for JsonResult, and ignored unknown input members.

Prerequisites are the exact .NET SDK 9.0.305 and an available local port 5187. Add a global.json that disables roll-forward, use Microsoft.NET.Sdk.Web, target net9.0, enable nullable reference types, and add controller services plus controller routing. Stop if dotnet --version is not exactly 9.0.305; otherwise a different SDK may build or interpret the project.

  1. Create four controller actions: a concrete nullable Payload? returning raw null, an empty IReadOnlyList, an IActionResult returning new JsonResult(null), and a POST that echoes known properties from FilterInput.
  2. Run dotnet --version and dotnet build -c Release. Pass only if the version is exactly 9.0.305 and the build exits with code zero; any other SDK or a nonzero build is a failure.
  3. Start the sandbox with dotnet run -c Release --no-build --urls http://localhost:5187, then call the three GET endpoints with curl -i. Pass when the status and bodies are 204 with empty body, 200 with [], and 200 with null in that order.
  4. POST {"resourceType":"report","extraField":123} to the bind endpoint. Pass when the response is 200 and both known properties, resourceTypeList and enabled, are null; fail if the unknown names populate either property or the request does not complete as specified.
dotnet --version
dotnet build -c Release
dotnet run -c Release --no-build --urls http://localhost:5187
curl -i http://localhost:5187/api/probe/raw-null
curl -i http://localhost:5187/api/probe/empty-list
curl -i http://localhost:5187/api/probe/json-null
curl -i -H "Content-Type: application/json" -d '{"resourceType":"report","extraField":123}' http://localhost:5187/api/probe/bind

The actual observable summary was HTTP/1.1 204 No Content with an empty body for raw-null; HTTP/1.1 200 OK with [] for empty-list; HTTP/1.1 200 OK with null for json-null; and 200 with {"resourceTypeList":null,"enabled":null} for bind. These are real results from this fixed environment, not invented console output, benchmark data, or a universal promise for future runtime versions.

A reader can reproduce each check independently. If raw null returns 200, inspect MVC options and the selected result path. If the array is not [], inspect the action return and custom converters. If JsonResult returns no content, confirm the action really constructs JsonResult. If an unknown input member triggers rejection, document that stricter configuration as part of the local contract rather than treating the different result as a test infrastructure failure.

Compatibility, security, and review boundaries

Compatibility review must include the target framework, installed runtime, MVC formatter options, JSON options, return type, and client parser. A result observed on .NET 9 should be retested after any framework upgrade, switch to Minimal APIs, custom formatter registration, or change to null-handling options.

Microsoft's API documentation identifies HttpNoContentOutputFormatter and its TreatNullValueAsNoContent property. The model-binding documentation explains how request data is mapped into action inputs, while the action-return-type guidance distinguishes concrete types, IActionResult, ActionResult, and HTTP result forms. Together, those references support the mechanism and design choices, but they do not replace a test against the application's exact middleware and options.

Review every raw return null in controller code. Decide whether it means no representation, a missing resource, an empty list, or a nullable JSON document. Review collection endpoints for a stable array shape. Review clients for correct handling of every promised successful status and for parsing only when a body exists. Review DTO changes as public interface changes, particularly renames that can cause old callers to send members the new model no longer recognizes.

Keep reproductions in a local, disposable test project with fixed inputs. They should not connect to live data, irreversible operations, payment flows, or real accounts. The four endpoints above need no external dependency and reveal only framework behavior. That isolation also prevents a contract test from becoming a data-quality test. If business validation is added, provide synthetic cases and assert both the error status and machine-readable error body.

Do not claim a speed improvement from choosing 204 over [] or from accepting unknown fields. Any byte difference in these tiny examples is secondary to semantic clarity, and no benchmark was run. If performance becomes a requirement, create a separate benchmark plan and report the runtime, transport, payload sizes, sampling method, and uncertainty. Contract guidance should remain valid even when performance results change.

A practical API contract checklist

A useful review checklist connects each endpoint decision to an observable test. It prevents code style preferences from replacing protocol semantics and gives maintainers a small regression suite to run whenever framework, formatter, serializer, DTO, or client code changes.

First, record the success status and body shape for single resources, collections, and commands. Second, make absence explicit with not-found or no-content results rather than an unexplained raw null. Third, submit exact client JSON in integration tests and assert that every meaningful field reaches the expected model property. Fourth, check unknown-member behavior deliberately. Fifth, include the SDK and runtime versions in the test record.

For collections, verify that zero results serialize to an array. For nullable properties inside a valid resource, verify that the surrounding object still exists. For a no-content result, verify that clients do not parse JSON. For an explicit JSON null contract, verify both 200 and the four-byte null body. For invalid required input, verify a defined client-error response rather than accepting a broadened operation. These checks should be small enough to run on every relevant change.

Documentation should mirror the tests. OpenAPI descriptions and examples should not promise a 200 object when the implementation returns 204 for one branch. Client SDK assumptions should be reviewed when the response union changes. If compatibility requires supporting an old JSON member name during migration, make that mapping explicit and test both names for a bounded period. Silent ignorance should never be the migration plan.

Finally, keep the contract matrix near the endpoint code or test suite. A future maintainer should be able to answer three questions without running the UI: What status is returned? Is there a body, and what is its JSON shape? What happens when an expected input member is missing or renamed? If those answers are precise and executable, the API is considerably easier to evolve.

Conclusion

ASP.NET Core null behavior is an HTTP contract concern, not a minor C# detail. Return an empty array for a successful empty collection, use an explicit missing-resource result for absence, use NoContent() when no representation is promised, and reserve explicit JSON null for an intentionally nullable document protected by tests.

Model binding deserves the same discipline. An action can return 200 after unknown JSON members were ignored and expected properties stayed null. Integration tests must therefore submit real payload names and assert status, raw body, and bound behavior. With a fixed runtime, a result matrix, and a small local reproduction, formatter or DTO changes become visible regressions instead of mysterious client failures.

Advertisement