Razor Pages, MVC o Blazor: quanto costa sbagliare
Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 27 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Ha guidato progetti enterprise, formato centinaia di sviluppatori e aiutato aziende di ogni dimensione a semplificare la complessità trasformando il software in guadagni per il business.

Un'azienda di logistica di Padova ha un gestionale web per le spedizioni, scritto in ASP.NET Web Forms nel 2011. Funziona. Sessanta schermate, trenta utenti interni, un server che nessuno vuole spegnere. Il responsabile IT ha davanti un compito che sembra semplice: portarlo su .NET moderno prima che il sistema operativo del server esca dal supporto.

Poi apre la documentazione e trova tre nomi per la stessa cosa. Razor Pages, MVC, Blazor. Un fornitore gli propone Blazor perché "è il futuro". Un altro MVC perché "lo conosciamo". Un collega, che ha letto un articolo, dice che Razor Pages è la scelta giusta per le applicazioni a pagine. Nessuno gli dice quanto costa sbagliare, e nessuno gli dà un numero per decidere.

Questo articolo ti dice che cos'è Razor Pages e in cosa è diverso da MVC e da Blazor, quanto costa ciascuna strada in tre anni per un'azienda tipo con sessanta schermate, quale numero decide la scelta, come si costruisce una pagina vera con codice che compila, come si aggiunge l'interattività senza cambiare tecnologia, e come si passa da Web Forms o MVC 5 a Razor Pages una pagina alla volta, senza fermare l'azienda.

Troverai il conto delle quattro strade in euro, la soglia in schermate interattive oltre la quale conviene cambiare, il codice C# e Razor dell'intera pagina di modifica di un ordine, il test che ti avvisa se una pagina resta senza protezione, la configurazione per far convivere il vecchio e il nuovo, e un piano di sessanta giorni.

Una premessa. Dal 1999 progetto software per aziende, e ho portato in produzione applicazioni Web Forms, MVC e Razor Pages. Non ti vendo una tecnologia. Il caso che uso è un'azienda tipo, costruita con ordini di grandezza e medie di settore: non è un cliente, è un metodo. Lo rifai con i tuoi numeri in un'ora.

Che cos'è Razor Pages e in cosa è diverso da MVC e da Blazor?

Razor Pages è il modo di ASP.NET Core per costruire applicazioni web in cui ogni schermata è una pagina. Una pagina sono due file che stanno uno accanto all'altro: un file con il disegno (la parte in HTML) e un file con il codice C# che risponde alle richieste. L'indirizzo della pagina è deciso dalla cartella in cui sta il file. Nessun elenco di rotte da mantenere, nessun controller da cercare.

MVC, la tecnologia più vecchia, separa le stesse cose in tre posti. Il controller riceve la richiesta, il modello contiene i dati, la vista li disegna. Per una schermata di modifica di un ordine servono almeno tre file in tre cartelle diverse, più un oggetto che trasporta i dati dal controller alla vista. È ottimo quando lo stesso codice deve servire più clienti diversi, per esempio un sito e un'applicazione per il telefono. È più lavoro quando serve una sola cosa: far vedere e modificare una scheda.

Blazor è un'altra famiglia. Qui la pagina non è un documento che arriva dal server: è un insieme di componenti con uno stato, che reagiscono ai clic senza ricaricare la pagina. Nella versione che gira sul server, il browser tiene aperta una connessione continua e il server aggiorna lo schermo pezzo per pezzo. È il modo giusto per schermate molto vive: griglie con modifica in cella, tabelloni che si aggiornano da soli, editor con anteprima. Costa di più per le schermate semplici, perché porta con sé una struttura che a una scheda di anagrafica non serve.

La differenza che conta per chi decide non è tecnica, è di forma. In Razor Pages il lavoro si ripete in modo prevedibile: una schermata, due file, un posto dove guardare. In MVC si ripete in tre posti. In Blazor si ripete in componenti, ma con regole diverse sullo stato, sulla connessione, sulla memoria del server.

Un chiarimento che toglie confusione. Sono tutte e tre ASP.NET Core, girano sullo stesso server, usano lo stesso accesso ai dati (Entity Framework Core), lo stesso sistema di accesso e protezione, lo stesso modo di pubblicare. Scegliere una non è un matrimonio: nella stessa applicazione puoi avere pagine Razor Pages, azioni MVC e componenti Blazor insieme. Lo vedremo, perché è la strada che nel conto risulta più economica.

La domanda sbagliata è "qual è la tecnologia migliore". Quella giusta è: quante delle mie schermate sono documenti da compilare e quante sono strumenti da usare? Ti do il modo di contarle.

Com'è fatta l'azienda tipo e da quali numeri parte?

Per non parlare in astratto uso un caso. È un gestionale interno di un'azienda di medie dimensioni, da ricostruire in ASP.NET Core. Le misure sono ordini di grandezza di settore, scritte in modo che tu possa sostituirle con le tue.

