Free Handbook · Every example compiled & verified

C# + Tools

Nine mini-labs on C# as it is used at work: the dotnet CLI, NuGet, ASP.NET Core minimal APIs, xUnit, EF Core with PostgreSQL, Redis, Blazor, Docker and GitHub Actions.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 12 · what you'll be able to do

  • Create a solution with an app and a test project using the dotnet CLI, and read a .csproj file
  • Add NuGet packages and pin their versions for the whole solution
  • Build a minimal API with dependency injection, and test logic with xUnit facts and theories
  • Store data in PostgreSQL through EF Core, cache it in Redis, and show it in a Blazor component
  • Package a service in a multi-stage Docker image and build and test it on every push with GitHub Actions
01

The .NET toolchain at a glance

Up to now every example has been a single Program.cs run with dotnet run. Real C# work happens in projects (.csproj) grouped in a solution, with libraries from NuGet, tests, a database, a container image and a CI pipeline. None of the labs below is exotic — together they are the everyday stack of a .NET backend team. The code is static (it needs packages and services), so read it, then build it on your machine.

JobToolLab
Create, build, run, test, publishdotnet CLI1
Third-party librariesNuGet2
HTTP APIsASP.NET Core (minimal APIs)3
Automated testsxUnit4
Relational dataEF Core + PostgreSQL5
CachingRedis6
Web UI in C#Blazor7
Packaging and deploymentDocker8
Build and test on every pushGitHub Actions9
02

Lab 1: the dotnet CLI and project structure

The dotnet command is the whole toolchain: templates, builds, package restore, tests and publishing. Visual Studio and Rider call the same commands under the hood, so knowing them means you can build anywhere — including a CI server with no IDE.

C# + .NET

A solution with a web API, a class library and a test project

This layout — one solution, src/ for code, tests/ for tests — is what most .NET repositories look like. The API references the library; the tests reference the library. Business logic lives in Shop.Core, where it is easy to test without starting a web server.

bash
# a solution file groups projects (.NET 10 creates the simpler XML .slnx format)
dotnet new sln -n Shop

dotnet new webapi   -o src/Shop.Api        # ASP.NET Core API
dotnet new classlib -o src/Shop.Core       # business logic
dotnet new xunit    -o tests/Shop.Tests    # tests

dotnet sln add src/Shop.Api src/Shop.Core tests/Shop.Tests
dotnet add src/Shop.Api reference src/Shop.Core
dotnet add tests/Shop.Tests reference src/Shop.Core

dotnet build                               # restore packages + compile everything
dotnet test                                # build + run all tests
dotnet run --project src/Shop.Api          # start the API
dotnet watch --project src/Shop.Api        # restart / hot reload on every save
dotnet publish src/Shop.Api -c Release -o out   # optimised build to deploy

# Shop/
#   Shop.slnx
#   src/Shop.Api/Shop.Api.csproj, Program.cs, appsettings.json
#   src/Shop.Core/Shop.Core.csproj
#   tests/Shop.Tests/Shop.Tests.csproj
xmlsrc/Shop.Core/Shop.Core.csproj
<Project Sdk="Microsoft.NET.Sdk">

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

</Project>

A modern .csproj is tiny: every .cs file in the folder is included automatically. The web project uses Sdk="Microsoft.NET.Sdk.Web" instead.

Where the single-file examples fit
dotnet run app.cs, used throughout this handbook, is a .NET 10 file-based app: a project without a .csproj. When a script outgrows one file, dotnet project convert app.cs turns it into a normal project with the same settings.
03

Lab 2: NuGet packages

NuGet is the package manager for .NET, and nuget.org is its public registry. A package is added as a <PackageReference> in the .csproj; dotnet restore (run automatically by build) downloads it into a shared cache. In a solution with many projects, Central Package Management keeps every version in one file so two projects can never drift onto different versions of the same library.

C# + NuGet

Adding a package and pinning versions centrally

