C# & .NET

C# 14 and .NET 10: Sharper Tools for Modern Software

Afzal AhmedFaz Ahmed
·27 July 2026·24 min read
C# 14.NET 10ASP.NET Core 10EF Core 10.NET AspireBlazorOpenAPIDevSecOpsCloud ArchitectureSecurity

Why This Matters

My working notes and examples for understanding which C# 14 and .NET 10 features may matter in real systems, from language additions to ASP.NET Core, EF Core and Aspire.

Learning the new features by understanding the architecture, not just memorising syntax

Let’s do this properly, because this is an exciting moment for C# developers.

When a new language and platform release arrives, it is tempting to scan the feature list, copy a few examples and say that we are up to date. In my experience, that is the least useful way to learn. I want to sit with you as I would in a mentoring session and connect these changes to the systems we actually build: secure APIs, data-heavy applications, distributed services and software that a team must support for years.

Here is the first distinction I want you to remember:

C# 14 is the language improvement. .NET 10 is the platform improvement. Architecture is how we use both to build systems that survive real production.

A developer reading only the feature list says, “C# 14 has extension members and field-backed properties.”

A stronger developer says, “C# 14 reduces boilerplate, improves readability, supports safer domain modelling, makes high-performance APIs easier to consume, and helps teams express intent more clearly.”

A developer who stops at the headline says, “.NET 10 has better ASP.NET Core and EF Core.”

A stronger developer says, “.NET 10 is an LTS platform for building cloud-ready, observable, secure, high-performance applications with modern web APIs, Blazor, EF Core, Aspire orchestration, DevSecOps pipelines, and production hardening.”

That is the difference between learning features and understanding engineering.

Microsoft’s .NET support policy lists .NET 10 as an LTS release, originally released on November 11, 2025, with support ending on November 14, 2028. That matters architecturally because enterprises prefer stable, supported platforms for production systems. (Microsoft) C# 14 is supported on .NET 10, and Microsoft lists its main language features as extension members, null-conditional assignment, nameof for unbound generic types, span conversion improvements, simple lambda modifiers, field-backed properties, partial events and constructors, user-defined compound assignment operators, and file-based app directives. (Microsoft Learn)

Now let me show you what that means in code.

1. The architecture mental model: C# is expression, .NET is execution

Think of a real enterprise application: a loan management supermarket.

Users log in. Brokers submit loan applications. Underwriters review risk. Documents are uploaded. Credit checks happen. Payments and commissions are calculated. Dashboards show live status. Emails and notifications are sent. Admins manage roles and permissions.

At first, you may see screens, controllers, tables and APIs. I want to help you see the layers beneath them:

Frontend
  ↓
ASP.NET Core API
  ↓
Application Layer / CQRS
  ↓
Domain Model
  ↓
Infrastructure Layer
  ↓
EF Core / SQL Server / Redis / Blob Storage / Message Broker
  ↓
Observability / Security / CI/CD / Cloud Hosting

Every technology choice should serve an architectural quality: performance, scalability, maintainability, security, usability or resilience. That is the lens I use when assessing these releases.

A good .NET 10 project starts with a clean project file:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <!-- We are targeting .NET 10, the runtime/platform version. -->
    <TargetFramework>net10.0</TargetFramework>

    <!-- Nullable reference types help us find possible null bugs at compile time. -->
    <Nullable>enable</Nullable>

    <!-- Implicit usings reduce repeated using statements for common namespaces. -->
    <ImplicitUsings>enable</ImplicitUsings>

    <!-- Treat warnings seriously in professional projects.
         This helps stop poor code from quietly entering production. -->
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>

</Project>

This is not just configuration. It is culture. You are telling the compiler: “Help me write safer code.”

Simple, safe and maintainable code is part of architecture, not just style. Coding standards, analyzers, version control and team consistency all contribute to long-term software quality.

2. C# 14 extension members: building richer domain language

Before C# 14, extension methods were useful, but limited. You could add methods to existing types, but not extension properties, not extension static members in the same expressive way, and not a clean block of related extension members.

C# 14 adds extension members with extension blocks. Microsoft describes this as a way to declare extension properties as well as extension methods, and to define extension members that behave like instance or static members of the type you extend. (Microsoft Learn)

Now imagine this domain model:

public enum LoanStatus
{
    Draft,
    Submitted,
    UnderReview,
    Approved,
    Rejected,
    Completed
}

public sealed class LoanApplication
{
    public Guid Id { get; init; }
    public string ApplicantName { get; init; } = "";
    public decimal RequestedAmount { get; init; }
    public decimal ApplicantAnnualIncome { get; init; }
    public LoanStatus Status { get; set; }
    public DateTime SubmittedOnUtc { get; set; }
}

We could put every helper inside LoanApplication, but that can pollute the domain entity with presentation or reporting concerns. Extension members give us a nice middle ground.

public static class LoanApplicationExtensions
{
    // C# 14 extension block.
    // These members appear as if they belong to LoanApplication,
    // but we have not modified the original class.
    extension(LoanApplication loan)
    {
        // Extension property.
        // This reads naturally in calling code: loan.IsHighValue
        public bool IsHighValue => loan.RequestedAmount >= 500_000m;

        // Another extension property.
        // Useful in dashboards, status panels, or reporting.
        public bool RequiresUnderwriterReview =>
            loan.Status is LoanStatus.Submitted or LoanStatus.UnderReview;

        // Extension method.
        // This keeps a business-style calculation readable.
        public decimal LoanToIncomeRatio()
        {
            if (loan.ApplicantAnnualIncome <= 0)
            {
                return decimal.MaxValue;
            }

            return loan.RequestedAmount / loan.ApplicantAnnualIncome;
        }

        // Presentation helper.
        // Be careful: this is useful for UI/reporting, but do not let
        // UI-specific formatting leak into core domain rules.
        public string ToDashboardLabel() =>
            $"{loan.ApplicantName} - {loan.Status} - {loan.RequestedAmount:C}";
    }
}

Now calling code becomes expressive:

public sealed class LoanDashboardService
{
    public LoanDashboardRow BuildRow(LoanApplication loan)
    {
        return new LoanDashboardRow
        {
            Id = loan.Id,
            Applicant = loan.ApplicantName,
            Label = loan.ToDashboardLabel(),
            IsHighValue = loan.IsHighValue,
            NeedsReview = loan.RequiresUnderwriterReview,
            LoanToIncomeRatio = loan.LoanToIncomeRatio()
        };
    }
}

Why was this added?

Because modern C# applications often need expressive APIs. When you build libraries, shared domain helpers, DTO transformations, validation helpers, or framework-style utilities, extension members let you improve readability without inheritance and without modifying the original type.

A word of caution: do not abuse extension members. They are not a replacement for proper domain modelling. If a rule is essential to the identity of the entity, keep it inside the entity or domain service. If it is a reusable helper, query helper, formatting helper, or integration helper, extension members can be excellent.

A clean example is pagination:

public sealed record PagedResult<T>(
    IReadOnlyList<T> Items,
    int PageNumber,
    int PageSize,
    int TotalCount);

public static class QueryableExtensions
{
    extension<T>(IQueryable<T> query)
    {
        public async Task<PagedResult<T>> ToPagedResultAsync(
            int pageNumber,
            int pageSize,
            CancellationToken cancellationToken = default)
        {
            // Defensive guard. Pagination bugs can kill database performance.
            if (pageNumber < 1)
            {
                throw new ArgumentOutOfRangeException(nameof(pageNumber));
            }

            if (pageSize is < 1 or > 500)
            {
                throw new ArgumentOutOfRangeException(nameof(pageSize));
            }

            var totalCount = await query.CountAsync(cancellationToken);

            var items = await query
                .Skip((pageNumber - 1) * pageSize)
                .Take(pageSize)
                .ToListAsync(cancellationToken);

            return new PagedResult<T>(
                items,
                pageNumber,
                pageSize,
                totalCount);
        }
    }
}

Now your application query handler reads beautifully:

public sealed class GetSubmittedLoansHandler
{
    private readonly LoanDbContext _db;

    public GetSubmittedLoansHandler(LoanDbContext db)
    {
        _db = db;
    }