MisuraValoreCome si rileva
Schermate da costruire60Conta i file .aspx, o le viste, o le voci di menu del programma attuale
Schermate interattive18 (il 30%)Quelle in cui l'utente lavora su una griglia, trascina, ricalcola senza ricaricare. Il resto sono schede, elenchi con filtri, moduli, report
Modifiche richieste ogni anno120Richieste evase negli ultimi dodici mesi: un campo, una regola, un filtro
Utenti contemporaneicirca 80Picco giornaliero: conta per le connessioni di Blazor
Costo di un'ora di programmatore60 euroCosto del lavoro diviso per le ore lavorate
Costo di una giornata480 euro8 ore a 60 euro
Orizzonte del conto3 anniCostruzione e manutenzione, senza il valore del lavoro dopo

La riga che pesa è la seconda. Una schermata è interattiva se l'utente la usa come uno strumento: modifica più righe e il totale si aggiorna, trascina una scheda da una colonna all'altra, vede i dati cambiare mentre un collega li modifica. Se invece apre, legge, compila un modulo e salva, è una pagina. La grande maggioranza dei gestionali aziendali è fatta di pagine. In un gestionale aziendale la quota di schermate interattive è di solito bassa, spesso sotto un terzo.

Un modo pratico per contarle: prendi l'elenco del menu e, per ogni voce, chiediti se l'utente, finito di lavorare, ha premuto "Salva" una volta o ha fatto venti operazioni senza mai uscire dalla schermata. Se ha premuto Salva una volta, è una pagina.

Quali strade esistono per costruire le sessanta schermate?

Una schermata di modifica in tre modi: Razor Pages con due file in una cartella, MVC con controller, vista e oggetto dati in tre cartelle, Blazor Server con un componente e una connessione aperta per utente

Le strade vere sono quattro. Non ho incluso "restare dove sei": è una scelta che ha un costo diverso, di cui parlo più avanti, e non si confronta alla pari con una costruzione nuova.

A. Tutto in Razor Pages

Ogni schermata è una pagina. Le schermate interattive si ottengono con un po' di JavaScript e con piccole richieste che aggiornano un pezzo di pagina. È la strada con la minore quantità di concetti da imparare e con il minor numero di file per schermata. Regge bene fino a quando le schermate interattive restano poche, perché il JavaScript scritto a mano per ricostruire uno strumento complesso cresce in modo disordinato.

B. Tutto in MVC

Controller, modelli, viste. Ogni schermata ha il suo controller o un'azione dentro un controller più grande, la sua vista e il suo oggetto dati. È la strada che conoscono quasi tutti gli sviluppatori .NET da quindici anni, ed è questo il suo vero vantaggio: trovare chi la sa fare è facile. Costa un po' più di Razor Pages per ogni schermata, perché sono più file da tenere allineati. Diventa la scelta giusta in un caso preciso: quando le stesse operazioni devono servire anche un'applicazione per il telefono o un altro programma, e allora i controller diventano anche l'interfaccia verso l'esterno.

C. Tutto in Blazor Server

Ogni schermata è un componente, pagine semplici comprese. Il vantaggio è l'omogeneità: un solo modo di costruire, anche per le schermate più vive. Gli svantaggi sono due. Le schermate semplici costano di più perché portano la struttura dei componenti. E il server deve tenere in memoria lo stato di ogni utente collegato e una connessione aperta per ciascuno: con ottanta utenti non è un problema, con ottomila diventa un tema di progetto.

D. Razor Pages per le pagine, Blazor per le schermate vive

Un'unica applicazione. Le schermate che sono documenti restano pagine, quelle che sono strumenti diventano componenti Blazor con il loro indirizzo. Si condividono l'accesso, la protezione, i dati, le regole di business. Si paga la complessità di conoscere due modi di lavorare, e un piccolo costo per far convivere le due metà, ma si evita sia il costo delle pagine semplici fatte come componenti, sia il costo del JavaScript che ricostruisce a mano uno strumento.

Il punto che conviene fissare: la quarta non è una scelta di compromesso, è la composizione delle prime. Nasce dal fatto che ASP.NET Core permette di mescolare, e il conto di cui parlo adesso mostra quando mescolare rende.

Quanto costa ogni strada in tre anni?

Costo in tre anni con 60 schermate di cui 18 interattive: MVC circa 132.648 euro, Blazor Server 123.660, Razor Pages 118.080, Razor Pages più Blazor 109.044

Il conto ha quattro voci per ogni strada, e un'ipotesi dichiarata per ciascuna. Le ipotesi sono le giornate per costruire una schermata semplice e una interattiva, le ore per fare una modifica, i giorni di avviamento e il costo dell'infrastruttura in più.

  1. Costruzione. Giornate per schermata, per tipo. Razor Pages: 1,5 giornate per una schermata semplice e 3,5 per una interattiva. MVC: 1,8 e 3,5. Blazor: 2,0 e 2,3. Razor Pages più Blazor: 1,55 e 2,6.
  2. Avviamento. Giorni per decidere la struttura, preparare lo scheletro, formare il gruppo. Razor Pages 3, MVC 5, Blazor 12, la combinazione 8.
  3. Manutenzione. 120 modifiche l'anno per tre anni. Ore per modifica, per tipo di schermata: Razor Pages 2,0 e 4,0; MVC 2,5 e 4,0; Blazor 2,6 e 2,7; combinazione 2,1 e 3,0.
  4. Infrastruttura. Blazor Server tiene una connessione aperta per utente: 25 euro al mese in più per un server dimensionato a ottanta utenti, 900 euro in tre anni. La combinazione ne usa una parte: 300 euro.

