DEV Community

Cover image for New Features in .NET 10 and C# 14
Anton Martyniuk
Anton Martyniuk

Posted on • Originally published at antondevtips.com

New Features in .NET 10 and C# 14

.NET 10 and C# 14 is out today (November 11, 2025).

As a Long-Term Support (LTS) release, .NET 10 will receive three years of support until November 14, 2028.
This makes it a solid choice for production applications that need long-term stability.

In this post, we will explore:

  • What's New in .NET 10
  • What's New in C# 14
  • What's New in ASP.NET Core in .NET 10
  • What's New in EF Core 10
  • Other Changes in .NET 10

Let's dive in!

P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.

What's New in .NET 10

File-Based Apps

The biggest addition in .NET 10 is support for file-based apps.
This feature changes how you can write C# code for scripts and small utilities.

Traditionally, even the simplest C# application required three things: a solution file (sln), a project file (csproj), and your source code file (*.cs).
You would then use your IDE or the dotnet run command to build and run the app.

Starting with .NET 10, you can create a single *.cs file and run it directly:

dotnet run main.cs
Enter fullscreen mode Exit fullscreen mode

This puts C# on equal with Python, JavaScript, TypeScript and other scripting languages.
This makes C# a good option for CLI utilities, automation scripts, and tooling, without a project setup.

File-based apps can reference NuGet packages and SDKs using special # directives at the top of your file.
This lets you include any library you need without a project file.

You can even create a single-file app that uses EF Core and runs a Minimal API:

#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.EntityFrameworkCore.Sqlite@9.0.0

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder();

builder.Services.AddDbContext<OrderDbContext>(options =>
{
    options.UseSqlite("Data Source=orders.db");
});

var app = builder.Build();

app.MapGet("/orders", async (OrderDbContext db) =>
{
    return await db.Orders.ToListAsync();
});

app.Run();
return;

public record Order(string OrderNumber, decimal Amount);