    public async Task<PagedResult<LoanSummaryDto>> HandleAsync(
        int page,
        int size,
        CancellationToken cancellationToken)
    {
        return await _db.LoanApplications
            .AsNoTracking()
            .Where(x => x.Status == LoanStatus.Submitted)
            .OrderByDescending(x => x.SubmittedOnUtc)
            .Select(x => new LoanSummaryDto
            {
                Id = x.Id,
                ApplicantName = x.ApplicantName,
                RequestedAmount = x.RequestedAmount,
                SubmittedOnUtc = x.SubmittedOnUtc
            })
            .ToPagedResultAsync(page, size, cancellationToken);
    }
}

That is a practical C# 14 feature serving architecture.

3. Field-backed properties: less boilerplate, safer invariants

C# has always allowed auto-properties:

public string ApplicantName { get; set; } = "";

That is concise, but sometimes you need logic in the setter. Before C# 14, you had to create a private backing field manually:

private string _applicantName = "";

public string ApplicantName
{
    get => _applicantName;
    set => _applicantName = string.IsNullOrWhiteSpace(value)
        ? throw new ArgumentException("Applicant name is required.")
        : value.Trim();
}

C# 14 introduces the field contextual keyword for field-backed properties. Microsoft explains that field lets you write accessor logic without declaring an explicit backing field; the compiler synthesizes the backing field for you. (Microsoft Learn)

Now we can write:

public sealed class Broker
{
    public Guid Id { get; init; }

    public string Name
    {
        get;

        // C# 14 field-backed property.
        // 'field' refers to the compiler-generated backing field.
        set => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Broker name is required.", nameof(value))
            : value.Trim();
    } = "";

    public string Email
    {
        get;
        set => field = value.Contains('@')
            ? value.Trim().ToLowerInvariant()
            : throw new ArgumentException("A valid email is required.", nameof(value));
    } = "";
}

Why was this added?

Because many properties start as auto-properties and later need validation, normalisation, trimming, null protection, or defensive logic. Previously, once you added logic, the property expanded into several lines of backing field noise. C# 14 lets you keep the property compact while still protecting the object.

This is very useful in domain objects and configuration objects. When we map configuration into .NET objects using the ASP.NET Core options pattern, field-backed properties can help make those objects safer:

public sealed class CreditCheckOptions
{
    public string Endpoint
    {
        get;
        set => field = Uri.IsWellFormedUriString(value, UriKind.Absolute)
            ? value
            : throw new ArgumentException("Credit check endpoint must be an absolute URI.");
    } = "";

    public int TimeoutSeconds
    {
        get;
        set => field = value is >= 1 and <= 60
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be between 1 and 60 seconds.");
    } = 30;
}

