diff --git a/CleanArchitecture.slnx b/CleanArchitecture.slnx
index eb9a52985..a778b476d 100644
--- a/CleanArchitecture.slnx
+++ b/CleanArchitecture.slnx
@@ -15,6 +15,8 @@
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index aa588ae9a..e660d6269 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,8 @@
+
+
diff --git a/src/Migrator/IMigrationExecutor.cs b/src/Migrator/IMigrationExecutor.cs
new file mode 100644
index 000000000..1546c51ea
--- /dev/null
+++ b/src/Migrator/IMigrationExecutor.cs
@@ -0,0 +1,6 @@
+namespace CleanArchitecture.Migrator;
+
+public interface IMigrationExecutor
+{
+ Task ExecuteAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Migrator/MigrationExecutor.cs b/src/Migrator/MigrationExecutor.cs
new file mode 100644
index 000000000..a3de5439b
--- /dev/null
+++ b/src/Migrator/MigrationExecutor.cs
@@ -0,0 +1,65 @@
+using CleanArchitecture.Infrastructure.Data;
+using CleanArchitecture.Shared.SQLScripts;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace CleanArchitecture.Migrator;
+
+public class MigrationExecutor : IMigrationExecutor
+{
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+ private readonly ApplicationDbContext _context;
+
+ public MigrationExecutor(
+ IConfiguration configuration,
+ ILogger logger,
+ ApplicationDbContext context)
+ {
+ _configuration = configuration;
+ _logger = logger;
+ _context = context;
+ }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken = default)
+ {
+ var connectionString = _configuration.GetConnectionString("CleanArchitectureDb")
+ ?? throw new InvalidOperationException(
+ "Connection string 'CleanArchitectureDb' not found in configuration.");
+
+ _logger.LogInformation("=== Database Migration Started ===");
+
+ await RunEfCoreMigrationsAsync(cancellationToken);
+ RunSqlScripts(connectionString);
+
+ _logger.LogInformation("=== Database Migration Completed Successfully ===");
+ }
+
+ private async Task RunEfCoreMigrationsAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Applying EF Core migrations...");
+
+ var pendingMigrations = await _context.Database.GetPendingMigrationsAsync(cancellationToken);
+ var migrations = pendingMigrations.ToList();
+
+ if (migrations.Count == 0)
+ {
+ _logger.LogInformation("No pending EF Core migrations found.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} pending migration(s): {Migrations}",
+ migrations.Count, string.Join(", ", migrations));
+
+ await _context.Database.MigrateAsync(cancellationToken);
+
+ _logger.LogInformation("EF Core migrations applied successfully.");
+ }
+
+ private void RunSqlScripts(string connectionString)
+ {
+ _logger.LogInformation("Deploying SQL scripts via DbUp...");
+ ScriptDeployer.Deploy(connectionString, _logger);
+ }
+}
diff --git a/src/Migrator/Migrator.csproj b/src/Migrator/Migrator.csproj
new file mode 100644
index 000000000..8e3c5c128
--- /dev/null
+++ b/src/Migrator/Migrator.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ CleanArchitecture.Migrator
+ CleanArchitecture.Migrator
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
diff --git a/src/Migrator/MigratorModule.cs b/src/Migrator/MigratorModule.cs
new file mode 100644
index 000000000..ddb3db5a9
--- /dev/null
+++ b/src/Migrator/MigratorModule.cs
@@ -0,0 +1,55 @@
+using CleanArchitecture.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CleanArchitecture.Migrator;
+
+public static class MigratorModule
+{
+ public static void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ var connectionString = configuration.GetConnectionString("CleanArchitectureDb")
+ ?? throw new InvalidOperationException(
+ "Connection string 'CleanArchitectureDb' not found. " +
+ "Ensure appsettings.json is properly configured.");
+
+ // Register DbContext with the same configuration as the main application
+ services.AddDbContext((sp, options) =>
+ {
+ options.AddInterceptors(sp.GetServices());
+
+#if (UsePostgreSQL)
+ options.UseNpgsql(connectionString, npgsqlOptions =>
+ {
+ npgsqlOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.GetName().Name);
+ npgsqlOptions.EnableRetryOnFailure(
+ maxRetryCount: 5,
+ maxRetryDelay: TimeSpan.FromSeconds(30),
+ errorCodesToAdd: null);
+ });
+#elif (UseSqlServer)
+ options.UseSqlServer(connectionString, sqlOptions =>
+ {
+ sqlOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.GetName().Name);
+ sqlOptions.EnableRetryOnFailure(
+ maxRetryCount: 5,
+ maxRetryDelay: TimeSpan.FromSeconds(30),
+ errorNumbersToAdd: null);
+ });
+#else
+ options.UseSqlite(connectionString, sqliteOptions =>
+ {
+ sqliteOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.GetName().Name);
+ });
+#endif
+
+ options.ConfigureWarnings(warnings =>
+ warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
+ });
+
+ // Register migration executor
+ services.AddTransient();
+ }
+}
diff --git a/src/Migrator/Program.cs b/src/Migrator/Program.cs
new file mode 100644
index 000000000..ebc5f8d8c
--- /dev/null
+++ b/src/Migrator/Program.cs
@@ -0,0 +1,31 @@
+using CleanArchitecture.Migrator;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+var host = Host.CreateDefaultBuilder(args)
+ .ConfigureServices((hostContext, services) =>
+ {
+ MigratorModule.ConfigureServices(services, hostContext.Configuration);
+ })
+ .Build();
+
+using var scope = host.Services.CreateScope();
+var services = scope.ServiceProvider;
+var logger = services.GetRequiredService>();
+
+try
+{
+ logger.LogInformation("CleanArchitecture Database Migrator starting...");
+
+ var migrationExecutor = services.GetRequiredService();
+ await migrationExecutor.ExecuteAsync();
+
+ logger.LogInformation("CleanArchitecture Database Migrator completed successfully.");
+ return 0;
+}
+catch (Exception ex)
+{
+ logger.LogCritical(ex, "CleanArchitecture Database Migrator terminated unexpectedly.");
+ return 1;
+}
diff --git a/src/Migrator/appsettings.json b/src/Migrator/appsettings.json
new file mode 100644
index 000000000..6fd339bdc
--- /dev/null
+++ b/src/Migrator/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "ConnectionStrings": {
+ "CleanArchitectureDb": "Server=(localdb)\\mssqllocaldb;Database=CleanArchitectureDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.EntityFrameworkCore": "Information",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "CleanArchitecture.Migrator": "Debug"
+ }
+ }
+}
diff --git a/src/Shared.SQLScripts/Everytime/01_SPs/__sample_sp.sql b/src/Shared.SQLScripts/Everytime/01_SPs/__sample_sp.sql
new file mode 100644
index 000000000..0e97d5c13
--- /dev/null
+++ b/src/Shared.SQLScripts/Everytime/01_SPs/__sample_sp.sql
@@ -0,0 +1,19 @@
+-- =============================================
+-- Sample Stored Procedure: GetTodoListsSummary
+-- This script runs on every deployment (no journaling).
+-- Add your stored procedures to this folder.
+-- =============================================
+-- CREATE OR ALTER PROCEDURE [dbo].[GetTodoListsSummary]
+-- AS
+-- BEGIN
+-- SET NOCOUNT ON;
+-- SELECT
+-- tl.Id,
+-- tl.Title,
+-- COUNT(ti.Id) AS ItemCount,
+-- SUM(CASE WHEN ti.Done = 1 THEN 1 ELSE 0 END) AS CompletedCount
+-- FROM TodoLists tl
+-- LEFT JOIN TodoItems ti ON ti.ListId = tl.Id
+-- GROUP BY tl.Id, tl.Title;
+-- END
+-- GO
diff --git a/src/Shared.SQLScripts/Everytime/02_Functions/__sample_function.sql b/src/Shared.SQLScripts/Everytime/02_Functions/__sample_function.sql
new file mode 100644
index 000000000..c17952379
--- /dev/null
+++ b/src/Shared.SQLScripts/Everytime/02_Functions/__sample_function.sql
@@ -0,0 +1,15 @@
+-- =============================================
+-- Sample Function placeholder
+-- This script runs on every deployment (no journaling).
+-- Add your SQL functions to this folder.
+-- =============================================
+-- CREATE OR ALTER FUNCTION [dbo].[fn_GetTodoItemStatus](@TodoItemId INT)
+-- RETURNS NVARCHAR(20)
+-- AS
+-- BEGIN
+-- DECLARE @Status NVARCHAR(20);
+-- SELECT @Status = CASE WHEN Done = 1 THEN 'Completed' ELSE 'Pending' END
+-- FROM TodoItems WHERE Id = @TodoItemId;
+-- RETURN ISNULL(@Status, 'Unknown');
+-- END
+-- GO
diff --git a/src/Shared.SQLScripts/Everytime/03_Views/__sample_view.sql b/src/Shared.SQLScripts/Everytime/03_Views/__sample_view.sql
new file mode 100644
index 000000000..d81bb77ee
--- /dev/null
+++ b/src/Shared.SQLScripts/Everytime/03_Views/__sample_view.sql
@@ -0,0 +1,16 @@
+-- =============================================
+-- Sample View placeholder
+-- This script runs on every deployment (no journaling).
+-- Add your SQL views to this folder.
+-- =============================================
+-- CREATE OR ALTER VIEW [dbo].[vw_TodoListSummary]
+-- AS
+-- SELECT
+-- tl.Id,
+-- tl.Title,
+-- COUNT(ti.Id) AS TotalItems,
+-- SUM(CASE WHEN ti.Done = 1 THEN 1 ELSE 0 END) AS CompletedItems
+-- FROM TodoLists tl
+-- LEFT JOIN TodoItems ti ON ti.ListId = tl.Id
+-- GROUP BY tl.Id, tl.Title;
+-- GO
diff --git a/src/Shared.SQLScripts/Onetime/01_Seeding/__sample_seed.sql b/src/Shared.SQLScripts/Onetime/01_Seeding/__sample_seed.sql
new file mode 100644
index 000000000..b7d82e100
--- /dev/null
+++ b/src/Shared.SQLScripts/Onetime/01_Seeding/__sample_seed.sql
@@ -0,0 +1,9 @@
+-- =============================================
+-- Sample Seed Script
+-- This script runs only once (journaled by DbUp).
+-- Add your seed data scripts to this folder.
+-- Use a naming convention like: 001_SeedInitialData.sql
+-- =============================================
+-- INSERT INTO TodoLists (Title, Colour_Code)
+-- SELECT 'Default List', '#00FF00'
+-- WHERE NOT EXISTS (SELECT 1 FROM TodoLists WHERE Title = 'Default List');
diff --git a/src/Shared.SQLScripts/Onetime/02_Migrations/__sample_migration.sql b/src/Shared.SQLScripts/Onetime/02_Migrations/__sample_migration.sql
new file mode 100644
index 000000000..3f80f8b4d
--- /dev/null
+++ b/src/Shared.SQLScripts/Onetime/02_Migrations/__sample_migration.sql
@@ -0,0 +1,8 @@
+-- =============================================
+-- Sample Migration Script
+-- This script runs only once (journaled by DbUp).
+-- Use for one-time data migrations or schema changes
+-- that are not handled by EF Core.
+-- Use a naming convention like: 001_AddIndexOnTodoItems.sql
+-- =============================================
+-- CREATE INDEX IX_TodoItems_Done ON TodoItems(Done) WHERE Done = 0;
diff --git a/src/Shared.SQLScripts/ScriptDeployer.cs b/src/Shared.SQLScripts/ScriptDeployer.cs
new file mode 100644
index 000000000..25dc0a586
--- /dev/null
+++ b/src/Shared.SQLScripts/ScriptDeployer.cs
@@ -0,0 +1,111 @@
+using DbUp;
+using DbUp.Engine;
+using DbUp.Helpers;
+using Microsoft.Extensions.Logging;
+using System.Reflection;
+
+namespace CleanArchitecture.Shared.SQLScripts;
+
+public static class ScriptDeployer
+{
+ public static void Deploy(string connectionString, ILogger logger)
+ {
+ logger.LogInformation("Starting SQL scripts deployment on connection: {ConnectionString}",
+ MaskConnectionString(connectionString));
+
+ DeployEverytimeScripts(connectionString, logger);
+ DeployOnetimeScripts(connectionString, logger);
+
+ logger.LogInformation("SQL scripts deployment completed successfully.");
+ }
+
+ public static void DeployFunctions(string connectionString, ILogger logger)
+ {
+ DeployEverytimeFunctionScripts(connectionString, logger);
+ }
+
+ private static void DeployEverytimeScripts(string connectionString, ILogger logger)
+ {
+ logger.LogInformation("Deploying everytime scripts (SPs, Functions, Views)...");
+
+ var upgrader = DeployChanges.To
+ .SqlDatabase(connectionString)
+ .WithScriptsEmbeddedInAssembly(
+ Assembly.GetExecutingAssembly(),
+ s => s.Contains("everytime", StringComparison.OrdinalIgnoreCase)
+ && !s.Contains("02_Functions", StringComparison.OrdinalIgnoreCase))
+ .LogToConsole()
+ .JournalTo(new NullJournal())
+ .Build();
+
+ ExecuteUpgrade(upgrader, logger, "Everytime scripts");
+ }
+
+ private static void DeployEverytimeFunctionScripts(string connectionString, ILogger logger)
+ {
+ logger.LogInformation("Deploying everytime function scripts...");
+
+ var upgrader = DeployChanges.To
+ .SqlDatabase(connectionString)
+ .WithScriptsEmbeddedInAssembly(
+ Assembly.GetExecutingAssembly(),
+ s => s.Contains("02_Functions", StringComparison.OrdinalIgnoreCase))
+ .LogToConsole()
+ .JournalTo(new NullJournal())
+ .Build();
+
+ ExecuteUpgrade(upgrader, logger, "Function scripts");
+ }
+
+ private static void DeployOnetimeScripts(string connectionString, ILogger logger)
+ {
+ logger.LogInformation("Deploying onetime scripts (Seeding, Migrations)...");
+
+ var upgrader = DeployChanges.To
+ .SqlDatabase(connectionString)
+ .WithScriptsEmbeddedInAssembly(
+ Assembly.GetExecutingAssembly(),
+ s => s.Contains("onetime", StringComparison.OrdinalIgnoreCase))
+ .LogToConsole()
+ .Build();
+
+ ExecuteUpgrade(upgrader, logger, "Onetime scripts");
+ }
+
+ private static void ExecuteUpgrade(UpgradeEngine upgrader, ILogger logger, string scriptCategory)
+ {
+ var result = upgrader.PerformUpgrade();
+
+ if (!result.Successful)
+ {
+ logger.LogError(result.Error, "Failed to deploy {ScriptCategory}.", scriptCategory);
+ throw new InvalidOperationException(
+ $"SQL script deployment failed for '{scriptCategory}'. See inner exception for details.",
+ result.Error);
+ }
+
+ logger.LogInformation("{ScriptCategory} deployed successfully.", scriptCategory);
+ }
+
+ ///
+ /// Masks the connection string for safe logging (hides password).
+ ///
+ private static string MaskConnectionString(string connectionString)
+ {
+ // Simple masking — replace password value if present
+ var parts = connectionString.Split(';');
+ for (var i = 0; i < parts.Length; i++)
+ {
+ if (parts[i].TrimStart().StartsWith("Password", StringComparison.OrdinalIgnoreCase) ||
+ parts[i].TrimStart().StartsWith("Pwd", StringComparison.OrdinalIgnoreCase))
+ {
+ var eqIndex = parts[i].IndexOf('=');
+ if (eqIndex >= 0)
+ {
+ parts[i] = parts[i][..(eqIndex + 1)] + "***";
+ }
+ }
+ }
+ return string.Join(';', parts);
+ }
+}
diff --git a/src/Shared.SQLScripts/Shared.SQLScripts.csproj b/src/Shared.SQLScripts/Shared.SQLScripts.csproj
new file mode 100644
index 000000000..3d1383d3f
--- /dev/null
+++ b/src/Shared.SQLScripts/Shared.SQLScripts.csproj
@@ -0,0 +1,27 @@
+
+
+
+ CleanArchitecture.Shared.SQLScripts
+ CleanArchitecture.Shared.SQLScripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+