public class OrderDbContext : DbContext
{
    public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options) { }
    public DbSet<Order> Orders { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

You can also reference existing project files from your script:

#:project ../ClassLib/ClassLib.csproj
Enter fullscreen mode Exit fullscreen mode

Cross-Platform Shell Scripts

You can write cross-platform C# shell scripts that are executed directly on Unix-like systems.
Use the #! directive to specify the command to run the script:

#!/usr/bin/env dotnet
Enter fullscreen mode Exit fullscreen mode

Then make the file executable and run it:

chmod +x app.cs
./app.cs
Enter fullscreen mode Exit fullscreen mode

Converting to a Full Project

When your script grows and needs more structure, you can convert it to a regular project using the dotnet project convert command:

dotnet project convert app.cs
Enter fullscreen mode Exit fullscreen mode

Note: Support for file-based apps with multiple files will likely come in future .NET releases.

You can see the complete list of new features in .NET 10 here.

What's New in C# 14

C# 14 is one of the most significant releases in recent years.

Let's explore the key features:

Extension Members

Extension members are my favorite feature in C# 14. They represent a modern evolution of extension methods that have been part of C# since version 3.0.

Traditional Extension Method Syntax

Before C# 14, you created extensions by writing static methods with a this parameter:

public static class StringExtensions
{
    public static bool IsNullOrEmpty(this string value)
    {
        return string.IsNullOrEmpty(value);
    }

    public static string Truncate(this string value, int maxLength)
    {
        if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
        {
            return value;
        }

        return value.Substring(0, maxLength);
    }
}
Enter fullscreen mode Exit fullscreen mode

New Extension Keyword Syntax

The new syntax separates the receiver (the type you're extending) from the members you're adding. Instead of putting this on each method parameter, you declare an extension block that specifies the receiver once:

public static class StringExtensions
{
    extension(string value)
    {
        public bool IsNullOrEmpty()
        {
            return string.IsNullOrEmpty(value);
        }

        public string Truncate(int maxLength)
        {
            if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
                return value;

            return value.Substring(0, maxLength);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The extension block takes the receiver as a parameter.
Inside the block, you write your methods and properties just like they were actual members of the type.
The value parameter is available to all members within the block.

Both old and a new syntax compile to identical code, so they work the same way. You can even use both styles in the same static class.

The new extension syntax supports:

  • Instance methods
  • Instance properties
  • Static methods
  • Static properties

Extension Properties

Extension properties make your code more readable and expressive. Instead of calling methods, you can use properties that feel more natural.

For example, when working with collections, you frequently check if they're empty.
Instead of writing !items.Any() everywhere, you can create an IsEmpty property:

public static class CollectionExtensions
{
    extension<T>(IEnumerable<T> source)
    {
        public bool IsEmpty => !source.Any();

        public bool HasItems => source.Any();

        public int Count => source.Count();
    }
}

public void ProcessOrders(IEnumerable<Order> orders)
{
    if (orders.IsEmpty)
    {
        Console.WriteLine("No orders to process");
        return;
    }

    foreach (var order in orders)
    {
        // Process order
    }
}
Enter fullscreen mode Exit fullscreen mode

Private Fields and Caching

Inside an extension block, you can define private fields and methods, just like in a regular class.
This is useful when you need to cache expensive calculations:

public static class CollectionExtensions
{
    extension<T>(IEnumerable<T> source)
    {
        private List<T>? _materializedList;

        public List<T> MaterializedList => _materializedList ??= source.ToList();

        public bool IsEmpty => MaterializedList.Count == 0;

        public T FirstItem => MaterializedList[0];
    }
}
Enter fullscreen mode Exit fullscreen mode

The backing field _materializedList ensures that ToList() is only called once, no matter how many times you access the properties.

Static Extension Members

Static extensions let you add factory methods or utility functions to a type, not the instance.
To create static extensions, use extension without naming the receiver parameter:

public static class ProductExtensions
{
    extension(Product)
    {
        public static Product CreateDefault() =>
            new Product
            {
                Name = "Unnamed Product",
                Price = 0,
                StockQuantity = 0,
                Category = "Uncategorized",
                CreatedDate = DateTime.UtcNow
            };

        public static bool IsValidPrice(decimal price) =>
            price >= 0 && price <= 1000000;

        public static string DefaultCategory => "General";
    }
}
Enter fullscreen mode Exit fullscreen mode

You can call these static members directly on the type:

var product = Product.CreateDefault();
if (Product.IsValidPrice(999.99m))
{
    product.Price = 999.99m;
}
Enter fullscreen mode Exit fullscreen mode

Null-Conditional Assignment

The null-conditional operators (?. and ?[]) can now be used for assignment, not just for accessing members.

Before C# 14, you needed an explicit null check before assigning to a property:

if (user is not null)
{
    user.Profile = LoadProfile();
}
Enter fullscreen mode Exit fullscreen mode

Now you can simplify this using the ?. operator:

user?.Profile = LoadProfile();
Enter fullscreen mode Exit fullscreen mode

This is cleaner and more consistent with how null-conditional operators work for reading values.

The Field Keyword

The field keyword eliminates the need for explicit backing fields in many common property scenarios.

Previously, you needed to declare a private backing field:

public class Record
{
    private string _msg;

    public string Message
    {
        get => _msg;
        set => _msg = value ?? throw new ArgumentNullException(nameof(value));
    }
}
Enter fullscreen mode Exit fullscreen mode

With the field keyword, you can reference the compiler-generated backing field directly:

public class Record
{
    public string Message
    {
        get;
        set => field = value ?? throw new ArgumentNullException(nameof(value));
    }
}
Enter fullscreen mode Exit fullscreen mode

You can use field in any accessor: get, set, or init.

If you already use "field" as a variable name in your code, you can avoid conflicts by using @field or this.field, or by renaming your existing variable.

The field keyword is particularly useful for lazy initialization and default values:

public class ConfigReader
{
    public string FilePath
    {
        get => field ??= "data/config.json";
        set => field = value;
    }

    public Dictionary<string, string> ConfigValues
    {
        get => field ??= new Dictionary<string, string>();
        set => field = value;
    }
}
Enter fullscreen mode Exit fullscreen mode

This avoids declaring separate backing fields for FilePath and ConfigValues.

Lambda Parameters with Modifiers

C# 14 allows you to use parameter modifiers like out, ref, in, scoped, and ref readonly in lambda expressions without specifying parameter types.

Previously, you had to include complete type declarations when using modifiers:

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

TryParse<int> parse = (string text, out int result) => Int32.TryParse(text, out result);
Enter fullscreen mode Exit fullscreen mode

Now you can omit the types and let the compiler infer them:

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

TryParse<int> parse = (text, out result) => Int32.TryParse(text, out result);
Enter fullscreen mode Exit fullscreen mode

This makes lambda expressions more concise while maintaining type safety.

Partial Constructors and Events

C# 14 extends partial member support to include constructors and events. This is particularly useful for source generators.

Partial constructors must have one defining declaration and one implementing declaration:

public partial class User
{
    // This is the defining part
    public partial User(string name);
}

public partial class User
{
    // This is the implementing part
    public partial User(string name)
        : this() // Calls another constructor in the same class
    {
        Name = name;
    }

    public User() { }

    public string Name { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Rules for partial constructors:

  • Only the implementing part can use this() or base() to call another constructor
  • Only one part of the class can use a primary constructor

Partial events also require one defining and one implementing declaration:

public partial class Downloader
{
    public partial event Action<string> DownloadCompleted;
}

public partial class Downloader
{
    public partial event Action<string> DownloadCompleted
    {
        add { }
        remove { }
    }
}
Enter fullscreen mode Exit fullscreen mode

The implementing part must include both add and remove accessors.

You can see the complete list of new features in C# 14 here.

What's New in ASP.NET Core in .NET 10

Validation Support in Minimal APIs

ASP.NET Core 10 adds built-in validation support for Minimal APIs.
This feature automatically validates data sent to your endpoints, including query parameters, headers, and request bodies.

Register validation services by calling AddValidation:

builder.Services.AddValidation();
Enter fullscreen mode Exit fullscreen mode

The validation system automatically discovers types used in your Minimal API handlers and validates them using attributes from the System.ComponentModel.DataAnnotations namespace.

You can apply validation attributes directly to endpoint parameters:

app.MapPost("/products",
    ([Range(1, int.MaxValue)] int productId, [Required] string name) =>
    {
        return TypedResults.Ok(new { productId, name });
    });
Enter fullscreen mode Exit fullscreen mode

Record types work with validation too:

public record Product(
    [Required] string Name,
    [Range(1, 1000)] int Quantity);

app.MapPost("/products", (Product product) =>
{
    return TypedResults.Ok(product);
});
Enter fullscreen mode Exit fullscreen mode

When validation fails, the runtime automatically returns a 400 Bad Request response with details about the validation errors.

You can disable validation for specific endpoints:

app.MapPost("/products", (int productId, string name) =>
    TypedResults.Ok(productId))
    .DisableValidation();
Enter fullscreen mode Exit fullscreen mode

You can customize error responses by implementing IProblemDetailsService and registering it in the dependency injection container.
This lets you create consistent, user-friendly error messages across your application.

JSON Patch Support in Minimal APIs

To enable JSON Patch support with System.Text.Json, install the package:

dotnet add package Microsoft.AspNetCore.JsonPatch.SystemTextJson --prerelease
Enter fullscreen mode Exit fullscreen mode

Server-Sent Events (SSE)

Server-Sent Events (SSE) provide a lightweight, reliable way for ASP.NET Core apps to push continuous streams of data without the complexity of bidirectional protocols.

Server-Sent Events (SSE) is a web standard that enables a server to push real-time data to web clients over a single HTTP connection.
Unlike traditional request-response patterns where clients must repeatedly poll the server for updates, SSE allows the server to initiate communication and send data whenever new information becomes available.

For sending Server-Sent Events, you need to provide a data stream via IAsyncEnumerable<T>.

Here's a service that generates stock price updates:

public record StockPriceEvent(string Id, string Symbol, decimal Price, DateTime Timestamp);

public class StockService
{
    public async IAsyncEnumerable<StockPriceEvent> GenerateStockPrices(
       [EnumeratorCancellation] CancellationToken cancellationToken)
    {
       var symbols = new[] { "MSFT", "AAPL", "GOOG", "AMZN" };

       while (!cancellationToken.IsCancellationRequested)
       {
          var symbol = symbols[Random.Shared.Next(symbols.Length)];
          var price = Math.Round((decimal)(100 + Random.Shared.NextDouble() * 50), 2);
          var id = DateTime.UtcNow.ToString("o");

          yield return new StockPriceEvent(id, symbol, price, DateTime.UtcNow);

          await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
       }
    }
}
Enter fullscreen mode Exit fullscreen mode

Use TypedResults.ServerSentEvents to create an endpoint that streams data:

builder.Services.AddSingleton<StockService>();

app.MapGet("/stocks", (StockService stockService, CancellationToken ct) =>
{
    return TypedResults.ServerSentEvents(
       stockService.GenerateStockPrices(ct),
       eventType: "stockUpdate"
    );
});
Enter fullscreen mode Exit fullscreen mode

Clients can connect to this endpoint and receive real-time updates as they're generated.

You can read an in-depth guide on Server-Sent Events with ASP.NET Core here.

OpenAPI 3.1 Support

ASP.NET Core 10 adds support for generating OpenAPI 3.1 documents.
While the version number suggests a minor update, OpenAPI 3.1 is significant because it includes full support for JSON Schema draft 2020-12.

Key Changes in OpenAPI 3.1:

  • Nullable types use a type array that includes null instead of a nullable: true property
  • Integer types (int and long) may appear without the type: integer field and use a pattern field instead, depending on your JSON serialization settings
  • The default OpenAPI version is now 3.1

You can explicitly set the OpenAPI version when adding OpenAPI support:

builder.Services.AddOpenApi(options =>
{
    options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_1;
});
Enter fullscreen mode Exit fullscreen mode

YAML Format Support

ASP.NET Core now supports serving OpenAPI documents in YAML format.
YAML is more concise than JSON and supports multi-line strings, making it easier to read long descriptions.

To serve YAML documents, specify a .yaml or .yml extension:

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi("/openapi/{documentName}.yaml");
}
Enter fullscreen mode Exit fullscreen mode

What's New in Blazor

Blazor receives several improvements in .NET 10:

  • Hot Reload for Blazor WebAssembly and .NET on WebAssembly
  • Environment configuration in standalone Blazor WebAssembly apps
  • Performance profiling and diagnostic counters for Blazor WebAssembly
  • NotFoundPage parameter for the Blazor router
  • Static asset preloading in Blazor Web Apps
  • Improved form validation

You can see the complete list of ASP.NET Core 10 features here.

What's New in EF Core 10

Complex Types

Complex types let you model data that belongs to an entity but doesn't have its own identity.
While entity types map to database tables, complex types can map to columns in the same table (table splitting) or to a JSON column.

Complex types enable document modeling patterns that can improve performance by avoiding JOINs and simplifying your database schema.

Table Splitting

Map a customer's addresses as complex types:

modelBuilder.Entity<Customer>(b =>
{
    b.ComplexProperty(c => c.ShippingAddress);
    b.ComplexProperty(c => c.BillingAddress);
});
Enter fullscreen mode Exit fullscreen mode

Optional Complex Types

EF 10 adds support for optional complex types:

public class Customer
{
    public int Id { get; set; }
    public Address ShippingAddress { get; set; }
    public Address? BillingAddress { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

JSON Mapping

Map complex types to JSON columns:

modelBuilder.Entity<Customer>(b =>
{
    b.ComplexProperty(c => c.ShippingAddress, c => c.ToJson());
    b.ComplexProperty(c => c.BillingAddress, c => c.ToJson());
});
Enter fullscreen mode Exit fullscreen mode

Struct Support

Complex types support .NET structs:

public struct Address
{
    public required string Street { get; set; }
    public required string City { get; set; }
    public required string ZipCode { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

LeftJoin and RightJoin Operators

LEFT JOIN is common in database queries, but previous versions of EF Core made it complicated to write.
You needed to combine SelectMany, GroupJoin, and DefaultIfEmpty in a specific way.

.NET 10 adds first-class LINQ support for LeftJoin, making these queries much simpler:

var query = context.Students
    .LeftJoin(
        context.Departments,
        student => student.DepartmentID,
        department => department.ID,
        (student, department) => new
        {
            student.FirstName,
            student.LastName,
            Department = department.Name ?? "[NONE]"
        });
Enter fullscreen mode Exit fullscreen mode

EF 10 also supports RightJoin, which keeps all data from the second collection and only matching data from the first.

Both methods translate to the appropriate SQL JOIN operations.

Note that C# query syntax (from x select x.Id) doesn't yet support these operators.

ExecuteUpdate for JSON Columns

While EF Core has supported JSON columns for several versions, ExecuteUpdate couldn't modify them.
EF 10 changes this by allowing you to update JSON properties in bulk operations.

Given this model with a JSON column:

public class Blog
{
    public int Id { get; set; }
    public BlogDetails Details { get; set; }
}

public class BlogDetails
{
    public string Title { get; set; }
    public int Views { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>().ComplexProperty(b => b.Details, bd => bd.ToJson());
}
Enter fullscreen mode Exit fullscreen mode

You can now update properties within the JSON column:

await context.Blogs.ExecuteUpdateAsync(s =>
    s.SetProperty(b => b.Details.Views, b => b.Details.Views + 1));
Enter fullscreen mode Exit fullscreen mode

This generates efficient SQL that updates the JSON data directly in the database.

Named Query Filters

Global query filters let you define conditions that apply to all queries for an entity type. This simplifies implementing patterns like soft deletion and multi-tenancy.

Previously, EF Core supported only one query filter per entity type.

EF 10 introduces named query filters, letting you define multiple filters and control them independently:

modelBuilder.Entity<Blog>()
    .HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
    .HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);
Enter fullscreen mode Exit fullscreen mode

You can selectively disable specific filters in individual queries:

var allBlogs = await context.Blogs
    .IgnoreQueryFilters(["SoftDeletionFilter"])
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This gives you fine-grained control over which filters apply to each query.

Regular Lambdas in ExecuteUpdateAsync

ExecuteUpdateAsync lets you perform bulk updates directly in the database without loading entities into memory.

In previous versions, you had to provide changes as expression trees, making it challenging to build dynamic updates.

EF 10 allows regular lambdas with conditional logic:

await context.Blogs.ExecuteUpdateAsync(s =>
{
    s.SetProperty(b => b.Views, 8);

    if (nameChanged)
    {
        s.SetProperty(b => b.Name, "foo");
    }
});
Enter fullscreen mode Exit fullscreen mode

This is much simpler than manually building expression trees and makes conditional updates straightforward.

You can see the complete list of EF Core 10 features here.

Other Changes in .NET 10

Additional resources for .NET 10:

Summary

.NET 10 and C# 14 represent a significant step forward for the .NET ecosystem.

The file-based apps feature makes C# viable for scripting scenarios that were previously dominated by Python, Node.js and JavaScript.

Extension members give you flexibility by letting you add properties to types, not just methods.

The improvements to Minimal APIs, EF Core, and ASP.NET Core make building modern web applications faster and more intuitive.

As an LTS release, .NET 10 provides a stable foundation for both new and existing applications.

Whether you're building Web APIs, desktop applications, or CLI tools, this release offers features that improve productivity and code quality.

I've been testing .NET 10 in personal projects and will begin migrating commercial projects this month.

Are you excited about .NET 10? Hit reply and let me know.

P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.

Top comments (0)