Projects and Solutions

Read and write SDK-style .csproj files, organise an API solution in a .slnx file and decide when a second project is worth having.

Syntax<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> </Project>

An SDK-style project is a short MSBuild file whose Sdk attribute imports hundreds of lines of defaults. Those defaults glob files: every **/*.cs under the project folder compiles (except bin and obj), and the Web SDK treats appsettings*.json and wwwroot/** as content copied to the publish output. You add only what differs from the defaults.

Choosing the SDK

Sdk attributeUse forWhat it adds
Microsoft.NET.SdkClass libraries, console tools, test projectsBase implicit usings (System, LINQ, collections, tasks)
Microsoft.NET.Sdk.WebAPIs and web appsFramework reference to Microsoft.AspNetCore.App, ASP.NET implicit usings, content items, Server GC on
Microsoft.NET.Sdk.WorkerWorker servicesHosting implicit usings, appsettings*.json as content

A class library that needs ASP.NET Core types (a shared middleware or endpoint-registration library) keeps Microsoft.NET.Sdk and adds <FrameworkReference Include="Microsoft.AspNetCore.App" />. Do not reference the old Microsoft.AspNetCore.Http 2.x packages: they are unmaintained and drag vulnerable dependencies into the graph.

ImplicitUsings and Nullable

ImplicitUsings=enable generates obj/.../Orders.Api.GlobalUsings.g.cs with global using lines for the SDK's namespaces; add your own with <Using Include="..." /> items. Nullable=enable turns on compile-time flow analysis only. It adds no run-time checks: a JSON body that sends null, or omits the property, still leaves null in a non-nullable string. RespectNullableAnnotations rejects explicit nulls only; omitted values need required members, RespectRequiredConstructorParameters, or validation with [Required] and AddValidation(). Make the warnings count with <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>, appending so gates set in Directory.Build.props survive; on an existing codebase, enable it project by project.

Solutions and .slnx

A solution is just a list of projects for IDEs and for running dotnet build or dotnet test at the root; build order comes from ProjectReference items, not from the solution. The .NET 10 SDK creates .slnx files: plain XML with folders and project paths instead of the GUID-heavy .sln format. dotnet sln migrate converts an existing file. Delete the old .sln afterwards: with both in one folder, dotnet build fails with MSB1011 because it cannot choose, and the two drift apart.

Layering an API solution

Start with one API project and one test project, organised by feature folders. Add a class library when a second host needs the same code, for example Orders.Core shared by Orders.Api and Orders.Worker. References point inward: hosts reference Core, Core references nothing web-specific, and MSBuild rejects cycles. Splitting every layer into its own project by habit adds build time and InternalsVisibleTo plumbing without enforcing much.

What goes wrong

  • A project nested inside another project's folder: the outer project globs the inner one's .cs files and fails with duplicate types or missing xUnit references. Keep src/ and tests/ siblings.
  • Files outside the content rules (email templates, seed CSVs) are missing from the container image until marked CopyToPublishDirectory.

Example

Example · xml
<!-- src/Orders.Api/Orders.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>Shop.Orders.Api</RootNamespace>
    <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
    <UserSecretsId>shop-orders-api</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <!-- Extra global usings on top of the Web SDK's implicit ones -->
    <Using Include="System.Text.Json" />
    <Using Include="Microsoft.AspNetCore.Http.HttpResults" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Orders.Core\Orders.Core.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
  </ItemGroup>

  <ItemGroup>
    <InternalsVisibleTo Include="Orders.Api.Tests" />
    <!-- Not content by default: without this the templates are missing from the image -->
    <None Update="EmailTemplates\**\*.html" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
  </ItemGroup>

</Project>

When to use it

  • Split order rules into Orders.Core when a new Orders.Worker host must reuse them, with references pointing only inward.
  • Migrate Shop.sln to Shop.slnx with dotnet sln migrate and update the CI step that still names the .sln file.
  • Ship HTML email templates with the API by marking them CopyToPublishDirectory after they went missing from the image.

More examples

A .slnx solution file

Solution folders mirror the directories, and there are no project-type GUIDs or configuration matrices to merge. Pull-request conflicts in the old <code>.sln</code> format were mostly GUID noise; here a new project is one line. CI scripts that hard-code <code>Shop.sln</code> must be updated in the same change that runs <code>dotnet sln migrate</code>.

Example · xml
<!-- Shop.slnx, as written by dotnet new sln and dotnet sln add -->
<Solution>
  <Folder Name="/src/">
    <Project Path="src/Orders.Api/Orders.Api.csproj" />
    <Project Path="src/Orders.Core/Orders.Core.csproj" />
    <Project Path="src/Orders.Worker/Orders.Worker.csproj" />
  </Folder>
  <Folder Name="/tests/">
    <Project Path="tests/Orders.Api.Tests/Orders.Api.Tests.csproj" />
  </Folder>
</Solution>

A library that uses ASP.NET Core types

A <code>FrameworkReference</code> gives a class library compile-time access to <code>HttpContext</code>, <code>IEndpointRouteBuilder</code> and the rest of ASP.NET Core without shipping any assemblies: the API's runtime image provides them. Projects still referencing <code>Microsoft.AspNetCore.Http.Abstractions</code> 2.2 or similar packages get unpatched code and audit warnings; replace them with this one line.

Example · xml
<!-- src/Shop.Web.Common/Shop.Web.Common.csproj: middleware and endpoint helpers shared by several APIs -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <!-- Comes from the shared framework in the runtime image, so it is patched with the runtime -->
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>

</Project>

Program.cs with no using lines

With <code>ImplicitUsings</code> and <code>Nullable</code> enabled this file needs no <code>using</code> lines, and dereferencing <code>customer.Email</code> without a null check would raise warning CS8602, which <code>WarningsAsErrors</code> turns into a build failure. The log line records whether an email exists rather than the address itself, keeping personal data out of logs.

Example · csharp
// The Web SDK's implicit usings cover WebApplication, ILogger, HttpClient, LINQ and tasks.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var customers = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase)
{
    ["CUST-0042"] = new("CUST-0042", "Dana Whitfield", Email: null),
    ["CUST-0077"] = new("CUST-0077", "Ravi Menon", Email: "[email protected]"),
};

app.MapGet("/customers/{id}", (string id, ILogger<Program> logger) =>
{
    if (!customers.TryGetValue(id, out var customer))
        return Results.NotFound();

    // Email is string?, so the compiler makes this code handle the missing case.
    var canEmail = customer.Email is not null;
    logger.LogInformation("Customer {CustomerId} looked up, email on file: {CanEmail}", id, canEmail);
    return Results.Ok(new { customer.Id, customer.Name, canEmail });
});

app.Run();

record Customer(string Id, string Name, string? Email);

Discussion

  • Be the first to comment on this lesson.