ASP.NET Framework to .NET 8: A Razor Build and Request Verification Guide
Migrating an ASP.NET Framework MVC application to .NET 8 should not be declared complete merely because the solution builds. A dependable acceptance process separates package restore, solution compilation, application startup, and real HTTP requests into four observable gates. This guide focuses on Razor view verification during an ASP.NET Framework to .NET 8 migration. It intentionally does not compete with general API contract, dependency injection lifetime, or database query performance topics already covered elsewhere.
Why a successful build is not enough for a Razor migration
A successful build proves that the current project graph and compile-time inputs passed, but it does not prove that every routed view, layout, partial, request service, and runtime branch can render correctly.
A legacy ASP.NET MVC solution commonly carries several kinds of migration debt at once: older project files, pinned package versions, assumptions based on System.Web, and a large collection of .cshtml files. Retargeting a project to net8.0 does not translate those assumptions. ASP.NET Core has a different hosting model, HTTP abstraction, configuration system, and Razor toolchain. Members that appeared implicit in an older view base class may no longer be available in the same form.
Define completion with evidence rather than a single green command. First, dotnet restore must resolve a coherent asset graph. Second, dotnet build must return zero while the complete log remains free of NuGet, MSBuild, and C# errors. Third, the web process must actually listen on a sandbox endpoint. Fourth, a route matrix must request the home page, shared layout, representative partials, an error page, and important anonymous routes. These gates are related, but none substitutes for another.
Razor also has more than one compilation moment. Microsoft documents build-time and publish-time Razor compilation, and it documents optional runtime compilation for development. Runtime compilation is useful when a controlled migration sandbox needs immediate feedback after a view changes. It is not evidence that the deployed application needs that package, and it is not a replacement for route-level testing. The practical question is not which mode sounds preferable; it is which gate catches a failure and whether that gate is part of the acceptance process.
Separate restore failures from compilation failures
When dotnet build returns a nonzero exit code, preserve the complete output and classify NuGet, MSBuild, and C# diagnostics separately instead of counting only error codes that begin with CS.
A package downgrade such as NU1605 can appear when a lower-level library requires a newer direct or transitive dependency while the web project pins an older version. Building the library alone may succeed because its graph is internally coherent. Building the complete solution can still fail when the upper project imposes a conflicting constraint. The correct repair is to inspect the current restore graph, identify the project that owns the direct reference, and align that reference with the minimum requirement. A version copied from an unrelated incident is not a migration strategy.
Project shape requires a separate check. A class library accidentally configured as an executable can produce CS5001 because it has no entry point. A project that uses a third-party attribute without a direct package reference may compile only while another project accidentally supplies the assembly. Modernizing project boundaries often exposes these hidden dependencies. Resolve restore and ordinary compilation issues before diagnosing Razor so each failure carries a clearer signal.
Use a pinned SDK and preserve the exit status:
mkdir razor-migration-check && cd razor-migration-check
dotnet new globaljson --sdk-version 8.0.100 --roll-forward disable
dotnet new mvc -n RazorCheck -f net8.0
dotnet restore RazorCheck/RazorCheck.csproj
dotnet build RazorCheck/RazorCheck.csproj -f net8.0 --no-restore -v minimal
printf 'BUILD_EXIT=%s\n' "$?"
Although dotnet build performs an implicit restore when necessary, migration diagnostics are easier to interpret when restore and build are separate commands. The restore log then answers questions about sources, resolved versions, and framework compatibility, while build --no-restore answers compilation questions against the already resolved graph. If the repository supports a package lock file, validate locked mode as a separate repeatability control.
A useful evidence bundle contains the pinned global.json, the project files, the restore log, the build log, and the exact command line. Do not reduce that bundle to a screenshot of a green IDE. Command output is easier to compare in automation, and the process exit code gives a deterministic pass or fail condition.
Understand build-time and runtime Razor compilation
Build-time Razor compilation catches many syntax and symbol errors early, while optional runtime compilation recompiles changed views in a controlled development workflow; neither mode replaces requests against representative application routes.
For an ASP.NET Core 8 sandbox, add a compatible 8.0.x version of Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation, then chain AddRazorRuntimeCompilation() from AddControllersWithViews(). Keep the target framework, shared framework, and package major version aligned. Otherwise, the experiment measures version mixing rather than the migration behavior that matters.
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllersWithViews()
.AddRazorRuntimeCompilation();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
Do not infer that every view works because a simple undefined symbol fails during build. A routed view may depend on the current request, injected services, ViewData, a model subtype, a shared layout, a partial, a tag helper, or a conditional branch that the compiler cannot validate as an end-to-end scenario. Generated code can compile while an actual request still encounters a missing registration, a null value, a model mismatch, or a changed HTTP abstraction. The acceptance unit is therefore a route matrix, not merely a project file.
Treat Razor line and column reports as diagnostic clues rather than infallible coordinates in the original .cshtml. Razor generates C#, so a reported location can reflect generated context. Preserve the full exception, view path, and requested route. Then search migration code for assumptions about older request, response, session, and System.Web APIs. Replace each assumption with an explicit ASP.NET Core abstraction and a focused test. Avoid broad compatibility shims that conceal behavior which has not yet been verified.
Microsoft also states that runtime compilation disables Razor Hot Reload. That compatibility consequence matters in a developer workflow: enabling runtime compilation to diagnose legacy views changes how edits are observed. Record the selected workflow in the repository rather than letting each developer enable a different combination without a testable reason.
Use four auditable acceptance gates
The minimum acceptance pipeline checks restore, build, startup, and HTTP routes in order, gives every gate an observable pass condition, and stops immediately when an earlier gate fails.
Gate one: restore. Pin the .NET 8 SDK, run dotnet restore, and preserve all output. Pass only when the command returns zero and no downgrade error remains. If NU1605 appears, use dotnet list package --include-transitive to compare top-level and transitive versions, then change the project that owns the direct reference.
Gate two: build. Run dotnet build --no-restore so another restore does not alter the diagnostic context. Pass only when the exit code is zero. Searching exclusively for CS diagnostics is unsafe because NuGet and MSBuild failures can also produce a nonzero command result. If a library builds but the solution fails, compare project references and upper-level package constraints.
Gate three: startup. Start the application on a loopback endpoint in a local machine or disposable container. Wait for the host to report that it is listening, then make an HTTP request. A process name does not prove readiness, and a launch command that returned does not prove the background process survived. Pass only when the process remains alive and the endpoint is reachable.
Gate four: route matrix. Request a list of safe GET endpoints that covers the home page, shared layout, at least one partial, an error page, and representative anonymous feature pages. Preserve each response status and the corresponding server log. When a page needs state, substitute in-memory or disposable test data. The test must not depend on a live account, a payment path, or an irreversible write.
| Gate | Primary evidence | Pass condition | Frequent false conclusion |
|---|---|---|---|
| Restore | Restore log and asset graph | Zero exit; no downgrade | A lower library builds, so the graph is fine |
| Build | Complete build log | Zero exit | No CS match means success |
| Startup | Host log and process state | Process stays alive; endpoint connects | A process exists, so the app is ready |
| Routes | HTTP status and exception log | Every expected route passes | The home page represents all views |
The gates should run in this order because later evidence can otherwise hide an earlier failure. For example, a developer may launch an old binary after a new build fails. The endpoint then answers successfully even though the current source was never compiled. Running the newly built output from a clean sandbox and recording the artifact path prevents that misleading result.
Verification and reproduction in a pinned .NET 8 sandbox
The following numbered procedure creates only a local test project and calls a loopback endpoint; the scheduler environment executed the core build and HTTP checks without using external application data.
Prerequisites are .NET SDK 8.0.100, access to the official NuGet package source, a disposable working directory, and an unused loopback port. The sample targets net8.0 and pins Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation to 8.0.22. Run the commands from a local shell or an isolated test container.
- Create the MVC project and
global.json. Confirm thatdotnet --versionprints exactly8.0.100, then add Runtime Compilation version 8.0.22. - Chain
AddRazorRuntimeCompilation()afterAddControllersWithViews(). Run restore and build; the unchanged template view must produce a zero build exit code. - Start with
dotnet run --no-build --no-launch-profile --urls http://127.0.0.1:5199, then request/; the expected result is HTTP 200. - While the process is running, add an undefined Razor symbol to
Views/Home/Index.cshtmland request/again; the expected result is HTTP 500 withCompilationFailedExceptionandCS0103in the server log. - Remove the intentional error and repeat the request; the expected result is HTTP 200 without any external write.
dotnet new mvc -n RazorCheck -f net8.0
cd RazorCheck
dotnet add package Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation --version 8.0.22
dotnet restore
dotnet build -f net8.0 --no-restore
dotnet run --no-build --no-launch-profile --urls http://127.0.0.1:5199
curl -i http://127.0.0.1:5199/
The scheduler produced real results. SDK 8.0.100 was available and the pinned 8.0.22 package restored. A clean MVC view built successfully, and the loopback root route returned HTTP 200. After an undefined Razor symbol was added while the server was running, the same route returned HTTP 500; the server log contained CompilationFailedException and CS0103. In a separate run where that intentional error remained before compilation, the pinned SDK build also failed with one CS0103, proving that this simple view symbol error can be caught at the build gate.
These checks validate behavior, not performance. They do not establish throughput, startup improvement, memory savings, or a success-rate metric. A migration team should add representative integration tests for its own layouts, partials, model types, and service registrations without turning this small sandbox result into a broad performance claim.
Migration risks, decisions, and delivery checklist
Delivery should be decided from route coverage and readable evidence; if a critical view still fails, keep the migration incomplete rather than extrapolating readiness from build or startup alone.
First, restrict runtime compilation to development or migration diagnostics. Build-time and publish-time compilation are preferable delivery gates because they expose failures before deployment and avoid carrying unnecessary compilation support into the released application. Verify the chosen publish settings explicitly instead of assuming that local development behavior equals the packaged result.
Second, align package major versions with the target framework and let the current restore graph determine exact upgrades. A package version that fixed one historical solution is evidence about that graph, not a universal baseline. Central package management, a lock file where appropriate, and a pinned SDK make future changes reviewable.
Third, assign route ownership. Every view family should have a safe anonymous test or controlled integration test that covers its layout, partials, error behavior, and conditional branches. Use only local, containerized, or disposable test data. When stopping a test process from one shell through another, verify both process state and endpoint state so quoting behavior cannot leave an old process serving misleading results.
Use this delivery checklist:
-
global.jsonpins a supported .NET 8 SDK with an agreed roll-forward policy. - Restore and build run separately, and logs retain
NU,MSB, andCSdiagnostics. - Direct and transitive package versions have been checked against the actual asset graph.
- Library, web project, and solution exits can be evaluated independently.
- The home page, layout, partials, error page, and key anonymous routes receive real GET requests.
- Runtime compilation exists only in a controlled test configuration, with publish behavior tested separately.
- Failed requests preserve status, view path, and server diagnostics instead of relying on generated coordinates alone.
- No live account, payment workflow, production data source, or irreversible operation participates in verification.
The core conclusion is straightforward: acceptance for an ASP.NET Framework to .NET 8 migration must move from “it compiles” to “every stage is observable and every critical route is reproducible.” A successful build remains necessary, but only the combined restore, build, startup, and request gates provide defensible evidence that the intended Razor migration scope is complete.