Link Search Menu Expand Document

1. Introducing Autobarn

This is where the workshop starts. Autobarn is a small ASP.NET Core MVC website for listing second-hand cars. It has no HTTP API yet. Every later module builds on this code, so this page covers what’s in the solution rather than what changed.

What’s in this module

01-introducing-autobarn/
├── Autobarn.slnx                  Solution file (XML "slnx" format)
├── Directory.Build.props          Shared MSBuild settings for every project
├── Directory.Packages.props       Central NuGet package versions
├── global.json                    Pins the .NET SDK to version 10
├── Autobarn.Data/                 Class library: EF Core entities + DbContext
│   ├── AutobarnDbContext.cs
│   └── Entities/
│       ├── Vehicle.cs
│       ├── VehicleMake.cs
│       └── VehicleModel.cs
└── Autobarn.Website/              ASP.NET Core MVC web application
    ├── Program.cs
    ├── Controllers/               Home, Makes, CarModels, Vehicles
    ├── Models/                    VehicleDto, RegistrationYearAttribute, ErrorViewModel
    └── Views/                     Razor views for each controller

There are two projects:

Project Type Purpose
Autobarn.Data Class library The domain model and the Entity Framework Core DbContext
Autobarn.Website ASP.NET Core MVC Server-rendered HTML pages for browsing makes, models and vehicles, and a form for advertising a vehicle

Step 1: Shared build configuration

Files: Directory.Build.props, Directory.Packages.props, global.json

MSBuild picks up Directory.Build.props automatically for every project below it. That means the target framework, implicit usings and nullable reference types are set once for the whole solution:

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

Directory.Packages.props turns on Central Package Management (ManagePackageVersionsCentrally). Each project lists a <PackageReference> with no version, and the version comes from this one file. Several packages are listed here that the first module doesn’t use yet, including Microsoft.AspNetCore.OpenApi, Scalar.AspNetCore and Microsoft.Extensions.Configuration.Json. Later modules reference them without touching this file.

Why: In a workshop with several projects and eight modules, keeping framework and package versions in one place stops the projects drifting apart.

Step 2: The domain entities

Files: Autobarn.Data/Entities/VehicleMake.cs, VehicleModel.cs, Vehicle.cs

The domain has three levels: a make (manufacturer) has many models, and a model has many vehicles.

public class VehicleMake {
	public string Code { get; set; } = "";          // e.g. "nissan"
	public string Name { get; set; } = "";          // e.g. "Nissan"
	public ICollection<VehicleModel> Models { get; set; } = [];
}
public class VehicleModel {
	public string Code { get; set; } = "";          // e.g. "nissan-note"

	// The part of the code that identifies this model within its make, e.g. "note"
	public string ModelCode => Code.StartsWith($"{MakeCode}-", StringComparison.OrdinalIgnoreCase)
		? Code[(MakeCode.Length + 1)..]
		: Code;

	public VehicleMake VehicleMake { get; set; } = null!;
	public string MakeCode { get; set; } = "";      // e.g. "nissan"
	public string Name { get; set; } = "";          // e.g. "Note"
	public ICollection<Vehicle> Vehicles { get; set; } = [];
}
public class Vehicle {
	public string Registration { get; set; } = "";  // e.g. "OUTATIME"
	public string Color { get; set; } = "";
	public int Year { get; set; }
	public VehicleModel Model { get; set; } = null!;
	public string ModelCode { get; set; } = "";     // e.g. "nissan-note"
}

Some things to notice:

  • Natural keys. Makes and models are identified by readable codes such as nissan and nissan-note, and vehicles by their registration plate. There are no integer IDs. These codes are what later modules put in API URLs.
  • VehicleModel.ModelCode is a computed property with no setter, so EF Core doesn’t map it to a column. It isn’t used much in this module. In Module 05 it produces short URLs like /makes/nissan/models/note.
  • XML doc comments (///<summary>, ///<example>) are on every property. The .csproj files set <GenerateDocumentationFile>true</GenerateDocumentationFile>. Module 04 uses these comments to fill in the OpenAPI description.

Step 3: The DbContext

File: Autobarn.Data/AutobarnDbContext.cs

public class AutobarnDbContext(
	DbContextOptions<AutobarnDbContext> options
) : DbContext(options) {

	public virtual DbSet<VehicleMake> Makes { get; set; }
	public virtual DbSet<VehicleModel> Models { get; set; }
	public virtual DbSet<Vehicle> Vehicles { get; set; }

	protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
		if (Database.IsSqlite()) {
			configurationBuilder.Properties<string>().UseCollation("NOCASE");
		}
	}

	protected override void OnModelCreating(ModelBuilder modelBuilder) {
		modelBuilder.Entity<VehicleMake>(entity => {
			entity.HasKey(e => e.Code);
			entity.Property(e => e.Code).HasMaxLength(32).IsUnicode(false);
			entity.Property(e => e.Name).HasMaxLength(32).IsUnicode(false);
			entity.HasMany(e => e.Models).WithOne(m => m.VehicleMake).HasForeignKey(m => m.MakeCode);
		});
		// ...similar configuration for VehicleModel and Vehicle
	}
}
  • The context uses a primary constructor, so the options arrive through dependency injection.
  • Every string column uses SQLite’s NOCASE collation. Lookups like m.Code == id then match regardless of case, so /makes/Details/NISSAN and /makes/Details/nissan find the same row.
  • OnModelCreating sets the natural keys, column lengths and the make → model → vehicle relationships.