Queste ore e queste giornate sono il punto debole di ogni confronto, e ti dico perché le ho scelte. Una scheda in Razor Pages è due file e un'azione: la stima che uso è un giorno e mezzo, compresi i test e la revisione. In MVC l'azione sta in un controller, c'è una vista e un oggetto che trasporta i dati: poco più lavoro. Una schermata interattiva in Razor Pages costa il doppio perché il JavaScript va scritto e provato a mano. In Blazor la schermata semplice costa di più per la struttura, e quella interattiva meno perché lo strumento è l'ambiente naturale del componente.

StradaCostruzioneAvviamentoManutenzione (3 anni)InfrastrutturaTotale in 3 anni
A. Razor Pages60.4801.44056.1600118.080
B. MVC66.5282.40063.7200132.648
C. Blazor Server60.1925.76056.808900123.660
D. Razor Pages più Blazor53.7123.84051.192300109.044

Con diciotto schermate interattive su sessanta, la strada D costa circa 109.000 euro. Razor Pages da sola circa 118.000, Blazor da solo circa 124.000, MVC circa 133.000. Fra la più economica e la più cara ci sono 23.600 euro, cioè circa quarantanove giornate di lavoro. Non è una cifra che decide il bilancio di un'azienda, ma è la differenza fra due mesi di lavoro in più o in meno per una persona, e si ripete a ogni progetto.

Guarda dove nasce la differenza. Non è nel costo di una pagina, è nel prodotto fra il numero di schermate e le ore per modifica. Un'ora in più per ogni modifica, moltiplicata per centoventi modifiche l'anno e per tre anni, sono 360 ore, cioè 21.600 euro: è il numero più pesante del conto. La manutenzione costa quasi quanto la costruzione, ed è per questo che la forma del codice pesa più dello strumento.

Un'avvertenza che ti devo. Questi numeri non vanno presi come tariffe. Un gruppo che ha già dieci anni di MVC alle spalle avrà ore per modifica più basse in MVC. Un gruppo che non ha mai visto Blazor pagherà l'avviamento il doppio. Sono i miei numeri medi: il valore sta nel metodo, che ti permette di cambiare le ipotesi e di vedere se la conclusione resta in piedi.

Qual è il numero che decide quale scegliere?

Costo in tre anni al variare delle schermate interattive: fino a 7 conviene Razor Pages, da 8 a 40 Razor Pages più Blazor, da 41 in su Blazor Server; MVC non è mai il più economico

Il numero è uno solo: quante delle tue schermate sono interattive. Ho ripetuto il conto con tutte le quote, da zero a sessanta schermate interattive su sessanta. Ecco dove cambia la strada più economica.

Schermate interattive su 60Strada che costa menoPerché
Da 0 a 7A. Solo Razor PagesLe poche schermate vive si fanno con un po' di JavaScript, e non vale la pena di avviare un secondo modo di lavorare. A 6 schermate interattive: 97.920 euro, contro 99.108 della combinazione
Da 8 a 40D. Razor Pages più BlazorLe schermate vive sono abbastanza da pagare l'avviamento di Blazor, e le pagine restano economiche. A 18: 109.044 euro, contro 118.080 di Razor Pages da sola
41 e oltreC. Solo BlazorLe schermate semplici sono diventate una minoranza: tenere due modi di lavorare costa più che unificarli. A 45: 128.520 euro, contro 131.400 della combinazione

MVC non vince mai nel conto. Non vuol dire che sia sbagliato: vuol dire che, per un gestionale con una sola interfaccia, costa di più di Razor Pages a parità di risultato. MVC conviene in due casi che il conto non vede. Il primo è quando hai già molto codice MVC 5 da portare, perché il lavoro di porting è più piccolo. Il secondo è quando le stesse azioni devono servire anche un'applicazione per il telefono, e il controller diventa un'interfaccia comune.

Le soglie sono un'indicazione, non una legge. A 7 schermate interattive Razor Pages è in vantaggio di 336 euro, a 8 passa avanti la combinazione di 516: differenze da mezza giornata in tre anni, e a quel punto decide il gruppo che hai. Se i tuoi programmatori conoscono già Blazor, usalo anche da 4 schermate. Se non lo conoscono e hanno una consegna vicina, usa Razor Pages anche da 15.

Una seconda domanda decide i casi vicini alla soglia: quanto cambieranno le schermate interattive? Uno strumento che cambia ogni mese, con regole nuove e colonne nuove, è molto meglio come componente. Uno strumento fermo da cinque anni e destinato a restare fermo è molto meglio come pagina con cento righe di JavaScript, e non lo si tocca.

Come si costruisce una pagina vera con Razor Pages?

Prendiamo la schermata più comune di un gestionale: la modifica di un ordine. Un cliente, un importo, un pulsante per salvare, e sotto le righe dell'ordine. Il codice di questo capitolo l'ho compilato su .NET 10 con un piccolo progetto di prova: se lo copi, funziona.

