Sunday, July 19, 2026

Azure Content Understanding: Classify and Route to Different Analyzers

In this post, let's have a look at how we can classify a document that contains multiple document types, and route each part to a different analyzer, using Azure AI Content Understanding and the .NET SDK.

Say you receive a single PDF that contains an invoice, a bank statement and a loan application, all combined together, something like this:
Combined Document
Now you want each part identified, and you want different fields extracted from each. Content Understanding supports exactly this: a classifier splits the file into segments, and each category can optionally point at its own analyzer that does the field extraction.

Let's have a look on how to achieve this. 

We are going to create two custom analyzers, one for invoices and one for loan applications. Then we are going to create a classifier with three categories, where the third one (Bank_Statement) is deliberately left without an analyzer. That last bit turns out to be the most interesting part of the whole exercise.

First, the packages (versions are the latest as of today, they will change).
dotnet add package Azure.AI.ContentUnderstanding
dotnet add package Azure.Identity
You will need a Microsoft Foundry resource in a supported region, with the required models deployed and set as defaults, and the Cognitive Services User role assigned to yourself. That role is needed even if you own the resource.
using Azure;
using Azure.AI.ContentUnderstanding;
using Azure.Identity;

const string Endpoint = "https://<your-foundry-resource>.services.ai.azure.com";
const string DocumentUrl = "https://github.com/Azure-Samples/azure-ai-content-understanding-python/raw/refs/heads/main/data/mixed_financial_docs.pdf";

var credential = new DefaultAzureCredential();
ContentUnderstandingClient client = new(new Uri(Endpoint), credential);

var suffix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var loanApplicationAnalyzerId = $"loan_application_analyzer_{suffix}";
var invoiceAnalyzerId = $"invoice_analyzer_{suffix}";
var classifierId = $"classifier_{suffix}";
var completionModel = "gpt-5.2";
Now the first custom analyzer for Invoice. Nothing fancy here, just a field schema describing what we want out of an invoice.
// Invoice analyzer.
ContentAnalyzer invoiceAnalyzer = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Extracts key fields from invoices",
    Models =
    {
        ["completion"] = completionModel
    },
    FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition>
    {
        ["InvoiceNumber"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Invoice identifier."
        },
        ["VendorName"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Name of the vendor issuing the invoice."
        },
        ["TotalAmount"] = new()
        {
            Type = ContentFieldType.Number,
            Method = GenerationMethod.Extract,
            Description = "Invoice total including tax."
        },
    })
};

await CreateAnalyzer(client, invoiceAnalyzerId, invoiceAnalyzer);
Now the second analyzer for Loan Application, same like before, just different fields.
// Loan application analyzer.
ContentAnalyzer loanApplicationAnalyzer = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Extracts key fields from loan applications",
    Models =
    {
        ["completion"] = completionModel
    },
    FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition>
    {
        ["ApplicantName"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Full name of the loan applicant."
        },
        ["LoanAmountRequested"] = new()
        {
            Type = ContentFieldType.Number,
            Method = GenerationMethod.Extract,
            Description = "Total loan amount requested."
        },
        ["LoanPurpose"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Generate,
            Description = "Stated purpose of the loan."
        },
    }),
};

await CreateAnalyzer(client, loanApplicationAnalyzerId, loanApplicationAnalyzer);
Next, the classifier. This is where the routing happens. Note EnableSegment, which is what makes the service split a multi-document file rather than treating it as one document, and AnalyzerId on each category, which is what points a category at an analyzer.
// Classifier: Invoice and Loan application route to their analyzers, Bank statement is classified only.
ContentAnalyzer classifier = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Splits a multi-type document and routes each part to its analyzer",
    Models =
    {
        ["completion"] = completionModel
    },
    Config = new ContentAnalyzerConfig
    {
        EnableSegment = true,
        ContentCategories =
        {
            ["Invoice"] = new ContentCategoryDefinition
            {
                Description = "Billing documents requesting payment for goods or services, with line items, taxes and totals.",
                AnalyzerId = invoiceAnalyzerId,
            },
            ["Loan_Application"] = new ContentCategoryDefinition
            {
                Description = "Requests for funding, including applicant details, financial history, loan amount and purpose.",
                AnalyzerId = loanApplicationAnalyzerId,
            },
            ["Bank_Statement"] = new ContentCategoryDefinition
            {
                Description = "Statements summarizing account activity over a period, including deposits, withdrawals and balances.",
                // No AnalyzerId: this category is classified but not routed to an analyzer.
            }
        }
    }
};