Then in Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Bind appsettings.json section to strongly typed options.
builder.Services
    .AddOptions<CreditCheckOptions>()
    .Bind(builder.Configuration.GetSection("CreditCheck"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

var app = builder.Build();
app.Run();

My rule of thumb:

Use field when the property is still basically a property, but needs small guard logic. Do not turn property setters into business workflows. A property setter should not call a database, send an email, or publish a message.

4. Null-conditional assignment: cleaner optional updates

Before C# 14, if you wanted to assign to something only when the receiver was not null, you wrote:

if (loan is not null)
{
    loan.Status = LoanStatus.UnderReview;
}

C# 14 allows null-conditional assignment using ?. and ?[] on the left-hand side of an assignment. Microsoft states that the right side is evaluated only when the left side is not null. (Microsoft Learn)

Now:

loan?.Status = LoanStatus.UnderReview;

This looks small, but small improvements matter when they remove noise without hiding intent.

A practical example:

public sealed class LoanScreenState
{
    public LoanApplication? SelectedLoan { get; set; }
    public string? LastInfoMessage { get; set; }
}

public static class LoanScreenStateExtensions
{
    public static void MarkSelectedLoanAsReviewed(LoanScreenState state)
    {
        // If no loan is selected, nothing happens.
        // The assignment only happens if SelectedLoan is not null.
        state.SelectedLoan?.Status = LoanStatus.UnderReview;

        state.LastInfoMessage = state.SelectedLoan is null
            ? "No loan selected."
            : "Loan moved to under review.";
    }
}

A more interesting example is compound assignment:

public sealed class LoanStats
{
    public int ReviewCount { get; set; }
}

LoanStats? stats = GetStatsOrNull();

// Only increments if stats is not null.
// C# 14 supports null-conditional compound assignment.
stats?.ReviewCount += 1;

Why was this added?

Because null checking is a common source of repeated ceremony. C# has been moving steadily toward safer, clearer null handling: nullable reference types, pattern matching, null-coalescing operators, and now null-conditional assignment.

A word of caution: do not use this where failure should be explicit. If a missing object is a bug, throw. If a missing object is genuinely optional, null-conditional assignment is clean.

Bad usage:

// Dangerous if tenant must always exist.
// This silently does nothing if tenant is null.
tenant?.CurrentPlan = BillingPlan.Enterprise;

Better:

if (tenant is null)
{
    throw new InvalidOperationException("Tenant must exist before assigning a billing plan.");
}

tenant.CurrentPlan = BillingPlan.Enterprise;

Good engineers know when silence is elegance and when silence hides a production bug.

5. nameof(List<>): better generic diagnostics

Before C# 14, nameof worked nicely with closed generic types such as nameof(List), but not unbound generic types such as List<>. C# 14 now supports unbound generic types in nameof; for example, nameof(List<>) evaluates to List. (Microsoft Learn)

This helps library authors, framework developers, diagnostics, logging, validation, and error messages.

public static class Guard
{
    public static void NotEmpty<T>(
        IEnumerable<T> values,
        string parameterName)
    {
        if (!values.Any())
        {
            throw new ArgumentException(
                $"{nameof(IEnumerable<>)} parameter '{parameterName}' must contain at least one item.",
                parameterName);
        }
    }
}

A more practical enterprise example:

public sealed class RepositoryDiagnostics
{
    public static string BuildRepositoryName<TEntity>()
    {
        // Clear diagnostic name for logging and observability.
        return $"{nameof(IRepository<>)}<{typeof(TEntity).Name}>";
    }
}

public interface IRepository<TEntity>
{
    Task<TEntity?> FindAsync(Guid id, CancellationToken cancellationToken);
}

Calling:

var name = RepositoryDiagnostics.BuildRepositoryName<LoanApplication>();

// Output:
// IRepository<LoanApplication>

Why was this added?

Because modern C# code uses generics heavily: repositories, handlers, validators, mappers, result types, options, events, commands, queries, and pipelines. Better generic names improve diagnostics without fragile string literals.

My rule of thumb:

Use nameof wherever possible instead of hardcoded names. Hardcoded strings rot. nameof follows refactoring.

6. Span improvements: performance without unsafe code

Span and ReadOnlySpan are not beginner toys. They are performance tools. They allow you to work with slices of memory without allocating new arrays or strings. C# 14 gives span types more natural language support through implicit conversions, making APIs easier to consume. Microsoft’s C# 14 notes describe first-class support for Span and ReadOnlySpan involving new implicit conversions, improving performance without risking safety. (Microsoft Learn)

Imagine we need to parse loan references:

LN-2026-000123

A naive implementation might split strings:

public static int ParseLoanNumberSlow(string reference)
{
    // Allocates an array and substrings.
    var parts = reference.Split('-');

    return int.Parse(parts[2]);
}

For normal business apps, this may be acceptable. But for high-throughput parsing, logging, importing files, background workers, or ETL, allocations add up.

A span-based version:

public static int ParseLoanNumberFast(ReadOnlySpan<char> reference)
{
    // Example: LN-2026-000123
    // We want the final part after the last '-'.

    var lastDashIndex = reference.LastIndexOf('-');

    if (lastDashIndex < 0)
    {
        throw new FormatException("Loan reference is invalid.");
    }

    var numberPart = reference[(lastDashIndex + 1)..];

    return int.Parse(numberPart);
}

Calling code is simple:

string reference = "LN-2026-000123";

int number = ParseLoanNumberFast(reference);

Why does this matter?

Because performance is part of architecture. Allocation, garbage collection, database access, multithreading and caching are design concerns, not afterthoughts.

I do not want you to optimise everything. I want you to understand where allocation matters and where it does not.

Good places for spans:

Parsing large files. Handling protocol messages. High-throughput APIs. Serialisation/deserialisation. Text processing. Reducing allocations in hot paths.

Bad places for spans:

Every normal business method. Code where readability is more important and performance is not measured. Trying to look clever in code review.

7. Lambda modifiers: cleaner delegate code

C# 14 lets you use parameter modifiers like out, ref, in, scoped, or ref readonly in lambda parameters without specifying all parameter types. Microsoft gives the example of an out parameter lambda becoming less verbose. (Microsoft Learn)

Before, you might need:

TryParse<int> parse = (string text, out int result) =>
    int.TryParse(text, out result);

Now:

public delegate bool TryParse<T>(string text, out T result);

TryParse<int> parse = (text, out result) =>
    int.TryParse(text, out result);

A real example in a validation pipeline:

public sealed class ImportColumnParser
{
    private readonly Dictionary<string, TryParse<object>> _parsers = new();

    public ImportColumnParser()
    {
        // Cleaner lambda with 'out' modifier.
        _parsers["RequestedAmount"] = (text, out result) =>
        {
            if (decimal.TryParse(text, out var amount))
            {
                result = amount;
                return true;
            }

            result = default!;
            return false;
        };

        _parsers["SubmittedOn"] = (text, out result) =>
        {
            if (DateTime.TryParse(text, out var date))
            {
                result = date;
                return true;
            }

            result = default!;
            return false;
        };
    }
}

Why was this added?

Because C# is heavily used for functional-style pipelines: LINQ, validation, parsing, mapping, middleware, endpoint filters, background workflows, and delegates. Cleaner lambdas make this code easier to read.

8. Partial constructors and events: better source generation

C# has had partial classes and partial methods for years. They are important for generated code. C# 14 expands partial members to include instance constructors and events. Microsoft explains that partial constructors and partial events must have one defining declaration and one implementing declaration. (Microsoft Learn)

Why does this matter?

Because modern .NET increasingly uses source generators. ASP.NET Core, JSON serialisation, logging, dependency injection, validation, mapping, OpenAPI generation, and AI-assisted tooling all benefit from code generation.

Imagine a source generator creates part of a class:

public partial class LoanImportJob
{
    // Generated declaration.
    public partial LoanImportJob();

    public partial event EventHandler<LoanImportedEventArgs>? LoanImported;
}

You implement the behaviour:

public partial class LoanImportJob
{
    private readonly List<string> _errors = [];

    public partial LoanImportJob()
    {
        // Your custom constructor implementation.
        _errors = new List<string>();
    }

    public partial event EventHandler<LoanImportedEventArgs>? LoanImported
    {
        add
        {
            Console.WriteLine("Handler added.");
            field += value;
        }
        remove
        {
            Console.WriteLine("Handler removed.");
            field -= value;
        }
    }
}

The exact shape of generated code depends on the generator, but the architectural reason is clear: partial members create a clean seam between generated code and developer-owned code.

My rule of thumb:

Partial members are not for splitting normal business classes randomly across ten files. They are most useful when code generation, framework tooling, or platform integration needs a safe extension point.

9. File-based apps: C# becomes better for quick utilities

.NET 10 also makes C# more useful for quick scripts and small utilities. Microsoft’s .NET 10 overview says the SDK includes enhanced file-based apps with publish support and native AOT, and Microsoft documentation explains that file-based apps can use #: directives such as package, property, and SDK configuration inside a single .cs file. (Microsoft Learn) (Microsoft Learn)

A small utility could look like this:

#:package Humanizer@2.14.1

using Humanizer;

var daysUntilCompletion = 42;

Console.WriteLine($"Estimated completion: {daysUntilCompletion.Days().Humanize()}");

Run it directly:

dotnet run loan-estimate.cs

Why is this exciting?

Because many .NET developers use PowerShell or Python for quick internal tools simply because C# traditionally required project ceremony. File-based apps reduce that friction.

Where would I use this?

Quick data cleanup. One-off migration checks. CSV inspection. Internal admin scripts. Build helpers. Smoke test utilities. Small proof-of-concept demos.

Where would I not use it?

Large applications. Shared enterprise services. Long-term business workflows. Anything that needs full project structure, testing, packaging, and CI/CD.

Use lightweight tools, but do not let a lightweight experiment quietly become production architecture.

10. .NET 10 SDK and tooling: productivity is architecture too

The .NET 10 SDK includes support for Microsoft.Testing.Platform in dotnet test, standardises CLI command order, supports native tab-completion generation, improves tools with dotnet tool exec and dnx, provides CLI introspection, and improves file-based apps. (Microsoft Learn)

This may sound like “tooling news,” but it matters.

A serious engineering team depends on repeatability:

dotnet restore
dotnet build
dotnet test
dotnet publish

When tooling improves, onboarding improves. CI/CD improves. Local development improves. Automation improves.

A typical GitHub Actions pipeline:

name: build-test-publish

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source
        uses: actions/checkout@v4

      - name: Install .NET 10 SDK
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Restore dependencies
        run: dotnet restore

      - name: Build solution
        run: dotnet build --configuration Release --no-restore

      - name: Run tests
        run: dotnet test --configuration Release --no-build

      - name: Publish API
        run: dotnet publish src/Loan.Api/Loan.Api.csproj --configuration Release --output ./publish

Requirements should connect to repositories, test plans and delivery pipelines rather than sitting in isolation. This is important:

A professional .NET developer does not only write code. A professional .NET developer writes code that can be built, tested, scanned, deployed, monitored, and supported.

11. ASP.NET Core 10: better web applications and APIs

ASP.NET Core is where many .NET developers live. Microsoft’s .NET 10 overview says ASP.NET Core 10 introduces improvements including Blazor improvements, OpenAPI enhancements, minimal API updates, enhanced form validation, improved diagnostics, and passkey support for Identity. (Microsoft Learn)

A clean minimal API in .NET 10 might look like this:

var builder = WebApplication.CreateBuilder(args);

// OpenAPI support allows your APIs to be discoverable and testable.
// In enterprise systems, OpenAPI is also used for client generation,
// documentation, integration agreements, and contract-first discussions.
builder.Services.AddOpenApi();

builder.Services.AddDbContext<LoanDbContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default"));
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.MapGet("/api/loans/{id:guid}", async (
    Guid id,
    LoanDbContext db,
    CancellationToken cancellationToken) =>
{
    var loan = await db.LoanApplications
        .AsNoTracking()
        .Where(x => x.Id == id)
        .Select(x => new LoanDetailsDto
        {
            Id = x.Id,
            ApplicantName = x.ApplicantName,
            RequestedAmount = x.RequestedAmount,
            Status = x.Status.ToString()
        })
        .SingleOrDefaultAsync(cancellationToken);

    return loan is null
        ? Results.NotFound()
        : Results.Ok(loan);
});

app.Run();

ASP.NET Core 10 also improves testing for apps using top-level statements. Previously, developers often manually added public partial class Program so integration test projects could reference it; .NET 10 can generate that declaration when needed and includes an analyzer to advise removal of explicit declarations. (Microsoft Learn)

That means cleaner integration testing:

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

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

    [Fact]
    public async Task GetLoan_WhenLoanDoesNotExist_Returns404()
    {
        var response = await _client.GetAsync($"/api/loans/{Guid.NewGuid()}");

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

Why does this matter architecturally?

Because testability is architecture. Testing and secure delivery are part of enterprise engineering, not optional activities added after coding.

12. EF Core 10: data access moves toward AI-aware enterprise systems

EF Core 10 is important because enterprise systems still live and die by data access.

Microsoft states EF Core 10 was released in November 2025 as an LTS release and requires .NET 10. EF Core 10 includes vector search support for Azure SQL Database and SQL Server 2025, enabling storage of embeddings for AI workloads such as semantic search and RAG. (Microsoft Learn)

That is a big deal for your career direction.

Imagine a loan platform where underwriters search previous similar applications:

"Show me previous bridging loans similar to this case, involving commercial property, high LTV, and delayed planning approval."

That is not normal SQL filtering. That is semantic similarity. EF Core 10 moving closer to vector search means .NET developers can participate directly in AI-driven data workflows.

A simplified conceptual model:

public sealed class LoanCaseDocument
{
    public Guid Id { get; set; }
    public Guid LoanApplicationId { get; set; }
    public string Text { get; set; } = "";

    // Conceptual: actual vector mapping depends on provider support.
    // This represents an embedding created from the document text.
    public float[] Embedding { get; set; } = [];
}

A production-ready flow might look like this:

Document uploaded
  ↓
Extract text
  ↓
Create embedding using AI model
  ↓
Store text + embedding
  ↓
Use vector search to find similar documents
  ↓
Use RAG to answer with citations

EF Core 10 also introduces named query filters, which allow multiple filters per entity type and selective disabling of specific filters. Microsoft gives examples such as soft deletion and multitenancy, where older EF versions supported only one query filter per entity, making selective disabling difficult. (Microsoft Learn)

This is very practical:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<LoanApplication>()
        .HasQueryFilter(
            "SoftDeleteFilter",
            loan => !loan.IsDeleted)
        .HasQueryFilter(
            "TenantFilter",
            loan => loan.TenantId == _tenantProvider.CurrentTenantId);
}

Normal query:

// Automatically excludes deleted rows
// and only returns rows for the current tenant.
var loans = await _db.LoanApplications
    .AsNoTracking()
    .ToListAsync(cancellationToken);

Admin query:

// Disable only soft-delete filter.
// Keep tenant isolation in place.
var loansIncludingDeleted = await _db.LoanApplications
    .IgnoreQueryFilters(["SoftDeleteFilter"])
    .AsNoTracking()
    .ToListAsync(cancellationToken);

This matters because it shows you understand both the feature and the risk.

Disabling all filters in a multi-tenant system can leak data. Named filters let you be precise. That is not just convenience. That is security architecture.

13. .NET Aspire: local orchestration for distributed systems

.NET Aspire deserves serious attention alongside orchestration, service discovery, telemetry, configuration, microservices and deployment. Here is the mental model I use:

A single web app is easy to run. A distributed app is not.

A real system may have:

Loan.Api
Broker.Api
Notification.Worker
Document.Worker
SQL Server
Redis
RabbitMQ
Blob Storage
Application Insights

Without orchestration, every developer has a different local setup. One person has Redis running. Another does not. One has wrong connection strings. Another has old ports. Debugging becomes messy.

Aspire helps define the application landscape:

var builder = DistributedApplication.CreateBuilder(args);

var sql = builder.AddSqlServer("sql")
    .AddDatabase("loansdb");

var cache = builder.AddRedis("cache");

var loanApi = builder.AddProject<Projects.Loan_Api>("loan-api")
    .WithReference(sql)
    .WithReference(cache);

var worker = builder.AddProject<Projects.Document_Worker>("document-worker")
    .WithReference(sql);

builder.Build().Run();

This is architectural documentation that runs.

It tells the team:

These are our services. These are our dependencies. These are our resources. These are the connections. This is the local distributed environment.

Why does .NET Aspire matter?

Because cloud-native development is not only about Kubernetes YAML. It is about giving developers a controlled, observable and repeatable environment before production.

14. Security in .NET 10: TLS, mTLS, tokens, claims, and certificates

Security remains layered: cryptography, TLS, certificates, JWT bearer tokens, OAuth, client certificates and encryption all have different jobs.

Take mTLS and client certificates. In .NET, we can configure Kestrel with certificates and use X509CertificateLoader with HttpClient to establish secure service-to-service communication.

A simplified .NET client example:

using System.Security.Cryptography.X509Certificates;

var clientCertificate = X509CertificateLoader
    .LoadPkcs12FromFile("client-certificate.pfx", "StrongPasswordHere");

var handler = new HttpClientHandler();
handler.ClientCertificates.Add(clientCertificate);

var httpClient = new HttpClient(handler)
{
    BaseAddress = new Uri("https://risk-service.internal")
};

var response = await httpClient.GetAsync("/api/risk/health");
response.EnsureSuccessStatusCode();

In production, you do not hardcode certificate passwords. You use Key Vault, Kubernetes secrets, managed identity, secure environment variables, or platform secret stores.

For API authentication:

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Identity:Authority"];
        options.Audience = "loan-api";

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("UnderwriterOnly", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim("role", "Underwriter");
    });
});