Il disegno della pagina

Il file del disegno si chiama Modifica.cshtml e sta nella cartella Pages/Ordini. La prima riga dichiara che è una pagina e che l'indirizzo accetta un numero, che è l'identificativo dell'ordine. L'indirizzo sarà quindi /Ordini/Modifica/42, senza che tu scriva da nessuna parte una regola di instradamento.

@page "{id:int}"
@model Gestionale.Pages.Ordini.ModificaModel
@{
    ViewData["Title"] = "Modifica ordine";
}

<h1>Modifica ordine</h1>

<form method="post">
    <div asp-validation-summary="ModelOnly"></div>

    <label asp-for="Input.Cliente"></label>
    <input asp-for="Input.Cliente" />
    <span asp-validation-for="Input.Cliente"></span>

    <label asp-for="Input.Importo"></label>
    <input asp-for="Input.Importo" />
    <span asp-validation-for="Input.Importo"></span>

    <button type="submit">Salva</button>
</form>

Le istruzioni asp-for sono aiuti del motore: collegano il campo al dato, scrivono il nome giusto, mostrano gli errori di validazione. Non c'è un solo nome di campo scritto a mano, e quindi non c'è un solo errore di battitura possibile.

Il codice che risponde

Il file accanto, Modifica.cshtml.cs, contiene la classe che risponde alle richieste. Ogni verbo ha il suo metodo: OnGetAsync quando la pagina viene aperta, OnPostAsync quando l'utente preme Salva. Non esiste un controller che sceglie quale azione chiamare: il nome del metodo basta.

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;

namespace Gestionale.Pages.Ordini;

[Authorize(Policy = "GestioneOrdini")]
public class ModificaModel(GestionaleDb db) : PageModel
{
    [BindProperty]
    public OrdineInput Input { get; set; } = new();

    public List<Riga> Righe { get; private set; } = [];

    public async Task<IActionResult> OnGetAsync(int id, CancellationToken token)
    {
        var ordine = await db.Ordini.AsNoTracking()
            .Where(o => o.Id == id)
            .Select(o => new OrdineInput { Cliente = o.Cliente, Importo = o.Importo })
            .FirstOrDefaultAsync(token);

        if (ordine is null) return NotFound();

        Input = ordine;
        Righe = await CaricaRigheAsync(id, null, token);
        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id, CancellationToken token)
    {
        if (!ModelState.IsValid)
        {
            Righe = await CaricaRigheAsync(id, null, token);
            return Page();
        }

        var ordine = await db.Ordini.FindAsync([id], token);
        if (ordine is null) return NotFound();

        ordine.Cliente = Input.Cliente;
        ordine.Importo = Input.Importo;
        await db.SaveChangesAsync(token);

        return RedirectToPage("Elenco");
    }

    public async Task<PartialViewResult> OnGetRigheAsync(int id, string? filtro, CancellationToken token)
        => Partial("_Righe", await CaricaRigheAsync(id, filtro, token));

    private Task<List<Riga>> CaricaRigheAsync(int id, string? filtro, CancellationToken token)
    {
        var query = db.Righe.AsNoTracking().Where(r => r.OrdineId == id);
        if (!string.IsNullOrWhiteSpace(filtro))
            query = query.Where(r => r.Descrizione.Contains(filtro));
        return query.OrderBy(r => r.Id).ToListAsync(token);
    }
}

public class OrdineInput
{
    [Required, StringLength(100)]
    public string Cliente { get; set; } = "";

    [Range(typeof(decimal), "0.01", "1000000")]
    public decimal Importo { get; set; }
}

Tre scelte in questo codice meritano di essere dette, perché sono le tre che si sbagliano più spesso.

La prima: la pagina non riceve l'entità del database ma una classe sua, OrdineInput, con soli due campi. È la difesa contro un errore classico, chiamato overposting: se leghi il form direttamente all'entità, un utente curioso può aggiungere alla richiesta un campo che non vedi sulla pagina (per esempio lo stato di pagamento) e il programma lo salverebbe. Con una classe di input, i campi che non esistono non si salvano.

La seconda: in lettura usiamo AsNoTracking e proiettiamo solo le colonne che servono. Per una scheda è poca cosa; per un elenco da cinquemila righe è la differenza fra mezzo secondo e quattro. Vale lo stesso discorso che faccio per ogni query: mai leggere più colonne di quante ne mostri.

La terza: dopo il salvataggio la pagina fa un reindirizzamento invece di mostrare di nuovo se stessa. Così il pulsante "ricarica" del browser non ripete il salvataggio e non compare l'avviso "rinviare il modulo?", che a un utente non tecnico fa paura.

La registrazione nel programma

Il file di avvio dice a ASP.NET Core quali servizi usare. Qui decidiamo anche la regola di protezione di default.

using Gestionale;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages(options =>
{
    options.Conventions.AuthorizeFolder("/");
    options.Conventions.AllowAnonymousToPage("/Accesso");
});

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o => o.LoginPath = "/Accesso");

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("GestioneOrdini", p => p.RequireRole("Ordini", "Amministrazione"));
});