await CreateAnalyzer(client, classifierId, classifier);
And then we analyze. The sample PDF above conveniently contains all three document types across four pages.
Operation<AnalysisResult> operation = await client.AnalyzeAsync(
    WaitUntil.Completed,
    classifierId,
    inputs: [new AnalysisInput { Uri = new Uri(DocumentUrl) }]);

AnalysisResult result = operation.Value;

// The first content is the whole file. Its segments show how the classifier split it,
// including categories that were not routed to an analyzer.
var wholeDocument = (DocumentContent)result.Contents![0];

Console.WriteLine($"Split into {wholeDocument.Segments?.Count ?? 0} segment(s):");
foreach (DocumentContentSegment segment in wholeDocument.Segments ?? [])
{
    Console.WriteLine($"  {segment.Category,-18} pages {segment.StartPageNumber}-{segment.EndPageNumber}");
}

// Only categories with an AnalyzerId get their own content entry, with extracted fields.
foreach (AnalysisContent content in result.Contents.Where(c => c.Category is not null))
{
    var document = (DocumentContent)content;
    Console.WriteLine($"\n{document.Category} (pages {document.StartPageNumber}-{document.EndPageNumber}) via {document.AnalyzerId}");

    foreach ((string name, ContentField field) in document.Fields)
    {
        Console.WriteLine($"  {name}: {field.Value ?? "(null)"}");
    }
}
And the output.
Split into 3 segment(s):
  Invoice            pages 1-1
  Bank_Statement     pages 2-3
  Loan_Application   pages 4-4

Invoice (pages 1-1) via invoice_analyzer_1784529162
  InvoiceNumber: INV-100
  VendorName: CONTOSO LTD.
  TotalAmount: 110

Loan_Application (pages 4-4) via loan_application_analyzer_1784529162
  ApplicantName: John Smith
  LoanAmountRequested: 25000
  LoanPurpose: Debt Consolidation
Now here is the part that is easy to get wrong. Bank_Statement shows up in the segment list, but it never appears in the second loop. That is not a bug, it is how the response is shaped, and it is much clearer if we look at the raw JSON.
{
  // omitted: id, status
  "result": {
    "analyzerId": "classifier_1784529162",
    "apiVersion": "2025-11-01",
    // omitted: createdAt, stringEncoding, warnings
    "contents": [
      {
        "path": "input1",
        "markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
        "startPageNumber": 1,
        "endPageNumber": 4,
        "unit": "inch",
        "pages": [
          // omitted: pageNumber, angle, width, height for each of the 4 pages
        ],
        "segments": [
          {
            "segmentId": "segment1",
            "startPageNumber": 1,
            "endPageNumber": 1,
            "category": "Invoice"
          },
          {
            "segmentId": "segment2",
            "startPageNumber": 2,
            "endPageNumber": 3,
            "category": "Bank_Statement"
          },
          {
            "segmentId": "segment3",
            "startPageNumber": 4,
            "endPageNumber": 4,
            "category": "Loan_Application"
          }
        ],
        "analyzerId": "classifier_1784529162",
        "mimeType": "application/pdf"
      },
      {
        "path": "input1/segment1",
        "category": "Invoice",
        "markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
        "fields": {
          "InvoiceNumber": {
            "type": "string",
            "valueString": "INV-100",
            "spans": [
              {
                "offset": 90,
                "length": 7
              }
            ],
            "confidence": 0.738,
            "source": "D(1,7.4772,1.3993,8.0103,1.3987,8.0105,1.5459,7.4774,1.5465)"
          },
          "VendorName": {
            "type": "string",
            "valueString": "CONTOSO LTD.",
            "confidence": 0.939
            // omitted: spans, source
          },
          "TotalAmount": {
            "type": "number",
            "valueNumber": 110,
            "confidence": 0.9
            // omitted: spans, source
          }
        },
        "startPageNumber": 1,
        "endPageNumber": 1,
        // omitted: kind, unit, pages, segments
        "analyzerId": "invoice_analyzer_1784529162"
      },
      {
        "path": "input1/segment3",
        "category": "Loan_Application",
        "markdown": "# Contoso Bank Loan Application Form\n...",
        "fields": {
          "ApplicantName": {
            "type": "string",
            "valueString": "John Smith",
            "confidence": 0.981
            // omitted: spans, source
          },
          "LoanAmountRequested": {
            "type": "number",
            "valueNumber": 25000,
            "confidence": 0.745
            // omitted: spans, source
          },
          "LoanPurpose": {
            "type": "string",
            "valueString": "Debt Consolidation",
            "confidence": 0.675
            // omitted: spans, source
          }
        },
        "startPageNumber": 4,
        "endPageNumber": 4,
        // omitted: kind, unit, pages, segments
        "analyzerId": "loan_application_analyzer_1784529162"
      }
    ]
  },
  "usage": {
    "documentPagesStandard": 4,
    "contextualizationTokens": 4000,
    "tokens": {
      "gpt-5.2-input": 7710,
      "gpt-5.2-output": 222
    }
  }
}
Look at the path values. We have input1, input1/segment1 and input1/segment3. There is no input1/segment2. The service numbered all three segments in the parent content, then only emitted content entries for the two that had an analyzer attached. The gap in the numbering is the giveaway.