Endpoint:

app.MapPost("/api/loans/{id:guid}/approve", async (
    Guid id,
    LoanDbContext db,
    CancellationToken cancellationToken) =>
{
    var loan = await db.LoanApplications.FindAsync([id], cancellationToken);

    if (loan is null)
    {
        return Results.NotFound();
    }

    loan.Status = LoanStatus.Approved;

    await db.SaveChangesAsync(cancellationToken);

    return Results.NoContent();
})
.RequireAuthorization("UnderwriterOnly");

Security is not one feature. Security is layered.

Authentication proves who you are. Authorization decides what you can do. TLS protects the connection. mTLS can prove the client service identity. Secrets management protects credentials. Logging and audit trails support investigation. Hardening reduces the attack surface.

The feature is only one part of the story. Cloud hardening, identity controls, logging, network restrictions, infrastructure automation and incident readiness determine whether the complete system is defensible.

15. Pulling it together: a modern .NET 10/C# 14 application

Now let’s combine the thinking.

A good .NET 10 loan management API might look like this:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

builder.Services.AddDbContext<LoanDbContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default"));
});

builder.Services.AddScoped<ILoanDecisionService, LoanDecisionService>();

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();

builder.Services.AddAuthorization();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/api/loans", async (
    CreateLoanRequest request,
    LoanDbContext db,
    ILoanDecisionService decisionService,
    CancellationToken cancellationToken) =>
{
    var loan = new LoanApplication
    {
        Id = Guid.NewGuid(),
        ApplicantName = request.ApplicantName,
        RequestedAmount = request.RequestedAmount,
        ApplicantAnnualIncome = request.ApplicantAnnualIncome,
        Status = LoanStatus.Draft
    };

    var decision = decisionService.Evaluate(loan);

    if (!decision.CanSubmit)
    {
        return Results.BadRequest(decision.Errors);
    }

    loan.Status = LoanStatus.Submitted;
    loan.SubmittedOnUtc = DateTime.UtcNow;

    db.LoanApplications.Add(loan);
    await db.SaveChangesAsync(cancellationToken);

    return Results.Created($"/api/loans/{loan.Id}", loan.Id);
})
.RequireAuthorization();

app.Run();

Domain service:

public interface ILoanDecisionService
{
    LoanDecision Evaluate(LoanApplication loan);
}

public sealed class LoanDecisionService : ILoanDecisionService
{
    public LoanDecision Evaluate(LoanApplication loan)
    {
        var errors = new List<string>();

        if (loan.RequestedAmount <= 0)
        {
            errors.Add("Requested amount must be greater than zero.");
        }

        if (loan.ApplicantAnnualIncome <= 0)
        {
            errors.Add("Applicant income must be greater than zero.");
        }

        if (loan.LoanToIncomeRatio() > 5)
        {
            errors.Add("Loan-to-income ratio is too high.");
        }

        return new LoanDecision(
            CanSubmit: errors.Count == 0,
            Errors: errors);
    }
}

public sealed record LoanDecision(
    bool CanSubmit,
    IReadOnlyList<string> Errors);

Here we are using several modern ideas together:

C# 14 extension members for clean domain helpers. Field-backed properties for safe state. ASP.NET Core for APIs. EF Core for data access. OpenAPI for discoverability. JWT authorization for security. Async EF calls for scalable I/O. Nullable reference types for safety. DI for testability and maintainability. A clean separation between endpoint, domain decision, and persistence.

That is how you should speak in an interview.

Do not say, “I know .NET 10.”

Say:

“I understand .NET 10 as a production platform. I use C# 14 language features to reduce boilerplate and improve clarity, ASP.NET Core for APIs and web apps, EF Core 10 for data access including modern query filters and emerging AI/vector scenarios, Aspire for local distributed orchestration, OpenAPI for API contracts, CI/CD for delivery, and security-by-design through authentication, authorization, TLS, secrets, and cloud hardening.”

That answer tells me you understand the platform, not merely its version number.

16. Release reality: what is current and supported

C# 14 and .NET 10 were released in November 2025. Microsoft lists .NET 10 as a Long Term Support release supported until November 2028. EF Core 10 is also an LTS release and requires the .NET 10 SDK to build and the .NET 10 runtime to run.

