.NETAPI Design
June 22, 2026

Preventing ASP.NET Core API Changes from Breaking Existing .NET Clients with Versioned REST Contracts

A travel platform exposes an ASP.NET Core API that returns tour packages. The first contract represents a destination as one text field. A later requirement needs separate country, city, and location values, so the server team replaces the string with a complex object.

The new model is better for the server, but existing mobile apps and partner integrations still expect the old shape. Their deployments are outside the server team's control. Releasing the new contract at the existing endpoint can turn a valid internal refactoring into a breaking public change.

The safe solution is to treat the service interface as an independently versioned contract. Keep the current contract available, introduce the new shape under a separate version, publish accurate OpenAPI documents, and give .NET consumers typed proxies that handle authentication, status codes, and serialization consistently.

The Failure Is at the Contract Boundary

Service-Oriented Architecture, commonly shortened to SOA, places cooperating components in separate processes and makes them communicate through messages. The main benefit is interoperability: clients and servers can use different programming languages, frameworks, and deployment platforms as long as they agree on the communication contract.

That contract is more than a C# class. It includes:

  • The resource URL
  • The HTTP method
  • Path and query parameters
  • Request and response body shapes
  • Status codes
  • Authentication requirements
  • Field meanings
  • Versioning rules

A server-side type can be renamed or reorganized without affecting clients only when the external message contract remains compatible.

Consider the original response model:

public sealed record PackageV1Dto(
    int Id,
    string Name,
    string Destination,
    decimal Price);

A new internal design may prefer this model:

public sealed record DestinationDto(
    string Country,
    string City,
    string Location);

public sealed record PackageV2Dto(
    int Id,
    string Name,
    DestinationDto Destination,
    decimal Price);

Changing Destination from a string to an object is a breaking change. An old client cannot interpret the new value using its existing type.

The server should not silently change the established endpoint. It should preserve the old representation and publish the new one as a new API version.

Model the API Around Resources

REST uses native HTTP concepts instead of creating another protocol layer above HTTP. URLs identify resources, HTTP verbs describe the operation, headers carry metadata and authorization, bodies carry request or response data, and status codes describe the result.

For the package catalog, use resource-oriented routes:

GET    /api/v1/packages
GET    /api/v1/packages/{id}
POST   /api/v1/packages
PUT    /api/v1/packages/{id}
DELETE /api/v1/packages/{id}

The same resource design can be introduced for version 2:

GET    /api/v2/packages
GET    /api/v2/packages/{id}
POST   /api/v2/packages
PUT    /api/v2/packages/{id}
DELETE /api/v2/packages/{id}

The route should represent the resource being acted upon. The HTTP method should represent what happens to that resource.

HTTP method Intended operation Typical successful response
GET Read a resource 200 OK
POST Create a resource 201 Created or 202 Accepted
PUT Replace a resource representation 200 OK or 204 No Content
PATCH Apply modification instructions 200 OK
DELETE Remove a resource 200 OK

PUT is idempotent: repeating the same replacement should leave the resource in the same final state. A PATCH operation may not be idempotent when it describes an action such as incrementing a value.

A method can also affect resources other than the one named in the URL. The important rule is that the HTTP verb describes the operation performed on the addressed resource.

Choose an Explicit Versioning Strategy

Common REST versioning approaches include:

  • A version in the URI
  • A query parameter
  • A version in the Accept media type
  • A custom request header

The critical requirement is not choosing the most sophisticated option. It is allowing several versions to remain accessible during migration.

URI versioning makes the contract visible and easy to route:

/api/v1/packages/42
/api/v2/packages/42

This approach is useful when old consumers must keep working while new consumers adopt a changed representation.

The migration becomes controlled:

  1. Keep version 1 available.
  2. Add version 2 without changing version 1 semantics.
  3. Publish separate documentation for both versions.
  4. Move consumers individually.
  5. Remove version 1 only after its supported consumers have migrated.

Do not reuse version 1 for a response whose shared fields have changed meaning. Compatibility is based not only on matching property names, but also on preserving their semantics.

Keep Additive Changes Inside the Existing Version

Not every response change requires a new API version.

