Architecture Reference: File-Based Pageless ASP.NET Web Forms

Sep 16, 2026
Updated Sep 16, 2026
adriancs

Architecture Reference: File-Based Pageless ASP.NET Web Forms

Companion document: Data access in this reference is written against
MySqlExpress (https://github.com/adriancs2/MySqlExpress) /
SQLiteExpress (https://github.com/adriancs2/SQLiteExpress).
Their README is the authority for API details. This document governs architecture, file-based routing conventions, and usage rules.


Overview & Core Principles

File-Based Pageless Architecture merges the raw performance and control of ASP.NET Web Forms pipeline interception with the zero-boilerplate simplicity of File-Based Routing.

Terminology note: “File-based routing” here is technically namespace-based routing: at startup, the engine discovers compiled handler classes and derives URLs from their namespaces and class names, rather than reading physical file or folder paths. Developers are responsible for keeping those names aligned with the file and folder structure; a mismatch does not change the derived URL, and links must use that URL. The remainder of this document retains “file-based” as shorthand for this organizational convention, with new handler files compiled into the scanned assembly before discovery.

Full HTML documents and JSON APIs are rendered entirely in C# using StringBuilder + Response.Write, intercepted at Application_BeginRequest in Global.asax.cs and dispatched dynamically through an in-memory dictionary populated by file/assembly discovery.

  • Eliminated Features: No .aspx files, .master files, ViewState, <asp:*> server controls, Page_Load, postbacks, or manual switch(path) route maintenance.
  • The File-Based Rule: Adding a .cs file in the /routes/ (or /engine/RH/) folder automatically creates and mounts the matching URL route at application startup.

web.config Requirements

  • <system.web><sessionState mode="Off" /></system.web> — Disables AcquireRequestState lock for clean BeginRequest routing.
  • <system.webServer><httpErrors existingResponse="PassThrough" /></system.webServer> — Handlers remain the single source of truth for 404/403/500 output.

Architectural Constraints & Compensations

  • Process-Local Session: Anonymous session state lost on recycle --> lsid rehydration covers logged-in users.
  • Synchronous BeginRequest: Keep handler work short; offload long-running jobs to HostingEnvironment.QueueBackgroundWorkItem.
  • HttpContext.Current Dependencies: Keep testable business logic in plain repo/service classes with zero HttpContext dependency.

File-Based Routing Engine (RouteEngine.cs)

Instead of maintaining a fragile, monolithic switch(path) in Global.asax.cs, routes are auto-discovered once at startup and stored in a high-performance static Dictionary<string, Action>.

Route Mapping Convention

  • /routes/Invoice.cs --> /Invoice (and /invoice)
  • /routes/Books.cs --> /books
  • /routes/Index.cs or /routes/Home.cs --> / and /home
  • /routes/admin/Users.cs --> /admin/Users
  • /routes/api/BookApi.cs --> /api/BookApi

RouteEngine.cs Implementation

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Web;

namespace App.Engine
{
    public static class RouteEngine
    {
        // Static dictionary for O(1) nanosecond route dispatch
        private static readonly Dictionary<string, Action> _exactRoutes = 
            new Dictionary<string, Action>(StringComparer.OrdinalIgnoreCase);

        private static readonly List<PrefixRoute> _prefixRoutes = new List<PrefixRoute>();

        public class PrefixRoute
        {
            public string Prefix { get; set; }
            public Action<string> Handler { get; set; }
        }

        static RouteEngine()
        {
            RegisterDiscoveredRoutes();
        }

        public static void Init()
        {
            // Trigger static constructor
        }

        private static void RegisterDiscoveredRoutes()
        {
            var asm = Assembly.GetExecutingAssembly();
            var types = asm.GetTypes();

            foreach (var type in types)
            {
                // Discover classes in App.Routes (or App.RH / App.Engine.RH)
                if (!type.IsClass || string.IsNullOrEmpty(type.Namespace)) continue;
                if (!type.Namespace.StartsWith("App.Routes") && !type.Namespace.StartsWith("App.RH")) continue;

                // 1. Parameterized / Prefix Handler: public static void HandleRequest(string param)
                var prefixMethod = type.GetMethod("HandleRequest", 
                    BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(string) }, null);

                if (prefixMethod != null)
                {
                    string routePrefix = CalculateRoutePath(type) + "/";
                    var del = (Action<string>)Delegate.CreateDelegate(typeof(Action<string>), prefixMethod);
                    _prefixRoutes.Add(new PrefixRoute { Prefix = routePrefix.ToLower(), Handler = del });
                    continue;
                }

                // 2. Exact Handler: public static void HandleRequest()
                var method = type.GetMethod("HandleRequest", 
                    BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null);

                if (method != null)
                {
                    string routePath = CalculateRoutePath(type);
                    var del = (Action)Delegate.CreateDelegate(typeof(Action), method);

                    _exactRoutes[routePath] = del;

                    // Support root path aliases
                    if (routePath.Equals("/index", StringComparison.OrdinalIgnoreCase) || 
                        routePath.Equals("/home", StringComparison.OrdinalIgnoreCase))
                    {
                        _exactRoutes["/"] = del;
                    }
                }
            }
        }

        private static string CalculateRoutePath(Type type)
        {
            // Convert namespace + class name to URL path
            // e.g., App.Routes.Admin.Users -> /admin/users
            // e.g., App.Routes.Invoice     -> /invoice
            string ns = type.Namespace;
            string prefix = ns.StartsWith("App.Routes") ? "App.Routes" : "App.RH";
            string relativeNs = ns.Length > prefix.Length ? ns.Substring(prefix.Length).TrimStart('.') : "";

            string subPath = string.IsNullOrEmpty(relativeNs) 
                ? "" 
                : "/" + relativeNs.Replace('.', '/');

            return (subPath + "/" + type.Name).ToLower();
        }

        public static bool TryDispatch(string path)
        {
            if (string.IsNullOrEmpty(path)) path = "/";
            path = path.TrimEnd('/');
            if (path == "") path = "/";

            // 1. Exact match lookup (O(1) RAM speed)
            if (_exactRoutes.TryGetValue(path, out var exactHandler))
            {
                exactHandler();
                return true;
            }

            // 2. Prefix / Parametric match
            string lowerPath = path.ToLower();
            for (int i = 0; i < _prefixRoutes.Count; i++)
            {
                var pr = _prefixRoutes[i];
                if (lowerPath.StartsWith(pr.Prefix))
                {
                    string param = path.Substring(pr.Prefix.Length);
                    pr.Handler(param);
                    return true;
                }
            }

            return false;
        }
    }
}

Clean Global.asax.cs Entry Point

Global.asax.cs is now completely decoupled from individual application features. It simply initializes the route engine and forwards incoming paths:

using System;
using System.Web;
using App.Engine;

namespace App
{
    public class Global : HttpApplication
    {
        protected void Application_Start(object sender, EventArgs e)
        {
            RouteEngine.Init();
        }

        protected void Application_BeginRequest(object sender, EventArgs e)
        {
            // Restore session context if lsid / ssid cookies exist
            AppSession.TryRestoreFromCookie();

            string path = Request.Path;

            // Auto-dispatch via File-Based Route Engine
            if (RouteEngine.TryDispatch(path))
            {
                return;
            }

            // Fallthrough: 404 Handler if route not found
            ApiHelper.WriteError("Route not found: " + path, 404);
            ApiHelper.EndResponse();
        }
    }
}

File Structure & Directory Layout

Adding a new page or API requires only adding files inside /routes/. No edits to central files are ever needed.

/Global.asax.cs               <-- Clean entrypoint: calls RouteEngine.TryDispatch()
/web.config                   <-- sessionState mode="Off", httpErrors PassThrough
/engine/
    config.cs                 <-- Connection strings & global settings
    RouteEngine.cs            <-- Static reflection-to-RAM route engine
    ApiHelper.cs              <-- JSON serializers & clean termination helpers
    Guard.cs                  <-- Auth & ownership guards
    StaticAsset.cs            <-- Timestamped cache-busting helper
    PageTemplate.cs           <-- Master HTML layout shell
    Models/
        obBook.cs             <-- Data Models with 'ob' prefix
/routes/                      <-- FILE-BASED ROUTES (Auto-discovered)
    Index.cs                  <-- Maps to: / and /index
    Invoice.cs                <-- Maps to: /Invoice
    Books.cs                  <-- Maps to: /books
    UserProfile.cs            <-- Maps to: /userprofile/* (Parameterized)
    api/
        BookApi.cs            <-- Maps to: /api/BookApi
    admin/
        Users.cs              <-- Maps to: /admin/Users
/css/ (site.css, books.css, components/book-picker.css)
/js/  (site.js, books.js, components/book-picker.js)

Creating File-Based Handlers

1. Standard Page Handler (/routes/Invoice.cs)

Maps automatically to http://localhost/Invoice and http://localhost/invoice:

using System.Text;
using System.Web;
using App.Engine;

namespace App.Routes
{
    public static class Invoice
    {
        public static void HandleRequest()
        {
            Guard.LoggedIn();

            var pt = new PageTemplate 
            { 
                Title = "Invoices", 
                Description = "Manage invoices", 
                BodyClass = "page-invoice" 
            };
            pt.ExtraHeaderText = StaticAsset.Css("/css/invoice.css") + "\n" + StaticAsset.Script("/js/invoice.js");

            var sb = new StringBuilder();
            sb.Append(pt.GenerateHtmlHeader());
            sb.Append(@"
            <div class='container my-4'>
                <h1 class='h3 mb-3'>Invoice Management</h1>
                <div id='invoice-list-container'>Loading...</div>
            </div>");
            sb.Append(pt.GenerateHtmlFooter());

            var res = HttpContext.Current.Response;
            res.ContentType = "text/html; charset=utf-8";
            res.Write(sb.ToString());
            ApiHelper.EndResponse();
        }
    }
}

2. Standard API Handler (/routes/api/BookApi.cs)

Maps automatically to http://localhost/api/BookApi (action-dispatched):

using System;
using System.Web;
using App.Engine;

namespace App.Routes.Api
{
    public static class BookApi
    {
        public static void HandleRequest()
        {
            var req = HttpContext.Current.Request;
            string action = (req["action"] + "").ToLower().Trim();

            try
            {
                switch (action)
                {
                    case "get-books-json": GetBooksJson(); break;
                    case "save-book":      Guard.Mutating(); SaveBook(); break;
                    case "delete-book":    Guard.Mutating(); DeleteBook(); break;
                    default: 
                        ApiHelper.WriteError($"Unknown action: {action}", 400); 
                        break;
                }
            }
            catch (GuardException gx) { ApiHelper.WriteError(gx.Message, gx.StatusCode); }
            catch (Exception ex)      { Log.Error(ex); ApiHelper.WriteError("Server error", 500); }

            ApiHelper.EndResponse();
        }

        private static void GetBooksJson()
        {
            int userId = AppSession.LoginUser.Id;
            var books = BookRepo.GetByUserId(userId);
            ApiHelper.WriteJson(new { success = true, items = books });
        }

        private static void SaveBook()
        {
            var req = HttpContext.Current.Request;
            int.TryParse(req.Form["id"] + "", out int id);
            string title = (req.Form["title"] + "").Trim();

            if (string.IsNullOrEmpty(title))
            {
                ApiHelper.WriteError("Title is required", 400);
                return;
            }

            int userId = AppSession.LoginUser.Id;
            if (id <= 0)
            {
                BookRepo.Insert(userId, title);
                ApiHelper.WriteSuccess("Book created successfully");
            }
            else
            {
                if (!BookRepo.IsOwnedBy(id, userId)) { ApiHelper.WriteError("Not found", 404); return; }
                BookRepo.Update(id, title);
                ApiHelper.WriteSuccess("Book updated successfully");
            }
        }

        private static void DeleteBook()
        {
            var req = HttpContext.Current.Request;
            int.TryParse(req.Form["id"] + "", out int id);
            int userId = AppSession.LoginUser.Id;

            if (!BookRepo.IsOwnedBy(id, userId)) { ApiHelper.WriteError("Not found", 404); return; }
            BookRepo.Delete(id);
            ApiHelper.WriteSuccess("Book deleted");
        }
    }
}

3. Parameterized URL Handler (/routes/UserProfile.cs)

Maps automatically to http://localhost/userprofile/{id} by declaring HandleRequest(string param):

using System.Text;
using System.Web;
using App.Engine;

namespace App.Routes
{
    public static class UserProfile
    {
        public static void HandleRequest(string userSlug)
        {
            // userSlug contains whatever comes after /userprofile/
            var user = UserRepo.GetBySlug(userSlug);
            if (user == null)
            {
                ApiHelper.WriteError("User profile not found", 404);
                ApiHelper.EndResponse();
                return;
            }

            var pt = new PageTemplate { Title = user.FullName, BodyClass = "page-profile" };
            var sb = new StringBuilder();
            sb.Append(pt.GenerateHtmlHeader());
            sb.Append($@"
            <div class='profile-card'>
                <h2>{HttpUtility.HtmlEncode(user.FullName)}</h2>
                <p>Bio: {HttpUtility.HtmlEncode(user.Bio)}</p>
            </div>");
            sb.Append(pt.GenerateHtmlFooter());

            var res = HttpContext.Current.Response;
            res.ContentType = "text/html; charset=utf-8";
            res.Write(sb.ToString());
            ApiHelper.EndResponse();
        }
    }
}

Custom Session State (AppSession)

Never use HttpContext.Session or IRequiresSessionState.

  • Architecture Model: ssid cookie --> in-process ConcurrentDictionary<string, StateObject> (RAM) --> persistent login_sessions DB record referenced by the lsid cookie.
  • Fast Path: Normal requests resolve the random ssid token directly against the RAM dictionary; application session state is process-local and intentionally bypasses the built-in ASP.NET SessionStateModule.
  • Persistent Login Recovery: The RAM dictionary is lost on app-pool recycle. A logged-in user with a valid lsid cookie is rehydrated from the DB-backed login session by AppSession.TryRestoreFromCookie() on the next request. Anonymous RAM-only session state is not durable.
  • Storage:
    1. ssid cookie: Hex key for in-memory ConcurrentDictionary<string, StateObject>.
    2. login_sessions DB table: Persistent "Remember Me" record holding hashed lsid token (SHA-256).
  • Cookie Flags: HttpOnly = true, Secure = ctx.Request.IsSecureConnection, SameSite = SameSiteMode.Lax.
  • Facade Access:
      if (!AppSession.IsLoggedIn) { Response.Redirect("/login"); return; }
      obUser me = AppSession.LoginUser;

API & JSON Standards

  • Engine: Newtonsoft.Json (JsonConvert.SerializeObject). Match C# properties (PascalCase) or DB columns (snake_case). Never use camelCase.
  • Response Structure: { "success": true, "message": "Success", "data": {}, "items": [] }

ApiHelper Utility Class

public static class ApiHelper
{
    static HttpResponse Response => HttpContext.Current.Response;
    static HttpRequest Request => HttpContext.Current.Request;

    public static string GetBaseUrl() {
        Uri url = Request.Url;
        return $"{url.Scheme}://{url.Host}{(url.IsDefaultPort ? "" : ":" + url.Port)}";
    }

    public static void WriteJson(object obj) {
        Response.ContentType = "application/json";
        Response.Write(JsonConvert.SerializeObject(obj));
    }
    public static void WriteSuccess(string message = "Success") => WriteJson(new { success = true, message });
    public static void WriteError(string message, int statusCode = 400) {
        Response.StatusCode = statusCode;
        WriteJson(new { success = false, message });
    }
    public static void EndResponse() {
        Response.TrySkipIisCustomErrors = true;
        try { Response.Flush(); } catch { }
        Response.SuppressContent = true;
        HttpContext.Current.ApplicationInstance.CompleteRequest();
    }
}

EndResponse() replaces Response.End() — flushes buffers and triggers CompleteRequest() cleanly without ThreadAbortException.


HTML String Composition Rules

Compose HTML as multiline interpolated verbatim strings ($@"...") using single-quoted attributes ('). Never chain one-line sb.Append calls with escaped quotes and + concatenation.

// ✅ Approved pattern — HTML shape visible, single quotes, values encoded
sb.Append($@"
<div class='card-book' data-id='{b.Id}'>
    <strong>{HttpUtility.HtmlEncode(b.Title)}</strong><br>
    Author: {HttpUtility.HtmlEncode(b.Author)}<br>
    <button type='button' class='btn-edit'>Edit</button>
</div>");
  • Approved String Forms:
    1. $@"...": Default for multiline with dynamic values (leading newline after $@").
    2. @"...": Static multiline HTML or blocks containing inline CSS/JS object literals (no $).
    3. $"...": Single-line tag emitted conditionally.
  • Encoding Rules: HTML content --> HttpUtility.HtmlEncode(v), attributes --> HttpUtility.HtmlAttributeEncode(v), inside <script> --> JsonConvert.SerializeObject(v).
  • Button Type Rule: Any <button> whose purpose is to fire JavaScript must explicitly declare type='button'. Never rely on the browser default.

PageTemplate (Master Shell) & StaticAsset

Cache-Busting (StaticAsset)

public static class StaticAsset {
    public static string Script(string path) => $"<script src='{path}?v={Version(path)}'></script>";
    public static string Css(string path) => $"<link rel='stylesheet' href='{path}?v={Version(path)}' />";
    public static string Component(string name) => Css($"/css/components/{name}.css") + "\n" + Script($"/js/components/{name}.js");
    static string Version(string path) {
        string phys = HostingEnvironment.MapPath("~" + path);
        return (phys != null && File.Exists(phys)) ? File.GetLastWriteTimeUtc(phys).ToString("yyyyMMddHHmmss") : "0";
    }
}
  • Use StaticAsset for every CSS/JS reference.
  • Do not memoize Version(): File write timestamp lookups are fast metadata checks that keep asset invalidation instant without app pool recycling.

Frontend Communication Patterns

API Wrapper (apiPost)

// site.js — Returns parsed JSON response object directly
async function apiPost(url, formData) {
    var res = await fetch(url, { method: 'POST', body: formData });
    if (!res.ok && res.status !== 400) throw new Error('Server error ' + res.status);
    return await res.json();
}

async function saveBook() {
    var fd = new FormData();
    fd.append('action', 'save-book');
    fd.append('id', document.getElementById('book-id').value);
    fd.append('title', document.getElementById('book-title').value);

    var data = await apiPost('/api/bookapi', fd);
    
    if (data.success) {
        alert(data.message);
    } else {
        alert('Validation error: ' + data.message);
    }
}

Backend Data Access & HTTP-Free Core Rules

  • Row Ownership: Scope entity operations by userId directly in SQL (WHERE id = @id AND user_id = @uid).
  • Keep HTTP Handlers Thin: Static request handlers handle HTTP concerns only (read request, check session, call repo, write response). Keep database access in repository classes with no HttpContext dependency, enabling direct invocation via diagnostic test scripts.

Request Lifecycle Diagram

HTTP Request (IIS)
  │
  ▼
Application_BeginRequest (Global.asax.cs)
  │
  ├──► AppSession.TryRestoreFromCookie() (ssid → RAM; lsid → DB rehydrate)
  │
  ├──► RouteEngine.TryDispatch(path) (O(1) Static Dictionary Lookup)
  │      │
  │      ▼
  │    [Discovered Handler: /routes/Invoice.cs or /routes/api/BookApi.cs]
  │      │
  │      ├──► Guard.LoggedIn() / Guard.Mutating()
  │      ├──► HTTP-free Repo / Service Call
  │      ├──► Render PageTemplate HTML / WriteJson()
  │      └──► ApiHelper.EndResponse() (Flushes & calls CompleteRequest())
  │
  ▼
EndRequest -> HTTP Response sent to Client

Summary of File-Based Pageless Benefits for Autonomous AI

FeatureClassic Pageless (v4 Switch)File-Based Pageless
Adding a FeatureAdd handler file + edit Global.asax.cs switchDrop 1 file into /routes/ (Zero edits to Global.asax)
Collision RiskHigh (editing central switch repeatedly)Zero (Atomic file creation)
Context Window OverheadMust read Global.asax.cs switch listOnly reads target handler file
Performanceswitch(path) string comparisons$\mathcal{O}(1)$ in-memory delegate dictionary
DeploymentRecompile assembly on project changeAuto-discovered on compile/startup