That makes the platform suitable for production evaluation, but “LTS” does not remove migration work. Monthly servicing updates, third-party compatibility, hosting support, test infrastructure and team tooling still need ownership.

Junior: If .NET 10 is LTS, should every .NET 8 application upgrade immediately?
>
Senior: Plan the upgrade promptly, but do not confuse urgency with improvisation. Inventory dependencies, run compatibility tests, benchmark critical paths and rehearse deployment and rollback. .NET 8 itself reaches the end of its support period in November 2026, so postponement also has a cost.
Pin the SDK used by local development and CI with global.json where the repository needs that stability:
{
  "sdk": {
    "version": "10.0.1xx",
    "rollForward": "latestPatch",
    "allowPrerelease": false
  }
}

The version shown is a pattern, not a recommendation to copy an unavailable SDK. Select an installed, approved .NET 10 feature band and define the roll-forward policy deliberately. Runtime servicing patches normally roll forward so security and reliability fixes are applied; validate patch deployment through the organisation’s normal pipeline.

Keep target frameworks and package major versions aligned:

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <Nullable>enable</Nullable>
  <ImplicitUsings>enable</ImplicitUsings>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>

Do not set LangVersion to preview for production merely to access a blog example. C# 14 is the default language version for .NET 10 projects. Explicitly overriding language version can be useful in a shared build policy, but language/runtime mismatches require care because some features need newer libraries or runtime support.

17. Mentoring build: a version-safe loan submission API

Let us harden the earlier endpoint. Requirements are:

  • create a draft loan once even if the client retries;
  • validate money, applicant and product rules;
  • authenticate the caller and enforce tenant ownership;
  • persist an outbox event with the application;
  • return an explicit contract rather than the EF entity;
  • trace failures without logging personal or financial details;
  • deploy alongside the previous version during a rolling release.
Junior: Which C# 14 feature should we start with?
>
Senior: None. Start with the domain and failure contract. Then use new syntax where it makes that contract clearer.
The API request is a boundary model:
public sealed record CreateLoanRequest(
    string ApplicantReference,
    decimal RequestedAmount,
    string Currency,
    decimal DeclaredAnnualIncome,
    string ProductCode,
    string IdempotencyKey);

public sealed record CreateLoanResponse(
    Guid ApplicationId,
    string Status,
    long Version,
    Uri Location);

Do not accept TenantId, CreatedBy, Status or approval fields from the client. The server derives authority and lifecycle state.

18. Use field-backed properties for local invariants

C# 14’s field contextual keyword lets an accessor use its compiler-generated backing field. It is useful when an auto-property needs a small invariant without a named field.

public sealed class LoanApplication
{
    public Guid Id { get; private init; }

    public string ApplicantReference
    {
        get;
        private init => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Applicant reference is required.")
            : value.Trim();
    }

    public decimal RequestedAmount
    {
        get;
        private init => field = value > 0
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value));
    }

    public LoanStatus Status { get; private set; } = LoanStatus.Draft;
    public long Version { get; private set; }
}

The feature reduces boilerplate; it does not turn property setters into a full domain model. Rules involving several values or current state belong in construction and behaviour methods.

If the type already contains an identifier named field, C# 14 can make code confusing because field has contextual meaning in property accessors. Rename the identifier or disambiguate it deliberately. Do not adopt a new feature when it makes an existing vocabulary harder to read.

EF Core materialisation also matters. Private setters, constructors and owned value objects must match the mapping. Write database integration tests rather than assuming a language feature is transparent to the ORM.

19. Extension members should express stable domain vocabulary

Traditional extension methods remain valid. C# 14 adds extension blocks that can define extension properties and static extensions as well as methods. A small domain-oriented example is:

public static class LoanExtensions
{
    extension(LoanApplication loan)
    {
        public decimal LoanToIncomeRatio =>
            loan.DeclaredAnnualIncome <= 0
                ? decimal.MaxValue
                : loan.RequestedAmount / loan.DeclaredAnnualIncome;

        public bool RequiresManualReview =>
            loan.Status is LoanStatus.Submitted && loan.LoanToIncomeRatio > 4.5m;
    }
}

The threshold is illustrative, not lending guidance. A real rule needs an approved effective version and tests.

Junior: Why not add those properties directly to LoanApplication?
>
Senior: If they are core domain behaviour and we own the type, direct members may be clearer. Extensions are strongest for types we cannot modify, optional capability layers or cohesive helper APIs. Do not move the domain out of its own model just to demonstrate syntax.
Extension members use static dispatch. They cannot override existing instance members or provide polymorphism. Namespace imports affect discoverability and potential ambiguity. Public library authors should review naming and compatibility carefully.

An extension property should not hide expensive I/O. loan.LatestCreditReport looks like cheap member access; if it performs HTTP or SQL, use an asynchronous method on an explicit service. Familiar syntax must not conceal operational cost.

20. Null-conditional assignment and business intent

C# 14 permits ?. or ?[] on the left side of assignment and compound assignment. The right side is evaluated only when the receiver is non-null.

auditContext?.Tags["loan.product"] = application.ProductCode;
responseMetadata?.CorrelationId = traceIdentifier;

This is concise for optional enrichment. It can be dangerous when absence is a defect:

// Suspicious: silently skips an operation when customer is unexpectedly null.
customer?.ActiveLoanId = application.Id;
Junior: The operator avoids NullReferenceException. Is that not safer?
>
Senior: It is safer only when “do nothing if absent” is the intended contract. If the customer must exist, throw or return a clear failure. Null-conditional syntax must not erase an invariant.
Remember that the receiver is evaluated once and the right side only when non-null. Compound assignment is supported, while increment and decrement are not. Use it for optional mechanics, not silent business branching.

21. Span improvements: optimise measured parsing boundaries

C# 14 improves conversions involving arrays, Span and ReadOnlySpan, including extension receiver and generic inference scenarios. That makes high-performance APIs easier to compose; it does not mean every string operation should become span-based.

Suppose an import endpoint parses a fixed application reference:

public static bool TryParseApplicationReference(
    ReadOnlySpan<char> value,
    out ApplicationReference result)
{
    result = default;
    if (value.Length is < 8 or > 32)
        return false;

    var separator = value.IndexOf('-');
    if (separator <= 0 || separator == value.Length - 1)
        return false;

    var prefix = value[..separator];
    var number = value[(separator + 1)..];
    if (!prefix.Equals("LN", StringComparison.OrdinalIgnoreCase) ||
        !long.TryParse(number, out var parsed))
        return false;

    result = new ApplicationReference(parsed);
    return true;
}

This may avoid temporary substrings on a hot import path. Benchmark representative loads with BenchmarkDotNet and profile the application. For an endpoint parsing one identifier, network and database time dominate; the string version may be more maintainable.

Spans are stack-only ref struct values with lifetime restrictions. They cannot be stored in ordinary heap objects or cross await boundaries freely. Parse or copy before awaiting. Never return a span over storage whose lifetime ends.

Junior: Should our public domain API accept spans everywhere now?
>
Senior: Only where callers benefit and the lifetime contract remains understandable. Performance APIs are part of design, not decoration.

22. Build a command with idempotency and concurrency

The application use case owns coordination:

public sealed record CreateLoanCommand(
    TenantId TenantId,
    UserId ActorId,
    ApplicantReference Applicant,
    Money RequestedAmount,
    Money AnnualIncome,
    ProductCode Product,
    IdempotencyKey IdempotencyKey);

public sealed class CreateLoanUseCase(
    ILoanApplicationRepository applications,
    IIdempotencyStore idempotency,
    IUnitOfWork unitOfWork,
    TimeProvider timeProvider)
{
    public async Task<CreateLoanResult> ExecuteAsync(
        CreateLoanCommand command,
        CancellationToken cancellationToken)
    {
        var fingerprint = command.CreateStableFingerprint();
        var prior = await idempotency.FindAsync(
            command.TenantId, command.IdempotencyKey, cancellationToken);

        if (prior is not null)
            return prior.Match(fingerprint);

        var application = LoanApplication.Create(
            Guid.NewGuid(), command, timeProvider.GetUtcNow());

        applications.Add(application);
        application.AddDomainEvent(new LoanApplicationCreated(
            application.Id, application.Version));

        idempotency.AddPending(command.TenantId, command.IdempotencyKey,
            fingerprint, application.Id);

        await unitOfWork.CommitAsync(cancellationToken);
        return CreateLoanResult.From(application);
    }
}