builder.Services.AddDbContext<GestionaleDb>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Gestionale")));

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();
app.MapReverseProxy();

app.Run();

La riga AuthorizeFolder("/") è quella che fa la differenza. Dice: tutte le pagine richiedono l'accesso, tranne quelle che consento esplicitamente. Si parte dal chiuso e si apre, non dall'aperto e si chiude. Il motivo è semplice: la dimenticanza peggiore è la pagina nuova senza protezione, e con questa regola quella dimenticanza non esiste. Più sotto vedrai il test che lo verifica.

La politica GestioneOrdini dà un nome alla regola "chi può modificare gli ordini". Il nome compare sulla pagina con l'attributo di autorizzazione, e il giorno in cui cambia chi può modificare gli ordini, cambi una riga in un posto.

Come si aggiunge interattività senza passare a Blazor?

Torniamo alla modifica dell'ordine. Sotto il modulo ci sono le righe, e l'utente vuole filtrarle mentre scrive, senza ricaricare la pagina. È la schermata più piccola che si possa chiamare interattiva, e si risolve senza cambiare tecnologia.

Il trucco di Razor Pages si chiama gestore con nome. Una pagina può avere più metodi che rispondono, oltre a quello della richiesta normale. Il metodo OnGetRigheAsync che hai visto nel codice sopra risponde all'indirizzo ?handler=Righe e restituisce non una pagina intera ma un frammento: le sole righe, già filtrate.

// Nel file Modifica.cshtml.cs
    public async Task<PartialViewResult> OnGetRigheAsync(int id, string? filtro, CancellationToken token)
        => Partial("_Righe", await CaricaRigheAsync(id, filtro, token));

<!-- Nel file Modifica.cshtml -->
<input id="filtro" type="search" placeholder="Filtra le righe" />
<table>
    <tbody id="righe">
        <partial name="_Righe" model="Model.Righe" />
    </tbody>
</table>

<script>
    const filtro = document.getElementById('filtro');
    const ordineId = @RouteData.Values["id"];
    let attesa;
    filtro.addEventListener('input', () => {
        clearTimeout(attesa);
        attesa = setTimeout(async () => {
            const url = `?handler=Righe&id=${ordineId}&filtro=${encodeURIComponent(filtro.value)}`;
            const risposta = await fetch(url);
            document.getElementById('righe').innerHTML = await risposta.text();
        }, 300);
    });
</script>

Il frammento è un file a parte, _Righe.cshtml, che disegna le righe e basta. Lo stesso frammento è usato sia al primo caricamento sia dalle richieste successive: un solo posto in cui è definito come si vede una riga. Il JavaScript fa una sola cosa: aspetta che l'utente smetta di scrivere per trecento millisecondi, chiede il frammento e sostituisce le righe.

Sono quindici righe di JavaScript, nessuna libreria, nessun passaggio di compilazione. Ed è qui che si vede il limite di questo modo di lavorare, che è anche il motivo per cui esiste la soglia dei 7 e 8 casi. Con un filtro, funziona benissimo. Con una griglia in cui ogni cella si modifica, i totali si ricalcolano, le righe si riordinano e due utenti non devono pestarsi i piedi, quelle quindici righe diventano trecento, poi mille, e nessuno le sa più leggere. A quel punto stai ricostruendo Blazor a mano, con meno strumenti.

La regola pratica che uso: se la schermata richiede più di un gestore che restituisce un frammento e più di una cinquantina di righe di JavaScript, sta chiedendo di essere un componente.

Come si mettono insieme le due metà

Nella strada D la stessa applicazione ospita le pagine e i componenti. In .NET 10 i componenti con un loro indirizzo si aggiungono registrando i servizi dei componenti Razor e abilitando la modalità interattiva sul server, e poi mappandoli accanto alle pagine. L'accesso, la protezione e il contesto dati sono già quelli dell'applicazione. Una schermata Blazor chiede ai servizi la stessa classe di accesso ai dati che usa la pagina di modifica, senza passare da un'interfaccia aggiuntiva.

La cosa da decidere all'inizio è il confine: si stabilisce una cartella per le pagine e una per i componenti, e ogni schermata sta da una parte sola. Non si mescolano nella stessa schermata, perché è lì che gli stati diventano due e i bug diventano sottili. Se una pagina deve ospitare un pezzo vivo, l'alternativa onesta è fare il pezzo con un frammento come sopra, oppure fare tutta la schermata in Blazor.

Come si protegge ogni pagina e come si prova che nessuna sia rimasta aperta?

La protezione delle pagine è l'errore più costoso di un gestionale web. Una pagina di elenco che mostra tutti i clienti senza chiedere chi sei è un incidente di sicurezza, e con la normativa sulla protezione dei dati è anche una segnalazione al Garante entro settantadue ore. Dimenticarsene non è un'ipotesi: succede, perché sessanta schermate vengono aggiunte da persone diverse in anni diversi.

Due difese. La prima l'hai già vista: la cartella intera è protetta di default. La seconda è un test automatico che non si fida della prima. Il test enumera ogni pagina registrata nell'applicazione, le chiede senza essersi identificato, e fallisce se una risponde con qualcosa di diverso da un reindirizzamento alla pagina di accesso o da un rifiuto.