dotnet add src/Shop.Api package Polly.Core edits the .csproj for you. With Directory.Packages.props at the solution root, each project lists only the package name; the version comes from the central file. Check for outdated and vulnerable packages regularly with dotnet list package --outdated and dotnet list package --vulnerable.

xml
<!-- Directory.Packages.props (solution root) -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Polly.Core" Version="8.6.4" />
    <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
    <PackageVersion Include="xunit" Version="2.9.3" />
  </ItemGroup>
</Project>

<!-- src/Shop.Api/Shop.Api.csproj: name only, no version -->
<ItemGroup>
  <PackageReference Include="Polly.Core" />
  <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
Check before you add
Every package is code you now ship and maintain. Before adding one, look at its download count, last release date, license and open security advisories on nuget.org — and check whether the base library already does the job (JSON, HTTP, logging and dependency injection are all built in).
04

Lab 3: an ASP.NET Core minimal API

ASP.NET Core is the web framework of .NET, and minimal APIs are its shortest form: map a route to a lambda. Parameters are bound automatically — from the route, the query string, the JSON body, or the dependency injection container, which creates your services and hands them in. Records make natural request and response types, and they are serialized to JSON for you.

C# + .NET

A products API with dependency injection and validation

Create it with dotnet new web -o Shop.Api, replace Program.cs, and dotnet run. AddSingleton registers one shared store for the app's lifetime; the endpoint lambdas just ask for IProductStore. TypedResults makes the possible responses (200, 201, 400, 404) part of the method's type, which also documents them in OpenAPI.

C#
using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductStore, InMemoryProductStore>();
builder.Services.AddOpenApi();                     // /openapi/v1.json in development

var app = builder.Build();
if (app.Environment.IsDevelopment()) app.MapOpenApi();

var products = app.MapGroup("/api/products");

products.MapGet("/", (IProductStore store) => store.All());

products.MapGet("/{id:int}", (int id, IProductStore store) =>
    store.Find(id) is { } p ? Results.Ok(p) : Results.NotFound());

products.MapPost("/", (NewProduct body, IProductStore store) =>
{
    if (string.IsNullOrWhiteSpace(body.Name) || body.PriceCents < 0)
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["product"] = ["Name is required and price cannot be negative."],
        });
    Product saved = store.Add(body);
    return Results.Created($"/api/products/{saved.Id}", saved);
});

app.Run();

public record Product(int Id, string Name, int PriceCents);
public record NewProduct(string Name, int PriceCents);

public interface IProductStore
{
    IEnumerable<Product> All();
    Product? Find(int id);
    Product Add(NewProduct p);
}

public class InMemoryProductStore : IProductStore
{
    private readonly ConcurrentDictionary<int, Product> _items = new();   // many requests at once
    private int _nextId;

    public IEnumerable<Product> All() => _items.Values.OrderBy(p => p.Id);
    public Product? Find(int id) => _items.GetValueOrDefault(id);
    public Product Add(NewProduct p)
    {
        int id = Interlocked.Increment(ref _nextId);
        return _items[id] = new Product(id, p.Name, p.PriceCents);
    }
}

// curl -X POST localhost:5000/api/products -H "Content-Type: application/json" \
//      -d '{"name":"Pen","priceCents":150}'
// curl localhost:5000/api/products/1
Dependency injection and the patterns behind it →
Service lifetimes
Singleton: one instance for the whole app (caches, the in-memory store above — must be thread-safe). Scoped: one per HTTP request (an EF Core DbContext). Transient: a new one every time it is asked for. Injecting a scoped service into a singleton is a classic bug; ASP.NET Core throws at startup in Development to catch it.
05

Lab 4: unit tests with xUnit

xUnit is the most widely used .NET test framework (MSTest and NUnit are the others, and all three run with dotnet test). A [Fact] is a test with no inputs; a [Theory] runs the same test once per [InlineData] row. xUnit creates a new instance of the test class for every test, so tests cannot leak state into each other.

C# + .NET

Testing a price calculator: a fact, a theory and an exception

Each test follows Arrange, Act, Assert. The theory replaces four near-identical tests with one method and a table of cases, and the test runner reports each row separately. Assert.Throws returns the exception, so you can check its message too. Run dotnet test; a failure prints the expected and actual values.