The database needs a unique constraint over tenant, operation and idempotency key. Two concurrent first requests can both see no prior record; only persistence resolves the race. Catch the unique violation narrowly, reload the record and compare fingerprints.

Store the result or stable business identity, not an ASP.NET response object. Reusing the same key with different request content returns 409. Retention matches the documented retry window and data policy.

The outbox event should commit in the same transaction as the loan and idempotency record. A dispatcher publishes it at least once; consumers deduplicate by message ID. .NET 10 does not make distributed transactions disappear.

23. Minimal API boundary with validation and Problem Details

Keep the endpoint thin and explicit:

app.MapPost("/api/loans", async Task<Results<
    Created<CreateLoanResponse>,
    ValidationProblem,
    Conflict<ProblemDetails>,
    ProblemHttpResult>> (
        CreateLoanRequest request,
        ClaimsPrincipal principal,
        ICreateLoanMapper mapper,
        CreateLoanUseCase useCase,
        CancellationToken cancellationToken) =>
{
    var validation = CreateLoanRequestValidator.Validate(request);
    if (!validation.IsValid)
        return TypedResults.ValidationProblem(validation.Errors);

    var identity = RequestIdentity.From(principal);
    var result = await useCase.ExecuteAsync(
        mapper.Map(request, identity), cancellationToken);

    var response = new CreateLoanResponse(
        result.ApplicationId,
        result.Status.ToString(),
        result.Version,
        new Uri($"/api/loans/{result.ApplicationId}", UriKind.Relative));

    return TypedResults.Created(response.Location.ToString(), response);
})
.RequireAuthorization("LoanCreator")
.WithName("CreateLoan")
.WithSummary("Creates one draft loan application idempotently.");

The exact typed-result union may need adjustment to match the application’s exception and mapping strategy; compile and contract-test it. Do not force every outcome into a dense signature if central exception handling makes the endpoint clearer.

Derive tenant and actor from authenticated claims, not the body. Resource authorisation may also check branch, product or applicant access. Authentication proves identity; it does not grant every operation.

Return DTOs, not EF entities. Avoid leaking navigation properties, internal status details or concurrency tokens not intended for clients. OpenAPI should show examples, validation, authentication and stable error codes.

24. EF Core 10 features should answer a persistence problem

EF Core 10 includes named query filters. They can make multiple filters, such as tenant isolation and soft deletion, independently identifiable. That is useful, but query filters remain one defence layer.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<LoanApplication>()
        .HasQueryFilter("TenantFilter",
            loan => loan.TenantId == currentTenant.Id)
        .HasQueryFilter("NotDeleted",
            loan => !loan.IsDeleted);
}

Verify the exact API against the EF Core 10 version used by the project. Administrative code that disables a filter needs explicit authorisation and tests. Raw SQL and separate contexts can bypass filters. Database permissions and repository/query boundaries still matter.

Junior: Does a tenant query filter guarantee isolation?
>
Senior: It reduces accidental omissions in ordinary LINQ. It is not a complete security boundary. Test generated SQL, raw paths, background jobs and any filter disabling.
EF Core 10 also improves complex types, JSON-column updates and LINQ translation. Adopt them where the data model benefits, not because the release notes contain them. A relational schema with explicit columns may be better for frequently queried business data than a JSON document.

Use migrations compatible with rolling deployment: add new nullable/defaulted structures, deploy code that handles old and new shapes, backfill, then enforce or remove later. A migration that drops a column during the first deployment makes rollback unsafe.

Database integration tests should use the production provider when translation, constraints or concurrency matter. A mocked DbSet does not prove a query compiles to efficient SQL.

25. JSON hardening and API compatibility

.NET 10 adds stricter JSON options, including the ability to disallow duplicate properties. Duplicate keys can create ambiguity when different components keep different values.

Enable strictness deliberately and test clients:

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.AllowDuplicateProperties = false;
    options.SerializerOptions.RespectNullableAnnotations = true;
});

Confirm exact property names and availability against the targeted .NET 10 API. Strict nullable enforcement at serialisation boundaries helps but does not replace request validation or domain invariants.

Adding enum values can break clients that assume exhaustive known strings. Prefer tolerant reader behaviour where appropriate and document unknown handling. Additive response fields are usually safer than renames/removals. Consumer-driven or schema contract tests catch unintended changes.

Patch documents deserve special care. ASP.NET Core 10 has a System.Text.Json-based JSON Patch implementation. Patch operations can expose mass-assignment and invariant risks; map allowed operations onto application commands rather than applying arbitrary paths directly to tracked entities.

26. Authentication, authorisation and certificate claims

TLS protects transport when correctly configured; it does not identify whether an authenticated caller may create a loan. Use the platform’s current authentication handlers, validate issuer, audience, signature and lifetime, then enforce policies and resource rules.

For service-to-service mTLS, certificate validation must check the intended trust, expiry and revocation policy. Do not accept any certificate merely because the handshake succeeded. Certificate rotation and observability need runbooks.

Never log tokens, client secrets, full certificates or financial request bodies. Use managed identity/workload identity where the environment supports it, and a secret manager for remaining secrets. Configuration validation should fail startup when a production secret or authority is missing.

Security headers, rate limits, request size limits, anti-forgery for cookie-authenticated browser endpoints, safe CORS and output encoding remain relevant. A runtime upgrade does not replace threat modelling.

27. TimeProvider, cancellation and deadlines

Use TimeProvider rather than static DateTime.UtcNow for business timestamps that tests must control. Keep one timestamp per operation where consistency matters.

Pass CancellationToken from the endpoint through EF Core and HTTP calls. Cancellation means the caller no longer waits; it does not prove an external write was undone. If the server commits before cancellation is observed, idempotency allows the client to discover the existing result.

Apply outbound deadlines with HttpClient resilience policy and distinguish caller cancellation from timeout. Retry only transient failures and only safe or idempotent operations. A write whose response is lost may have succeeded.

Junior: Can we catch OperationCanceledException and return 408?
>
Senior: Not blindly. The server may be observing client disconnect, its own timeout or host shutdown. Often no response can be delivered. Classify the source for telemetry and preserve consistency rather than inventing one HTTP meaning.

28. Testing the language feature and the production behaviour

Test field-backed property invariants through public construction, not generated backing-field details. Test extension-member results as domain behaviour. Add a focused test for null-conditional assignment only when its short-circuit side effect matters.

[Fact]
public void Optional_audit_enrichment_does_not_evaluate_value_when_absent()
{
    AuditContext? audit = null;
    var evaluated = false;

    string BuildValue()
    {
        evaluated = true;
        return "value";
    }

    audit?.Tags["key"] = BuildValue();

    evaluated.Should().BeFalse();
}

More important tests prove idempotency, tenant isolation, validation, optimistic concurrency, transaction/outbox atomicity and API contracts. Run two concurrent create requests with the same key and assert one application. Reuse the key with different content and assert conflict. Fail event publication and show the outbox remains pending.

Upgrade tests should run the full suite under .NET 10, execute database migrations against a restored production-shaped schema, start the published artefact in a clean environment and verify health, authentication and representative endpoints.

Benchmark only known hot paths. Compare before and after on the same hardware/configuration and inspect allocations and disassembly only when necessary. Runtime improvements are welcome, but release notes cannot predict every workload.

29. Migration plan from .NET 8 to .NET 10

Inventory target frameworks, SDK pins, NuGet packages, analyzers, source generators, test adapters, container base images, deployment hosts and observability agents. Identify deprecated APIs and breaking changes for both .NET 9 and .NET 10 because skipping a major version does not skip its compatibility effects.

Then:

  1. Create a migration branch with the .NET 10 SDK pinned.
  2. Update target framework and Microsoft package family coherently.
  3. Restore and compile with warnings visible.
  4. Update test platform/tooling and run every suite.
  5. Upgrade EF Core and generate/review migrations only when model changes require them.
  6. Compare API schemas and serialisation fixtures.
  7. Run load and memory baselines for critical workloads.
  8. Build the production container from an approved .NET 10 base image.
  9. Deploy to a production-like environment and rehearse rollback.
  10. Canary, observe and expand.