REST clients can usually ignore response fields they do not understand. Adding an optional field can therefore remain compatible when all existing fields keep the same meaning.

For example, adding AvailableSeats to version 1 can be safe:

public sealed record PackageV1Dto(
    int Id,
    string Name,
    string Destination,
    decimal Price,
    int? AvailableSeats);

An older client can deserialize only the fields it knows. A newer client can use the additional field.

Use a new version when a change:

  • Removes a field required by existing consumers
  • Changes a field from one type to another
  • Changes the meaning of an existing field
  • Changes required input in a way old clients cannot provide
  • Changes status-code behavior that consumers rely on
  • Replaces a resource or operation with incompatible semantics

Adding fields is often compatible. Reinterpreting fields is not.

Implement Side-by-Side ASP.NET Core Controllers

ASP.NET Core maps HTTP requests to controller action methods through route and HTTP verb attributes. Dependency injection supplies controller constructor parameters.

Create a shared application service so both API versions reuse the same business behavior while mapping results into different contracts.

public interface IPackageCatalog
{
    Task<PackageDetails?> FindAsync(
        int packageId,
        CancellationToken cancellationToken);
}

public sealed record PackageDetails(
    int Id,
    string Name,
    string Country,
    string City,
    string Location,
    decimal Price);

Version 1 preserves the original destination string:

[ApiController]
[Route("api/v1/packages")]
[EndpointGroupName("Version1")]
public sealed class PackagesV1Controller : ControllerBase
{
    private readonly IPackageCatalog _catalog;

    public PackagesV1Controller(IPackageCatalog catalog)
    {
        _catalog = catalog;
    }

    [HttpGet("{id:int}")]
    [EndpointSummary("Returns a package using the version 1 contract.")]
    [ProducesResponseType<PackageV1Dto>(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<PackageV1Dto>> Get(
        int id,
        CancellationToken cancellationToken)
    {
        var package = await _catalog.FindAsync(
            id,
            cancellationToken);

        if (package is null)
        {
            return NotFound();
        }

        return Ok(new PackageV1Dto(
            package.Id,
            package.Name,
            $"{package.City}, {package.Country}",
            package.Price));
    }
}

Version 2 returns the structured destination:

[ApiController]
[Route("api/v2/packages")]
[EndpointGroupName("Version2")]
public sealed class PackagesV2Controller : ControllerBase
{
    private readonly IPackageCatalog _catalog;

    public PackagesV2Controller(IPackageCatalog catalog)
    {
        _catalog = catalog;
    }

    [HttpGet("{id:int}")]
    [EndpointSummary("Returns a package using the version 2 contract.")]
    [ProducesResponseType<PackageV2Dto>(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<PackageV2Dto>> Get(
        int id,
        CancellationToken cancellationToken)
    {
        var package = await _catalog.FindAsync(
            id,
            cancellationToken);

        if (package is null)
        {
            return NotFound();
        }

        return Ok(new PackageV2Dto(
            package.Id,
            package.Name,
            new DestinationDto(
                package.Country,
                package.City,
                package.Location),
            package.Price));
    }
}

Both controllers call the same application service. Only the boundary mapping differs.

This avoids duplicating business rules while allowing both external contracts to remain stable.

Return Status Codes That Describe the Result

A REST client should not need to inspect a successful response body to discover that the operation failed.

ASP.NET Core's ControllerBase provides methods for common results:

  • Ok() for 200
  • Created() for 201
  • Accepted() for 202
  • BadRequest() for 400
  • NotFound() for 404
  • Unauthorized() for 401
  • Forbid() for 403
  • StatusCode() for another explicit result

Use 401 and 403 for different conditions:

  • 401 Unauthorized means the request did not contain valid authentication.
  • 403 Forbidden means authentication succeeded, but the authenticated identity lacks permission.

Use validation attributes for request models. With [ApiController], invalid input can produce a 400 response before the action executes.

public sealed record CreatePackageV2Request
{
    [Required]
    [MaxLength(120)]
    public required string Name { get; init; }

    [Required]
    public required DestinationDto Destination { get; init; }

    public decimal Price { get; init; }
}

Keep the set of possible responses in the OpenAPI description so client developers can implement each result path intentionally.

Secure Every Request with a Token

HTTP is stateless. The server does not remember that a previous request authenticated the caller. A protected client must send its authentication token on every request.

Bearer tokens use the Authorization header:

Authorization: Bearer <token>

ASP.NET Core can validate JSON Web Tokens, or JWTs, through the authentication services. The configuration should verify the token lifetime, issuer, audience, and signature.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services
    .AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters =
            new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                ValidIssuer = "Travel.Identity",
                ValidAudience = "Travel.PackageApi",
                IssuerSigningKey =
                    packageApiSigningKey
            };
    });

builder.Services
    .AddAuthorizationBuilder()
    .AddPolicy(
        "PackageReaders",
        policy => policy.RequireClaim(
            "permission",
            "packages.read"));

Protect the controller or individual actions:

[Authorize(Policy = "PackageReaders")]
[HttpGet("{id:int}")]
public async Task<ActionResult<PackageV2Dto>> Get(
    int id,
    CancellationToken cancellationToken)
{
    // Load and map the package.
}

The token contains claims that can support authorization decisions. Authentication establishes that the token is valid. Authorization decides whether its claims permit the requested operation.

The API must use HTTPS when credentials or bearer tokens cross the network.

Publish Separate OpenAPI Documents

OpenAPI describes routes, parameters, request bodies, response types, status codes, and authorization requirements in a technology-neutral format. It can support documentation, contract review, and client generation.

ASP.NET Core can generate OpenAPI documents from controller metadata.

Configure one document for each API version:

builder.Services.AddOpenApi(
    "v1",
    options =>
    {
        options.ShouldInclude =
            description =>
                description.GroupName == "Version1";
    });

builder.Services.AddOpenApi(
    "v2",
    options =>
    {
        options.ShouldInclude =
            description =>
                description.GroupName == "Version2";
    });

Map the documentation endpoint after building the application:

var app = builder.Build();

app.UseAuthorization();
app.MapControllers();
app.MapOpenApi();

app.Run();

The EndpointGroupName attributes on the controllers decide which operations appear in each document.

Enrich the contract with endpoint metadata such as:

  • EndpointName
  • EndpointSummary
  • EndpointDescription
  • Tags
  • Description on properties or parameters
  • ProducesResponseType

If OpenAPI is exposed in production, decide whether it requires authorization. The document is generated from the API metadata and can also be cached rather than recomputed on every request.

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(
        policy => policy.Expire(
            TimeSpan.FromMinutes(30)));
});

var app = builder.Build();

app.UseOutputCache();

app.MapOpenApi()
    .CacheOutput();

OpenAPI is not a substitute for compatibility testing. It makes the contract explicit so changes can be reviewed before deployment.

Use a Typed HttpClient Proxy for Consumers

A .NET client should not create and dispose a new HttpClient for every request. Client creation is expensive, and repeatedly releasing clients can leave underlying connections waiting for cleanup.

HttpClientFactory creates clients while pooling the underlying handlers. A typed client also keeps service-specific HTTP behavior inside one proxy rather than spreading URLs, headers, and deserialization across controllers or application services.

Define a client contract:

public interface IPackageApiClient
{
    Task<PackageV2Dto?> GetAsync(
        int packageId,
        string bearerToken,
        CancellationToken cancellationToken);
}

Implement it with an injected HttpClient:

public sealed class PackageApiClient : IPackageApiClient
{
    private readonly HttpClient _httpClient;

    public PackageApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<PackageV2Dto?> GetAsync(
        int packageId,
        string bearerToken,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(
            HttpMethod.Get,
            $"api/v2/packages/{packageId}");

        request.Headers.Authorization =
            new AuthenticationHeaderValue(
                "Bearer",
                bearerToken);

        using var response = await _httpClient.SendAsync(
            request,
            cancellationToken);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        if (response.StatusCode == HttpStatusCode.Forbidden)
        {
            throw new InvalidOperationException(
                "The caller cannot read package data.");
        }

        response.EnsureSuccessStatusCode();

        return await response.Content
            .ReadFromJsonAsync<PackageV2Dto>(
                cancellationToken: cancellationToken);
    }
}

