Meet The Author

I'm Ethan Jackson, An 25 years old blogger Currently living in London, United Kingdom. I'm a Skilled Blogger, Part Time web Developer And Creating new things as a web Designer.

author

How to Identify Critical Changes Prior to Release using ASP.NET Core API Tests?

Leave a Comment

Even if an API passes every test in its own project, it might still cause problems for the apps that rely on it. An endpoint may begin delivering a new status code, a property name may change, or a response field may become nullable. During development, these modifications can seem insignificant, but they can lead to problems with frontend, mobile, and third-party integrations.


Because of this, evaluating an API entails more than just seeing if its endpoints provide the desired information.

You also need to verify that the API continues to follow the contract consumers rely on.

In ASP.NET Core, you can combine integration tests, response validation, OpenAPI document checks, and contract testing to catch breaking changes before release. This article explains how to build that testing approach using practical C# examples.

What Is an API Breaking Change?

An API breaking change occurs when an existing consumer can no longer use an API in the way it previously could.

For example, imagine an endpoint returns this JSON response:

{
  "id": 101,
  "name": "Laptop",
  "price": 799.99
}

A frontend application expects the name property to display the product title.

Now suppose a developer renames the property to productName:

{
  "id": 101,
  "productName": "Laptop",
  "price": 799.99
}

The API might still return HTTP 200, and its internal tests might pass. However, a consumer expecting name may stop displaying the product correctly.

Breaking changes can affect several parts of an API contract:

  • Routes, HTTP methods, and query parameters.
  • Request and response property names.
  • Data types and nullability.
  • HTTP status codes.
  • Authentication and authorization requirements.
  • Validation rules and error response formats.
  • Pagination behavior and default values.

A good API testing strategy checks these behaviors, not just whether the application starts successfully.

Create an ASP.NET Core API Test Project

For this example, assume an ASP.NET Core application exposes a product endpoint.

A typical solution structure might look like this:

ProductApi/
    Controllers/
    Models/
    Program.cs

ProductApi.Tests/
    ProductApi.Tests.csproj
    ProductEndpointTests.cs

The test project should reference the API project and include a compatible version of Microsoft.AspNetCore.Mvc.Testing, along with a test framework such as xUnit.

The ASP.NET Core integration testing infrastructure provides WebApplicationFactory<TEntryPoint>, which allows tests to start the application in a test host and send HTTP requests without deploying it to a real server.

For applications that use top-level statements, you may need to expose the entry point to the test project. One common approach is to add the following declaration at the end of Program.cs:

public partial class Program
{
}

This makes the generated Program type accessible to the test project when configured appropriately.

Write an Integration Test for an API Endpoint

Integration tests verify how the API behaves when the application processes an actual HTTP request. Unlike isolated unit tests, they can exercise routing, middleware, model binding, serialization, filters, and endpoint logic together.

Consider this response model:

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

The API exposes a route such as GET /api/products/101.

A basic integration test can verify the status code and response data.

using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public sealed class ProductEndpointTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductEndpointTests(
        WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProduct_ReturnsExpectedResponse()
    {
        var response = await _client.GetAsync(
            "/api/products/101");

        Assert.Equal(
            HttpStatusCode.OK,
            response.StatusCode);

        var product =
            await response.Content
                .ReadFromJsonAsync<ProductResponse>();

        Assert.NotNull(product);
        Assert.Equal(101, product.Id);
        Assert.Equal("Laptop", product.Name);
        Assert.Equal(799.99m, product.Price);
    }
}

This test assumes that the test environment provides a product with ID 101 and that the API returns the stated values. In a real project, arrange the required test data or replace the production data source with a test implementation.

The test verifies more than whether the endpoint returns HTTP 200. It also checks whether the response contains the expected values and can be deserialized into the expected model.

However, deserialization alone is not always enough. Depending on serializer settings and model definitions, an unexpected JSON property may be ignored, or a missing property may receive a default value. You should also test the response contract directly when the exact JSON shape matters.

Verify the JSON Response Contract

Suppose a consumer requires the name property to remain part of the response. You can inspect the raw JSON to catch accidental changes.

using System.Net;
using System.Text.Json;
using Xunit;

[Fact]
public async Task GetProduct_PreservesRequiredJsonProperties()
{
    var response = await _client.GetAsync(
        "/api/products/101");

    Assert.Equal(
        HttpStatusCode.OK,
        response.StatusCode);

    using var document = JsonDocument.Parse(
        await response.Content.ReadAsStringAsync());

    var root = document.RootElement;

    Assert.True(
        root.TryGetProperty("id", out var id));

    Assert.True(
        root.TryGetProperty("name", out var name));

    Assert.True(
        root.TryGetProperty("price", out var price));

    Assert.Equal(101, id.GetInt32());
    Assert.Equal("Laptop", name.GetString());
    Assert.Equal(799.99m, price.GetDecimal());
}

This test fails if the name property is renamed or removed. It also verifies the values and expected JSON types.

The JSON property names used by the test must match the API's actual serialization contract. ASP.NET Core applications commonly use camel-case JSON names, but serializer configuration can change that behavior.

When Should You Compare the Entire JSON Response?

Checking individual properties is useful when consumers depend on a small, stable set of fields.

For APIs with strict response contracts, you may also compare the complete JSON structure against an approved fixture or snapshot. This can detect changes to nested objects, property names, and response shapes.

Full-response comparisons need careful maintenance. A harmless addition may fail a strict comparison even when existing consumers remain compatible. Decide whether additional fields are allowed before choosing the test approach.

Test HTTP Status Codes and Error Responses

An endpoint's contract includes its failure behavior. Consumers may depend on receiving 404 Not Found for a missing resource or 400 Bad Request for invalid input.

For example:

[Fact]
public async Task GetUnknownProduct_ReturnsNotFound()
{
    var response = await _client.GetAsync(
        "/api/products/999999");

    Assert.Equal(
        HttpStatusCode.NotFound,
        response.StatusCode);
}

This test assumes the API returns 404 when the requested product does not exist.

You should also test invalid request data. For an endpoint that creates a product, useful scenarios include a missing required name, a negative price, malformed JSON, and an unauthenticated request.

The expected status code depends on the API's documented behavior. For example, an API might use 400 for validation errors and 401 when authentication is missing. Tests should reflect the actual contract rather than impose arbitrary expectations.

For applications that return structured validation errors, verify important fields in the error response as well. Otherwise, a change to the error format may break frontend validation messages even when the status code remains unchanged.

Detect Changes in the OpenAPI Document

Integration tests cover specific scenarios. An OpenAPI document provides a broader description of an API's public contract, including paths, methods, parameters, request bodies, response schemas, and security requirements.

Checking the OpenAPI document can help detect changes that individual endpoint tests do not cover.

For example, suppose an API previously exposed this operation:

paths:
  /api/products/{id}:
    get:
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer

A later change might remove the endpoint, rename its route, or change the parameter type. A contract comparison can identify these differences before release.

Use OpenAPI Contract Checks

A practical approach is to generate the OpenAPI document during the build or from a controlled test environment, then compare it with the version approved for the current API release.

The comparison should identify changes such as:

  • Removed paths or HTTP methods.
  • Removed request or response properties.
  • Changed property types.
  • New required request fields.
  • Changes to authentication requirements.
  • Removed response status codes.

Not every difference is necessarily breaking. Adding an optional response property is often compatible with existing clients, while removing a required field may not be. The comparison tool and review process should account for the rules your API follows.

Also, OpenAPI checks cannot replace runtime tests. A document may describe the correct response schema even when the endpoint returns different data due to a serialization or business-logic problem.

Add Consumer-Driven Contract Testing

