diff --git a/SimpleModule.slnx b/SimpleModule.slnx
index 8462e602..6be2cf58 100644
--- a/SimpleModule.slnx
+++ b/SimpleModule.slnx
@@ -28,6 +28,7 @@
+
diff --git a/cli/SimpleModule.Cli/Commands/Seed/SeedCommand.cs b/cli/SimpleModule.Cli/Commands/Seed/SeedCommand.cs
new file mode 100644
index 00000000..2d06c4f1
--- /dev/null
+++ b/cli/SimpleModule.Cli/Commands/Seed/SeedCommand.cs
@@ -0,0 +1,127 @@
+using System.Diagnostics;
+using SimpleModule.Cli.Infrastructure;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace SimpleModule.Cli.Commands.Seed;
+
+public sealed class SeedCommand : Command
+{
+ public override int Execute(CommandContext context, SeedSettings settings)
+ {
+ var solution = SolutionContext.Discover();
+ if (solution is null)
+ {
+ AnsiConsole.MarkupLine(
+ "[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]"
+ );
+ return 1;
+ }
+
+ var seederProject = Path.Combine(
+ solution.RootPath,
+ "tools",
+ "SimpleModule.PerfSeeder",
+ "SimpleModule.PerfSeeder.csproj"
+ );
+ if (!File.Exists(seederProject))
+ {
+ AnsiConsole.MarkupLine(
+ $"[red]Perf seeder project not found at {seederProject.EscapeMarkup()}[/]"
+ );
+ return 1;
+ }
+
+ var args = BuildForwardArgs(settings, solution);
+
+ AnsiConsole.MarkupLine(
+ "[bold blue]Running perf seeder[/] (this may take a while for large counts)..."
+ );
+ AnsiConsole.MarkupLine(
+ $"[dim] dotnet run -c Release --project {seederProject.EscapeMarkup()} -- {string.Join(' ', args).EscapeMarkup()}[/]"
+ );
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ WorkingDirectory = solution.RootPath,
+ UseShellExecute = false,
+ };
+ startInfo.ArgumentList.Add("run");
+ startInfo.ArgumentList.Add("-c");
+ startInfo.ArgumentList.Add("Release");
+ startInfo.ArgumentList.Add("--project");
+ startInfo.ArgumentList.Add(seederProject);
+ startInfo.ArgumentList.Add("--");
+ foreach (var arg in args)
+ {
+ startInfo.ArgumentList.Add(arg);
+ }
+
+ try
+ {
+ using var process = Process.Start(startInfo);
+ if (process is null)
+ {
+ AnsiConsole.MarkupLine("[red]Failed to start dotnet process.[/]");
+ return 1;
+ }
+ process.WaitForExit();
+ return process.ExitCode;
+ }
+#pragma warning disable CA1031 // Do not catch general exception types
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ AnsiConsole.MarkupLine($"[red]Seeder failed: {ex.Message.EscapeMarkup()}[/]");
+ return 1;
+ }
+ }
+
+ private static List BuildForwardArgs(SeedSettings settings, SolutionContext solution)
+ {
+ var args = new List
+ {
+ "--module",
+ settings.Module,
+ "--batch-size",
+ settings.BatchSize.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ "--seed",
+ settings.RandomSeed.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ };
+
+ if (settings.Count is { } count)
+ {
+ args.Add("--count");
+ args.Add(count.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+ if (!string.IsNullOrWhiteSpace(settings.Connection))
+ {
+ args.Add("--connection");
+ args.Add(settings.Connection);
+ }
+ if (!string.IsNullOrWhiteSpace(settings.Provider))
+ {
+ args.Add("--provider");
+ args.Add(settings.Provider);
+ }
+ if (settings.Truncate)
+ {
+ args.Add("--truncate");
+ }
+ if (settings.CreateSchema)
+ {
+ args.Add("--create-schema");
+ }
+
+ // Always pass the host project so the seeder reads the right appsettings.json.
+ var hostDir = Path.GetDirectoryName(solution.ApiCsprojPath);
+ if (!string.IsNullOrWhiteSpace(hostDir))
+ {
+ args.Add("--project");
+ args.Add(hostDir);
+ }
+
+ return args;
+ }
+}
diff --git a/cli/SimpleModule.Cli/Commands/Seed/SeedSettings.cs b/cli/SimpleModule.Cli/Commands/Seed/SeedSettings.cs
new file mode 100644
index 00000000..a609add9
--- /dev/null
+++ b/cli/SimpleModule.Cli/Commands/Seed/SeedSettings.cs
@@ -0,0 +1,50 @@
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+namespace SimpleModule.Cli.Commands.Seed;
+
+public sealed class SeedSettings : CommandSettings
+{
+ [CommandOption("-m|--module ")]
+ [Description("Target module: products, orders, or all. Default: all.")]
+ public string Module { get; set; } = "all";
+
+ [CommandOption("-c|--count ")]
+ [Description(
+ "Row count override applied to each target module. "
+ + "Defaults: products=1,000,000, orders=100,000."
+ )]
+ public int? Count { get; set; }
+
+ [CommandOption("--connection ")]
+ [Description(
+ "Override the database connection string. By default reads Database:DefaultConnection from the host's appsettings.json."
+ )]
+ public string? Connection { get; set; }
+
+ [CommandOption("--provider ")]
+ [Description(
+ "Override the database provider (Sqlite|PostgreSql|SqlServer). By default auto-detected."
+ )]
+ public string? Provider { get; set; }
+
+ [CommandOption("--batch-size ")]
+ [Description("Rows per SaveChanges batch. Default: 5000.")]
+ public int BatchSize { get; set; } = 5000;
+
+ [CommandOption("--seed ")]
+ [Description("Randomizer seed for deterministic data generation. Default: 42.")]
+ public int RandomSeed { get; set; } = 42;
+
+ [CommandOption("--truncate")]
+ [Description(
+ "Delete existing rows in the target tables before seeding. Products keeps rows with Id <= 10 (migration seed)."
+ )]
+ public bool Truncate { get; set; }
+
+ [CommandOption("--create-schema")]
+ [Description(
+ "Call EnsureCreated on each target DbContext before seeding. Useful against a fresh database when migrations are unavailable."
+ )]
+ public bool CreateSchema { get; set; }
+}
diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs
index 3487db1f..34db3159 100644
--- a/cli/SimpleModule.Cli/Program.cs
+++ b/cli/SimpleModule.Cli/Program.cs
@@ -2,6 +2,7 @@
using SimpleModule.Cli.Commands.Doctor;
using SimpleModule.Cli.Commands.Install;
using SimpleModule.Cli.Commands.New;
+using SimpleModule.Cli.Commands.Seed;
using Spectre.Console.Cli;
var app = new CommandApp();
@@ -43,6 +44,12 @@
config
.AddCommand("doctor")
.WithDescription("Validate project structure and conventions");
+
+ config
+ .AddCommand("seed")
+ .WithDescription(
+ "Seed the configured database with bulk test data for perf testing (Products, Orders). AuditLogs are populated automatically by the audit interceptor on real operations."
+ );
});
return app.Run(args);
diff --git a/tools/SimpleModule.PerfSeeder/Program.cs b/tools/SimpleModule.PerfSeeder/Program.cs
new file mode 100644
index 00000000..3ba23e7d
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/Program.cs
@@ -0,0 +1,10 @@
+using SimpleModule.PerfSeeder;
+using Spectre.Console.Cli;
+
+var app = new CommandApp();
+app.Configure(config =>
+{
+ config.SetApplicationName("SimpleModule.PerfSeeder");
+ config.PropagateExceptions();
+});
+return await app.RunAsync(args).ConfigureAwait(false);
diff --git a/tools/SimpleModule.PerfSeeder/SeedCommand.cs b/tools/SimpleModule.PerfSeeder/SeedCommand.cs
new file mode 100644
index 00000000..bdba1570
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/SeedCommand.cs
@@ -0,0 +1,239 @@
+using System.Data.Common;
+using System.Diagnostics;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using SimpleModule.Database;
+using SimpleModule.Orders;
+using SimpleModule.PerfSeeder.Seeders;
+using SimpleModule.Products;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace SimpleModule.PerfSeeder;
+
+public sealed class SeedCommand : AsyncCommand
+{
+ private const int DefaultProductsCount = 1_000_000;
+ private const int DefaultOrdersCount = 100_000;
+
+ public override async Task ExecuteAsync(CommandContext context, SeedSettings settings)
+ {
+ var projectPath = ResolveProjectPath(settings.ProjectPath);
+ if (projectPath is null)
+ {
+ AnsiConsole.MarkupLine(
+ "[red]Could not find a host project. Pass --project .[/]"
+ );
+ return 1;
+ }
+
+ var dbOptions = LoadDatabaseOptions(projectPath, settings);
+ if (string.IsNullOrWhiteSpace(dbOptions.DefaultConnection))
+ {
+ AnsiConsole.MarkupLine(
+ "[red]Database:DefaultConnection is empty. Configure appsettings.json or pass --connection.[/]"
+ );
+ return 1;
+ }
+
+ DatabaseProvider provider;
+ try
+ {
+ provider = DatabaseProviderDetector.Detect(
+ dbOptions.DefaultConnection,
+ dbOptions.Provider
+ );
+ }
+ catch (InvalidOperationException ex)
+ {
+ AnsiConsole.MarkupLine($"[red]{ex.Message.EscapeMarkup()}[/]");
+ return 1;
+ }
+
+ AnsiConsole.MarkupLine(
+ $"[bold]Perf seeder[/] — project: [cyan]{projectPath.EscapeMarkup()}[/], provider: [cyan]{provider}[/]"
+ );
+ AnsiConsole.MarkupLine(
+ $"[dim]Connection: {Redact(dbOptions.DefaultConnection).EscapeMarkup()}[/]"
+ );
+
+ var module = settings.Module.Trim();
+ var runAll = module.Length == 0 || module.Equals("all", StringComparison.OrdinalIgnoreCase);
+ var runProducts = runAll || module.Equals("products", StringComparison.OrdinalIgnoreCase);
+ var runOrders = runAll || module.Equals("orders", StringComparison.OrdinalIgnoreCase);
+
+ if (!runProducts && !runOrders)
+ {
+ AnsiConsole.MarkupLine(
+ $"[red]Unknown module '{module.EscapeMarkup()}'. Valid: products, orders, all.[/]"
+ );
+ return 1;
+ }
+
+ var totalStopwatch = Stopwatch.StartNew();
+
+ if (runProducts)
+ {
+ var count = settings.Count ?? DefaultProductsCount;
+ using var db = BuildContext(dbOptions, provider);
+ if (settings.CreateSchema)
+ {
+ EnsureTablesCreated(db);
+ }
+ await new ProductsSeeder(db, provider)
+ .RunAsync(count, settings.BatchSize, settings.RandomSeed, settings.Truncate)
+ .ConfigureAwait(false);
+ }
+
+ if (runOrders)
+ {
+ var count = settings.Count ?? DefaultOrdersCount;
+ using var db = BuildContext(dbOptions, provider);
+ if (settings.CreateSchema)
+ {
+ EnsureTablesCreated(db);
+ }
+ await new OrdersSeeder(db, provider)
+ .RunAsync(count, settings.BatchSize, settings.RandomSeed, settings.Truncate)
+ .ConfigureAwait(false);
+ }
+
+ totalStopwatch.Stop();
+ AnsiConsole.MarkupLine($"[green]Done in {totalStopwatch.Elapsed.TotalSeconds:F1}s.[/]");
+ return 0;
+ }
+
+ private static string? ResolveProjectPath(string? explicitPath)
+ {
+ if (!string.IsNullOrWhiteSpace(explicitPath))
+ {
+ return Path.GetFullPath(explicitPath);
+ }
+
+ var dir = Directory.GetCurrentDirectory();
+ while (dir is not null)
+ {
+ if (Directory.GetFiles(dir, "*.slnx").Length > 0)
+ {
+ var candidate = Path.Combine(dir, "template", "SimpleModule.Host");
+ if (File.Exists(Path.Combine(candidate, "appsettings.json")))
+ {
+ return candidate;
+ }
+
+ var srcHosts = Directory.GetDirectories(Path.Combine(dir, "src"), "*.Host");
+ foreach (var host in srcHosts)
+ {
+ if (File.Exists(Path.Combine(host, "appsettings.json")))
+ {
+ return host;
+ }
+ }
+ }
+
+ dir = Directory.GetParent(dir)?.FullName;
+ }
+
+ return null;
+ }
+
+ private static DatabaseOptions LoadDatabaseOptions(string projectPath, SeedSettings settings)
+ {
+ var config = new ConfigurationBuilder()
+ .SetBasePath(projectPath)
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddJsonFile("appsettings.Development.json", optional: true)
+ .AddEnvironmentVariables()
+ .Build();
+
+ var options = config.GetSection("Database").Get() ?? new DatabaseOptions();
+
+ if (!string.IsNullOrWhiteSpace(settings.Connection))
+ {
+ options.DefaultConnection = settings.Connection;
+ }
+ if (!string.IsNullOrWhiteSpace(settings.Provider))
+ {
+ options.Provider = settings.Provider;
+ }
+
+ return options;
+ }
+
+ private static TContext BuildContext(
+ DatabaseOptions dbOptions,
+ DatabaseProvider provider
+ )
+ where TContext : DbContext
+ {
+ var builder = new DbContextOptionsBuilder();
+ switch (provider)
+ {
+ case DatabaseProvider.PostgreSql:
+ builder.UseNpgsql(dbOptions.DefaultConnection);
+ break;
+ case DatabaseProvider.SqlServer:
+ builder.UseSqlServer(dbOptions.DefaultConnection);
+ break;
+ default:
+ builder.UseSqlite(dbOptions.DefaultConnection);
+ break;
+ }
+
+ var ctx = (TContext?)
+ Activator.CreateInstance(typeof(TContext), builder.Options, Options.Create(dbOptions));
+ if (ctx is null)
+ {
+ throw new InvalidOperationException(
+ $"Could not construct DbContext of type {typeof(TContext).Name}."
+ );
+ }
+
+ ctx.ChangeTracker.AutoDetectChangesEnabled = false;
+ ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
+ return ctx;
+ }
+
+ ///
+ /// Creates the target DbContext's tables even when the physical DB already exists
+ /// (EnsureCreated skips table creation when any tables are present). Swallows
+ /// duplicate-table errors so it's safe to call across multiple module contexts
+ /// sharing the same database.
+ ///
+ private static void EnsureTablesCreated(DbContext db)
+ {
+ if (db.Database.EnsureCreated())
+ {
+ return;
+ }
+ try
+ {
+ db.GetService().CreateTables();
+ }
+ catch (DbException)
+ {
+ // Some/all tables already exist — fine.
+ }
+ }
+
+ private static string Redact(string connectionString)
+ {
+ // Hide password-like values in log output.
+ var parts = connectionString.Split(';');
+ for (var i = 0; i < parts.Length; i++)
+ {
+ var kv = parts[i].Split('=', 2);
+ if (
+ kv.Length == 2
+ && kv[0].Trim().Equals("Password", StringComparison.OrdinalIgnoreCase)
+ )
+ {
+ parts[i] = $"{kv[0]}=***";
+ }
+ }
+ return string.Join(';', parts);
+ }
+}
diff --git a/tools/SimpleModule.PerfSeeder/SeedSettings.cs b/tools/SimpleModule.PerfSeeder/SeedSettings.cs
new file mode 100644
index 00000000..d73f58a2
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/SeedSettings.cs
@@ -0,0 +1,55 @@
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+namespace SimpleModule.PerfSeeder;
+
+public sealed class SeedSettings : CommandSettings
+{
+ [CommandOption("-m|--module ")]
+ [Description("Target module: products, orders, or all. Default: all.")]
+ public string Module { get; set; } = "all";
+
+ [CommandOption("-c|--count ")]
+ [Description(
+ "Row count override. Applied per module. " + "Defaults: products=1000000, orders=100000."
+ )]
+ public int? Count { get; set; }
+
+ [CommandOption("--connection ")]
+ [Description(
+ "Override the database connection string. By default reads Database:DefaultConnection from appsettings.json."
+ )]
+ public string? Connection { get; set; }
+
+ [CommandOption("--provider ")]
+ [Description(
+ "Override the database provider (Sqlite|PostgreSql|SqlServer). By default auto-detected from the connection string."
+ )]
+ public string? Provider { get; set; }
+
+ [CommandOption("--project ")]
+ [Description(
+ "Path to the host project directory used for appsettings.json discovery. Default: ./template/SimpleModule.Host relative to the solution root."
+ )]
+ public string? ProjectPath { get; set; }
+
+ [CommandOption("--batch-size ")]
+ [Description("Rows per SaveChanges batch. Default: 5000.")]
+ public int BatchSize { get; set; } = 5000;
+
+ [CommandOption("--seed ")]
+ [Description("Randomizer seed for deterministic data generation. Default: 42.")]
+ public int RandomSeed { get; set; } = 42;
+
+ [CommandOption("--truncate")]
+ [Description(
+ "Delete existing rows in the target tables before seeding. Keeps Products rows with Id <= 10 (the migration seed)."
+ )]
+ public bool Truncate { get; set; }
+
+ [CommandOption("--create-schema")]
+ [Description(
+ "Call EnsureCreated on each target DbContext before seeding. Useful against a fresh database when migrations are unavailable. Skips silently if tables already exist."
+ )]
+ public bool CreateSchema { get; set; }
+}
diff --git a/tools/SimpleModule.PerfSeeder/Seeders/OrdersSeeder.cs b/tools/SimpleModule.PerfSeeder/Seeders/OrdersSeeder.cs
new file mode 100644
index 00000000..609b22d2
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/Seeders/OrdersSeeder.cs
@@ -0,0 +1,118 @@
+using System.Diagnostics;
+using Bogus;
+using Microsoft.EntityFrameworkCore;
+using SimpleModule.Database;
+using SimpleModule.Orders;
+using SimpleModule.Orders.Contracts;
+using Spectre.Console;
+
+namespace SimpleModule.PerfSeeder.Seeders;
+
+internal sealed class OrdersSeeder(OrdersDbContext db, DatabaseProvider provider)
+{
+ private const int UserPoolSize = 1000;
+ private const int ProductIdMax = 1000;
+ private const int MaxItemsPerOrder = 5;
+
+ public async Task RunAsync(int count, int batchSize, int randomSeed, bool truncate)
+ {
+ AnsiConsole.MarkupLine($"[bold]-> Orders[/] (target: {count:N0} rows)");
+
+ if (truncate)
+ {
+ var itemsTable = db.QuoteTable(typeof(OrderItem));
+ var ordersTable = db.QuoteTable(typeof(Order));
+ // Delete children first — the FK from OrderItems.OrderId to Orders is restrict by default.
+ // Table names come from EF model metadata, not user input — safe from injection.
+#pragma warning disable EF1002
+ var deletedItems = await db
+ .Database.ExecuteSqlRawAsync($"DELETE FROM {itemsTable}")
+ .ConfigureAwait(false);
+ var deletedOrders = await db
+ .Database.ExecuteSqlRawAsync($"DELETE FROM {ordersTable}")
+ .ConfigureAwait(false);
+#pragma warning restore EF1002
+ AnsiConsole.MarkupLine(
+ $"[dim] truncated {deletedOrders:N0} orders, {deletedItems:N0} items[/]"
+ );
+ }
+
+ await EnableSqliteFastPathAsync(provider, db).ConfigureAwait(false);
+
+ var rng = new Randomizer(randomSeed);
+ var now = DateTimeOffset.UtcNow;
+
+ // Build the faker inline so we can capture rng for deterministic distinct-product sampling.
+ Order BuildOrder()
+ {
+ var itemCount = rng.Int(1, MaxItemsPerOrder);
+ // Distinct ProductIds within an order (composite PK is OrderId, ProductId).
+ var pickedProducts = new HashSet(itemCount);
+ var items = new List(itemCount);
+ while (pickedProducts.Count < itemCount)
+ {
+ var pid = rng.Int(1, ProductIdMax);
+ if (pickedProducts.Add(pid))
+ {
+ items.Add(new OrderItem { ProductId = pid, Quantity = rng.Int(1, 10) });
+ }
+ }
+ return new Order
+ {
+ UserId = rng.Int(1, UserPoolSize)
+ .ToString(System.Globalization.CultureInfo.InvariantCulture),
+ Items = items,
+ Total = Math.Round((decimal)rng.Double(10, 500), 2),
+ CreatedAt = now.AddMinutes(-rng.Int(0, 60 * 24 * 30)),
+ UpdatedAt = now,
+ CreatedBy = "perf-seeder",
+ UpdatedBy = "perf-seeder",
+ ConcurrencyStamp = Guid.NewGuid().ToString("N"),
+ };
+ }
+
+ var sw = Stopwatch.StartNew();
+ var inserted = 0L;
+ var itemsInserted = 0L;
+ var remaining = count;
+ var nextReport = (long)batchSize * 5;
+
+ await using var tx = await db.Database.BeginTransactionAsync().ConfigureAwait(false);
+ while (remaining > 0)
+ {
+ var take = Math.Min(batchSize, remaining);
+ var batch = new List(take);
+ for (var i = 0; i < take; i++)
+ {
+ var o = BuildOrder();
+ batch.Add(o);
+ itemsInserted += o.Items.Count;
+ }
+ await db.Orders.AddRangeAsync(batch).ConfigureAwait(false);
+ await db.SaveChangesAsync().ConfigureAwait(false);
+ db.ChangeTracker.Clear();
+ inserted += take;
+ remaining -= take;
+
+ if (inserted >= nextReport || remaining == 0)
+ {
+ SeederProgress.Report("orders", inserted, count, sw);
+ nextReport = inserted + (long)batchSize * 5;
+ }
+ }
+ await tx.CommitAsync().ConfigureAwait(false);
+
+ SeederProgress.Final($"orders ({itemsInserted:N0} items)", inserted, sw);
+ }
+
+ private static async Task EnableSqliteFastPathAsync(DatabaseProvider provider, DbContext ctx)
+ {
+ if (provider != DatabaseProvider.Sqlite)
+ {
+ return;
+ }
+ await ctx.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;").ConfigureAwait(false);
+ await ctx.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;").ConfigureAwait(false);
+ await ctx.Database.ExecuteSqlRawAsync("PRAGMA temp_store=MEMORY;").ConfigureAwait(false);
+ }
+}
diff --git a/tools/SimpleModule.PerfSeeder/Seeders/ProductsSeeder.cs b/tools/SimpleModule.PerfSeeder/Seeders/ProductsSeeder.cs
new file mode 100644
index 00000000..0f5d8a89
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/Seeders/ProductsSeeder.cs
@@ -0,0 +1,95 @@
+using System.Diagnostics;
+using System.Globalization;
+using Bogus;
+using Microsoft.EntityFrameworkCore;
+using SimpleModule.Database;
+using SimpleModule.Products;
+using SimpleModule.Products.Contracts;
+using Spectre.Console;
+
+namespace SimpleModule.PerfSeeder.Seeders;
+
+internal sealed class ProductsSeeder(ProductsDbContext db, DatabaseProvider provider)
+{
+ ///
+ /// Ids 1..10 are reserved for the migration seed data in ProductConfiguration.
+ /// We skip these on truncate so the referential data stays intact.
+ ///
+ private const int MigrationSeedMaxId = 10;
+
+ public async Task RunAsync(int count, int batchSize, int randomSeed, bool truncate)
+ {
+ AnsiConsole.MarkupLine($"[bold]-> Products[/] (target: {count:N0} rows)");
+
+ if (truncate)
+ {
+ var table = db.QuoteTable(typeof(Product));
+ // Table name comes from EF model metadata, not user input — safe from injection.
+#pragma warning disable EF1002
+ var deleted = await db
+ .Database.ExecuteSqlRawAsync(
+ $"DELETE FROM {table} WHERE \"Id\" > {MigrationSeedMaxId}"
+ )
+ .ConfigureAwait(false);
+#pragma warning restore EF1002
+ AnsiConsole.MarkupLine($"[dim] truncated {deleted:N0} existing rows[/]");
+ }
+
+ await EnableSqliteFastPathAsync(provider, db).ConfigureAwait(false);
+
+ var faker = new Faker()
+ .UseSeed(randomSeed)
+ .RuleFor(p => p.Name, f => f.Commerce.ProductName())
+ .RuleFor(
+ p => p.Price,
+ f =>
+ decimal.Parse(
+ f.Commerce.Price(1, 1000),
+ NumberStyles.Any,
+ CultureInfo.InvariantCulture
+ )
+ )
+ .RuleFor(p => p.CreatedAt, f => f.Date.Past(1))
+ .RuleFor(p => p.UpdatedAt, (_, p) => p.CreatedAt)
+ .RuleFor(p => p.ConcurrencyStamp, _ => Guid.NewGuid().ToString("N"));
+
+ var sw = Stopwatch.StartNew();
+ var inserted = 0L;
+ var remaining = count;
+ var nextReport = (long)batchSize * 10;
+
+ await using var tx = await db.Database.BeginTransactionAsync().ConfigureAwait(false);
+ while (remaining > 0)
+ {
+ var take = Math.Min(batchSize, remaining);
+ var batch = faker.Generate(take);
+ await db.Products.AddRangeAsync(batch).ConfigureAwait(false);
+ await db.SaveChangesAsync().ConfigureAwait(false);
+ db.ChangeTracker.Clear();
+ inserted += take;
+ remaining -= take;
+
+ if (inserted >= nextReport || remaining == 0)
+ {
+ SeederProgress.Report("products", inserted, count, sw);
+ nextReport = inserted + (long)batchSize * 10;
+ }
+ }
+ await tx.CommitAsync().ConfigureAwait(false);
+
+ SeederProgress.Final("products", inserted, sw);
+ }
+
+ private static async Task EnableSqliteFastPathAsync(DatabaseProvider provider, DbContext ctx)
+ {
+ if (provider != DatabaseProvider.Sqlite)
+ {
+ return;
+ }
+ // WAL mode + relaxed sync drastically improves bulk insert throughput.
+ // These pragmas persist for the connection lifetime only, so this is safe.
+ await ctx.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;").ConfigureAwait(false);
+ await ctx.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;").ConfigureAwait(false);
+ await ctx.Database.ExecuteSqlRawAsync("PRAGMA temp_store=MEMORY;").ConfigureAwait(false);
+ }
+}
diff --git a/tools/SimpleModule.PerfSeeder/Seeders/SeederHelpers.cs b/tools/SimpleModule.PerfSeeder/Seeders/SeederHelpers.cs
new file mode 100644
index 00000000..4fcd4591
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/Seeders/SeederHelpers.cs
@@ -0,0 +1,30 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+
+namespace SimpleModule.PerfSeeder.Seeders;
+
+internal static class SeederHelpers
+{
+ ///
+ /// Returns the provider-appropriate quoted table identifier (e.g. "products"."Products"
+ /// under PostgreSQL, "Products_Products" under SQLite).
+ ///
+ public static string QuoteTable(this DbContext ctx, Type entityType)
+ {
+ var et =
+ ctx.Model.FindEntityType(entityType)
+ ?? throw new InvalidOperationException(
+ $"Entity type {entityType.Name} is not part of the model."
+ );
+ return QuoteTable(et);
+ }
+
+ public static string QuoteTable(IEntityType et)
+ {
+ var table =
+ et.GetTableName()
+ ?? throw new InvalidOperationException($"Entity {et.ClrType.Name} has no table name.");
+ var schema = et.GetSchema();
+ return schema is null ? $"\"{table}\"" : $"\"{schema}\".\"{table}\"";
+ }
+}
diff --git a/tools/SimpleModule.PerfSeeder/Seeders/SeederProgress.cs b/tools/SimpleModule.PerfSeeder/Seeders/SeederProgress.cs
new file mode 100644
index 00000000..3c017685
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/Seeders/SeederProgress.cs
@@ -0,0 +1,32 @@
+using System.Diagnostics;
+using Spectre.Console;
+
+namespace SimpleModule.PerfSeeder.Seeders;
+
+internal static class SeederProgress
+{
+ public static void Report(string label, long inserted, long total, Stopwatch sw)
+ {
+ var elapsed = sw.Elapsed.TotalSeconds;
+ var rate = elapsed > 0 ? inserted / elapsed : 0;
+ var pct = total > 0 ? (double)inserted / total * 100 : 0;
+ AnsiConsole.MarkupLine(
+ $"[dim] {label.EscapeMarkup()}:[/] "
+ + $"{inserted:N0}/{total:N0} "
+ + $"[green]({pct:F1}%)[/] "
+ + $"[dim]{rate:N0} rows/s[/]"
+ );
+ }
+
+ public static void Final(string label, long inserted, Stopwatch sw)
+ {
+ sw.Stop();
+ var elapsed = sw.Elapsed.TotalSeconds;
+ var rate = elapsed > 0 ? inserted / elapsed : 0;
+ AnsiConsole.MarkupLine(
+ $"[green][[ok]][/] {label.EscapeMarkup()}: "
+ + $"inserted {inserted:N0} rows in {elapsed:F1}s "
+ + $"[dim]({rate:N0} rows/s)[/]"
+ );
+ }
+}
diff --git a/tools/SimpleModule.PerfSeeder/SimpleModule.PerfSeeder.csproj b/tools/SimpleModule.PerfSeeder/SimpleModule.PerfSeeder.csproj
new file mode 100644
index 00000000..263448fa
--- /dev/null
+++ b/tools/SimpleModule.PerfSeeder/SimpleModule.PerfSeeder.csproj
@@ -0,0 +1,26 @@
+
+
+ Exe
+ net10.0
+ SimpleModule.PerfSeeder
+ Performance-test data seeder. Populates the configured database with large volumes of realistic data (Products, Orders, AuditLogs) for manual perf exploration and benchmarks.
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+