--project src/Medications.Api # after changing the model
+dotnet ef database update --project src/Medications.Api # apply without running the API
+```
## Some Design notes
-- Validation uses [DataAnnotations] on the request DTO, enforced by .NET 10's [`AddValidation()`]. The route `id` is validated the same way, through a [`[Range]`][range] attribute on the handler parameter, so every validation error has the same [`HttpValidationProblemDetails`] shape.
-- `Name` is capped at 200 characters, defined once in `Medication.NameMaxLength` and used by both [`[StringLength]`][stringlength] on the DTO and [`HasMaxLength`] in the EF model.
-- The DTO doesn't use the C# [`required`][required-keyword] keyword for `Name`: a body without `name` would then fail deserialization with a generic 400, instead of a normal `errors.Name` validation error.
-- Errors are [Problem Details (RFC 9457)][rfc9457] on every path: [`AddProblemDetails`] + [`UseExceptionHandler`] (with [`StatusCodeSelector`]) + [`UseStatusCodePages`]. [`ThrowOnBadRequest`] is enabled for all environments so binding failures keep their `detail` outside Development, and [`SuppressDiagnosticsCallback`] keeps those client errors out of Error-level logs.
+- Validation uses DataAnnotations on the request DTO, enforced by .NET 10's built-in minimal API validation ([`AddValidation()`]). The route `id` is validated the same way, so every validation error has the same response shape.
+- `Name` is capped at 200 characters, defined once in `Medication.NameMaxLength` and used by both the DTO and the EF model.
+- The DTO doesn't use the C# `required` keyword for `Name`: a missing `name` then returns a normal field-level validation error instead of a generic 400.
+- Every error response uses the same JSON shape (ASP.NET Core's Problem Details), whether it is a validation error, malformed JSON, an invalid route value or a 404. This is wired once in Program.cs, not per endpoint.
+- Binding failures keep their explanatory `detail` message in Production too (by default ASP.NET Core only includes it in Development), and client mistakes like malformed JSON are not logged as server errors.
- Invalid ids (`0` or negative) return `400`; well-formed ids that don't exist return `404`.
-- `CreationDate` is set by the server, using an injected [`TimeProvider`] ([`FakeTimeProvider`] in tests). The request and response DTOs are separate types, so `Id` and `CreationDate` are never accepted as input.
-- `GET /api/medications/{id}` is not in the challenge spec; it exists as the target of the `Location` header returned by [`CreatedAtRoute`] on POST.
+- `CreationDate` is set by the server (injected `TimeProvider`, faked in tests); `Id` and `CreationDate` are never accepted as input.
+- `GET /api/medications/{id}` is not in the challenge spec; it exists as the target of POST's `Location` header.
+- The DbContext is registered with Aspire's [`AddSqlServerDbContext`], which adds retries, health checks and telemetry on top of `AddDbContext`.
+- The [SQL Server container] is pinned to a specific image tag instead of `2022-latest` (which changes over time), is kept between runs with its data in a volume, and its generated password lives in the AppHost's user secrets.
+- Migrations run at startup to keep the demo to a single command; a real deployment would apply them as a separate step.
+
+---
+
+
+
+Made with ❤️ by Fernando Tona
+[Website](https://fernandotonacoder.github.io) • [LinkedIn](https://www.linkedin.com/in/fernandotona/) • [GitHub](https://github.com/fernandotonacoder)
+
+
[Minimal APIs]: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
-[EF Core]: https://learn.microsoft.com/en-us/ef/core/
-[InMemory provider]: https://learn.microsoft.com/en-us/ef/core/providers/in-memory/
[.NET Aspire]: https://learn.microsoft.com/en-us/dotnet/aspire/
-[Scalar]: https://github.com/scalar/scalar
-[xunit]: https://xunit.net/
[.NET 10 SDK]: https://dotnet.microsoft.com/download/dotnet/10.0
-[DataAnnotations]: https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations
[`AddValidation()`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.validationservicecollectionextensions.addvalidation
-[range]: https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.rangeattribute
-[stringlength]: https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.stringlengthattribute
-[`HttpValidationProblemDetails`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.httpvalidationproblemdetails
-[`HasMaxLength`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.metadata.builders.propertybuilder.hasmaxlength
-[required-keyword]: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/required
-[rfc9457]: https://www.rfc-editor.org/rfc/rfc9457
-[`AddProblemDetails`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.problemdetailsservicecollectionextensions.addproblemdetails
-[`UseExceptionHandler`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.exceptionhandlerextensions.useexceptionhandler
-[`StatusCodeSelector`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.exceptionhandleroptions.statuscodeselector
-[`UseStatusCodePages`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.statuscodepagesextensions.usestatuscodepages
-[`ThrowOnBadRequest`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.routing.routehandleroptions.throwonbadrequest
-[`SuppressDiagnosticsCallback`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.exceptionhandleroptions.suppressdiagnosticscallback
-[`TimeProvider`]: https://learn.microsoft.com/en-us/dotnet/api/system.timeprovider
-[`FakeTimeProvider`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.time.testing.faketimeprovider
-[`CreatedAtRoute`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.typedresults.createdatroute
+[`AddSqlServerDbContext`]: https://aspire.dev/integrations/databases/efcore/sql-server/sql-server-connect/
+[SQL Server container]: https://aspire.dev/integrations/databases/sql-server/sql-server-host/
+[dotnet-ef]: https://learn.microsoft.com/en-us/ef/core/cli/dotnet
+[GitHub Actions]: .github/workflows/ci.yml
diff --git a/src/Medications.Api/Medications.Api.csproj b/src/Medications.Api/Medications.Api.csproj
index 043f62a..aa3744e 100644
--- a/src/Medications.Api/Medications.Api.csproj
+++ b/src/Medications.Api/Medications.Api.csproj
@@ -1,8 +1,13 @@
+
-
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
diff --git a/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs b/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs
new file mode 100644
index 0000000..47867f1
--- /dev/null
+++ b/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs
@@ -0,0 +1,54 @@
+//
+using System;
+using Medications.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Medications.Api.Migrations
+{
+ [DbContext(typeof(MedicationsDbContext))]
+ [Migration("20260807025640_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Medications.Api.Entities.Medication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.ToTable("Medications");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Medications.Api/Migrations/20260807025640_InitialCreate.cs b/src/Medications.Api/Migrations/20260807025640_InitialCreate.cs
new file mode 100644
index 0000000..3adb0d7
--- /dev/null
+++ b/src/Medications.Api/Migrations/20260807025640_InitialCreate.cs
@@ -0,0 +1,37 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Medications.Api.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Medications",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false),
+ Quantity = table.Column(type: "int", nullable: false),
+ CreationDate = table.Column(type: "datetimeoffset", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Medications", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Medications");
+ }
+ }
+}
diff --git a/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs b/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs
new file mode 100644
index 0000000..1f9e894
--- /dev/null
+++ b/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs
@@ -0,0 +1,51 @@
+//
+using System;
+using Medications.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Medications.Api.Migrations
+{
+ [DbContext(typeof(MedicationsDbContext))]
+ partial class MedicationsDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Medications.Api.Entities.Medication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreationDate")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.ToTable("Medications");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Medications.Api/Program.cs b/src/Medications.Api/Program.cs
index 9cb2ca1..6982e82 100644
--- a/src/Medications.Api/Program.cs
+++ b/src/Medications.Api/Program.cs
@@ -4,7 +4,7 @@
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase("Medications"));
+builder.AddSqlServerDbContext("medications");
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.Configure(options => options.ThrowOnBadRequest = true);
@@ -22,6 +22,12 @@
var app = builder.Build();
+await using (var scope = app.Services.CreateAsyncScope())
+{
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ await dbContext.Database.MigrateAsync();
+}
+
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
diff --git a/src/Medications.Api/appsettings.json b/src/Medications.Api/appsettings.json
index 10f68b8..a5cbd90 100644
--- a/src/Medications.Api/appsettings.json
+++ b/src/Medications.Api/appsettings.json
@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "medications": "Server=localhost;Database=Medications;Trusted_Connection=True;TrustServerCertificate=True"
+ }
}
diff --git a/src/Medications.AppHost/AppHost.cs b/src/Medications.AppHost/AppHost.cs
index e602bb6..d15f6c1 100644
--- a/src/Medications.AppHost/AppHost.cs
+++ b/src/Medications.AppHost/AppHost.cs
@@ -1,8 +1,20 @@
+using Microsoft.Extensions.Configuration;
using Projects;
var builder = DistributedApplication.CreateBuilder(args);
+var medicationsDb = !string.IsNullOrWhiteSpace(builder.Configuration.GetConnectionString("medications"))
+ ? builder.AddConnectionString("medications")
+ : builder.AddSqlServer("sql")
+ .WithImageTag("2022-CU26-ubuntu-22.04")
+ .WithContainerName("medications-sql")
+ .WithLifetime(ContainerLifetime.Persistent)
+ .WithDataVolume()
+ .AddDatabase("medications");
+
builder.AddProject("medications-api")
- .WithUrlForEndpoint("https", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 });
+ .WithReference(medicationsDb)
+ .WaitFor(medicationsDb)
+ .WithUrlForEndpoint("http", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 });
await builder.Build().RunAsync();
diff --git a/src/Medications.AppHost/Medications.AppHost.csproj b/src/Medications.AppHost/Medications.AppHost.csproj
index 185a886..131a53f 100644
--- a/src/Medications.AppHost/Medications.AppHost.csproj
+++ b/src/Medications.AppHost/Medications.AppHost.csproj
@@ -5,6 +5,10 @@
dc8941e3-0794-4122-a9d7-f3c18746f746
+
+
+
+
diff --git a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj
index 3fd85d6..1234f5e 100644
--- a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj
+++ b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj
@@ -1,9 +1,6 @@
- net10.0
- enable
- enable
false
@@ -15,6 +12,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+