Integration tests usually focus on the API provider's expected behavior. Consumer-driven contract testing adds another perspective: it verifies the interactions that actual consumers rely on.

Imagine a product API used by a web application and a mobile application. The web application may depend on the product name and price, while the mobile application may also depend on an image URL.

A consumer-driven contract records the expected request and response for a specific interaction. The provider can then verify that its implementation still satisfies those expectations.

This approach is especially useful when multiple teams maintain separate services and deploy them independently.

Testing approachMain purposeBest use
Unit testsVerify isolated application logicBusiness rules and validation
Integration testsVerify real application request handlingRouting, middleware, serialization, and status codes
JSON contract testsVerify response fields and valuesStable API response shapes
OpenAPI comparisonIdentify changes to the documented APIRelease reviews and compatibility checks
Consumer-driven contract testsVerify interactions required by consumersMicroservices and independently deployed applications

Contract testing does require coordination. Teams need to maintain consumer expectations and decide when a contract change is intentional. Without that process, contract tests can become outdated and block legitimate improvements.

Run API Tests in CI/CD Before Release

Automated tests provide the most value when they run before changes reach production.

Add API tests to your continuous integration pipeline so that a pull request or release candidate cannot proceed when required contract checks fail.

A typical pipeline might follow this sequence:

  1. Restore dependencies and compile the solution.
  2. Run unit tests.
  3. Run integration tests against the test configuration.
  4. Generate or validate the OpenAPI document.
  5. Compare the API contract with the approved baseline.
  6. Publish test results and block the release when required checks fail.

For example, a pipeline can execute the test project with the .NET CLI:

dotnet restore
dotnet build --no-restore
dotnet test --no-build

These commands assume the solution has already been configured to include the relevant test project. The OpenAPI comparison and contract-testing steps must be added separately using the tools selected by your team.

Keep test configuration isolated from production. Use test databases, controlled test data, and test credentials. Avoid making destructive requests against production services as part of automated compatibility checks.

Common Mistakes to Avoid

Testing Only the HTTP Status Code

A successful status code does not guarantee that the response contains the expected properties or values. Validate the response body whenever consumers depend on its structure.

Sharing Production Data with Tests

Tests that depend on changing production records can become unreliable. Create predictable test data or use a controlled test data source.

Ignoring Error Responses

Clients often rely on validation errors, authentication failures, and missing-resource responses. Test these cases alongside successful requests.

Treating Every OpenAPI Difference as Breaking

A new optional property may be compatible with existing clients. Review schema changes based on consumer expectations instead of blocking every difference automatically.

Running Contract Tests Only Before Deployment

Finding a breaking change during a release is more expensive than detecting it during a pull request. Run the essential checks early and repeat them at release time when appropriate.

Best Practices for Reliable API Testing

A reliable strategy combines several testing levels instead of relying on one tool.

  • Define the contract: Document important routes, status codes, request fields, response fields, and authentication requirements.
  • Test consumer-critical behavior: Focus on the fields and interactions that existing applications actually use.
  • Keep tests deterministic: Control test data, environment variables, and external dependencies.
  • Validate error behavior: Treat failure responses as part of the API contract.
  • Review schema changes: Use OpenAPI comparison to identify changes that deserve compatibility review.
  • Test backward compatibility: When supporting existing clients, verify that new versions preserve the interactions those clients require.
  • Make failures actionable: Include the endpoint, expected behavior, and actual result in test failure messages.

Advantages and Limitations

AdvantagesLimitations
Detects incompatible changes before releaseRequires maintenance of tests and contract baselines
Protects frontend and mobile consumersCannot automatically represent every consumer's behavior
Improves confidence in API versioningIntegration tests may need database and service setup
Makes release reviews more consistentStrict snapshots can flag harmless changes
Helps multiple teams coordinate API changesConsumer-driven contracts require team coordination

The right balance depends on the application. A small internal API may need integration tests and a few contract checks. A public API with many independent consumers may benefit from a formal compatibility policy, OpenAPI diffing, and consumer-driven contract testing.

Summary

Breaking API changes often involve small differences that are easy to miss during development. A renamed JSON property, a changed status code, or a new required request field can disrupt applications even when the API still builds and starts correctly.

ASP.NET Core integration tests help verify real HTTP behavior. JSON contract tests protect important response fields, while OpenAPI comparison identifies changes to the documented interface. Consumer-driven contract tests add another layer of protection when multiple applications depend on the same API.

Run these checks in CI/CD, use predictable test data, and review compatibility changes before release. This approach helps teams identify API problems earlier and release changes with greater confidence.

https://hostforlifeasp.net/ 

Read More

How to Prevent Lost Messages in.NET by Using the Transactional Outbox Pattern?

Leave a Comment

Numerous.NET programs must publish an event to a message broker like RabbitMQ, Kafka, or Azure Service Bus and update a database. To enable payment, inventory, and notification services to proceed with the process, an order service can, for instance, save a new order in SQL Server and broadcast an OrderCreated event.

The issue is that a message broker operation and a database transaction are often distinct processes.

 

If the database update succeeds but message publishing fails, the order is saved while other services never receive the event. If the application publishes the message first and the database transaction later fails, other services may receive an event for an order that does not exist.

The Transactional Outbox Pattern helps solve this problem by saving the business data and the event record in the same database transaction. A separate background process publishes the saved event to the message broker.

In this article, you will learn how the pattern works, how to implement it in ASP.NET Core with Entity Framework Core, how to handle publishing failures, and what you need to consider before using it in production.

What Is the Transactional Outbox Pattern?

The Transactional Outbox Pattern stores messages in an outbox table in the same database as the application's business data.

Instead of publishing an event immediately after saving an order, the application performs two database operations in one transaction:

  1. Save the order.

  2. Save the event in the outbox table.

If the transaction commits, both records are saved. If it rolls back, neither record is saved.

A background worker then reads unpublished outbox records and sends them to the message broker.

The basic flow looks like this:

Client Request
      |
      v
.NET Application
      |
      v
Database Transaction
   |             |
   v             v
Save Order   Save Outbox Event
   |             |
   +------+------+
          |
          v
    Commit Transaction
          |
          v
    Outbox Background Worker
          |
          v
    Message Broker
          |
          v
    Other Services

The important point is that the business update and the event record are committed atomically within the database. Message publishing happens separately, so a broker failure does not erase the event that needs to be published.

Why Direct Message Publishing Can Fail

Consider an ASP.NET Core endpoint that creates an order.

A simplified implementation might look like this:

public async Task CreateOrderAsync(
    Order order,
    CancellationToken cancellationToken)
{
    _dbContext.Orders.Add(order);

    await _dbContext.SaveChangesAsync(cancellationToken);

    await _messagePublisher.PublishAsync(
        new OrderCreated(order.Id),
        cancellationToken);
}

This code saves the order first and publishes the event afterward.

Suppose the database operation succeeds, but the message broker becomes unavailable before the event is published.

The order exists in the database, but the downstream services do not know about it. Unless the application has another recovery mechanism, the event may be lost permanently.

Reversing the order does not solve the problem:

await _messagePublisher.PublishAsync(
    new OrderCreated(order.Id),
    cancellationToken);

_dbContext.Orders.Add(order);

await _dbContext.SaveChangesAsync(cancellationToken);

If the database transaction fails, the event may already have been published.

These failures occur because the database and message broker do not share one atomic transaction.

The outbox pattern avoids this particular consistency gap by recording the event alongside the business change.

Step 1: Create the Outbox Entity

Start by creating an entity that stores messages waiting to be published.

public sealed class OutboxMessage
{
    public Guid Id { get; set; }

    public string Type { get; set; } = string.Empty;