C#
// src/Shop.Core/Pricing.cs
namespace Shop.Core;

public static class Pricing
{
    public static int TotalCents(int unitCents, int qty)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(qty);
        int total = unitCents * qty;
        return qty >= 10 ? total * 90 / 100 : total;   // 10% bulk discount
    }
}

// tests/Shop.Tests/PricingTests.cs
using Shop.Core;

namespace Shop.Tests;

public class PricingTests
{
    [Fact]
    public void SingleItemHasNoDiscount()
    {
        int total = Pricing.TotalCents(250, 1);
        Assert.Equal(250, total);
    }

    [Theory]
    [InlineData(100, 0, 0)]
    [InlineData(100, 9, 900)]
    [InlineData(100, 10, 900)]     // discount starts at 10
    [InlineData(100, 20, 1800)]
    public void BulkDiscount(int unit, int qty, int expected)
    {
        Assert.Equal(expected, Pricing.TotalCents(unit, qty));
    }

    [Fact]
    public void NegativeQuantityIsRejected()
    {
        var ex = Assert.Throws<ArgumentOutOfRangeException>(() => Pricing.TotalCents(100, -1));
        Assert.Equal("qty", ex.ParamName);
    }
}
Testing async code and endpoints
Test methods can be public async Task and await freely; use await Assert.ThrowsAsync<T>(…) for async failures. To test the Lab 3 API end to end in memory, add the Microsoft.AspNetCore.Mvc.Testing package and create a WebApplicationFactory<Program>: it starts the app without a real network port and gives you an HttpClient.
06

Lab 5: EF Core with PostgreSQL

Entity Framework Core is the standard .NET ORM: you describe tables as C# classes and a DbContext, write queries in LINQ, and EF translates them to SQL. The Npgsql provider connects it to PostgreSQL. Because DbSet<T> is an IQueryable, Where and OrderBy run in the database, not in your process — see IQueryable versus IEnumerable.

C# + PostgreSQL

A DbContext, a migration and an async query

Start Postgres with docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:17, add the Npgsql.EntityFrameworkCore.PostgreSQL package, and put the connection string in appsettings.json (and real passwords in user secrets or environment variables, never in Git). Migrations are C# files that create and change the schema; commit them with the code.

C#
// Data/ShopDb.cs
using Microsoft.EntityFrameworkCore;

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

public class Order
{
    public int Id { get; set; }
    public required string Customer { get; set; }
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; set; }
}

// Program.cs
builder.Services.AddDbContext<ShopDb>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));

app.MapGet("/api/orders/big", async (ShopDb db, CancellationToken ct) =>
    await db.Orders
        .Where(o => o.Total > 100)               // becomes WHERE "Total" > 100
        .OrderByDescending(o => o.CreatedAt)
        .Take(20)
        .AsNoTracking()                          // read-only: skip change tracking
        .ToListAsync(ct));

app.MapPost("/api/orders", async (Order order, ShopDb db) =>
{
    db.Orders.Add(order);
    await db.SaveChangesAsync();                 // INSERT ... RETURNING "Id"
    return Results.Created($"/api/orders/{order.Id}", order);
});

// appsettings.json:
//   "ConnectionStrings": { "Shop": "Host=localhost;Database=shop;Username=postgres;Password=dev" }
//
// dotnet tool install --global dotnet-ef
// dotnet ef migrations add InitialCreate     # generates Migrations/*.cs
// dotnet ef database update                  # applies them to the database
Learn the SQL that EF Core writes for you →
The N+1 query problem
Looping over 100 orders and touching order.Customer on each can fire 101 queries. Load related data up front with .Include(o => o.Customer) or project only what you need with Select. Turn on EF Core's SQL logging in development and read what it sends — it is the fastest way to spot this.
07

Lab 6: caching with Redis

Redis is an in-memory key-value store that several app instances can share, which makes it the usual place for a cache that survives restarts and works behind a load balancer. ASP.NET Core talks to it through the IDistributedCache interface, so the same code can use an in-memory cache in tests and Redis in production.