// In fondo a Program.cs serve questa riga, perché il test possa avviare l'applicazione:
// public partial class Program;

using System.Net;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Gestionale.Test;

public class AutorizzazioneTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private static readonly string[] Pubbliche = ["/Accesso"];

    [Fact]
    public async Task Ogni_pagina_senza_accesso_rimanda_alla_pagina_di_accesso()
    {
        var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });

        var percorsi = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
            .OfType<RouteEndpoint>()
            .Where(e => e.Metadata.GetMetadata<PageActionDescriptor>() is not null)
            .Select(e => "/" + Regex.Replace(e.RoutePattern.RawText ?? "", @"\{[^}]*\}", "1").Trim('/'))
            .Where(p => !Pubbliche.Contains(p))
            .Distinct()
            .ToList();

        Assert.NotEmpty(percorsi);

        foreach (var percorso in percorsi)
        {
            var risposta = await client.GetAsync(percorso);

            Assert.True(
                risposta.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Unauthorized,
                $"La pagina {percorso} risponde {(int)risposta.StatusCode} a chi non ha fatto l'accesso.");
        }
    }
}

Il test ha tre qualità. Si scrive una volta e protegge per sempre, perché enumera le pagine invece di elencarle. Non dipende dal database, perché si ferma alla barriera di accesso. E dà un messaggio chiaro: dice quale pagina risponde e con quale codice, così chi lo vede rosso in un rilascio sa dove andare.

La lista Pubbliche è l'unico punto in cui si dichiara che una pagina può essere aperta a tutti. Ogni nuova aggiunta alla lista passa dalla revisione del codice, e diventa una scelta visibile, non una dimenticanza. In un'azienda con più gruppi di sviluppo, questa lista è il posto in cui si guarda in una revisione trimestrale di sicurezza.

Una nota sull'accesso. La protezione dice che l'utente è entrato; non dice che può vedere quel dato. Il caso tipico è il numero nell'indirizzo: /Ordini/Modifica/42 deve far vedere l'ordine 42 solo a chi ha il diritto di vederlo. Chi è entrato e prova con 43, 44 e 45 non deve vedere gli ordini di un altro reparto o di un altro cliente. Nel codice sopra, il caricamento filtra solo per identificativo: se l'applicazione ha più clienti o più reparti, quel filtro va esteso. Ne parlo in modo esteso nella guida all'architettura multi-tenant, dove il filtro globale e la sicurezza a livello di riga sono costruiti proprio per non dipendere dalla memoria di chi scrive la pagina.

Come si passa da Web Forms o da MVC 5 a Razor Pages senza fermare l'azienda?

Il passaggio pagina per pagina: gli utenti arrivano alla nuova applicazione ASP.NET Core, le pagine migrate rispondono lì e il resto passa dal proxy al vecchio programma Web Forms o MVC 5

Questo è il caso dell'azienda di Padova all'inizio. La scelta peggiore è la riscrittura totale: diciotto mesi senza consegnare niente, l'azienda che continua a chiedere modifiche al vecchio programma, e un giorno di passaggio in cui si spera che tutto vada bene. La scelta migliore è il passaggio pagina per pagina, che ha un nome da libro di architettura (strangler) e una forma molto concreta.

L'idea: si mette davanti al vecchio programma una nuova applicazione ASP.NET Core. Ogni richiesta arriva alla nuova. Se la nuova ha la pagina corrispondente, risponde lei. Se non ce l'ha, la richiesta viene girata al vecchio programma, che risponde come ha sempre fatto. Gli utenti non vedono nessun salto. Il giorno in cui l'ultima pagina è migrata, il vecchio si spegne.

Lo strumento è YARP, un proxy inverso fatto da Microsoft come libreria per ASP.NET Core. La configurazione è questa:

{
  "ReverseProxy": {
    "Routes": {
      "vecchio-sito": {
        "ClusterId": "webforms",
        "Match": { "Path": "{**tutto}" }
      }
    },
    "Clusters": {
      "webforms": {
        "Destinations": {
          "server-vecchio": { "Address": "http://server-vecchio:8080/" }
        }
      }
    }
  }
}

La rotta {**tutto} prende ogni richiesta che nessuna pagina nuova ha preso. Le pagine Razor Pages registrate con MapRazorPages vincono sempre, perché un indirizzo preciso ha la precedenza su uno generico. Nel file di avvio che hai visto sopra ci sono già le due righe che servono: AddReverseProxy().LoadFromConfig(...) e MapReverseProxy().

Due problemi che il passaggio graduale porta con sé

Il primo è l'accesso condiviso. Se l'utente entra sulla pagina vecchia e poi passa a una nuova, la nuova deve riconoscerlo, altrimenti gli chiede di nuovo la password a metà lavoro. La soluzione dipende da come il vecchio programma gestisce gli utenti: la strada più pulita è spostare per prima cosa l'accesso nella nuova applicazione, e far sapere al vecchio che l'utente è già autenticato con un'intestazione che solo il proxy può aggiungere. È il primo pezzo da costruire, ed è il motivo per cui il piano comincia da lì.