    public string Payload { get; set; } = string.Empty;

    public DateTime OccurredOnUtc { get; set; }

    public DateTime? ProcessedOnUtc { get; set; }

    public string? Error { get; set; }
}

Each property has a specific purpose:

  • Id uniquely identifies the outbox message.

  • Type identifies the event type, such as OrderCreated.

  • Payload stores the serialized event data.

  • OccurredOnUtc records when the event was created.

  • ProcessedOnUtc indicates that publishing was completed according to the application's processing rules.

  • Error can store information about the most recent publishing failure.

The processing fields are a starting point. Production systems may also need retry counts, a next-attempt timestamp, a lock or lease expiry, and a dedicated dead-letter or failed-message state.

Step 2: Configure the Outbox Table with Entity Framework Core

Add the outbox entity to your application's DbContext.

public sealed class AppDbContext : DbContext
{
    public AppDbContext(
        DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();

    public DbSet<OutboxMessage> OutboxMessages =>
        Set<OutboxMessage>();

    protected override void OnModelCreating(
        ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<OutboxMessage>(entity =>
        {
            entity.HasKey(x => x.Id);

            entity.Property(x => x.Type)
                .HasMaxLength(200)
                .IsRequired();

            entity.Property(x => x.Payload)
                .IsRequired();

            entity.HasIndex(x => x.ProcessedOnUtc);
        });
    }
}

The index on ProcessedOnUtc can help queries that search for unprocessed messages, although the best index depends on the database provider and the exact query.

For a production system, review the generated database schema and query plans. A large outbox table may need a more suitable composite or filtered index, depending on the database.

After configuring the entity, create and apply an Entity Framework Core migration using your project's normal migration workflow.

Step 3: Save the Order and Event in One Transaction

Now update the order-creation logic so it saves both records together.

First, define the event:

public sealed record OrderCreated(
    Guid OrderId,
    DateTime OccurredOnUtc);

Then create the order and its outbox message:

using System.Text.Json;
using Microsoft.EntityFrameworkCore;

public sealed class OrderService
{
    private readonly AppDbContext _dbContext;

    public OrderService(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Guid> CreateOrderAsync(
        Order order,
        CancellationToken cancellationToken)
    {
        var orderId = order.Id;
        var occurredOnUtc = DateTime.UtcNow;

        var orderCreated = new OrderCreated(
            orderId,
            occurredOnUtc);

        var outboxMessage = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = nameof(OrderCreated),
            Payload = JsonSerializer.Serialize(orderCreated),
            OccurredOnUtc = occurredOnUtc
        };

        await using var transaction =
            await _dbContext.Database.BeginTransactionAsync(
                cancellationToken);

        _dbContext.Orders.Add(order);
        _dbContext.OutboxMessages.Add(outboxMessage);

        await _dbContext.SaveChangesAsync(cancellationToken);

        await transaction.CommitAsync(cancellationToken);

        return orderId;
    }
}

Both entities use the same DbContext and database transaction.

If saving either entity fails, the transaction does not commit. If the transaction commits successfully, the order and outbox message are stored together.

EF Core normally wraps a single SaveChangesAsync call in a transaction when the database provider supports transactions. The explicit transaction here makes the intended atomic boundary clear and also leaves room for additional database operations.

The message is not published inside this transaction. Keeping broker communication outside the database transaction avoids holding database locks while waiting for a network operation.

Step 4: Create a Background Worker to Publish Messages

A background worker can periodically find pending outbox messages and publish them.

Define a publisher abstraction for your chosen broker:

public interface IMessagePublisher
{
    Task PublishAsync(
        string messageType,
        string payload,
        Guid messageId,
        CancellationToken cancellationToken);
}

The implementation can use RabbitMQ, Kafka, Azure Service Bus, or another supported message broker.

Next, create a worker that reads pending records.

The following example illustrates the core publishing flow. It assumes a single worker and a publisher implementation that returns successfully only after the broker has accepted the message according to the configured acknowledgement policy.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public sealed class OutboxWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OutboxWorker> _logger;

    public OutboxWorker(
        IServiceScopeFactory scopeFactory,
        ILogger<OutboxWorker> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await ProcessPendingMessagesAsync(
                    stoppingToken);
            }
            catch (OperationCanceledException)
                when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(
                    ex,
                    "Error while processing outbox messages.");
            }

            await Task.Delay(
                TimeSpan.FromSeconds(5),
                stoppingToken);
        }
    }

    private async Task ProcessPendingMessagesAsync(
        CancellationToken cancellationToken)
    {
        using var scope = _scopeFactory.CreateScope();

        var dbContext = scope.ServiceProvider
            .GetRequiredService<AppDbContext>();

        var publisher = scope.ServiceProvider
            .GetRequiredService<IMessagePublisher>();

        var messages = await dbContext.OutboxMessages
            .Where(x => x.ProcessedOnUtc == null)
            .OrderBy(x => x.OccurredOnUtc)
            .Take(50)
            .ToListAsync(cancellationToken);

        foreach (var message in messages)
        {
            try
            {
                await publisher.PublishAsync(
                    message.Type,
                    message.Payload,
                    message.Id,
                    cancellationToken);

                message.ProcessedOnUtc = DateTime.UtcNow;
                message.Error = null;

                await dbContext.SaveChangesAsync(
                    cancellationToken);
            }
            catch (OperationCanceledException)
                when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception ex)
            {
                message.Error = ex.Message;

                await dbContext.SaveChangesAsync(
                    cancellationToken);

                _logger.LogWarning(
                    ex,
                    "Failed to publish outbox message {MessageId}.",
                    message.Id);
            }
        }
    }
}

This example demonstrates the basic process:

  1. Create a dependency injection scope.

  2. Load a limited batch of pending messages.

  3. Publish each message.

  4. Mark the message as processed after successful publishing.

  5. Record failures so they can be investigated.

The worker creates a scope because DbContext is normally registered as a scoped service and should not be injected directly into a long-lived hosted service.

However, this implementation is a starting point, not a complete multi-instance production dispatcher. It does not implement atomic message claiming, leases, exponential backoff, or concurrency control. Those protections become important when multiple worker instances may process the same outbox table.

Also, saving Error after a failure does not by itself provide a retry policy. A production implementation should track attempts and schedule retries deliberately.

Step 5: Handle Duplicate Messages Correctly

The outbox pattern prevents the database change and event record from becoming inconsistent, but it does not automatically guarantee exactly-once delivery.

Consider this sequence:

  1. The worker publishes an event successfully.

  2. The message broker accepts it.

  3. The worker crashes before setting ProcessedOnUtc.

  4. The worker restarts and publishes the same event again.

The event is delivered twice because the database still considers it pending.

This is an expected failure scenario in many outbox implementations. The usual solution is to make message processing idempotent.

The stable outbox message ID can serve as an idempotency key. A consumer can store processed message IDs and reject duplicate processing within the same database transaction as its business update.

For example, a consumer might maintain a table like this:

CREATE TABLE ProcessedMessages
(
    MessageId UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
    ProcessedOnUtc DATETIME2 NOT NULL
);

This SQL uses SQL Server data types. PostgreSQL and other databases require provider-specific types.

When processing a message, the consumer should record its ID and perform its business update atomically. If the ID already exists, the consumer can skip the repeated business operation.

A simple check followed by a separate insert is not sufficient under concurrent processing unless the database constraint and transaction handling protect against races.

Step 6: Register the Worker

Register the publisher and hosted service in the application's dependency injection configuration.

builder.Services.AddScoped<IMessagePublisher, MessagePublisher>();

builder.Services.AddHostedService<OutboxWorker>();

MessagePublisher represents your broker-specific implementation; it must be implemented and configured for the chosen messaging system.

