diff --git a/src/DemoApplication.Api/DemoApplication.Api.csproj b/src/DemoApplication.Api/DemoApplication.Api.csproj
index 3d663a6..d9ab760 100644
--- a/src/DemoApplication.Api/DemoApplication.Api.csproj
+++ b/src/DemoApplication.Api/DemoApplication.Api.csproj
@@ -7,6 +7,10 @@
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/src/DemoApplication.Api/Program.cs b/src/DemoApplication.Api/Program.cs
index 7790311..d7aca69 100644
--- a/src/DemoApplication.Api/Program.cs
+++ b/src/DemoApplication.Api/Program.cs
@@ -7,6 +7,7 @@
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddControllers();
+builder.Services.AddPersistence(builder.Configuration, builder.Environment.ContentRootPath);
builder.Services.AddSingleton();
builder.Services.AddHostedService();
diff --git a/src/DemoApplication.Api/appsettings.Development.json b/src/DemoApplication.Api/appsettings.Development.json
index 10f68b8..2866af7 100644
--- a/src/DemoApplication.Api/appsettings.Development.json
+++ b/src/DemoApplication.Api/appsettings.Development.json
@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "Persistence": {
+ "DatabasePath": "App_Data/demo-application-development.db"
+ }
}
diff --git a/src/DemoApplication.Api/appsettings.json b/src/DemoApplication.Api/appsettings.json
index 10f68b8..c534a8e 100644
--- a/src/DemoApplication.Api/appsettings.json
+++ b/src/DemoApplication.Api/appsettings.json
@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "Persistence": {
+ "DatabasePath": "App_Data/demo-application.db"
+ }
}
diff --git a/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj b/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj
index 7aa6826..3b72c8d 100644
--- a/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj
+++ b/src/DemoApplication.Infrastructure/DemoApplication.Infrastructure.csproj
@@ -1,6 +1,12 @@
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
diff --git a/src/DemoApplication.Infrastructure/DemoApplicationDbContext.cs b/src/DemoApplication.Infrastructure/DemoApplicationDbContext.cs
new file mode 100644
index 0000000..06c2a22
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/DemoApplicationDbContext.cs
@@ -0,0 +1,57 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace DemoApplication.Infrastructure;
+
+/// Owns the SQLite persistence model and its deterministic baseline seed data.
+public sealed class DemoApplicationDbContext : DbContext
+{
+ /// Initializes the context with configured provider options.
+ /// Provider and database options.
+ public DemoApplicationDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ /// Gets the baseline roles.
+ public DbSet Roles => Set();
+
+ /// Gets the minimal order index records.
+ public DbSet Orders => Set();
+
+ /// Gets the minimal shipment index records.
+ public DbSet Shipments => Set();
+
+ /// Configures table names, indexes, keys, and deterministic seed records.
+ /// Entity model builder supplied by Entity Framework Core.
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ // Keep the schema foundation independent from future business workflow entities.
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Roles");
+ entity.HasKey(role => role.Id);
+ entity.Property(role => role.Name).IsRequired().HasMaxLength(64);
+ entity.Property(role => role.NormalizedName).IsRequired().HasMaxLength(64);
+ entity.HasIndex(role => role.NormalizedName).IsUnique();
+ entity.HasData(
+ new PersistenceRole { Id = new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"), Name = "Administrator", NormalizedName = "ADMINISTRATOR" },
+ new PersistenceRole { Id = new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"), Name = "User", NormalizedName = "USER" });
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Orders");
+ entity.HasKey(order => order.Id);
+ entity.Property(order => order.Status).IsRequired().HasMaxLength(32);
+ entity.HasIndex(order => new { order.Status, order.CreatedUtc });
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("Shipments");
+ entity.HasKey(shipment => shipment.Id);
+ entity.Property(shipment => shipment.Status).IsRequired().HasMaxLength(32);
+ entity.HasIndex(shipment => new { shipment.OrderId, shipment.Status });
+ });
+ }
+}
diff --git a/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.Designer.cs b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.Designer.cs
new file mode 100644
index 0000000..0632c8b
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.Designer.cs
@@ -0,0 +1,108 @@
+//
+using System;
+using DemoApplication.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace DemoApplication.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(DemoApplicationDbContext))]
+ [Migration("20260731142231_InitialPersistence")]
+ partial class InitialPersistence
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("DemoApplication.Infrastructure.OrderIndexRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status", "CreatedUtc");
+
+ b.ToTable("Orders", (string)null);
+ });
+
+ modelBuilder.Entity("DemoApplication.Infrastructure.PersistenceRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Roles", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"),
+ Name = "Administrator",
+ NormalizedName = "ADMINISTRATOR"
+ },
+ new
+ {
+ Id = new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"),
+ Name = "User",
+ NormalizedName = "USER"
+ });
+ });
+
+ modelBuilder.Entity("DemoApplication.Infrastructure.ShipmentIndexRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("OrderId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrderId", "Status");
+
+ b.ToTable("Shipments", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.cs b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.cs
new file mode 100644
index 0000000..d2bfc82
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/Persistence/Migrations/20260731142231_InitialPersistence.cs
@@ -0,0 +1,95 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
+
+namespace DemoApplication.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class InitialPersistence : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Orders",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ Status = table.Column(type: "TEXT", maxLength: 32, nullable: false),
+ CreatedUtc = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Orders", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Roles",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ Name = table.Column(type: "TEXT", maxLength: 64, nullable: false),
+ NormalizedName = table.Column(type: "TEXT", maxLength: 64, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Roles", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Shipments",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ OrderId = table.Column(type: "TEXT", nullable: false),
+ Status = table.Column(type: "TEXT", maxLength: 32, nullable: false),
+ UpdatedUtc = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Shipments", x => x.Id);
+ });
+
+ migrationBuilder.InsertData(
+ table: "Roles",
+ columns: new[] { "Id", "Name", "NormalizedName" },
+ values: new object[,]
+ {
+ { new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"), "Administrator", "ADMINISTRATOR" },
+ { new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"), "User", "USER" }
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Orders_Status_CreatedUtc",
+ table: "Orders",
+ columns: new[] { "Status", "CreatedUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Roles_NormalizedName",
+ table: "Roles",
+ column: "NormalizedName",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Shipments_OrderId_Status",
+ table: "Shipments",
+ columns: new[] { "OrderId", "Status" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Orders");
+
+ migrationBuilder.DropTable(
+ name: "Roles");
+
+ migrationBuilder.DropTable(
+ name: "Shipments");
+ }
+ }
+}
diff --git a/src/DemoApplication.Infrastructure/Persistence/Migrations/DemoApplicationDbContextModelSnapshot.cs b/src/DemoApplication.Infrastructure/Persistence/Migrations/DemoApplicationDbContextModelSnapshot.cs
new file mode 100644
index 0000000..ae50664
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/Persistence/Migrations/DemoApplicationDbContextModelSnapshot.cs
@@ -0,0 +1,105 @@
+//
+using System;
+using DemoApplication.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace DemoApplication.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(DemoApplicationDbContext))]
+ partial class DemoApplicationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("DemoApplication.Infrastructure.OrderIndexRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status", "CreatedUtc");
+
+ b.ToTable("Orders", (string)null);
+ });
+
+ modelBuilder.Entity("DemoApplication.Infrastructure.PersistenceRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Roles", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("2e6bd8e6-1a43-4ec2-a2ef-6e9e2ef7f44c"),
+ Name = "Administrator",
+ NormalizedName = "ADMINISTRATOR"
+ },
+ new
+ {
+ Id = new Guid("d9aa8d4e-969c-4ea8-9b11-1c3f4d3b35f2"),
+ Name = "User",
+ NormalizedName = "USER"
+ });
+ });
+
+ modelBuilder.Entity("DemoApplication.Infrastructure.ShipmentIndexRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("OrderId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrderId", "Status");
+
+ b.ToTable("Shipments", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/DemoApplication.Infrastructure/PersistenceIndexRecords.cs b/src/DemoApplication.Infrastructure/PersistenceIndexRecords.cs
new file mode 100644
index 0000000..0b0244d
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/PersistenceIndexRecords.cs
@@ -0,0 +1,30 @@
+namespace DemoApplication.Infrastructure;
+
+/// Provides the minimum order shape required by persistence query indexes.
+public sealed class OrderIndexRecord
+{
+ /// Gets or sets the order identifier.
+ public Guid Id { get; set; }
+
+ /// Gets or sets the order status used by indexed queries.
+ public string Status { get; set; } = string.Empty;
+
+ /// Gets or sets the creation timestamp used by indexed queries.
+ public DateTime CreatedUtc { get; set; }
+}
+
+/// Provides the minimum shipment shape required by persistence query indexes.
+public sealed class ShipmentIndexRecord
+{
+ /// Gets or sets the shipment identifier.
+ public Guid Id { get; set; }
+
+ /// Gets or sets the related order identifier.
+ public Guid OrderId { get; set; }
+
+ /// Gets or sets the shipment status used by indexed queries.
+ public string Status { get; set; } = string.Empty;
+
+ /// Gets or sets the update timestamp used by indexed queries.
+ public DateTime UpdatedUtc { get; set; }
+}
diff --git a/src/DemoApplication.Infrastructure/PersistenceOptions.cs b/src/DemoApplication.Infrastructure/PersistenceOptions.cs
new file mode 100644
index 0000000..9eba7c5
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/PersistenceOptions.cs
@@ -0,0 +1,8 @@
+namespace DemoApplication.Infrastructure;
+
+/// Defines configurable storage locations for application persistence.
+public sealed class PersistenceOptions
+{
+ /// Gets or sets the SQLite database path relative to the content root or as an absolute path.
+ public string DatabasePath { get; set; } = "App_Data/demo-application.db";
+}
diff --git a/src/DemoApplication.Infrastructure/PersistenceRole.cs b/src/DemoApplication.Infrastructure/PersistenceRole.cs
new file mode 100644
index 0000000..674b9ed
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/PersistenceRole.cs
@@ -0,0 +1,14 @@
+namespace DemoApplication.Infrastructure;
+
+/// Stores the baseline role names required by the application.
+public sealed class PersistenceRole
+{
+ /// Gets or sets the stable role identifier.
+ public Guid Id { get; set; }
+
+ /// Gets or sets the display name of the role.
+ public string Name { get; set; } = string.Empty;
+
+ /// Gets or sets the normalized role name used for case-insensitive lookup.
+ public string NormalizedName { get; set; } = string.Empty;
+}
diff --git a/src/DemoApplication.Infrastructure/PersistenceServiceCollectionExtensions.cs b/src/DemoApplication.Infrastructure/PersistenceServiceCollectionExtensions.cs
new file mode 100644
index 0000000..b3aa755
--- /dev/null
+++ b/src/DemoApplication.Infrastructure/PersistenceServiceCollectionExtensions.cs
@@ -0,0 +1,46 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DemoApplication.Infrastructure;
+
+/// Registers the SQLite persistence foundation in the application host.
+public static class PersistenceServiceCollectionExtensions
+{
+ /// Registers the configurable SQLite context factory.
+ /// Application service collection.
+ /// Application configuration.
+ /// Host content root used to resolve relative paths.
+ /// The same service collection for composition chaining.
+ public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration, string contentRootPath)
+ {
+ // Validate the composition inputs before resolving paths or mutating the service collection.
+ ArgumentNullException.ThrowIfNull(
+ argument: configuration,
+ paramName: nameof(configuration));
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(
+ argument: contentRootPath,
+ paramName: nameof(contentRootPath));
+
+ // Resolve the configured path once so relative and absolute locations share the same provider setup.
+ var options = new PersistenceOptions
+ {
+ DatabasePath = configuration["Persistence:DatabasePath"] ?? "App_Data/demo-application.db"
+ };
+ var databasePath = Path.IsPathRooted(options.DatabasePath)
+ ? options.DatabasePath
+ : Path.Combine(contentRootPath, options.DatabasePath);
+ var databaseDirectory = Path.GetDirectoryName(databasePath);
+
+ if (!string.IsNullOrWhiteSpace(databaseDirectory))
+ {
+ // Ensure the configured parent exists before SQLite opens the file.
+ Directory.CreateDirectory(databaseDirectory);
+ }
+
+ // Register a factory so startup and future request scopes receive independently owned contexts.
+ services.AddDbContextFactory(dbContextOptions => dbContextOptions.UseSqlite($"Data Source={databasePath}"));
+ return services;
+ }
+}
diff --git a/src/DemoApplication.Infrastructure/StartupCheck.cs b/src/DemoApplication.Infrastructure/StartupCheck.cs
index afd7866..d10a2b2 100644
--- a/src/DemoApplication.Infrastructure/StartupCheck.cs
+++ b/src/DemoApplication.Infrastructure/StartupCheck.cs
@@ -1,13 +1,32 @@
using DemoApplication.Application.Abstractions;
+using Microsoft.EntityFrameworkCore;
namespace DemoApplication.Infrastructure;
/// Provides the baseline startup check for infrastructure wiring.
public sealed class StartupCheck : IStartupCheck
{
+ // Holds the factory that owns short-lived contexts for startup migration work.
+ private readonly IDbContextFactory _dbContextFactory;
+
+ /// Initializes the persistence startup check.
+ /// Factory for the isolated startup migration context.
+ public StartupCheck(IDbContextFactory dbContextFactory)
+ {
+ ArgumentNullException.ThrowIfNull(
+ argument: dbContextFactory,
+ paramName: nameof(dbContextFactory));
+
+ _dbContextFactory = dbContextFactory;
+ }
+
/// Completes successfully when baseline infrastructure is available.
- public Task CheckAsync(CancellationToken cancellationToken)
+ public async Task CheckAsync(CancellationToken cancellationToken)
{
- return Task.CompletedTask;
+ // Create a short-lived context so migration state is isolated from future request work.
+ await using DemoApplicationDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+
+ // Apply pending migrations before the host accepts traffic; seeded roles remain idempotent.
+ await dbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
}