c# / .net
.NET 6 makes small services simpler

.NET 6 arrived on November 8. The change I notice first is how little code stands between creating a web application and describing its first useful route. The startup machinery is still there, but a small service no longer needs to introduce it across several files before doing any work.
This is also an LTS release, which matters when choosing a runtime for a service expected to stay around. Microsoft's release announcement sets out the three-year support commitment and the wider runtime changes. The examples below use the released SDK 6.0.100, .NET 6.0.0, and C# 10.
I wrote about C# 9 and .NET 5 in June. Top-level statements and records were already available then. What is new here is the combination of C# 10, a simplified ASP.NET Core hosting model, and handlers that accept ordinary typed parameters.
A complete service in four small files
Consider an endpoint that returns illustrative appointment slots for a weekday. It is deliberately stateless: it advertises possible times and does not reserve them. That keeps persistence and concurrent booking out of an example whose purpose is to show the HTTP boundary.
Create an empty Availability directory. In global.json, select the SDK:
{
"sdk": {
"version": "6.0.100",
"rollForward": "disable"
}
}
Use this Availability.csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Put the response type in Slot.cs:
namespace Availability;
public record Slot(string Date, string StartsAt, int Minutes);
Then create Program.cs:
using System.Globalization;
using Availability;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapGet("/slots", (string? date) => FindSlots(date));
app.Run();
static IResult FindSlots(string? date)
{
if (!DateOnly.TryParseExact(date, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var day))
{
return Results.BadRequest(new { error = "Use date=yyyy-MM-dd." });
}
if (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
{
return Results.Ok(Array.Empty<Slot>());
}
var opening = new TimeOnly(9, 0);
var slots = Enumerable.Range(0, 4)
.Select(index => new Slot(
day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
opening.AddMinutes(index * 30).ToString("HH:mm", CultureInfo.InvariantCulture),
30))
.ToArray();
return Results.Ok(slots);
}
There are no package references to restore beyond the platform's normal
framework resolution. The SDK's web defaults supply common namespace
imports; System.Globalization and our own namespace remain explicit.
Start it with dotnet run --no-launch-profile --urls http://127.0.0.1:5080.
In another terminal:
curl --fail http://127.0.0.1:5080/health
curl --fail 'http://127.0.0.1:5080/slots?date=2021-11-22'
curl --fail 'http://127.0.0.1:5080/slots?date=2021-11-21'
curl -i 'http://127.0.0.1:5080/slots?date=not-a-date'
curl -i http://127.0.0.1:5080/slots
Monday returns four slots, starting at 09:00, 09:30, 10:00, and 10:30. Sunday returns an empty JSON array. A malformed or missing date returns HTTP 400 with a useful error. Those cases are part of the example, rather than something the reader has to guess from a happy-path request.
Less startup code, the same hosting responsibilities
In a typical .NET 5 application, I would build a host, select a startup
class, configure services, and configure the request pipeline there. The
new builder puts those related operations together. Register dependencies
on builder.Services before Build, and map routes on the resulting app.
The ASP.NET Core release post introduces this model and minimal APIs. A handler can bind route or query values, accept a request body, or receive a registered dependency without requiring a controller class around it.
That is useful for a handful of endpoints. I can follow the route, its input,
and its response in one place. If a handler grows into a screenful of
calculation, I move that calculation into a function or a module. Minimal
hosting does not mean the entire application belongs in Program.cs.
It also does not remove operational requirements. TLS termination, authentication, logs, shutdown behavior, and sensible resource limits still need to be designed. This sample listens only on loopback and makes no authorization claim. A real booking system would need both authorization and an atomic way to prevent two clients taking the same slot.
Existing controller applications do not need to be rewritten just to adopt .NET 6. I would retain controllers when their conventions and MVC features are helping the application. The small-service path is an additional tool, not a reason to churn a working API.
The C# 10 changes are mostly about reading less scaffolding
namespace Availability; applies to the rest of Slot.cs without another
pair of braces. File-scoped namespaces are an easy improvement for files
that already contain a single namespace. They make no difference to the
public name of the type.
Global usings address repetition across files. I can write a
global using System.Globalization; directive in a dedicated file if every
file in a project benefits from it. Implicit usings are the SDK's generated
set, selected by project type and enabled in our project file. The two
features work together, but they are not the same mechanism.
The C# 10 overview also covers record structs and improvements around lambdas. I would use those when they fit the data and API involved, rather than convert every record into a value type because the syntax is new.
My preference is to keep common framework imports unobtrusive and unusual dependencies visible. If a reviewer cannot tell where a type comes from, removing one more using directive has stopped helping. This is a readability choice, not a contest to produce the shortest possible file.
DateOnly is useful, but the boundary still matters
Our service handles a calendar date and local opening times. A midnight
DateTime would carry an unnecessary time component; it could also tempt
someone to apply a timezone conversion to a value that is not an instant.
DateOnly and TimeOnly express the intended values more directly.
Microsoft's date and time explanation describes those new types. They do not identify a timezone or make 09:00 an unambiguous point on the global timeline. If appointments span locations, the location's timezone and daylight-saving rules must join the model.
There is another practical boundary: .NET 6's built-in System.Text.Json
does not serialize these new types automatically. The response record
therefore exposes deliberately formatted strings. Returning DateOnly
directly would require a converter. A clean internal type is not proof that
every serializer and database provider already understands it.
The same care applies to parsing. A caller sends an explicit ISO-style calendar date, and the code parses that exact format. Depending on the server's culture would make the API behave differently after deployment. The extra parsing code earns its place because it defines the contract.
LINQ additions remove some familiar small helpers
The new Chunk method is useful when an in-memory sequence needs bounded
batches. DistinctBy expresses uniqueness by a selected key without
introducing a comparer type. MinBy and MaxBy select an element by one of
its values rather than sorting an entire sequence just to take an endpoint.
These additions are documented in the
.NET 6 library changes.
For example, this can be inserted before app.Run() as a separate console
demonstration; it is not another route:
var branches = new[] { "north", "south", "north", "east", "west" };
foreach (var batch in branches.DistinctBy(name => name).Chunk(2))
{
Console.WriteLine(string.Join(", ", batch));
}
This prints two batches of unique names. For strings, ordinary Distinct
would already solve the uniqueness part; the key selector becomes more
useful with richer objects. I would not change old code merely to insert
the new method name.
Also distinguish LINQ over memory from a database provider's query language.
A method being available on Enumerable does not guarantee that a provider
can translate an equivalent query into SQL. Materializing all rows to make
a convenient helper available can turn a tidy expression into an expensive
endpoint.
Upgrade the runtime, then measure the service
The syntax is the visible part of the release. The runtime performance work also covers substantial changes across the JIT, libraries, and file I/O. Those are good reasons to run a representative application comparison.
They are not a percentage saving I can paste into a capacity plan. I would measure the old and new versions with identical payloads and concurrency, including failures and tail latency. The distinction between image size, heap usage, and working set from the Linux container article still applies. Fewer source files do not imply a smaller live heap.
For this service, I would accept the upgrade after checking the date cases, HTTP responses, deployment configuration, and a representative load test. Then I would keep the simpler startup code because it makes the service easier to understand. That is enough of a benefit without pretending that the rest of operating an API has disappeared.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