The worker and request handlers can now use the same outbox table.

For production deployments, ensure the worker has appropriate broker credentials, database permissions, logging, health checks, and shutdown behavior.

Important Production Considerations

Multiple Worker Instances

If several application instances run the same worker, they may load the same pending messages and publish duplicates.

Use a reliable claiming strategy, such as atomic row claiming or a lease with an expiry. The exact implementation depends on the database provider. In PostgreSQL, for example, FOR UPDATE SKIP LOCKED can help multiple workers claim different rows inside suitable transactions.

Do not hold database transactions open while waiting for the broker. Claim a batch, commit the claim, publish the messages, and then record the outcome using a design that safely handles expired claims and worker crashes.

Retry and Poison Messages

Temporary broker failures should usually trigger retries with a delay, often using exponential backoff and a maximum attempt policy.

A message that repeatedly fails because of invalid data should not block the entire outbox indefinitely. Define a policy for isolating failed messages, alerting operators, and safely replaying them after the underlying issue is fixed.

Ordering Requirements

If downstream consumers depend on event order, publishing messages purely by creation time may not be sufficient when several workers operate concurrently.

Define the ordering requirement explicitly. You may need to preserve order per aggregate, such as per order ID, while still allowing independent orders to be processed in parallel.

Outbox Table Growth

Processed messages should not remain in the table forever without a retention plan.

Archive or delete old processed records according to operational and audit requirements. Ensure cleanup does not remove pending or actively claimed messages, and monitor the outbox table's size and query performance.

Monitoring

Track pending message count, oldest pending message age, publishing failures, retry counts, and end-to-end delivery latency.

A growing number of pending messages may indicate that the broker is unavailable, the worker is unhealthy, or publishing capacity is insufficient.

Common Mistakes to Avoid

  • Publishing before the database transaction commits: Downstream services may receive an event for a business operation that later rolls back.

  • Saving the business record and outbox record separately: A failure between the operations can recreate the original consistency problem.

  • Assuming exactly-once delivery: A crash after publishing but before recording success can cause duplicates.

  • Running multiple workers without coordination: Several instances may publish the same pending event.

  • Ignoring retry limits: Permanently failing messages can remain pending indefinitely.

  • Keeping the outbox table forever: Large tables can increase maintenance and query costs.

  • Ignoring event versioning: Consumers may break when event payload structures change without a compatibility strategy.

Conclusion

The Transactional Outbox Pattern provides a reliable way to coordinate database changes and event publishing in .NET applications. By saving the business record and its event in the same database transaction, you avoid losing the event merely because the message broker becomes unavailable after the database commit.

A background worker can publish stored events when the broker is available again. However, a production implementation must also handle duplicate delivery, concurrent workers, retries, message ordering, and outbox cleanup.

Start with a clear transactional boundary, then build the publishing process around measurable reliability requirements. When combined with idempotent consumers and proper monitoring, the outbox pattern helps distributed services remain consistent even when individual components fail.

Windows Hosting Recommendation

HostForLIFE.eu receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2025, SQL Server 2025, ASP.NET Core 11, ASP.NET MVC 6.0, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More

Including Timeouts and Retries in Outbound ASP.NET Fundamental Requests

Leave a Comment

An ASP.NET Core application often depends on external services to complete a request. It might call a payment gateway, retrieve customer information from another API, or send data to a third-party service. When these dependencies become slow or temporarily unavailable, the application's response time and reliability can suffer.

A common mistake is to send an HTTP request and wait for it to finish without defining how long the application should wait or what should happen when the request fails.

Retries and timeouts help address these problems, but they must be configured carefully. Retrying every failure can overload an already struggling service, while excessively long timeouts can consume application resources. Retrying a non-idempotent operation can also create duplicate transactions.

ASP.NET Core provides IHttpClientFactory for managing HTTP clients, while the standard resilience libraries provide policies for timeouts, retries, and other transient failures.

This article explains how to configure outbound HTTP requests, apply retries and timeouts, handle common failures, and choose settings suitable for production applications.

Why Outbound HTTP Requests Need Resilience

An HTTP request can fail for several reasons:

  • The remote service is temporarily unavailable.

  • A network connection is interrupted.

  • The remote service responds more slowly than expected.

  • The service returns a temporary error such as HTTP 503.

  • The application reaches a configured timeout.

  • The remote service rejects the request because of authentication or validation errors.

Not all failures should trigger a retry. A temporary service outage might justify another attempt, but an HTTP 400 response usually indicates invalid input that will not become valid by repeating the request.

A resilient HTTP client needs to distinguish transient failures from permanent ones and place a limit on the time and number of attempts.

Configure IHttpClientFactory

IHttpClientFactory provides a central place to configure HTTP clients, manage their underlying handlers, and apply logging and resilience policies.

Install the standard HTTP resilience package:

dotnet add package Microsoft.Extensions.Http.Resilience

This package provides resilience integration for HttpClient, including support for retry and timeout strategies.

Next, register a named client in Program.cs.

using Microsoft.Extensions.Http.Resilience;
using Polly;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddHttpClient("CatalogApi", client =>
    {
        client.BaseAddress =
            new Uri("https://catalog.example.internal/");

        client.Timeout = Timeout.InfiniteTimeSpan;
    })
    .AddStandardResilienceHandler(options =>
    {
        options.TotalRequestTimeout.Timeout =
            TimeSpan.FromSeconds(10);

        options.AttemptTimeout.Timeout =
            TimeSpan.FromSeconds(3);

        options.Retry.MaxRetryAttempts = 2;

        options.Retry.Delay =
            TimeSpan.FromMilliseconds(300);

        options.Retry.BackoffType =
            DelayBackoffType.Exponential;

        options.Retry.UseJitter = true;
    });

var app = builder.Build();

app.MapGet("/", () => "API is running.");

app.Run();

The example configures a client for a fictional catalog service. Replace the base address with the actual service endpoint.

The important settings are:

  • AddHttpClient registers a client managed by the factory.

  • Timeout.InfiniteTimeSpan disables the HttpClient-level timeout so that the resilience handler controls the request deadline.

  • TotalRequestTimeout limits the overall time spent on the outbound operation, including retry attempts and their delays.

  • AttemptTimeout limits an individual attempt.

  • MaxRetryAttempts = 2 allows up to two retries after the initial attempt.

  • Exponential backoff increases the delay between retries.

  • Jitter adds variation to retry delays, reducing the chance that many clients retry simultaneously.

The total timeout must be large enough to accommodate the attempt timeouts and retry delays. The outer deadline still takes precedence if the operation takes too long.

The standard resilience handler also includes other strategies, such as a circuit breaker and rate limiting. Review the defaults and adjust them to the dependency's behavior rather than assuming every service needs identical settings.

Understand How Retry and Timeout Policies Work

A retry policy repeats an operation after a qualifying failure. A timeout policy limits how long an operation can continue.

These strategies solve different problems.

Strategy

Purpose

Example

Attempt timeout

Limits the duration of one attempt

Stop waiting after three seconds

Total request timeout

Limits the entire operation

End the operation after ten seconds

Retry

Repeats eligible transient failures

Retry a temporary HTTP 503 response

Circuit breaker

Temporarily blocks calls to an unhealthy dependency

Stop sending requests after repeated failures

Rate limiter

Limits concurrent or incoming operations

Prevent excessive outbound concurrency

Retries do not make a remote service available. They provide another opportunity for a temporary failure to recover. If the dependency remains unhealthy, repeated attempts can increase load and make the problem worse.

Use the Configured Client in an ASP.NET Core Service

Once the client is registered, inject IHttpClientFactory into the service that calls the remote API.

