Architecture Reference: File-Based Pageless ASP.NET Web Forms
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
.aspxfiles,.masterfiles, ViewState,<asp:*>server controls,Page_Load, postbacks, or manualswitch(path)route maintenance. - The File-Based Rule: Adding a
.csfile 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>— DisablesAcquireRequestStatelock for cleanBeginRequestrouting. -
<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 -->
lsidrehydration covers logged-in users. - Synchronous
BeginRequest: Keep handler work short; offload long-running jobs toHostingEnvironment.QueueBackgroundWorkItem. -
HttpContext.CurrentDependencies: Keep testable business logic in plain repo/service classes with zeroHttpContextdependency.
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.csor/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:
ssidcookie --> in-processConcurrentDictionary<string, StateObject>(RAM) --> persistentlogin_sessionsDB record referenced by thelsidcookie. - Fast Path: Normal requests resolve the random
ssidtoken directly against the RAM dictionary; application session state is process-local and intentionally bypasses the built-in ASP.NETSessionStateModule. - Persistent Login Recovery: The RAM dictionary is lost on app-pool recycle. A logged-in user with a valid
lsidcookie is rehydrated from the DB-backed login session byAppSession.TryRestoreFromCookie()on the next request. Anonymous RAM-only session state is not durable. - Storage:
-
ssidcookie: Hex key for in-memoryConcurrentDictionary<string, StateObject>. -
login_sessionsDB table: Persistent "Remember Me" record holding hashedlsidtoken (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 usecamelCase. - 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()replacesResponse.End()— flushes buffers and triggersCompleteRequest()cleanly withoutThreadAbortException.
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:
$@"...": Default for multiline with dynamic values (leading newline after$@").@"...": Static multiline HTML or blocks containing inline CSS/JS object literals (no$).$"...": 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 declaretype='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
StaticAssetfor 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
userIddirectly 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
HttpContextdependency, 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
| Feature | Classic Pageless (v4 Switch) | File-Based Pageless |
|---|---|---|
| Adding a Feature | Add handler file + edit Global.asax.cs switch | Drop 1 file into /routes/ (Zero edits to Global.asax) |
| Collision Risk | High (editing central switch repeatedly) | Zero (Atomic file creation) |
| Context Window Overhead | Must read Global.asax.cs switch list | Only reads target handler file |
| Performance | switch(path) string comparisons | $\mathcal{O}(1)$ in-memory delegate dictionary |
| Deployment | Recompile assembly on project change | Auto-discovered on compile/startup |