I could not find this spelled out in the docs, but from what I can see the response has two layers, and you need both:

  • Classification lives in Contents[0].Segments, and contains every category the classifier found, routed or not.
  • Extraction lives in  Contents[1..], and contains only the categories that had an AnalyzerId, each with its Fields.
One last practical note: analyzers are resources that live on your Foundry resource until you delete them, so remember to clean them up.
// Classifier first: it references the two analyzers.
foreach (var analyzerId in new[] { classifierId, invoiceAnalyzerId, loanApplicationAnalyzerId })
{
    try
    {
        await client.DeleteAnalyzerAsync(analyzerId);
    }
    catch (RequestFailedException ex) when (ex.Status == 404)
    {
        // Creation failed earlier, so let that exception surface instead of this one.
    }
}
More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Friday, July 17, 2026

ASP.NET Core 11.0: Async Validation in Minimal APIs

In this post, let's have a look at asynchronous validation support for Minimal APIs, which is going to be available in ASP.NET Core 11.0.

Some time back, I blogged about Validation Support for Minimal APIs and Custom Validation in ASP.NET Core 10.0. This is a continuation of that, but this time the validators run asynchronously.

With the latest .NET 11 Preview 6, Minimal APIs now supports asynchronous validators end-to-end.

Let's look at the two ways to write asynchronous validators.

AsyncValidationAttribute

This is the asynchronous version of a custom ValidationAttribute. We inherit from AsyncValidationAttribute and override IsValidAsync. Here we can resolve the services from the ValidationContext and use those services for our validation logic.
public record Product(
    [Required, UniqueSku] string Sku,
    [Required] string Name);

internal sealed class UniqueSkuAttribute : AsyncValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) =>
        throw new InvalidOperationException($"Validate this attribute with '{nameof(IsValidAsync)}'.");

    protected override async Task<ValidationResult?> IsValidAsync(object? value,
        ValidationContext validationContext,
        CancellationToken cancellationToken)
    {
        ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>();
        if (value is string sku && await catalogService.SkuExistsAsync(sku, cancellationToken))
        {
            return new ValidationResult("A product with that SKU already exists.");
        }

        return ValidationResult.Success;
    }
}
Note the UniqueSku attribute in Sku. That's the custom attribute we created that performs an asynchronous validation. Here since IsValid in ValidationAttribute is abstract, we have to implement it even though we validate asynchronously. Here it just throws. 

Something to remember: for minimal API validation using Microsoft.Extensions.Validation, the framework always calls the async path and never the sync path. So it's safe to throw.

Now say we have the following API.
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Add validation services to the container
builder.Services.AddValidation();

builder.Services.AddSingleton<ICatalogService, DummyCatalogService>();

WebApplication app = builder.Build();

app.MapPost("/products", (Product product) =>
    Results.Ok(product));

app.MapPost("/orders", (OrderRequest orderRequest) =>
    Results.Ok(orderRequest));

app.Run();
Just like before, we enable validation by calling AddValidation()

Now let's create a Product with an existing SKU.
@WebApplication1_HostAddress = http://localhost:5016

POST {{WebApplication1_HostAddress}}/products
Content-Type: application/json

{
  "sku": "EXISTING_SKU",
  "name": "Some Product"
}
Throws 400 as expected.
400: Bad Request
IAsyncValidatableObject