C# + Redis

Cache-aside with IDistributedCache

Add the Microsoft.Extensions.Caching.StackExchangeRedis package and run Redis with docker run -d -p 6379:6379 redis:8. The pattern is cache-aside: look in the cache; on a miss, load from the database, store the result with an expiry, and return it. The expiry is what keeps the cache from serving stale prices forever.

C#
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;

// Program.cs
builder.Services.AddStackExchangeRedisCache(o =>
{
    o.Configuration = builder.Configuration.GetConnectionString("Redis");   // "localhost:6379"
    o.InstanceName = "shop:";                                                // key prefix
});
builder.Services.AddScoped<ProductCatalog>();

// ProductCatalog.cs
public class ProductCatalog(IDistributedCache cache, ShopDb db)
{
    private static readonly DistributedCacheEntryOptions FiveMinutes = new()
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
    };

    public async Task<Product?> GetAsync(int id, CancellationToken ct)
    {
        string key = $"product:{id}";
        string? json = await cache.GetStringAsync(key, ct);
        if (json is not null)
            return JsonSerializer.Deserialize<Product>(json);          // hit

        Product? product = await db.Products.FindAsync([id], ct);       // miss
        if (product is not null)
            await cache.SetStringAsync(key, JsonSerializer.Serialize(product), FiveMinutes, ct);
        return product;
    }

    public Task InvalidateAsync(int id, CancellationToken ct) =>
        cache.RemoveAsync($"product:{id}", ct);                        // call after an update
}
HybridCache
.NET 9 added HybridCache (package Microsoft.Extensions.Caching.Hybrid), which combines a fast in-process cache with a distributed one like Redis and handles the "many requests miss at once and all hit the database" stampede for you: await cache.GetOrCreateAsync(key, async ct => await LoadAsync(ct)).
08

Lab 7: a Blazor component

Blazor builds interactive web UI in C# instead of JavaScript. A component is a .razor file: HTML markup with @ expressions, plus a @code block holding its state and event handlers. When state changes after an event, Blazor re-renders and updates only the parts of the page that changed. Components can run on the server (over a SignalR connection) or in the browser via WebAssembly — the component code is the same.

C# + Blazor

A to-do list with binding, events and a parameter

Create an app with dotnet new blazor -o Shop.Web --interactivity Server and add this file under Components/Pages. @bind keeps the input and the newTitle field in sync; @onclick wires a C# method to a button; [Parameter] lets a parent pass values in, e.g. <TodoList Title="Groceries" />.

razor
@page "/todos"
@rendermode InteractiveServer

<h3>@Title (@items.Count(i => !i.Done) left)</h3>

<input @bind="newTitle" @bind:event="oninput" placeholder="Something to do" />
<button @onclick="Add" disabled="@string.IsNullOrWhiteSpace(newTitle)">Add</button>

<ul>
    @foreach (var item in items)
    {
        <li>
            <input type="checkbox" @bind="item.Done" />
            <span style="@(item.Done ? "text-decoration: line-through" : "")">@item.Title</span>
        </li>
    }
</ul>

@code {
    [Parameter] public string Title { get; set; } = "My list";

    private readonly List<TodoItem> items = new();
    private string newTitle = "";

    private void Add()
    {
        items.Add(new TodoItem { Title = newTitle.Trim() });
        newTitle = "";                  // clears the textbox via @bind
    }

    private class TodoItem
    {
        public required string Title { get; init; }
        public bool Done { get; set; }
    }
}
Where Blazor is used
Blazor is strongest for internal tools, admin panels and line-of-business apps built by teams that already write C#: one language from database to UI, and shared validation classes between server and client. Public, SEO-heavy consumer sites more often use a JavaScript framework with an ASP.NET Core API behind it.
09

Lab 8: a multi-stage Docker image

Microsoft publishes two kinds of .NET image: sdk (everything needed to build — large) and aspnet / runtime (only what is needed to run — much smaller). A multi-stage Dockerfile builds in the first and copies just the published output into the second, so compilers and source code never ship.