using System.Net;
using System.Net.Http.Json;

public sealed class CatalogService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public CatalogService(
        IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task<ProductDto?> GetProductAsync(
        int productId,
        CancellationToken cancellationToken = default)
    {
        if (productId <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(productId));
        }

        var client =
            _httpClientFactory.CreateClient("CatalogApi");

        using var response = await client.GetAsync(
            $"api/products/{productId}",
            cancellationToken);

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

        response.EnsureSuccessStatusCode();

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

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

The service uses the named client so that all requests inherit its configured resilience behavior.

The cancellation token allows the calling operation to stop waiting when its work is no longer needed. For example, an incoming HTTP request may be cancelled because the client disconnected.

The code treats HTTP 404 as a missing product rather than an exceptional failure. Other unsuccessful status codes are passed to EnsureSuccessStatusCode, which raises an exception.

The resilience handler can retry eligible failures before the service receives the final response. The application should still handle the final failure appropriately.

Which HTTP Failures Should Be Retried?

A retry policy should focus on failures that may resolve without changing the request.

Failure

Usually retry?

Reason

HTTP 400 Bad Request

No

The request is usually invalid

HTTP 401 Unauthorized

No

Credentials or authentication must be corrected

HTTP 404 Not Found

No

The requested resource may not exist

HTTP 408 Request Timeout

Sometimes

The server may have timed out processing the request

HTTP 429 Too Many Requests

Sometimes

Respect the server's retry guidance and rate limits

HTTP 500 Internal Server Error

Depends

The error may be transient or caused by a persistent defect

HTTP 502 Bad Gateway

Often

A gateway may be experiencing a temporary upstream failure

HTTP 503 Service Unavailable

Often

The service may be temporarily unavailable

Network interruption

Often

A subsequent attempt may succeed

The standard resilience handler uses predefined behavior for eligible HTTP responses and transient failures. Confirm the installed package's defaults and configure the retryable status codes and exceptions when the dependency requires a different policy.

For HTTP 429 responses, respect Retry-After when provided and supported by the configured strategy. Avoid retrying faster than the server permits.

Common Mistakes When Configuring Retries

Retrying operations that are not idempotent

Consider an API that charges a customer's payment method. The first request may reach the payment provider and complete successfully, but the response may be lost because of a network interruption.

If the application blindly sends the same charge request again, the customer could be charged twice.

For payment creation, order creation, and similar operations, use the provider's idempotency mechanism when available. Reuse the same idempotency key for retries of the same logical operation.

Do not assume that a POST request is safe to retry merely because the HTTP client can repeat it.

Setting an excessive timeout

A long timeout can keep requests waiting while consuming sockets, memory, and other resources. Under heavy traffic, slow dependencies can contribute to thread-pool pressure and growing request queues.

Choose timeouts based on the application's response-time objective and the dependency's expected behavior.

Retrying every exception

Authentication failures, invalid requests, and programming errors usually require a different response than a temporary network failure.

Keep retry conditions narrow and avoid repeating operations that cannot succeed without a change in input or configuration.

Ignoring cancellation

Do not remove cancellation support from application services. Propagate the caller's CancellationToken through outbound operations so that cancelled work does not continue unnecessarily.

Best Practices for Production Applications

Set an end-to-end deadline

Choose a total outbound timeout that fits within the incoming request's overall deadline. If the application has only a limited time to respond, it cannot spend that entire period independently on every downstream service.

For workflows involving multiple services, allocate time budgets to each dependency rather than allowing every call to consume the full request deadline.

Use bounded retries with backoff

Keep the retry count small and increase delays between attempts when appropriate. Jitter helps distribute retry traffic over time.

Avoid stacking multiple independent retry policies without calculating their combined effect. If several layers retry the same operation, the total number of downstream attempts can grow rapidly.

Monitor the dependency

Track request duration, timeout frequency, HTTP status codes, retry counts, and final failure rates. These measurements help determine whether the problem comes from the application, the network, or the downstream service.

Avoid recording authorization headers, access tokens, or sensitive request and response bodies in logs.

Test failure scenarios

Use a test server or controlled mock to simulate slow responses, HTTP 503 responses, connection failures, and successful responses after a transient error.

Verify that the configured number of attempts is respected, the overall deadline is enforced, and cancellation stops unnecessary work.

Advantages and Disadvantages

Advantages

Disadvantages

Transient failures can recover without immediate application failure.

Retries increase request volume and may add latency.

Timeouts prevent requests from waiting indefinitely.

Aggressive timeouts can fail legitimate slow operations.

Centralized configuration improves consistency.

Shared defaults may not suit every downstream service.

Backoff and jitter help reduce synchronized retry traffic.

Poorly coordinated policies can amplify outages.

Metrics make dependency failures easier to investigate.

Monitoring and testing add implementation effort.

Summary

Retries and timeouts are important parts of reliable outbound HTTP communication in ASP.NET Core. IHttpClientFactory provides a centralized way to configure clients, while Microsoft.Extensions.Http.Resilience supplies strategies for controlling request duration and handling transient failures.

Start with a clear total request deadline, a shorter per-attempt timeout, and a small retry count. Use backoff and jitter, respect server rate limits, propagate cancellation, and monitor downstream failures.

Most importantly, retry only when another attempt is appropriate. For operations that can create payments, orders, or other persistent changes, use idempotency protections before enabling automatic retries. A carefully configured client can improve resilience without turning a temporary dependency problem into a larger outage.

Windows Hosting Recommendation

HostForLIFE.eu receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2025, SQL Server 2025, ASP.NET Core 11, ASP.NET MVC 6.0, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More

ASP.NET Core's Secure Password Hashing and Verification

Leave a Comment

Security is crucial when developing any online application that manages user accounts. One of the most serious flaws in a system is the storage of passwords in plain text. Plain-text passwords instantaneously reveal every user in the event that your database is hacked.

 

Because they are too quick to compute, even common hashing methods like MD5 or SHA-256 are no longer adequate for passwords, leaving them open to rainbow table and brute-force assaults.

Fortunately, ASP.NET Core provides a robust, built-in mechanism via the PasswordHasher<TUser> class from the Microsoft.AspNetCore.Identity namespace. It implements PBKDF2 (Password-Based Key Derivation Function 2) with a cryptographically secure random salt, iteration counts, and constant-time comparison out of the box.

In this comprehensive guide, we will explore how to implement secure password hashing and verification in your ASP.NET Core application.

Why Standard Hashing Falls Short (And Why Salting Matters)

A hash is a one-way mathematical function that converts data into a fixed-size string. However, if two users have the same password (e.g., "Password123!"), a basic hash will produce the exact same output. Attackers use precomputed tables of hashes (rainbow tables) to crack millions of passwords in seconds.

Salting solves this by appending a unique, random string (the salt) to every user's password before it is hashed. Because every salt is unique, two identical passwords will result in completely different stored hashes, rendering rainbow tables useless.

Step 1: Create a Dedicated Password Service

To keep your controllers clean and follow the Single Responsibility Principle, it is best practice to encapsulate password hashing and verification inside a dedicated service.

Create a class named PasswordService.cs:

using Microsoft.AspNetCore.Identity;

namespace YourNamespace.Services
{
    public class PasswordService
    {
        private readonly PasswordHasher<object> _passwordHasher = new();

        /// <summary>
        /// Hashes a plain-text password using PBKDF2 with a unique random salt.
        /// </summary>
        public string HashPassword(string password)
        {
            // Pass null for the user object since we are managing hashes independently of EF Core Identity
            return _passwordHasher.HashPassword(null!, password);
        }

        /// <summary>
        /// Verifies a plain-text password against a stored secure hash.
        /// </summary>
        public bool VerifyPassword(string hashedPassword, string providedPassword)
        {
            var result = _passwordHasher.VerifyHashedPassword(null!, hashedPassword, providedPassword);
            
            // Returns Success or SuccessRehashNeeded (if the hashing algorithm parameters were upgraded)
            return result == PasswordVerificationResult.Success || 
                   result == PasswordVerificationResult.SuccessRehashNeeded;
        }
    }
}

Step 2: Register the Service in Dependency Injection

Register your PasswordService in Program.cs so it can be injected into your controllers or API endpoints wherever registration and authentication occur.

builder.Services.AddScoped<PasswordService>();

Step 3: Implement Hashing During User Registration

When a new user registers, you must take their plain-text password from the request body, pass it through your PasswordService, and store only the resulting hash in your database. Never store the plain text.

[HttpPost("register")]
public IActionResult Register([FromBody] RegisterModel model, [FromServices] PasswordService passwordService)
{
    // 1. Hash the user's password securely
    var hashedPassword = passwordService.HashPassword(model.Password);

    // 2. Save the user and the hashedPassword to your database
    // var user = new User { Username = model.Username, PasswordHash = hashedPassword };
    // _dbContext.Users.Add(user);
    // _dbContext.SaveChanges();

    return Ok(new { message = "User registered successfully!" });
}

Step 4: Implement Verification During Login

When a user attempts to log in, fetch their user record from the database using their username, retrieve their stored password hash, and use the PasswordService to verify the incoming password.

[HttpPost("login")]
public IActionResult Login([FromBody] LoginModel model, [FromServices] PasswordService passwordService)
{
    // 1. Retrieve the user from your database
    // var user = _dbContext.Users.FirstOrDefault(u => u.Username == model.Username);
    // if (user == null) return Unauthorized(new { message = "Invalid credentials." });

    // (Mock retrieved hash for demonstration purposes)
    var storedPasswordHash = "AQAAAAIAAYagAAAAEP..."; 

    // 2. Verify the provided password against the stored hash
    bool isPasswordValid = passwordService.VerifyPassword(storedPasswordHash, model.Password);

    if (!isPasswordValid)
    {
        return Unauthorized(new { message = "Invalid username or password." });
    }

    // 3. Credentials are valid — generate and return your JWT token here
    return Ok(new { message = "Login successful! Token generated." });
}

Key Security Features of ASP.NET Core's PasswordHasher

  • Automatic Salting: Every password hash generated includes a cryptographically secure random salt embedded directly within the returned hash string.

  • Timing Attack Protection: The VerifyHashedPassword method compares hashes in constant time, preventing attackers from timing how long the comparison takes to guess character patterns.

  • Future-Proofing (SuccessRehashNeeded): The hash string contains a version header. If computational standards evolve and Microsoft updates the underlying algorithm in a future framework version, ASP.NET Core can recognize older secure hashes and automatically prompt a rehash when the user successfully logs in next.

Windows Hosting Recommendation

HostForLIFE.eu receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2022 R2, SQL Server 2022, ASP.NET Core 10.0 , ASP.NET MVC, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/

 

Read More

Eliminate Slow AI Calls in.NET Without Disguising the Reason

Leave a Comment

After waiting too long, a user dismisses an AI request. "Model timeout" is recorded in a background log. Even if some of the requests terminated because the caller had already departed, the team later increases the timeout. Two Infrastructure Infrastructure Infrastructure Infrastructure Infrastructure Infrastructure Infrastructure Infrastructure.

Keep caller cancellation and the application's time budget as separate signals, link them for the AI operation, and classify the result from the original signals. This lets a .NET application request cancellation through one token while preserving a useful local explanation of why it stopped waiting.

The example below uses a cooperative fake model call. It does not contact an AI provider, cancel remote billing, or prove that a provider stopped generation. Those are separate behaviors that must be verified for the actual integration.

What does cancellation mean in .NET?

.NET uses cooperative cancellation: a caller signals a token and participating operations observe it. Passing a token to an operation matters only when that operation responds appropriately.

This distinction becomes important with AI clients. A token may stop a local HTTP wait while the remote system continues working. A wrapper cannot establish what the provider did after the connection was interrupted. Record the remote outcome as unknown unless the provider exposes a documented way to determine it.

An application also needs to distinguish a request that its user canceled from one that exceeded its own waiting budget. Neither event, by itself, proves that the model produced an incorrect answer.

Set up the demonstration

Use the .NET 10 SDK and a console project. No external package or API credential is required:

dotnet new console --framework net10.0 --name AiCancellationDemo
cd AiCancellationDemo

Replace Program.cs with the following code. Code status: illustrative and untested. It has not been compiled in this drafting environment; the expected results below must be verified on the stated SDK.

using System;
using System.Threading;
using System.Threading.Tasks;

Console.WriteLine((await RunAsync(
    token => FakeModelAsync(10, token),
    TimeSpan.FromSeconds(5),
    CancellationToken.None)).Status);

Console.WriteLine((await RunAsync(
    token => FakeModelAsync(10_000, token),
    TimeSpan.FromMilliseconds(25),
    CancellationToken.None)).Status);

using var caller = new CancellationTokenSource();
caller.Cancel();

Console.WriteLine((await RunAsync(
    token => FakeModelAsync(10, token),
    TimeSpan.FromSeconds(5),
    caller.Token)).Status);

static async Task<string> FakeModelAsync(
    int delayMs,
    CancellationToken token)
{
    await Task.Delay(delayMs, token);
    return "Synthetic AI answer";
}

static async Task<CallOutcome> RunAsync(
    Func<CancellationToken, Task<string>> invoke,
    TimeSpan budget,
    CancellationToken callerToken)
{
    ArgumentNullException.ThrowIfNull(invoke);

    if (budget <= TimeSpan.Zero)
        throw new ArgumentOutOfRangeException(nameof(budget));

    using var deadline = new CancellationTokenSource();
    deadline.CancelAfter(budget);

    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        callerToken,
        deadline.Token);

    try
    {
        linked.Token.ThrowIfCancellationRequested();

        string answer = await invoke(linked.Token);

        linked.Token.ThrowIfCancellationRequested();

        return new CallOutcome(
            CallStatus.Completed,
            answer);
    }
    catch (OperationCanceledException)
        when (callerToken.IsCancellationRequested)
    {
        return new CallOutcome(
            CallStatus.CallerCancelled,
            null);
    }
    catch (OperationCanceledException)
        when (deadline.IsCancellationRequested)
    {
        return new CallOutcome(
            CallStatus.DeadlineExceeded,
            null);
    }
}