This is the asynchronous version of IValidatableObject. This is useful when the validation needs more than one property. We implement ValidateAsync, which returns an IAsyncEnumerable<ValidationResult>.
public record OrderRequest(
    [Range(1, int.MaxValue)] int ProductId,
    [Range(1, int.MaxValue)] int Quantity,
    string PromoCode) : IAsyncValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) =>
        throw new InvalidOperationException($"Validate this type with '{nameof(ValidateAsync)}'.");

    public async IAsyncEnumerable<ValidationResult> ValidateAsync(
        ValidationContext validationContext,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>();

        if (!await catalogService.HasStockAsync(ProductId, Quantity, cancellationToken))
        {
            yield return new ValidationResult(
                $"Product '{ProductId}' does not have '{Quantity}' unit(s) in stock.",
                [nameof(Quantity)]);
        }

        if (!await catalogService.IsPromoCodeValidAsync(PromoCode, cancellationToken))
        {
            yield return new ValidationResult(
                $"Promo code '{PromoCode}' is not valid.",
                [nameof(PromoCode)]);
        }
    }
}
IAsyncValidatableObject extends IValidatableObject, so the synchronous Validate still has to be there. Same as before, it just throws.

Ordering more than what we have in stock and also with an invalid Promo Code,
@WebApplication1_HostAddress = http://localhost:5016

POST {{WebApplication1_HostAddress}}/orders
Content-Type: application/json

{
  "productId": 1,
  "quantity": 100,
  "promoCode": "INVALID"
}
Throws 400 as expected.
400: Bad Request
Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, July 16, 2026

Received Microsoft MVP Award in Developer Technologies

I am humbled and honored once again to receive the precious Microsoft Most Valuable Professional (MVP) Award for the 13th consecutive year.

As always looking forward to another great year on top of Microsoft Development Stack.
Microsoft Most Valuable Professional (MVP)
Thank you Microsoft for your appreciation and Thank you everyone for your continuous support.

Happy Coding.

Regards,
Jaliya

Friday, July 3, 2026

.NET Options Validation in Application Startup

In this post, let's have a look at how we can validate options in application startup in a .NET application.

The Options pattern lets us bind a configuration section to a strongly-typed class. On top of that, we can validate the bound values so that a missing/incorrect configuration fails fast at application startup rather than blowing up at some random point at runtime when the options are first used. All of this lives in Microsoft.Extensions.Options, so it works the same in Console apps, Worker Services, ASP.NET Core and any other .NET host. 

Let's have a look at a simple example.

Say we have the following appsettings.json.
{
  "WeatherApi": {
    "BaseUrl": "Something",
    "TimeoutSeconds": 300,
    "Cache": {
      "DurationSeconds": 7200
    }
  }
}
And the following options classes and registration.
using Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddOptions<WeatherApiOptions>()
    .Bind(builder.Configuration.GetSection(WeatherApiOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Omitted for brevity

WebApplication app = builder.Build();

// Omitted for brevity

app.Run();

public class WeatherApiOptions
{
    public const string SectionName = "WeatherApi";

    [Required]
    [Url]
    public string BaseUrl { get; set; } = string.Empty;

    [Range(1, 60)]
    public int TimeoutSeconds { get; set; }

    [Required]
    [ValidateObjectMembers]
    public CacheOptions Cache { get; set; } = new();
}

public class CacheOptions
{
    [Range(30, 3600)]
    public int DurationSeconds { get; set; }
}
Here ValidateDataAnnotations() validates the DataAnnotation attributes on our options type. By default though, that validation is lazy, it only runs the first time someone accesses IOptions<WeatherApiOptions>.Value. That means a misconfigured application would happily start up and only fail later at runtime when the options are first used. ValidateOnStart() fixes that by forcing the validation to run eagerly at application startup, so we fail fast. (This kicks in as long as something actually starts the host, e.g. app.Run().)

Something to note is, the ValidateDataAnnotations() only validates the top-level options type. It does not recurse into nested objects (or into items of a collection). So the DataAnnotation attributes on CacheOptions are silently ignored. And for that, from .NET 8, onwards, two new attributes have been added to Microsoft.Extensions.Options:
  • [ValidateObjectMembers] - recursively validates the DataAnnotation attributes on a nested object.
  • [ValidateEnumeratedItems] - recursively validates the DataAnnotation attributes on each item of a collection.
The application now fails at startup, and notice that all the validation failures, including the ones on the nested object are reported at once.
Microsoft.Extensions.Options.OptionsValidationException: 

DataAnnotation validation failed for 'WeatherApiOptions' members: 
'BaseUrl' with the error: 'The BaseUrl field is not a valid fully-qualified http, https, or ftp URL.'.;
DataAnnotation validation failed for 'WeatherApiOptions' members:
'TimeoutSeconds' with the error: 'The field TimeoutSeconds must be between 1 and 60.'.;
DataAnnotation validation failed for 'WeatherApiOptions.Cache' members:
'DurationSeconds' with the error: 'The field DurationSeconds must be between 30 and 3600.'.
Hope this helps.

Happy Coding.

Regards,
Jaliya