Il secondo è lo stato condiviso. Web Forms tiene molte cose nella sessione del server: il carrello, i filtri, la pagina dove l'utente era arrivato. Una pagina nuova non le vede. Per ogni dato in sessione si decide se serve davvero (spesso no), oppure lo si sposta in un posto che entrambe le applicazioni leggono, di solito il database.

Un piano di sessanta giorni per le prime venti pagine

  1. Giorni 1-5: il censimento. Un foglio con tutte le schermate: quante volte sono usate al mese, quanto sono complesse, da quali altre dipendono. Si ottiene dai registri del server e da un'ora con chi le usa. Le dieci più usate e le dieci più facili guideranno l'ordine.
  2. Giorni 6-12: lo scheletro e l'accesso. La nuova applicazione con la regola di protezione di default, il proxy verso il vecchio e l'accesso condiviso. Si pubblica, e per gli utenti non cambia niente. Questo è il momento in cui si prova che il giro funziona.
  3. Giorni 13-20: la prima pagina, la più semplice. Non la più importante: la più semplice. Serve a trovare i problemi di accesso, di stile e di pubblicazione con un danno piccolo.
  4. Giorni 21-45: le pagine di lettura. Elenchi, schede, ricerche. Si migrano a gruppi di cinque e si pubblicano ogni settimana. Sono le pagine con meno rischio, perché leggono e non scrivono.
  5. Giorni 46-60: le prime pagine di scrittura. I moduli di inserimento e modifica, con test che confrontano i risultati vecchi e nuovi sugli stessi dati. Si cominciano dai moduli con meno regole.

Al sessantesimo giorno le venti pagine più usate sono nuove, e l'azienda ha già la prova che il passaggio funziona. Il vecchio continua a servire quaranta schermate usate raramente, che si migrano al ritmo che il budget consente. Anche questa è una scelta di costo: una schermata usata due volte l'anno può restare nel vecchio programma per anni, se il vecchio programma gira senza rischi.

Se il tuo caso è proprio questo, quello che conviene leggere accanto è la guida al costo del software legacy, che fa il conto di cosa costa restare fermi, e quella su ASP.NET Core rispetto agli altri framework web.

Che cosa si dimentica quasi sempre?

In una migrazione la lista delle cose che si scoprono tardi è quasi sempre la stessa. La metto qui perché è la parte che ti fa risparmiare più giornate.

  • Le stampe e i report. Web Forms aveva spesso un componente di report (Crystal Reports, ReportViewer) che in ASP.NET Core non esiste. Va deciso il sostituto prima di cominciare, perché è il lavoro che più spesso sfora: le stampe sono contrattuali per il cliente finale.
  • Le esportazioni in Excel. Quasi ogni schermata di elenco ha un pulsante "Esporta". Va trasformato in una libreria moderna e controllato colonna per colonna: i formati delle date e dei numeri sono dove si rompono i conti.
  • I controlli di terze parti. Le griglie e i calendari di vecchie suite commerciali non hanno equivalenti identici. Ogni controllo va censito all'inizio, con una nota su dove è usato.
  • Le pagine che nessuno ricorda. In ogni gestionale ci sono quattro o cinque pagine usate una volta al trimestre da una persona sola: la chiusura dell'anno, l'allineamento di un listino. Si scoprono quando quella persona le cerca, a gennaio. Il censimento dai registri del server le trova.
  • La gestione degli errori. Il vecchio programma mostrava una pagina d'errore con un testo che l'azienda aveva imparato a leggere. Il nuovo deve scrivere gli errori in un posto dove li leggi, con l'utente e la pagina. Un gestionale senza registro degli errori è un programma di cui sei l'ultimo a sapere i problemi.
  • I carichi di file. Dimensioni massime, tipi consentiti, dove vengono scritti. Il nuovo server ha un limite predefinito di circa trenta megabyte per richiesta: se il vecchio ne accettava di più, il limite va dichiarato e deciso.
  • I lavori notturni. Le elaborazioni che il vecchio programma faceva di notte, a volte dentro l'applicazione web stessa. Vanno trovate, e spostate in un servizio a parte che non dipende da una pagina aperta.

Quali errori evitare quando si sceglie e si costruisce?

  • Scegliere Blazor perché "è il futuro". Il futuro non è un criterio. Il criterio è la quota di schermate interattive. Con 4 schermate interattive su 60, Blazor da solo ti costa circa 26.600 euro più di Razor Pages (121.140 contro 94.560), e non ti dà niente di cui ti accorgi.
  • Scegliere MVC per abitudine. Se il gruppo conosce solo MVC, il costo della formazione su Razor Pages è di pochi giorni, perché usano lo stesso motore di disegno e le stesse convenzioni. Pochi giorni, contro un costo ricorrente su ogni schermata.
  • Legare il form all'entità del database. Il rischio dell'overposting. Una classe di input per ogni modulo, sempre.
  • Mettere la logica di business dentro la pagina. Un metodo di pagina deve leggere la richiesta, chiamare un servizio, scegliere la risposta. Il calcolo del prezzo o la regola dello sconto stanno in una classe a parte, che si prova senza avviare un server. Se la regola sta nella pagina, la riscrivi ogni volta che cambi tecnologia.
  • Rifare la grafica e le regole nello stesso momento. Una migrazione che cambia anche il modo di lavorare degli utenti perde la prova più importante: "il nuovo fa quello che faceva il vecchio". Prima si porta, poi si migliora.
  • Non misurare. Prima della migrazione, annota i tempi di risposta delle dieci pagine più usate. Dopo, confrontali. Senza il prima, non hai nessun modo di dire che è andata meglio, e ti troverai a discutere a sensazioni.
  • Dimenticare il test della protezione. È l'unico dei test di questo articolo che metterei prima di tutti gli altri.