enum CallStatus
{
    Completed,
    CallerCancelled,
    DeadlineExceeded
}

record CallOutcome(CallStatus Status, string? Answer);

The first case gives a short synthetic operation a generous budget. The second deliberately waits longer than its budget. The third uses an already-canceled caller token, making it clear that the wrapper should not start useful work.

CallOutcome separates a successful answer from cancellation status. It avoids passing a partial or missing answer onward as a completed result. An application can map that outcome to its own user interface or job state without rewriting the cancellation logic.

Why use two token sources?

The deadline source owns the application's local time budget. The caller token belongs to the request or job owner. CreateLinkedTokenSource produces a token that is signaled when either source is signaled.

The wrapper passes that linked token to the operation. It also checks before and after awaiting the result. The first check avoids starting an operation after cancellation is already known. The second prevents a late result from being labeled successful when cancellation was signaled during the wait but the operation returned normally.

The two catch filters inspect the original signals. Caller cancellation is checked first, so it wins if both are already signaled when the exception is handled. That is an explicit classification policy, not a precise reconstruction of which event happened first.

For a technical explanation prepared by Ranknod, that local-versus-remote distinction should stay visible: a canceled wait is not evidence of a canceled inference.

An OperationCanceledException with neither original source signaled is allowed to propagate. The wrapper does not relabel an unexplained provider or adapter cancellation as an application deadline. Other faults also propagate for the caller to handle.

