C# · STEP 4 · MODELS
C# · ASP.NET Core / Entity Framework Core: models and migrations
Add EF Core 10 and the Npgsql provider to the ASP.NET Core project, keep the Postgres connection string out of git, define a model and its DbContext, generate and run a migration, then expose full CRUD from an attribute-routed controller.
What you will learn#
- How to set the database settings
- How to create models and migrations
- How to use models to add, update, and delete records
Setup#
- Open up the same
firstdotnetproject from this morning - Add the following packages with NuGet (the .NET package manager, driven from the
dotnetCLI):
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQLdotnet add package Microsoft.EntityFrameworkCore.DesignEntity Framework Core (EF Core) is .NET's ORM. The first package is the Postgres provider; it depends on EF Core itself, so EF Core 10 comes along with it. The second is what the dotnet ef command-line tools need to build migrations. (dotnet package add … is the newer spelling of the same command; both work in .NET 10.)
- Install the EF Core tools once per machine (or
dotnet tool update --global dotnet-efif you already have an older one):
dotnet tool install --global dotnet-efdotnet ef --versionThe tool major version should match the EF Core packages — 10.x with 10.x.
Setting Up the Database Settings#
- Generate the standard .NET
.gitignore(it coversbin/,obj/and editor files):
dotnet new gitignoreCreate a Postgres database with the command
createdb firstdotnetdbAdd a
ConnectionStringssection toappsettings.Development.json, reflecting your local Postgres username and password. The Development file is read on top ofappsettings.jsonwheneverASPNETCORE_ENVIRONMENTisDevelopment, which is whatdotnet runsets.
appsettings.Development.json
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "ConnectionStrings": { "TurtleContext": "Host=localhost;Port=5432;Database=firstdotnetdb;Username=test5;Password=test5" }}Npgsql connection strings are Key=Value; pairs, not postgres:// URLs. If you would rather not have a password in a file at all, the Secret Manager stores it outside the project folder and the configuration system reads it the same way:
dotnet user-secrets initdotnet user-secrets set "ConnectionStrings:TurtleContext" "Host=localhost;Port=5432;Database=firstdotnetdb;Username=test5;Password=test5"WHY: configuration is layered — appsettings.json, then appsettings.{Environment}.json, then user secrets (Development only), then environment variables. In production the same key arrives as the environment variable ConnectionStrings__TurtleContext (double underscore for the colon), and no code changes.
Creating Our Model#
Create a Models folder and create a file called Turtle.cs.
Models/Turtle.cs
namespace firstdotnet.Models; public class Turtle{ public long Id { get; set; } public string? Name { get; set; } public int Age { get; set; }}Pretty straightforward: the properties of the class become the columns of the table. By convention a property called Id (or TurtleId) is the primary key and is generated by the database. string? says the column may be null; with nullable reference types on (the default), a plain string would be a warning until you gave it a value.
Now we need a database context for our model — the class that connects models to the database. Create TurtleContext.cs in the Models folder.
Models/TurtleContext.cs
using Microsoft.EntityFrameworkCore; namespace firstdotnet.Models; public class TurtleContext : DbContext{ public TurtleContext(DbContextOptions<TurtleContext> options) : base(options) { } // One DbSet per table. A context can hold many; you can also create several contexts. public DbSet<Turtle> Turtles => Set<Turtle>();}Now that we have our context we register it as a service so controllers can receive it through dependency injection. In Program.cs, before builder.Build():
Program.cs
using firstdotnet.Models;using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers();builder.Services.AddOpenApi(); // Register the db context as a service; read the connection string from configurationbuilder.Services.AddDbContext<TurtleContext>(options => options.UseNpgsql(builder.Configuration.GetConnectionString("TurtleContext"))); var app = builder.Build(); if (app.Environment.IsDevelopment()){ app.MapOpenApi();} app.UseHttpsRedirection();app.UseAuthorization();app.MapControllers();app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); app.Run();GetConnectionString("TurtleContext") is shorthand for builder.Configuration["ConnectionStrings:TurtleContext"].
Migrations#
We should be all wired up! Now let's have EF Core migrate our database.
- Create the initial migration. EF Core reads your context and models and writes a
Migrations/folder with a C# file describing the table:
dotnet ef migrations add InitialCreate- Then apply it to the database:
dotnet ef database updateOpen Migrations/<timestamp>_InitialCreate.cs — that file is the table definition, and it goes in git with the rest of the code. Every time you change a model, run migrations add <Name> again and database update to apply it. (The tools need the project to build, so fix compile errors first.)
We're good to go — now we just need to build the controller!
Using the Model#
We will use attribute routing. Create a new controller, TurtleController.cs, in the Controllers folder.
Controllers/TurtleController.cs
using firstdotnet.Models;using Microsoft.AspNetCore.Mvc; // Route and Http attributes, ControllerBaseusing Microsoft.EntityFrameworkCore; // ToListAsync namespace firstdotnet.Controllers; [ApiController][Route("turtles")] // The controller handles routes for /turtlespublic class TurtleController : ControllerBase{ // property to hold the database context private readonly TurtleContext _db; // constructor receives the context via dependency injection public TurtleController(TurtleContext db) { _db = db; } [HttpGet] // GET /turtles public async Task<IEnumerable<Turtle>> Index() { // return all the turtles return await _db.Turtles.ToListAsync(); } [HttpGet("{id}")] // GET /turtles/{id} public async Task<ActionResult<Turtle>> Show(long id) { var turtle = await _db.Turtles.FindAsync(id); if (turtle is null) { return NotFound(); // 404 instead of a null reference crash } return turtle; } [HttpPost] // POST /turtles public async Task<ActionResult<Turtle>> Create(Turtle turtle) { _db.Turtles.Add(turtle); await _db.SaveChangesAsync(); // nothing hits the database until SaveChanges // 201 Created with a Location header pointing at the new turtle return CreatedAtAction(nameof(Show), new { id = turtle.Id }, turtle); } [HttpPut("{id}")] // PUT /turtles/{id} public async Task<ActionResult<Turtle>> Update(long id, Turtle turtle) { var existing = await _db.Turtles.FindAsync(id); if (existing is null) { return NotFound(); } // change the tracked entity's properties; SaveChanges writes an UPDATE existing.Name = turtle.Name; existing.Age = turtle.Age; await _db.SaveChangesAsync(); return existing; } [HttpDelete("{id}")] // DELETE /turtles/{id} public async Task<IActionResult> Destroy(long id) { var turtle = await _db.Turtles.FindAsync(id); if (turtle is null) { return NotFound(); } _db.Turtles.Remove(turtle); await _db.SaveChangesAsync(); return NoContent(); // 204 }}A few things to notice:
[ApiController]reads theTurtleparameter onCreateandUpdatefrom the JSON body without a[FromBody], and answers 400 on its own if the JSON does not fit the model.ActionResult<Turtle>lets an action return either a turtle (200, serialised to JSON) or a status such asNotFound().- Everything is
async/await: database calls are I/O, andToListAsync,FindAsyncandSaveChangesAsyncfree the thread while Postgres works — same idea asawaitin Node. _db.Turtlesis anIQueryable; chain LINQ on it and EF Core turns it into SQL:_db.Turtles.Where(t => t.Age > 5).OrderBy(t => t.Name).ToListAsync().
Test all the routes with Postman or curl:
curl -X POST http://localhost:5xxx/turtles -H "Content-Type: application/json" -d '{"name": "Wilbert", "age": 5}'curl http://localhost:5xxx/turtlescurl -X PUT http://localhost:5xxx/turtles/1 -H "Content-Type: application/json" -d '{"name": "Dilbert", "age": 6}'curl -X DELETE http://localhost:5xxx/turtles/1Property names arrive and leave as camelCase (name, age) — System.Text.Json converts to and from the PascalCase properties for you. You've done it, head to lab!
Resources to Learn More#
- Getting started with EF Core — model, context, migrations and queries in one pass
- Migrations overview — how
migrations addanddatabase updatework and how to handle changes - EF Core tools reference (.NET CLI) — every
dotnet efcommand and option - Querying data and saving data — LINQ to SQL, tracking,
SaveChanges - Npgsql EF Core provider — Postgres-specific types, connection string keywords,
UseNpgsqloptions - Configuration in ASP.NET Core — appsettings, environments, environment variables, user secrets
See also