Do not combine the runtime migration with a broad architecture rewrite and adoption of every C# 14 feature. Separate mechanical platform movement from optional code modernisation. That keeps regressions diagnosable.

Multi-targeted libraries may need to support older consumers. Use target-framework-specific APIs behind small conditional boundaries and test every target. Do not let conditional compilation spread through domain code.

30. Debugging clinic: works locally, fails in the container

The application targets .NET 10 and passes locally, but the container exits with a missing runtime or method error. Inspect the published artefact, base image tag, deployment architecture, runtime identifiers and the output of dotnet --info inside the image.

Common causes include building framework-dependent output but running on an older runtime image, copying the wrong publish folder, mixing x64 and Arm assets, or resolving incompatible package versions. Use an immutable image built once and promoted between environments; do not rebuild production from floating tags.

Pin a supported base-image family and allow an automated process to refresh servicing patches. Record image digest in release evidence. A smoke test should start the final image and call health plus one representative endpoint.

If self-contained or Native AOT publishing is considered, verify reflection, dynamic loading, serializers, EF features and diagnostics against that deployment model. Smaller startup or image benefits must be measured against compatibility and support complexity.

31. Debugging clinic: stricter JSON breaks one client

After enabling duplicate-property rejection, one legacy client sends both requestedAmount and RequestedAmount. The request now fails before the endpoint.

First determine whether the old behaviour was defined or ambiguous. Capturing the “winning” duplicate depends on serializer behaviour and can hide malicious or accidental override. The safest response may be to fix the client and retain strict parsing.

Use telemetry that records validation category and client version without logging the financial body. Provide a stable Problem Details response. If compatibility demands a transition, isolate a time-bounded adapter endpoint rather than weakening every API silently. Add an expiry and usage dashboard.

Junior: Could we turn strict mode off until every client upgrades?
>
Senior: We could, but write down the security and ambiguity trade-off. A compatibility exception needs an owner, deadline and evidence of remaining traffic.

32. Operational dashboard and rollback

Monitor request rate, latency, allocation/GC signals, thread-pool starvation, dependency duration, database pool waits, status codes, idempotency replay/conflict, outbox age and domain outcome distribution. Compare the .NET 10 canary with the current baseline by version.

Deployment annotations make regressions visible. If 409 rates rise, inspect whether clients send stale versions or idempotency fingerprints changed. If allocation falls but database latency dominates, the runtime improvement may not change user experience.

Rollback must restore compatible application, schema and message contracts. Use expand/contract database changes. Keep events forward/backward readable across the deployment window. Verify the previous image can start against the migrated database before release.

Patch rollback is rarely the only response to a security update problem; Microsoft servicing patches contain important fixes. Capture diagnostics, consult official release notes and plan a corrected forward update while protecting service availability.

33. Code-review questions for modern C# adoption

When a pull request introduces a C# 14 feature, ask:

  • Does it clarify a real invariant or API?
  • Can every supported project/compiler consume it?
  • Does it hide cost, side effects or null-related defects?
  • Is a direct member or ordinary method clearer?
  • Does it affect serializers, ORMs, source generators or analyzers?
  • Are behaviour and compatibility tests present?
  • Is this migration scope or optional modernisation?
For .NET 10 platform changes, ask:
  • Is support policy and SDK/runtime patching owned?
  • Are packages and container images compatible?
  • Have API schema, JSON and authentication behaviours been compared?
  • Are database migrations rolling-deployment safe?
  • Did we measure critical performance rather than assume improvement?
  • Can the release be observed and rolled back as a compatible set?
The review should not reject new syntax because it is unfamiliar. Nor should novelty substitute for a design reason. A team can adopt features gradually through conventions and examples.

34. Exercises for the developer I am mentoring

Exercise one: choose whether to use field

Refactor three explicit backing-field properties. Keep one explicit because its name/documentation aids debugging, use field for a small invariant and leave an auto-property unchanged. Explain each choice.

Exercise two: expose null intent

Find three null checks. Replace only the case where “skip if absent” is intended with null-conditional assignment. For a required value, return or throw explicitly. Test whether the right-hand expression runs.

Exercise three: benchmark span parsing

Implement string and span versions of reference parsing. Benchmark representative batches, allocations and invalid inputs. Keep the version whose benefit justifies its cognitive cost.

Exercise four: race idempotent creation

Send two concurrent requests with one key against the real database engine. Assert one loan and one outbox event. Repeat with different payloads and assert conflict.

Exercise five: rehearse the upgrade

Restore a production-shaped .NET 8 database, run compatible migration and deploy the .NET 10 image. Run contracts, load and rollback. Record every manual step and automate it.

Exercise six: investigate strict JSON

Send duplicate properties, unknown fields, null into non-null contracts and new enum values. Decide which are rejected and document stable Problem Details responses.

35. Cross-links for continuing the mentoring path

Use C# Revision for Senior Developers for deeper language fundamentals and Clean C# Design Patterns and Defensive Code for domain and boundary design. Continue with High-Performance C# and .NET before introducing span or allocation complexity. The EF Core Best Practices guide expands query and transaction concerns, while Azure for .NET Developers takes the published service into managed identity, networking, deployment and operations.

These guides reinforce the same lesson: a newer language and runtime improve the toolbox, but correctness comes from explicit contracts, evidence and production discipline.

36. Partial constructors and events: generated code with visible seams

C# 14 permits partial instance constructors and partial events. Each requires one defining declaration and one implementing declaration. This primarily helps source-generation scenarios where generated and handwritten parts of a type collaborate without reflection or awkward hooks.

Imagine a generator creates typed event-envelope classes from a versioned schema. The generated declaration exposes a construction seam:

// Generated file: do not edit.
public sealed partial class LoanCreatedEnvelope
{
    public string MessageId { get; }
    public Guid LoanId { get; }
    public int SchemaVersion { get; }

    public partial LoanCreatedEnvelope(
        string messageId,
        Guid loanId,
        int schemaVersion);
}

The handwritten implementation can validate an application-specific rule:

public sealed partial class LoanCreatedEnvelope
{
    public partial LoanCreatedEnvelope(
        string messageId,
        Guid loanId,
        int schemaVersion)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
        if (loanId == Guid.Empty)
            throw new ArgumentException("Loan ID is required.", nameof(loanId));
        if (schemaVersion <= 0)
            throw new ArgumentOutOfRangeException(nameof(schemaVersion));

        MessageId = messageId;
        LoanId = loanId;
        SchemaVersion = schemaVersion;
    }
}

This example is only useful if the generator genuinely owns the public shape and the application owns implementation policy. If both sides are handwritten, one ordinary constructor is clearer.

Partial events can let generated code define an event contract while another part implements custom add and remove accessors. Be careful: custom event accessors introduce subscription lifetime, thread-safety and memory-leak concerns. Events inside long-lived services can retain shorter-lived subscribers.

Junior: Can partial members replace interfaces between generated and application code?
>
Senior: They solve a compile-time collaboration seam inside one partial type. Interfaces still express runtime substitutability and consumer contracts. Choose according to ownership.
Only the implementing partial constructor may specify a this() or base() initializer, and primary-constructor rules still apply. Treat compiler diagnostics as part of the design feedback rather than fighting them with more generated complexity.

37. Source-generator engineering standards

A source generator runs during compilation and can affect every developer build. It should be deterministic: the same declared inputs produce the same source regardless of machine, current time, network or file-system accidents.

Do not call remote services from a generator. Use additional files, analyzer configuration and compilation symbols as explicit inputs. Avoid reading arbitrary repository locations or environment secrets. Cache incremental steps appropriately and keep diagnostics actionable.

Generated files should have stable names, namespace and formatting, include an auto-generated marker and avoid leaking sensitive configuration. Public generated APIs are real compatibility contracts even if developers did not type them.

Test generator output with compilation tests:

input schema/source
  -> run generator
  -> inspect diagnostics
  -> compile generated + user source
  -> assert public contract and runtime behaviour

Snapshot tests can expose broad changes, but assertions should also verify semantic requirements. Update snapshots only after reviewing why the API changed. A huge regenerated diff deserves the same scrutiny as handwritten public code.

Version the generator with the consuming package policy. If a new generator emits syntax requiring C# 14, ensure consuming projects target a compatible language version. Diagnostics should explain the requirement rather than leaving users with obscure parser failures.