What should the program print?

Run dotnet run. Under normal scheduling, the expected output is:

Completed
DeadlineExceeded
CallerCancelled

These are expected demonstration outcomes, not observed results from this draft. The timing case depends on the scheduler, so a serious test suite should avoid using a tiny wall-clock race as its main proof.

Add a controllable fake operation to test four boundaries: success before either signal, an already-canceled caller, a deadline while the operation is pending, and an unrelated exception. For the simultaneous-signal case, assert the documented caller-first policy rather than guessing which cancellation “really” won.

Also test an operation that ignores the token. The wrapper will keep awaiting it. If it eventually returns after cancellation, the post-await check classifies the canceled result. If it never returns, this implementation never regains control. That limitation is essential to the design.

Is this a hard timeout?

No. A cancellation request is cooperative. This wrapper does not forcibly terminate an arbitrary operation at an exact elapsed time. If the caller must stop waiting independently of cooperation, design that waiting policy separately, including how the still-running task is observed and cleaned up.

Avoid disposing resources that an abandoned operation still needs without an explicit ownership strategy. Avoid treating “we stopped awaiting” as “nothing else can happen.” Both assumptions can create difficult background failures.

For a real AI SDK, inspect which methods accept a cancellation token and test the exact path you use, including streaming if relevant. Where an HTTP client's own timeout is involved, document how its exception is distinguished from the application budget. Do not rely on a generic catch block to infer that distinction from an error message.

What belongs in the operational record?

Record a request identifier, the local outcome, elapsed time, configured budget, and any permitted provider request identifier. Keep raw prompts, credentials, and private retrieved passages out of general-purpose logs.

A request that times out locally may require provider-specific reconciliation before a retry. A user who deliberately canceled may not want a retry at all. Those are product decisions that should follow a clear outcome rather than be hidden inside this wrapper.

Useful cancellation handling gives the next component enough information to act honestly. The user can leave, the application can enforce its waiting policy, and the operator can investigate the right cause without turning every interrupted AI request into the same incident.

Summary

Caller cancellation and an application's own time budget represent different signals and should be tracked separately. By linking the cancellation tokens for the operation while retaining the original sources for classification, a .NET application can distinguish completed requests, caller cancellations, and local deadline expirations without incorrectly assuming what happened on the remote AI provider.

Windows & ASP.NET Core 11 Hosting Recommendation

HostForLIFEASP.NET receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2022 R2, SQL Server 2022, ASP.NET Core 11, ASP.NET MVC, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month, HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More

Quicker string.Split in .NET 11: What Changed?

Leave a Comment

string.Split is one of those .NET APIs that most developers use without thinking much about its performance. 

The code is simple:

string[] parts = value.Split(',');

For configuration values, CSV-like input, command-line data, log entries, and other small strings, this is usually perfectly fine.

The situation changes when Split is used repeatedly on large amounts of data. The method can create multiple string objects and an array to hold the results. If this happens thousands or millions of times, allocation and processing costs can become noticeable.

Modern .NET has continued to improve common string-processing operations, including the implementation behind string.Split. These improvements can make splitting strings more efficient for supported scenarios.

The important thing to understand is that a faster implementation does not change the basic behavior of string.Split. It still creates the results requested by the API, and applications that generate large numbers of substrings can still create allocation pressure.

This article looks at how string.Split works, where .NET 11 improvements can help, what developers should watch for, and when another parsing approach makes more sense.

How string.Split Works

Consider this simple example:

string value = "C#,ASP.NET,SQL,Hostforlife";

string[] technologies = value.Split(',');

The result contains:

C#
ASP.NET
SQL
Hostforlife

The returned value is an array containing the individual parts.

For a small string, the cost is generally insignificant.

The potential problem appears when this operation is performed repeatedly.

For example:

foreach (string line in lines)
{
    string[] columns = line.Split(',');
    
    Process(columns);
}

If lines contains a large number of records, every call to Split can produce new result objects.

That means there are two separate things to think about:

  1. How quickly the runtime performs the split.

  2. How much memory the application allocates for the result.

Both can affect performance.

Why string.Split Performance Matters

Suppose an application processes log entries:

foreach (string line in logLines)
{
    string[] values = line.Split('|');

    ProcessLog(values);
}

If there are only a few hundred lines, this is unlikely to be a concern.

Now imagine a background service processing a large stream of records continuously.

The same operation becomes part of a hot path.

The application may then spend CPU time scanning strings and allocating arrays and substring objects.

The garbage collector eventually needs to clean up those temporary objects.

A faster Split implementation can reduce the CPU portion of this work, but it does not make allocations disappear.

What Has Changed in Modern .NET?

The .NET runtime has received many performance improvements in its core libraries and string-processing paths.

For string.Split, improvements can come from the implementation being optimized to perform common operations more efficiently.

Developers benefit from these changes without changing ordinary code such as:

var values = input.Split(',');

This is an important point.

A runtime optimization is different from an API redesign.

The API remains familiar while the implementation underneath can become more efficient.

The exact performance improvement depends on the overload, input data, separator, options, and target environment.

Common string.Split Overloads

string.Split has several overloads.

A simple separator:

string[] parts = input.Split(',');

Multiple separators:

string[] parts = input.Split(',', ';');

A string separator:

string[] parts = input.Split("::");

You can also control how empty entries are handled:

string[] parts = input.Split(
    ',',
    StringSplitOptions.RemoveEmptyEntries);

This matters because the selected overload and options can affect both behavior and performance.

RemoveEmptyEntries

Consider:

string input = "A,,B,,,C";

string[] values = input.Split(
    ',',
    StringSplitOptions.RemoveEmptyEntries);

The result contains only:

A
B
C

Without RemoveEmptyEntries, empty elements are retained.

This option can be useful when empty fields have no meaning in the application's input format.

However, do not use it automatically.

For CSV-like formats, an empty field can be meaningful.

For example:

John,,Developer

could mean:

Name = John
Department = empty
Role = Developer

Removing empty entries would change the data structure.

Performance optimization should never change the required semantics.

StringSplitOptions.TrimEntries

When input may contain spaces, TrimEntries can be useful.

For example:

string input = "C#, ASP.NET, SQL";

string[] values = input.Split(
    ',',
    StringSplitOptions.TrimEntries);

The resulting values are trimmed automatically.

This can be clearer than splitting first and then calling Trim() on every result.

However, whether it is faster for a particular workload should be measured rather than assumed.

The Allocation Problem

Consider:

string input = "A,B,C,D,E";

string[] values = input.Split(',');

The application needs an array to store the results.

The individual result strings also need to be represented.

If this happens repeatedly:

for (int i = 0; i < 1_000_000; i++)
{
    string[] values = input.Split(',');
}

the application creates a large number of temporary objects.

This can put pressure on the garbage collector.

The runtime may make the split operation itself more efficient, but the application still has to create and manage the requested result.

This is why allocation behavior matters when discussing string-processing performance.

Limit the Number of Results

Sometimes an application does not need every part of a string.

For example:

string input = "server01:8080:tcp:production";

string[] values = input.Split(
    ':',
    2);

If the application only needs the first part and the remainder, limiting the number of results can be useful.

For example:

server01
8080:tcp:production

The exact overload and behavior should be selected according to the application's parsing requirements.

The broader lesson is simple: do not ask an API to produce information you do not need.

string.Split vs Span-Based Parsing

For ordinary application code, string.Split is convenient and readable.

For high-throughput parsing, you may want to avoid creating multiple strings.

A span-based approach can work directly with the original input:

ReadOnlySpan<char> data = input.AsSpan();

int separator = data.IndexOf(',');

if (separator >= 0)
{
    ReadOnlySpan<char> first = data[..separator];
    ReadOnlySpan<char> remaining = data[(separator + 1)..];

    Console.WriteLine(first.ToString());
    Console.WriteLine(remaining.ToString());
}

Notice that first and remaining are spans over the existing string.

Calling ToString() creates strings, so even here you should only create them when the application actually needs them.

This approach is more verbose than Split.

That is the trade-off.

Comparison of Parsing Approaches

Approach

Easy to use

Creates result objects

Good for high-throughput parsing

string.Split

Yes

Yes

Sometimes

string.Split with options

Yes

Yes

Sometimes

IndexOf

Yes

Only when you create results

Yes

Span<T> parsing

Moderate

Can avoid intermediate strings

Yes

Dedicated parser

Depends

Depends on implementation

Useful for complex formats

For most business applications, string.Split remains a reasonable choice.

For performance-critical parsers, span-based processing may be worth considering.

A Practical Log-Processing Example

Imagine a service receiving log records in this format:

2026-09-16|INFO|Orders|Order processed

A simple implementation is:

public static void ProcessLog(string line)
{
    string[] parts = line.Split('|');

    if (parts.Length < 4)
    {
        return;
    }

    string timestamp = parts[0];
    string level = parts[1];
    string source = parts[2];
    string message = parts[3];

    Console.WriteLine($"{level}: {message}");
}

For normal application traffic, this code is easy to understand and maintain.

If profiling later shows that parsing consumes significant CPU and allocation time, then the parser can be optimized.

The important sequence is:

Working code
    |
    v
Measure
    |
    v
Find bottleneck
    |
    v
Optimize
    |
    v
Measure again

Do not start with complicated parsing simply because it might be faster.

When string.Split Is the Right Choice

Use string.Split when:

  • The input is relatively small.

  • Readability is important.

  • The operation is not a major CPU hotspot.

  • You need all resulting fields.

  • The input format is simple.

  • The application is not generating excessive temporary allocations.

For example:

string[] tags = input.Split(
    ',',
    StringSplitOptions.RemoveEmptyEntries |
    StringSplitOptions.TrimEntries);

This is clear and often exactly what application code needs.

When You Should Consider Another Approach

Consider a different parser when:

  • The application processes very large amounts of text.

  • Parsing appears in a CPU hotspot.

  • Allocation rate is high.

  • Only a few fields are needed.

  • The input is a well-defined high-throughput protocol.

  • Profiling shows Split is a significant contributor to the workload.

For example, a high-volume telemetry service may process millions of records.

In that type of workload, avoiding unnecessary intermediate strings can matter more than making a single Split call slightly faster.

Common Mistakes

Splitting the Same String More Than Once

Avoid code like:

if (input.Split(',').Length > 2)
{
    var parts = input.Split(',');
}

This performs the split twice.

Instead:

string[] parts = input.Split(',');

if (parts.Length > 2)
{
    // Use parts
}

Splitting When You Only Need One Value

Suppose you only need the text before the first comma.

This:

string first = input.Split(',')[0];

creates the complete split result even though only one value is required.

A targeted search can be more appropriate:

int index = input.IndexOf(',');

string first = index >= 0
    ? input[..index]
    : input;

Ignoring Empty Fields

Do not use RemoveEmptyEntries without checking the input format.

An empty field may carry business meaning.

Optimizing Without Profiling

A custom span-based parser can be more complicated than Split.

If Split consumes almost no meaningful application time, replacing it is not useful.

Best Practices for string.Split

1. Choose the Correct Overload

Use the simplest overload that accurately represents your input.

2. Avoid Repeated Splitting

Store and reuse the result when multiple operations need the same fields.

3. Use Options Carefully

RemoveEmptyEntries and TrimEntries can simplify parsing, but they change how input is interpreted.

4. Limit Results When Appropriate

If you only need a limited number of pieces, use an appropriate overload instead of processing everything.

5. Watch Allocations in Hot Paths

If Split is used inside a high-volume loop, check allocation behavior with a profiler or benchmark.

6. Consider Span-Based Parsing for Hot Code

If profiling identifies string parsing as a real bottleneck, spans can help avoid unnecessary intermediate strings.

Benchmarking string.Split

A simple benchmark can help compare different approaches.

For example:

using BenchmarkDotNet.Attributes;

public class SplitBenchmark
{
    private readonly string input =
        "server01,8080,production,healthy";

    [Benchmark]
    public string[] Split()
    {
        return input.Split(',');
    }
}

You can then add alternative implementations and compare them under the same conditions.

For example, if testing a span-based approach, make sure the benchmark measures the same final result.

Do not compare a method that returns four strings with one that only finds the first separator.

The workloads need to be equivalent.

What to Measure

When testing string.Split, measure:

  • Execution time

  • Allocated memory

  • Number of operations processed

  • CPU usage

  • Behavior with short inputs

  • Behavior with long inputs

  • Behavior with many separators

Input size matters.

A parser that performs well for a 50-character string may behave differently when processing a large line containing thousands of characters.

Troubleshooting High Allocation Rates

If a service shows high allocation activity and profiling points toward string parsing, investigate where Split is being called.

For example:

Request
  |
  +-- Validation
  |
  +-- Parsing
  |     |
  |     +-- string.Split
  |
  +-- Business logic
  |
  +-- Database

If parsing is responsible for a significant portion of allocations, consider:

  1. Reducing unnecessary calls.

  2. Avoiding repeated parsing.

  3. Processing only required fields.

  4. Using spans where appropriate.

  5. Measuring the new implementation.

Do not assume that every allocation must be eliminated.

Allocations are normal in .NET. The goal is to avoid unnecessary allocation in code where it actually affects application performance.

Advantages and Disadvantages

Advantages

Disadvantages

Simple and readable API

Produces result collections

Supports several separator types

Can create many temporary objects

Provides useful options

May be expensive in very hot parsing loops

Easy to maintain

Does not understand complex formats such as full CSV rules

Benefits from runtime improvements

Faster execution does not eliminate allocation costs

Is Faster string.Split Always Better?

Not necessarily.

Suppose an application spends:

5 ms  - database
2 ms  - network
1 ms  - business logic
0.1 ms - string.Split

Even a significant improvement in the split operation would have little effect on the total request time.

Now consider a data-processing service where parsing accounts for a large portion of CPU time.

In that case, improvements in string processing can become much more relevant.

This is why performance should always be considered in the context of the complete workload.

Summary

string.Split remains one of the easiest ways to break a string into multiple values, and modern .NET continues to improve the performance of common string-processing operations.

The improvements in the runtime can make existing Split code more efficient without requiring changes to application code. However, the fundamental allocation behavior remains important. If an application repeatedly splits large numbers of strings, the resulting arrays and strings can still contribute to memory usage and garbage-collection work.

For normal business logic, use string.Split when it makes the code clear and meets the application's needs. When profiling identifies parsing as a real bottleneck, look at reducing repeated work, limiting the data being parsed, or using span-based techniques to avoid unnecessary intermediate strings.

The practical rule is simple: use string.Split for readability and convenience, and move to a lower-level parsing approach only when measurements show that the extra complexity is justified.

Windows & ASP.NET Core Hosting Recommendation

HostForLIFEASP.NET receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2022 R2, SQL Server 2022, ASP.NET Core 11, ASP.NET MVC, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month, HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More
Previous PostOlder Posts Home