diff --git a/.github/modernize/assessment/reports/assessment-config.yaml b/.github/modernize/assessment/reports/assessment-config.yaml new file mode 100644 index 00000000..a96c7898 --- /dev/null +++ b/.github/modernize/assessment/reports/assessment-config.yaml @@ -0,0 +1,5 @@ +dotnet: + assessmentDomains: + - cloud-readiness + targetComputeServices: + - AKS.Windows diff --git a/.github/modernize/dotnet8-migration/plan.md b/.github/modernize/dotnet8-migration/plan.md new file mode 100644 index 00000000..8582985f --- /dev/null +++ b/.github/modernize/dotnet8-migration/plan.md @@ -0,0 +1,441 @@ +# Migration Plan: ContosoUniversity — .NET Framework 4.8 → .NET 8 + +## Executive Summary + +| Attribute | Current | Target | +|-----------|---------|--------| +| Runtime | .NET Framework 4.8 | .NET 8 LTS | +| Web Framework | ASP.NET MVC 5 | ASP.NET Core 8 MVC | +| ORM | EF Core 3.1 (on Framework) | EF Core 8.x | +| Messaging | MSMQ (System.Messaging) | Channel-based in-memory (dev) / Azure Service Bus (prod) | +| Config | Web.config / ConfigurationManager | appsettings.json / IConfiguration | +| DI | Manual (SchoolContextFactory) | Built-in ASP.NET Core DI | +| Auth | Windows Auth (IIS Express) | ASP.NET Core Identity (optional) | +| Client | Bootstrap 5.3 + jQuery 3.7 via bundles | wwwroot static files + LibMan/npm | +| Tests | None | xUnit + WebApplicationFactory integration tests | + +**Estimated effort**: 3–5 days for an experienced developer. +**Risk level**: Medium — MSMQ replacement and view migration are the largest tasks. + +--- + +## Phase 1: Project & Infrastructure (SDK-Style Project) + +**Goal**: Replace the legacy `.csproj` and `packages.config` with a minimal SDK-style project targeting `net8.0`. + +### Actions +1. Create new `ContosoUniversity.csproj` (SDK-style) +2. Create `Program.cs` with minimal hosting model +3. Delete `Global.asax`, `Global.asax.cs`, `packages.config`, `Properties/AssemblyInfo.cs` +4. Delete `Web.config` (root and Views) +5. Remove `App_Start/` folder (BundleConfig, FilterConfig, RouteConfig) + +### Before (Legacy .csproj — excerpt) +```xml + + + v4.8 + {349c5851-...} + + + +``` + +### After (SDK-style) +```xml + + + net8.0 + enable + enable + + + + + + + +``` + +### After (Program.cs — minimal hosting) +```csharp +using ContosoUniversity.Data; +using ContosoUniversity.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Services +builder.Services.AddControllersWithViews(); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(); + +var app = builder.Build(); + +// Middleware +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthorization(); + +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + +// Seed database +using (var scope = app.Services.CreateScope()) +{ + var context = scope.ServiceProvider.GetRequiredService(); + DbInitializer.Initialize(context); +} + +app.Run(); +``` + +--- + +## Phase 2: Data Layer (EF Core 8 Upgrade) + +**Goal**: Upgrade EF Core 3.1 → 8.x. The existing `SchoolContext` and models are already EF Core-compatible — minimal changes needed. + +### Actions +1. Update `SchoolContext` — remove `SchoolContextFactory`, rely on DI +2. Update model configurations for EF Core 8 conventions +3. Generate initial EF Core migration +4. Update `DbInitializer` to work with DI-provided context + +### Before (SchoolContextFactory — manual creation) +```csharp +public class SchoolContextFactory +{ + public static SchoolContext Create() + { + var connectionString = ConfigurationManager + .ConnectionStrings["DefaultConnection"].ConnectionString; + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlServer(connectionString); + return new SchoolContext(optionsBuilder.Options); + } +} +``` + +### After (DI-injected — no factory needed) +```csharp +// Registered in Program.cs: +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Controllers receive via constructor injection +public class StudentsController : Controller +{ + private readonly SchoolContext _context; + public StudentsController(SchoolContext context) => _context = context; +} +``` + +--- + +## Phase 3: Configuration & Dependency Injection + +**Goal**: Replace `Web.config` / `ConfigurationManager` with `appsettings.json` and the Options pattern. + +### Actions +1. Create `appsettings.json` and `appsettings.Development.json` +2. Map connection strings and app settings +3. Register all services in `Program.cs` +4. Remove all `ConfigurationManager` references + +### Before (Web.config) +```xml + + + + + + +``` + +### After (appsettings.json) +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=ContosoUniversity;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Notifications": { + "Provider": "InMemory" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Information" + } + } +} +``` + +--- + +## Phase 4: MSMQ Replacement + +**Goal**: Replace `System.Messaging` (MSMQ) with a cross-platform abstraction. Use `System.Threading.Channels` for development, with an option to swap in Azure Service Bus for production. + +### Actions +1. Define `INotificationService` interface +2. Implement `InMemoryNotificationService` using `Channel` +3. (Optional) Implement `AzureServiceBusNotificationService` +4. Delete `NotificationService.cs` (MSMQ-based) +5. Register via DI with provider selection + +### Before (MSMQ — Windows-only) +```csharp +using System.Messaging; + +public class NotificationService +{ + private readonly MessageQueue _queue; + public NotificationService() + { + _queuePath = ConfigurationManager.AppSettings["NotificationQueuePath"]; + if (!MessageQueue.Exists(_queuePath)) + _queue = MessageQueue.Create(_queuePath); + else + _queue = new MessageQueue(_queuePath); + } + public void SendNotification(...) { _queue.Send(message); } +} +``` + +### After (Cross-platform Channel-based) +```csharp +public interface INotificationService +{ + Task SendNotificationAsync(string entityType, string entityId, + string? displayName, EntityOperation operation, string? userName = null); + Task ReceiveNotificationAsync(CancellationToken ct = default); + Task> GetRecentNotificationsAsync(int count = 50); +} + +public class InMemoryNotificationService : INotificationService +{ + private readonly Channel _channel = + Channel.CreateBounded(1000); + private readonly List _history = new(); + + public async Task SendNotificationAsync(...) + { + var notification = new Notification { /* ... */ }; + _history.Add(notification); + await _channel.Writer.WriteAsync(notification); + } + + public async Task ReceiveNotificationAsync(CancellationToken ct) + { + if (await _channel.Reader.WaitToReadAsync(ct)) + return await _channel.Reader.ReadAsync(ct); + return null; + } + + public Task> GetRecentNotificationsAsync(int count) + => Task.FromResult(_history.TakeLast(count).Reverse().ToList()); +} +``` + +--- + +## Phase 5: MVC → ASP.NET Core MVC + +**Goal**: Convert controllers from `System.Web.Mvc` to `Microsoft.AspNetCore.Mvc`. Migrate Razor views from MVC 5 syntax to ASP.NET Core Tag Helpers. + +### Actions +1. Update all controllers to inherit from `Microsoft.AspNetCore.Mvc.Controller` +2. Replace `BaseController` with constructor DI +3. Convert `ActionResult` patterns (mostly compatible) +4. Replace `@Html.ActionLink` with `` Tag Helpers +5. Replace `@Styles.Render` / `@Scripts.Render` with `` / ` +Students +``` + +--- + +## Phase 6: Client Assets & Styling + +**Goal**: Move static files from `Content/` and `Scripts/` to `wwwroot/` following ASP.NET Core conventions. + +### Actions +1. Create `wwwroot/css/`, `wwwroot/js/`, `wwwroot/lib/` +2. Move `Site.css`, `notifications.css` → `wwwroot/css/` +3. Move `notifications.js` → `wwwroot/js/` +4. Use LibMan or npm for Bootstrap and jQuery +5. Delete `Scripts/`, `Content/`, bundle config +6. Remove WebGrease, Modernizr, Antlr dependencies + +### File Structure (After) +``` +wwwroot/ +├── css/ +│ ├── site.css +│ └── notifications.css +├── js/ +│ └── notifications.js +└── lib/ + ├── bootstrap/ + │ ├── css/bootstrap.min.css + │ └── js/bootstrap.bundle.min.js + ├── jquery/ + │ └── jquery.min.js + └── jquery-validation/ + └── jquery.validate.min.js +``` + +--- + +## Phase 7: Authentication & Security + +**Goal**: Replace Windows Authentication with ASP.NET Core middleware. Add CSRF protection (built-in with Tag Helpers). + +### Actions +1. Configure `app.UseAuthentication()` / `app.UseAuthorization()` in pipeline +2. Anti-forgery tokens are automatic with Tag Helpers `
` — verify all forms +3. Add HTTPS redirection (already in template) +4. (Optional) Add ASP.NET Core Identity if real auth is needed +5. Replace `"System"` user placeholder with actual user context + +### Notes +- The current app uses Windows Auth at IIS level only — no `[Authorize]` attributes +- For dev/demo purposes, anonymous access is acceptable +- CSRF: ASP.NET Core automatically validates `[ValidateAntiForgeryToken]` when using Tag Helpers + +--- + +## Phase 8: Testing & Validation + +**Goal**: Add automated tests to validate the migration is functionally equivalent. + +### Actions +1. Create `ContosoUniversity.Tests` project (xUnit) +2. Add integration tests using `WebApplicationFactory` +3. Test all CRUD endpoints return 200 +4. Test database seeding works +5. Test notification service (in-memory) +6. Validate build with `dotnet build --warnaserror` + +### Test Example +```csharp +public class StudentEndpointTests : IClassFixture> +{ + private readonly HttpClient _client; + + public StudentEndpointTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task Get_StudentsIndex_ReturnsSuccess() + { + var response = await _client.GetAsync("/Students"); + response.EnsureSuccessStatusCode(); + } +} +``` + +--- + +## Phase 9: Cleanup & Optimization + +**Goal**: Remove all legacy artifacts and resolve warnings. + +### Actions +1. Delete: `Global.asax`, `Global.asax.cs`, `packages.config`, `AssemblyInfo.cs` +2. Delete: `Web.config` (root + Views), `App_Start/` folder +3. Delete: `SchoolContextFactory.cs` +4. Delete: `Scripts/` and `Content/` folders (moved to wwwroot) +5. Remove `System.Messaging` reference +6. Run `dotnet format` to standardize code style +7. Ensure zero build warnings +8. Update solution file for new project structure + +--- + +## Dependency Migration Map + +| Legacy Package | .NET 8 Replacement | +|---|---| +| Microsoft.AspNet.Mvc 5.2.9 | Built-in (Microsoft.NET.Sdk.Web) | +| Microsoft.AspNet.Razor 3.2.9 | Built-in | +| Microsoft.AspNet.WebPages 3.2.9 | Built-in | +| Microsoft.AspNet.Web.Optimization 1.1.3 | Removed (use static files) | +| Microsoft.EntityFrameworkCore 3.1.32 | 8.0.11 | +| Microsoft.EntityFrameworkCore.SqlServer 3.1.32 | 8.0.11 | +| Microsoft.Data.SqlClient 2.1.4 | Transitive via EF Core 8 | +| Newtonsoft.Json 13.0.3 | System.Text.Json (built-in) | +| jQuery 3.7.1 | wwwroot/lib (LibMan) | +| Bootstrap 5.3.3 | wwwroot/lib (LibMan) | +| Modernizr 2.6.2 | Removed (unnecessary) | +| WebGrease 1.5.2 | Removed | +| Antlr 3.4.1 | Removed | +| System.Messaging | System.Threading.Channels | + +--- + +## Risk Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| MSMQ removal breaks notifications | High | Interface abstraction; in-memory impl preserves behavior | +| EF Core 3.1 → 8 breaking changes | Medium | Minimal — models already use EF Core conventions | +| View syntax differences | Medium | Methodical find-replace of @Html helpers → Tag Helpers | +| No existing tests | High | Build integration tests before migration to establish baseline | +| 44 NuGet packages | Low | Most are transitive; only ~6 direct packages needed in .NET 8 | diff --git a/.github/modernize/dotnet8-migration/tasks.md b/.github/modernize/dotnet8-migration/tasks.md new file mode 100644 index 00000000..f1b6dced --- /dev/null +++ b/.github/modernize/dotnet8-migration/tasks.md @@ -0,0 +1,644 @@ +# Migration Tasks: ContosoUniversity (.NET Framework 4.8 → .NET 8) + +## Task Hierarchy & Dependencies + +```mermaid +graph TD + P1[Phase 1: Project Infrastructure] --> P2[Phase 2: Data Layer] + P1 --> P3[Phase 3: Configuration & DI] + P3 --> P4[Phase 4: MSMQ Replacement] + P2 --> P5[Phase 5: MVC Migration] + P3 --> P5 + P4 --> P5 + P5 --> P6[Phase 6: Client Assets] + P5 --> P7[Phase 7: Auth & Security] + P6 --> P8[Phase 8: Testing] + P7 --> P8 + P8 --> P9[Phase 9: Cleanup] +``` + +--- + +## Phase 1: Project & Infrastructure +**Effort**: ~2 hours | **Dependencies**: None | **Risk**: Low + +### Task 1.1 — Create SDK-style .csproj +**File**: `ContosoUniversity.csproj` + +Replace the entire legacy project file: + +```xml + + + + net8.0 + enable + enable + ContosoUniversity + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + +``` + +### Task 1.2 — Create Program.cs (Minimal Hosting) +**File**: `Program.cs` (new) + +```csharp +using ContosoUniversity.Data; +using ContosoUniversity.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Add services +builder.Services.AddControllersWithViews(); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Configure pipeline +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthorization(); + +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + +// Seed database in development +if (app.Environment.IsDevelopment()) +{ + using var scope = app.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + DbInitializer.Initialize(context); +} + +app.Run(); +``` + +### Task 1.3 — Delete Legacy Startup Files +**Delete**: +- `Global.asax` +- `Global.asax.cs` +- `packages.config` +- `Properties/AssemblyInfo.cs` +- `App_Start/BundleConfig.cs` +- `App_Start/FilterConfig.cs` +- `App_Start/RouteConfig.cs` +- `Web.config` (root) +- `Views/Web.config` +- `ContosoUniversity.sln` (regenerate with `dotnet new sln`) + +--- + +## Phase 2: Data Layer (EF Core 8) +**Effort**: ~1.5 hours | **Dependencies**: Phase 1 | **Risk**: Low + +### Task 2.1 — Update SchoolContext for EF Core 8 +**File**: `Data/SchoolContext.cs` + +Minimal changes — EF Core 8 is backward-compatible with 3.1 model configuration. Key update: +- Remove explicit `datetime2` convention loop (EF Core 8 handles this natively) +- Keep TPH, composite key, and relationship configs + +```csharp +using ContosoUniversity.Models; +using Microsoft.EntityFrameworkCore; + +namespace ContosoUniversity.Data; + +public class SchoolContext : DbContext +{ + public SchoolContext(DbContextOptions options) : base(options) { } + + public DbSet Courses => Set(); + public DbSet Enrollments => Set(); + public DbSet Departments => Set(); + public DbSet OfficeAssignments => Set(); + public DbSet CourseAssignments => Set(); + public DbSet People => Set(); + public DbSet Students => Set(); + public DbSet Instructors => Set(); + public DbSet Notifications => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToTable("Course"); + modelBuilder.Entity().ToTable("Enrollment"); + modelBuilder.Entity().ToTable("Department"); + modelBuilder.Entity().ToTable("OfficeAssignment"); + modelBuilder.Entity().ToTable("CourseAssignment"); + modelBuilder.Entity().ToTable("Notification"); + + modelBuilder.Entity() + .ToTable("Person") + .HasDiscriminator("Discriminator") + .HasValue("Student") + .HasValue("Instructor"); + + modelBuilder.Entity() + .HasKey(c => new { c.CourseID, c.InstructorID }); + } +} +``` + +### Task 2.2 — Delete SchoolContextFactory +**Delete**: `Data/SchoolContextFactory.cs` + +No longer needed — DI provides `SchoolContext` instances. + +### Task 2.3 — Update DbInitializer +**File**: `Data/DbInitializer.cs` + +Ensure it's a static method accepting `SchoolContext` (likely already is). Verify `DateTime` values use UTC or explicit kinds. + +### Task 2.4 — Create Initial Migration +**Command**: +```bash +dotnet ef migrations add InitialCreate --context SchoolContext +``` + +--- + +## Phase 3: Configuration & Dependency Injection +**Effort**: ~1 hour | **Dependencies**: Phase 1 | **Risk**: Low + +### Task 3.1 — Create appsettings.json +**File**: `appsettings.json` (new) + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=ContosoUniversity;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Notifications": { + "Provider": "InMemory" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +``` + +### Task 3.2 — Create appsettings.Development.json +**File**: `appsettings.Development.json` (new) + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + } +} +``` + +### Task 3.3 — Remove All ConfigurationManager References +**Search & replace** in: +- `Services/NotificationService.cs` — will be rewritten (Phase 4) +- `Data/SchoolContextFactory.cs` — will be deleted (Task 2.2) +- `Global.asax.cs` — will be deleted (Task 1.3) + +--- + +## Phase 4: MSMQ Replacement +**Effort**: ~2 hours | **Dependencies**: Phase 3 | **Risk**: High (behavioral change) + +### Task 4.1 — Define INotificationService Interface +**File**: `Services/INotificationService.cs` (new) + +```csharp +using ContosoUniversity.Models; + +namespace ContosoUniversity.Services; + +public interface INotificationService +{ + Task SendNotificationAsync(string entityType, string entityId, + string? entityDisplayName, EntityOperation operation, string? userName = null); + Task ReceiveNotificationAsync(CancellationToken cancellationToken = default); + Task> GetRecentNotificationsAsync(int count = 50); +} +``` + +### Task 4.2 — Implement InMemoryNotificationService +**File**: `Services/InMemoryNotificationService.cs` (new) + +```csharp +using System.Threading.Channels; +using ContosoUniversity.Models; + +namespace ContosoUniversity.Services; + +public class InMemoryNotificationService : INotificationService +{ + private readonly Channel _channel = + Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest + }); + private readonly List _history = []; + private readonly Lock _lock = new(); + + public async Task SendNotificationAsync(string entityType, string entityId, + string? entityDisplayName, EntityOperation operation, string? userName = null) + { + var notification = new Notification + { + EntityType = entityType, + EntityId = entityId, + Operation = operation.ToString(), + Message = $"{entityType} '{entityDisplayName ?? entityId}' was {operation.ToString().ToLower()}d", + CreatedAt = DateTime.UtcNow, + CreatedBy = userName ?? "System", + IsRead = false + }; + + lock (_lock) { _history.Add(notification); } + await _channel.Writer.WriteAsync(notification); + } + + public async Task ReceiveNotificationAsync(CancellationToken ct = default) + { + try + { + if (await _channel.Reader.WaitToReadAsync(ct)) + return await _channel.Reader.ReadAsync(ct); + } + catch (OperationCanceledException) { } + return null; + } + + public Task> GetRecentNotificationsAsync(int count = 50) + { + lock (_lock) + { + return Task.FromResult(_history.TakeLast(count).Reverse().ToList()); + } + } +} +``` + +### Task 4.3 — Delete Legacy NotificationService +**Delete**: `Services/NotificationService.cs` (MSMQ-based) + +### Task 4.4 — Update Notification Model +**File**: `Models/Notification.cs` + +Remove any `System.Messaging` dependencies. Ensure model has an `Id` property for EF Core. + +--- + +## Phase 5: MVC → ASP.NET Core +**Effort**: ~3 hours | **Dependencies**: Phases 2, 3, 4 | **Risk**: Medium + +### Task 5.1 — Delete BaseController +**Delete**: `Controllers/BaseController.cs` + +Replace inheritance pattern with constructor injection in each controller. + +### Task 5.2 — Migrate StudentsController +**File**: `Controllers/StudentsController.cs` + +Key changes: +- Replace `using System.Web.Mvc` → `using Microsoft.AspNetCore.Mvc` +- Add constructor injection for `SchoolContext` and `INotificationService` +- Replace `db` field → `_context` +- Replace `ActionResult` → `IActionResult` (optional but idiomatic) +- Replace `new HttpStatusCodeResult(HttpStatusCode.BadRequest)` → `BadRequest()` +- Replace `HttpNotFound()` → `NotFound()` +- Add `async/await` for EF queries + +### Task 5.3 — Migrate CoursesController +**File**: `Controllers/CoursesController.cs` — Same pattern as 5.2 + +### Task 5.4 — Migrate InstructorsController +**File**: `Controllers/InstructorsController.cs` — Same pattern as 5.2 + +### Task 5.5 — Migrate DepartmentsController +**File**: `Controllers/DepartmentsController.cs` — Same pattern as 5.2 + +### Task 5.6 — Migrate NotificationsController +**File**: `Controllers/NotificationsController.cs` + +Update to use `INotificationService` via DI. + +### Task 5.7 — Migrate HomeController +**File**: `Controllers/HomeController.cs` — Simplest conversion. + +### Task 5.8 — Create _ViewImports.cshtml +**File**: `Views/_ViewImports.cshtml` (new) + +```cshtml +@using ContosoUniversity +@using ContosoUniversity.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +``` + +### Task 5.9 — Update _Layout.cshtml +**File**: `Views/Shared/_Layout.cshtml` + +Replace: +| MVC 5 | ASP.NET Core | +|-------|-------------| +| `@Styles.Render("~/Content/css")` | `` | +| `@Scripts.Render("~/bundles/jquery")` | `` | +| `@Html.ActionLink("Text", "Action", "Ctrl", ...)` | `Text` | +| `@RenderBody()` | `@RenderBody()` (unchanged) | +| `@RenderSection("scripts", required: false)` | `@await RenderSectionAsync("Scripts", required: false)` | + +### Task 5.10 — Update All Views +**Files**: All `.cshtml` files in `Views/` + +Replace in each view: +- `@Html.ActionLink(...)` → `` Tag Helpers +- `@Html.BeginForm(...)` → `` +- `@Html.DisplayNameFor(...)` → keep (still works) or use `