Quando conviene non scegliere nessuna di queste strade?

Succede, e dirlo fa parte del mestiere. Tre casi.

  • Il programma vecchio funziona e non c'è una scadenza. Se il server è supportato, i dati sono al sicuro e le modifiche richieste sono poche, il conto di restare fermo può essere più basso del conto di migrare. Il numero da guardare è il costo dei rischi: la mancanza di personale che conosce la tecnologia, la scadenza del sistema operativo, le vulnerabilità. Quando il rischio costa più dell'intervento, si migra. Prima no.
  • Il gestionale è in realtà un prodotto già pronto da comprare. Se sessanta schermate fanno quello che farebbe un software acquistabile con un abbonamento, la costruzione su misura non rende. Vale la pena di chiederselo prima di spendere 110.000 euro.
  • Le schermate sono quasi tutte strumenti. Se oltre i due terzi sono interattive, vale il ragionamento della terza fascia: una sola tecnologia, Blazor, e non pagare due modi di lavorare.

Se vuoi un secondo sguardo sul tuo caso, la strada è la consulenza software. Se la domanda è se conviene costruire su misura o comprare, c'è la pagina sul software su misura. E se stai valutando di spezzare il gestionale in servizi separati, prima di farlo leggi microservizi o monolite: la maggior parte dei gestionali con sessanta schermate sta benissimo in un solo programma ben fatto.

Per riassumere, in cinque righe. Conta le schermate che sono strumenti, non quelle che sono documenti. Se sono meno di otto su sessanta, Razor Pages e basta. Se sono fra otto e quaranta, Razor Pages per le pagine e Blazor per gli strumenti, nella stessa applicazione. Se sono di più, Blazor. Proteggi tutto di default e prova con un test che nessuna pagina sia rimasta aperta. Migra una pagina alla volta, con il vecchio programma dietro, finché l'ultima è passata.

Domande frequenti

È il modo di ASP.NET Core per costruire applicazioni web in cui ogni schermata è una pagina fatta di due file: uno con il disegno e uno con il codice C# che risponde. L'indirizzo della pagina è deciso dalla cartella in cui sta il file, senza elenchi di rotte da mantenere.

Razor Pages usa due file per schermata, MVC ne usa almeno tre in cartelle diverse (controller, vista, oggetto dati), Blazor costruisce la schermata con componenti che reagiscono ai clic e tengono una connessione aperta con il server. Sono tutte ASP.NET Core e possono convivere nella stessa applicazione.

Per un gestionale da 60 schermate di cui 18 interattive, in tre anni: Razor Pages più Blazor circa 109.044 euro, Razor Pages da sola 118.080, Blazor Server 123.660 e MVC 132.648. La differenza fra la più cara e la più economica è circa 23.600 euro.

Quante schermate sono interattive, cioè usate come strumenti e non come documenti da compilare. Su 60 schermate: fino a 7 conviene Razor Pages, da 8 a 40 Razor Pages per le pagine e Blazor per gli strumenti, da 41 in su Blazor.

Si mette davanti al vecchio programma una nuova applicazione ASP.NET Core con un proxy inverso (YARP). Le pagine già migrate rispondono dalla nuova, il resto viene girato al vecchio. Si migra una pagina alla volta e il vecchio si spegne quando l'ultima è passata.

Si protegge l'intera cartella delle pagine per impostazione e si aprono solo quelle dichiarate pubbliche. Poi un test automatico enumera ogni pagina dell'applicazione, la chiede senza accesso e fallisce se una risponde in modo diverso da un reindirizzamento all'accesso o da un rifiuto.

Lascia i tuoi dati nel form qui sotto

Matteo Migliore

Matteo Migliore è un imprenditore e architetto software con oltre 27 anni di esperienza nello sviluppo di soluzioni basate su .NET e nell'evoluzione di architetture applicative per imprese e organizzazioni di alto profilo.

Nel corso della sua carriera ha collaborato con realtà come Cotonella, Il Sole 24 Ore, FIAT e NATO, guidando team nello sviluppo di piattaforme scalabili e modernizzando ecosistemi legacy complessi.

Ha formato centinaia di sviluppatori e affiancato aziende di ogni dimensione nel trasformare il software in un vantaggio competitivo, riducendo il debito tecnico e portando risultati concreti in tempi misurabili.

Stai leggendo perché vuoi smettere di rattoppare software fragile.Scopri il metodo per progettare sistemi che reggono nel tempo.