C# + Docker

Build with the SDK image, run on the ASP.NET runtime image

Copying the .csproj and running dotnet restore before copying the rest of the source means Docker caches the restored packages: editing a .cs file does not re-download NuGet packages on the next build. The .NET images include a non-root app user; switching to it with USER $APP_UID limits the damage if the app is ever compromised. Build with docker build -t shop-api . and run with docker run -p 8080:8080 shop-api.

dockerfile
# ---- build stage ----
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# restore first: this layer is cached until a .csproj changes
COPY src/Shop.Api/Shop.Api.csproj src/Shop.Api/
COPY src/Shop.Core/Shop.Core.csproj src/Shop.Core/
RUN dotnet restore src/Shop.Api/Shop.Api.csproj

COPY src/ src/
RUN dotnet publish src/Shop.Api/Shop.Api.csproj -c Release -o /app --no-restore

# ---- runtime stage ----
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .

USER $APP_UID
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Shop.Api.dll"]
No Dockerfile at all
Since .NET 8 the SDK can build a container image itself: dotnet publish src/Shop.Api -t:PublishContainer produces an image in your local Docker using the right base image and a non-root user by default. Add a .dockerignore with bin/ and obj/ if you keep a Dockerfile, so local build output is never copied in.
10

Lab 9: CI with GitHub Actions

Continuous integration means every push and pull request is built and tested on a clean machine, so "works on my laptop" is caught before it is merged. GitHub Actions reads workflow files from .github/workflows/. For .NET the whole pipeline is the same four CLI commands you run locally.

C# + GitHub

Restore, build and test on every push

setup-dotnet installs the SDK version you name. Building with -c Release and --no-restore/--no-build avoids doing the same work twice. With TreatWarningsAsErrors in your .csproj, a new nullable warning fails the pipeline too. Protect the main branch so a pull request cannot merge until this job is green.

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore -c Release

      - name: Test
        run: dotnet test --no-build -c Release --logger trx --results-directory TestResults

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: TestResults
Solution (.sln / .slnx)
A file that groups several projects so they build and test together.
Project (.csproj)
An MSBuild file describing one assembly: target framework, settings and package references.
NuGet
The .NET package manager and registry; packages are added as PackageReference items.
Central Package Management
Directory.Packages.props holds every package version for a whole solution.
Minimal API
ASP.NET Core endpoints declared as route-to-lambda mappings in Program.cs.
Dependency injection
The framework creates services and passes them to the code that needs them, with a singleton, scoped or transient lifetime.
DbContext
The EF Core class representing a session with the database; exposes DbSet tables and SaveChanges.
Migration
A generated C# file that applies one change to the database schema.
Multi-stage build
A Dockerfile that builds in a large SDK image and copies only the output into a small runtime image.
Quick check

In the multi-stage Dockerfile, why are the .csproj files copied and restored before the rest of the source code?

Quick check

Which service lifetime should an EF Core DbContext have in an ASP.NET Core app?

Frequently asked questions

What tools does a C# developer need to know for a job?
Beyond the language: the dotnet CLI and project files, NuGet, ASP.NET Core for APIs, EF Core with a relational database such as PostgreSQL or SQL Server, a test framework such as xUnit, Git, Docker, and a CI system such as GitHub Actions or Azure DevOps. Cloud experience (Azure or AWS) is a common extra.
Should I use EF Core or Dapper with PostgreSQL?
EF Core gives you LINQ queries, change tracking and migrations, and suits most CRUD-heavy apps. Dapper maps the results of SQL you write yourself and is thinner and faster for complex reporting queries. Many teams use EF Core by default and drop to Dapper or raw SQL for the few queries that need it.
Is Blazor a replacement for JavaScript frameworks?
For teams that write C#, Blazor can replace React or Angular for internal tools and business apps. For public, content-heavy or SEO-critical sites, JavaScript frameworks remain more common, often with an ASP.NET Core API behind them.

Finish the C# handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.