Register the typed client once:

builder.Services.AddHttpClient<
    IPackageApiClient,
    PackageApiClient>(
    client =>
    {
        client.BaseAddress =
            new Uri("https://packages.example.test/");
    });

The proxy should inspect the status code before reading the body because different status codes may return different representations or no body at all.

The client can later be extended through the IHttpClientBuilder returned by AddHttpClient, including standard resilience handling or service discovery when the architecture requires them.

Test Compatibility Before Releasing Version 2

The migration needs tests at the contract boundary.

Version 1 regression tests

Verify that version 1 still returns:

  • A string destination
  • The original field names
  • The established success and failure status codes
  • The same authorization requirement

Version 2 contract tests

Verify that version 2 returns:

  • Country, city, and location as a structured object
  • The documented response types
  • 404 for an unknown package
  • 401 without a valid token
  • 403 when the token lacks the required claim

OpenAPI tests

Verify that:

  • Version 1 actions appear only in the version 1 document
  • Version 2 actions appear only in the version 2 document
  • Response status codes are documented
  • Authentication requirements are present
  • Required request fields are described correctly

Client tests

Test the typed proxy against representative responses:

  • 200 returns the deserialized package
  • 404 returns no package
  • 403 triggers the expected client behavior
  • An unexpected failure does not get treated as a valid response

A server test that validates only controller implementation is not enough. The contract must also be exercised from the consumer's perspective.

Common Mistakes

Replacing a field type at the existing endpoint

Changing a string into an object is not additive. Publish a new version and preserve the old representation.

Sharing server DTO assemblies with every client

SOA interoperability depends on message compatibility, not every client referencing the same compiled type. Shared binaries can recreate deployment coupling.

Versioning only the code

A version must be visible in the service contract and remain callable alongside supported versions.

Returning 200 for every result

Clients should receive meaningful HTTP status codes instead of parsing a success body to discover errors.

Using POST for every operation

Choose the HTTP method according to the resource operation. Correct semantics make the API easier to extend and understand.

Storing authentication state in a server session

REST requests are independent. Send the token with every protected request.

Treating OpenAPI as documentation written after implementation

Use the contract during design and review. It should describe the actual routes, inputs, outputs, status codes, and security requirements.

Creating HttpClient instances for individual calls

Use HttpClientFactory and typed proxies so handler pooling and service-specific configuration are managed consistently.

Reading the response body before checking the status

The representation may differ by status code. Decide the response path first, then deserialize the expected body.

Migration Checklist

  • [ ] Identify every consumer of the current contract
  • [ ] Separate internal domain models from external DTOs
  • [ ] Classify the change as additive or breaking
  • [ ] Preserve field meanings within an existing version
  • [ ] Introduce a new route for incompatible contracts
  • [ ] Keep old and new versions available during migration
  • [ ] Reuse application services instead of duplicating business rules
  • [ ] Use HTTP verbs that match resource operations
  • [ ] Return explicit success and failure status codes
  • [ ] Validate request models at the API boundary
  • [ ] Require a token on every protected request
  • [ ] Apply authorization policies to controllers or actions
  • [ ] Generate a separate OpenAPI document for each version
  • [ ] Document possible response types and status codes
  • [ ] Protect or cache OpenAPI endpoints in production when required
  • [ ] Use typed HttpClient proxies for .NET consumers
  • [ ] Inspect status codes before deserializing response bodies
  • [ ] Add regression tests for the old contract
  • [ ] Remove the old version only after supported consumers migrate

Conclusion

A public API is not an internal class library that can be refactored whenever the server changes. Its routes, message shapes, status codes, authentication rules, and field meanings form a contract with independently deployed consumers.

Preserve compatible changes inside the current version, introduce a new version for incompatible representations, and run both contracts side by side during migration. ASP.NET Core controllers provide explicit routes and status codes, JWT authorization protects every request, OpenAPI makes each version discoverable, and typed HttpClient proxies keep .NET consumers consistent.

This structure lets the service evolve without forcing every client to upgrade on the server's deployment schedule.

Share:

Comments0

Home Profile Menu Sidebar
Top