38. File-based apps: from useful experiment to owned utility

.NET 10 expands file-based app support, and C# 14 adds preprocessor directives used by that model. A single .cs file can be valuable for a migration check, data repair rehearsal or API probe without first creating a project.

The attraction is low ceremony:

#!/usr/bin/env dotnet

using System.Net.Http.Json;

using var client = new HttpClient
{
    BaseAddress = new Uri("https://localhost:7001")
};

var health = await client.GetFromJsonAsync<HealthResponse>("/health/ready");
Console.WriteLine($"Ready: {health?.Ready}");

file sealed record HealthResponse(bool Ready);

Verify directive and execution syntax against the installed SDK because file-based app capabilities evolved through previews and release. The example keeps credentials out of source and targets a local endpoint.

Junior: Could our production data repair remain a file-based app?
>
Senior: Possibly, if it has declared dependencies, tests, review, audit and a safe runbook. Once a utility becomes recurring or consequential, a normal project often gives clearer packaging and governance.
A script can still delete records, leak secrets or overload an API. Require dry-run mode, explicit environment, bounded batches, idempotency, checkpointing, cancellation and audit for operational tooling. Never make production the default target.
repair-loans.cs --environment production --dry-run
  -> authenticate with approved operator identity
  -> display tenant, filter and estimated count
  -> require change-ticket reference
  -> process bounded page
  -> persist checkpoint and outcome

Move to a project when the tool needs several files, reusable domain code, complex configuration, package locking, integration tests, scheduled execution or multiple maintainers. Low ceremony should accelerate learning, not bypass engineering controls.

39. Library and API compatibility during modernisation

A team may upgrade an application while maintaining NuGet packages consumed by .NET 8 services. Multi-target carefully:

<PropertyGroup>
  <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
</PropertyGroup>

The public API must remain usable on both targets. C# syntax is compiled into each target, but referenced APIs may exist only on .NET 10. Place target-specific implementation behind a small boundary and run tests for every target framework.

Binary and source compatibility differ. Adding an overload can make a previously unambiguous call ambiguous after recompilation. Adding a member that collides with an extension can change binding. New extension members deserve compatibility review, especially in widely imported namespaces.

Use API compatibility tooling or a reviewed public API baseline for libraries. Generate NuGet packages in CI, install them into representative consumer fixtures and run those builds. A passing library unit suite does not prove consumers still compile.

For HTTP APIs, maintain schema compatibility independently of the runtime upgrade. The browser does not care that the server uses C# 14; it cares about JSON, status, authentication and timing. Compare OpenAPI documents and contract fixtures before rollout.

40. Incident clinic: a runtime upgrade changes latency shape

After canary deployment, median latency improves but p99 becomes worse. CPU and allocation averages look healthy. Do not declare the upgrade successful or failed from one metric.

Compare identical workload segments by endpoint, payload size, instance, dependency and deployment version. Inspect GC pauses, thread-pool queue, database waits, outbound connection reuse, JIT/tiered-compilation warm-up and container CPU throttling. A changed base image or package can be responsible even when the ticket says “.NET 10 upgrade.”

Capture a controlled trace or runtime counters during the tail event. Reproduce with production-shaped load. If the regression appears only immediately after startup, warm-up and readiness may be insufficient. If it grows under sustained concurrency, inspect pools, locks and downstream saturation.

Junior: The .NET 10 JIT should be faster. Why benchmark our code?
>
Senior: Runtime improvements are workload-dependent, and the release changes more than the JIT. Measurement tells us which component and percentile changed.
Rollback the canary if the user impact crosses the predefined threshold and rollback is compatible. Preserve diagnostics before destroying the instance. Then isolate runtime, packages, image and configuration in smaller experiments.

41. Incident clinic: named query filter bypass exposes excess rows

An administrative export disables the soft-delete filter but accidentally disables tenant filtering too. Named filters are valuable precisely because they can allow selective disabling, but use must be explicit and reviewed.

Do not expose IgnoreQueryFilters() in a generic repository option. Create a focused administrative query requiring an authorised principal and, where supported, disable only the named filter intended for the operation. Still include an explicit tenant predicate for defence in depth.

Log the export identity, approved scope, row count and audit reference without logging row content. Add integration tests containing two tenants and deleted/non-deleted rows. Assert the administrative query returns deleted rows only for the authorised tenant.

If excess data was returned, treat it as a security incident: stop the export, preserve evidence, identify recipients and follow the organisation’s breach process. A code fix alone does not address data already disclosed.

The lesson is broader than EF Core 10. Convenience mechanisms reduce accidental mistakes only when privileged bypass paths are narrow, authorised and tested.

42. Definition of done for the modernised service

The service is ready when evidence shows:

  • approved .NET 10 SDK, runtime and package versions build reproducibly;
  • C# 14 features improve clarity and compile across every supported target;
  • request/domain boundaries preserve invariants and tenant authority;
  • idempotency, concurrency and outbox behaviours pass real database tests;
  • JSON and OpenAPI contracts remain compatible or are deliberately versioned;
  • authentication, resource authorisation and secret handling are tested;
  • container/image, hosting and observability components support .NET 10;
  • performance is compared against the prior production baseline;
  • rolling database and event changes permit mixed versions;
  • canary thresholds, dashboards, rollback and runbooks are rehearsed;
  • support policy and monthly patch ownership are recorded;
  • optional modernisation remains separate from required migration work.
This list is more meaningful than counting occurrences of field, extension blocks or spans. New syntax is successful when it reduces boilerplate or expresses intent without hiding behaviour. The platform upgrade is successful when users receive a supported, secure and operable service with no unexplained regression.

43. A mentoring conversation after release

Junior: We upgraded successfully. Should I modernise all old property backing fields next?
>
Senior: Only when touching code for a reason or when a focused mechanical change has clear value and review capacity. Stability is also valuable.
>
Junior: How do I keep learning the release without forcing features into production?
>
Senior: Build small experiments, read official docs and specifications, present trade-offs to the team, and wait for a problem where the feature makes the solution clearer.
Maintain a short repository page listing adopted language features, examples and compatibility requirements. Update analyzers and IDE tooling so developers receive consistent feedback. Pair on the first few uses. A shared convention prevents every pull request from reopening basic syntax debates.

Schedule a post-upgrade review after real traffic. Compare support incidents, build duration, deployment time, latency, allocation and developer friction. Remove temporary compatibility flags and old container images once rollback windows close. Capture unexpected lessons for the next platform upgrade.

That is modern development in the useful sense: not permanent novelty, but an evidence-led ability to adopt improvements without losing control of the system.

The final habit is to keep the evidence close to the code. Link the support policy and migration decision, retain benchmark baselines, version API examples, and make rollback commands part of a tested runbook. When a future maintainer asks why the service targets .NET 10 or why one hot parser uses spans, the answer should be discoverable without reconstructing an old conversation.

Likewise, record where we deliberately did not use a feature. A direct domain member may be clearer than an extension, an explicit null check may protect an invariant, and a normal project may govern an operational tool better than a file-based app. Knowing when not to modernise is part of mastering the release.

Adopt deliberately, measure honestly, service continuously, and always leave a safe, reversible path for the team that follows.

What I want you to take away

C# 14 is not trying to change the soul of C#. It is smoothing the rough edges.

Extension members make reusable APIs more expressive. Field-backed properties reduce boilerplate while protecting invariants. Null-conditional assignment cleans up optional updates. nameof for unbound generics improves diagnostics in generic-heavy code. Span conversions make high-performance APIs easier to use. Lambda modifiers reduce delegate ceremony. Partial constructors and events help source generation. File-based app directives make C# more practical for scripts and quick tools.

.NET 10 is not just “the next runtime.” It is the enterprise platform layer.

It gives us an LTS foundation. It improves SDK tooling and strengthens ASP.NET Core, Blazor, OpenAPI, Minimal APIs, testing, diagnostics, JSON handling, EF Core, cloud-native development and security.

The real lesson is this:

Start with the syntax, then learn the frameworks. As you grow, keep asking why a feature exists, where it belongs architecturally and when not to use it.

C# 14 and .NET 10 give you sharper tools. Architecture teaches you where to cut.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Technical Skills →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise modern C# and .NET interview questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →