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
nissanandnissan-note, and vehicles by their registration plate. There are no integer IDs. These codes are what later modules put in API URLs. VehicleModel.ModelCodeis 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.csprojfiles 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
NOCASEcollation. Lookups likem.Code == idthen match regardless of case, so/makes/Details/NISSANand/makes/Details/nissanfind the same row. OnModelCreatingsets 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
keepAliveConnectionstays 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.AutoValidateAntiforgeryTokenAttributeis 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
Registrationsetter uses the C# 14fieldkeyword to normalise input as it arrives."abc-123 xy"becomes"ABC123XY". [GeneratedRegex]builds the regex at compile time.RegistrationYearAttributeis 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 implementsIClientModelValidator, which emits the samedata-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.