Step 4: Wiring up the website

File: Autobarn.Website/Program.cs

// A named shared-cache in-memory database exists for as long as at least one connection to it is open,
// so we hold this connection open for the lifetime of the app, and each DbContext opens its own connection.
const string connectionString = "Data Source=autobarn;Mode=Memory;Cache=Shared";
await using var keepAliveConnection = new SqliteConnection(connectionString);
await keepAliveConnection.OpenAsync();

builder.Services.AddDbContext<AutobarnDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddControllersWithViews(options => options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));
builder.Services.AddValidation();

var app = builder.Build();
// ...
await using (var scope = app.Services.CreateAsyncScope()) {
	var db = scope.ServiceProvider.GetRequiredService<AutobarnDbContext>();
	await db.Database.EnsureCreatedAsync();
}
  • In-memory SQLite. The database lives in memory with a shared cache. A named in-memory database disappears when its last connection closes, so keepAliveConnection stays open for the whole life of the app. Nothing is installed and nothing is written to disk, and every restart begins with a fresh database.
  • EnsureCreatedAsync() creates the schema at startup. There are no migrations. In this module the tables start empty.
  • AutoValidateAntiforgeryTokenAttribute is a global filter that checks antiforgery tokens on every POST form. This protects the Advertise form.
  • AddValidation() is new in ASP.NET Core 10. It turns on DataAnnotations validation for minimal API endpoints. Nothing uses it yet, but Modules 05 and 07 depend on it.

Step 5: Controllers and views

Files: Controllers/*.cs, Views/**/*.cshtml

Each controller takes AutobarnDbContext through its primary constructor and queries it with EF Core:

public class MakesController(AutobarnDbContext db) : Controller {

	public async Task<IActionResult> Index() {
		var list = await db.Makes.ToListAsync();
		return View(list);
	}

	public async Task<IActionResult> Details(string id) {
		var make = await db.Makes
			.Include(m => m.Models)
			.FirstOrDefaultAsync(m => m.Code == id);
		if (make == null) return NotFound();
		return View(make);
	}
}
Route Shows
/Makes All makes
/Makes/Details/{code} One make and its models
/CarModels All models, grouped by make
/CarModels/Details/{code} One model and the vehicles of that model
/Vehicles All vehicles
/Vehicles/Details/{registration} One vehicle
/Vehicles/Advertise/{modelCode} A form for listing a vehicle for sale

Step 6: Advertising a vehicle (form handling and validation)

Files: Controllers/VehiclesController.cs, Models/VehicleDto.cs, Models/RegistrationYearAttribute.cs, Views/Vehicles/Advertise.cshtml

The Advertise form binds to VehicleDto, a separate input model. Posting straight to the Vehicle entity would expose it to over-posting.

public partial class VehicleDto {

	[HiddenInput(DisplayValue = false)]
	public string? ModelCode { get; set; }

	public string? ModelName { get; set; }

	[GeneratedRegex("[^A-Z0-9]")]
	private static partial Regex VehicleRegistrationRegex();

	[Required]
	[DisplayName("Registration Plate")]
	public string? Registration {
		get;
		set => field = value is null ? null : VehicleRegistrationRegex().Replace(value.ToUpperInvariant(), "");
	}

	[Required]
	[DisplayName("Year of first registration")]
	[RegistrationYear]
	public int? Year { get; set; }

	[Required]
	[DisplayName("Colour")]
	public string Color { get; set; } = "";
}
  • The Registration setter uses the C# 14 field keyword to normalise input as it arrives. "abc-123 xy" becomes "ABC123XY".
  • [GeneratedRegex] builds the regex at compile time.
  • RegistrationYearAttribute is a custom validation attribute. [Range(1950, 2026)] would go out of date because attribute arguments must be compile-time constants. This attribute works out the upper bound (DateTime.Today.Year) each time it runs. It also implements IClientModelValidator, which emits the same data-val-range-* attributes as [Range], so jQuery unobtrusive validation still runs in the browser.

The POST action adds checks that need the database:

[HttpPost]
public async Task<IActionResult> Advertise(VehicleDto dto) {
	if (await db.Vehicles.AnyAsync(v => v.Registration == dto.Registration))
		ModelState.AddModelError(nameof(dto.Registration), "That registration is already listed in our database.");

	var carModel = await db.Models.FirstOrDefaultAsync(m => m.Code == dto.ModelCode);
	if (carModel == null)
		ModelState.AddModelError(nameof(dto.ModelCode), $"Sorry, {dto.ModelCode} is not a valid model code.");

	if (!ModelState.IsValid) return View(dto);
	var vehicle = new Vehicle {
		Registration = dto.Registration!,
		Color = dto.Color,
		Model = carModel!,
		Year = dto.Year!.Value
	};
	await db.Vehicles.AddAsync(vehicle);
	await db.SaveChangesAsync();
	return RedirectToAction(nameof(Details), new { id = vehicle.Registration });
}

The action follows the Post/Redirect/Get pattern: after a successful save it redirects to the new vehicle’s details page. Module 07 exposes the same “create a vehicle” operation as an HTTP API and reuses both VehicleDto and these rules.

Running it

dotnet run --project Autobarn.Website

The site listens on http://localhost:5000 and https://localhost:5001, as set in Properties/launchSettings.json.

What’s next

The database is empty, so every page says “No makes found”. Module 02 seeds it with sample data.