Architecture Reference for Vanilla ASP.NET Web Forms

Mar 23, 2026
Updated Aug 17, 2026
adriancs

Architecture Reference for Vanilla ASP.NET Web Forms

Basics

Keep *.aspx pages and master pages. Abandon the stateful, ViewState-heavy control set.

❌ Don't use✅ Use instead
GridView, Repeater, DataListStringBuilderLiteralControl into a PlaceHolder
CheckBoxList, DropDownList (server-bound)Plain <input> / <select>, populated server-side or by fetch
UpdatePanelNative fetch() returning JSON or an HTML fragment
<asp:Button OnClick="..."> / postback<button type="button" onclick="saveItem()"> + fetch
ViewState round-trippingClient-side state, or a re-read on the next request

<asp:Literal>, <asp:PlaceHolder>, and master pages stay — they emit pure HTML and don't depend on the postback pipeline.

<!-- Requires JavaScript fetch API (dynamic interaction) -->
<button type="button" onclick="saveItem()">Save</button>

<!-- Never: triggers a full postback -->
<button type="submit">Save</button>

No full postbacks. Every action is a fetch FormData POST, a query-string GET, or XMLHttpRequest (file upload with progress).

⚠️ Known Architectural Constraints

Dropping <form runat="server"> also drops what it provided. These are accepted trade-offs, not oversights — each has a compensating rule later in this document.

ConstraintConsequenceCompensating rule
No <form runat="server">no ViewState MAC, no __EVENTVALIDATIONNo server postback controlsStandard HTML controls + Fetch API
Synchronous Page_Load handlersEvery DB call blocks a thread-pool threadKeep page/API work short; offload long jobs to Background Tasks
Static task state in process memoryLost on app-pool recycle; blocks web-farm / web-gardenBackground Tasks — treat "task not found" as ended, outcome unknown
HttpContext.Current inside code-behindCode-behind is not unit-testableBackend API Pattern — put logic in plain repo classes the page calls
Request["id"] names any rowAuthentication is not authorizationSecurity Baseline — scope every query by owner

Extensionless URL Routing

Map every *.aspx in ~/pages/** to a clean root URL using its bare filename. ~/pages/admin/Members.aspx is served at /Members. Eliminates /pages/... and .aspx from every link.

File-naming rule: every .aspx filename must be unique across the whole pages/ tree. Two "Login" pages → rename one (Login.aspx for members, OfficeLogin.aspx for staff). Collisions throw at startup so they can never silently shadow each other.

Route config (engine/RouteConfig.cs):

using System;
using System.IO;
using System.Web;
using System.Web.Hosting;
using System.Web.Routing;

namespace MyApp.engine
{
    public static class RouteConfig
    {
        public static void MapPageRoutes(string virtualRoot)
        {
            if (string.IsNullOrWhiteSpace(virtualRoot))
                throw new ArgumentNullException(nameof(virtualRoot));

            // Normalize "pages", "/pages", "~/pages" -> "~/pages"
            virtualRoot = virtualRoot.Trim().Replace('\\', '/');
            if (virtualRoot.StartsWith("~/"))
                { /* already app-relative */ }
            else if (virtualRoot.StartsWith("/"))
                virtualRoot = "~" + virtualRoot;
            else
                virtualRoot = "~/" + virtualRoot;

            string physicalRoot = HostingEnvironment.MapPath(virtualRoot);
            if (string.IsNullOrEmpty(physicalRoot) || !Directory.Exists(physicalRoot))
                return;

            string appPath = HostingEnvironment.ApplicationPhysicalPath;

            foreach (var file in Directory.EnumerateFiles(physicalRoot, "*.aspx", SearchOption.AllDirectories))
            {
                string name = Path.GetFileNameWithoutExtension(file);

                if (string.Equals(name, "Default", StringComparison.OrdinalIgnoreCase))
                    continue;

                string relative = file.Substring(appPath.Length).Replace('\\', '/');
                string virtualPath = "~/" + relative.TrimStart('/');
                string routeName = "page_" + name;

                if (RouteTable.Routes[routeName] != null)
                {
                    throw new InvalidOperationException(
                        "Route collision on '" + name + "'. " +
                        "Rename one of the .aspx files so each filename is unique.");
                }

                RouteTable.Routes.MapPageRoute(routeName, name, virtualPath);
            }
        }
    }
}

Application_Start responsibilities — route mapping, connection string init, DB schema/migration check, and starting the background sweeper:

protected void Application_Start(object sender, EventArgs e)
{
    RouteConfig.MapPageRoutes("~/pages");
    config.Init();                  // connection string, settings
    DbMigrator.EnsureSchema();      // schema check / migration
    SessionSweeper.Start();         // idle session + expired token cleanup
}

Static files (/css/..., /js/..., /fonts/...) and root Default.aspx pass through untouched — routing only fires for bare filenames. The default RouteExistingFiles = false ensures physical files always win.

Route parameters: MapPageRoute supports them (MapPageRoute("member", "u/{username}", "~/pages/Profile.aspx")), read via Page.RouteData.Values["username"]. ⚠️ A route segment is untrusted input — validate and decode it exactly like a form field before it reaches a path or a query.


Direct Root Paths — No ResolveUrl

With routing in place, every URL is stable and root-relative. Drop <%= ResolveUrl("~/...") %> everywhere — in .aspx and .cs — and write the final path directly.

Don't:

<link rel="stylesheet" href="<%= ResolveUrl("~/css/site.css") %>" />
<a href="<%= ResolveUrl("~/pages/admin/Members.aspx") %>">Members</a>
<script>
const REG_API = '<%= ResolveUrl("~/pages/RegisterApi.aspx") %>';
</script>
Response.Redirect("~/pages/Login.aspx", true);
string viewBase = ResolveUrl("~/pages/admin/MemberProfile.aspx");

Do:

<link rel="stylesheet" href="/css/site.css" />
<a href="/Members">Members</a>
<script>
const REG_API = '/RegisterApi';
</script>
Response.Redirect("/Login", true);
string viewBase = "/MemberProfile";

Why: ResolveUrl is only needed when the app might run under a virtual sub-directory. Modern deployments host at the site root — embracing that makes URLs readable literals, greppable, and identical between server-rendered HTML and JS. If you ever need a sub-path, set it once on a reverse proxy or the IIS site root; don't reintroduce ~/ everywhere.


No <form runat="server"> Tag

WebForms requires a server-side <form> only for postbacks and stateful server controls. With API + Fetch + Literal/PlaceHolder, it's dead weight — and it actively gets in the way:

  • Only one <form runat="server"> per page; you can't nest user forms.
  • Default submit (Enter, button click) triggers a postback you don't want.
  • ViewState / __EVENTTARGET plumbing loads on every request for no reason.

Master page — no server form:

<%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site.Master.cs" Inherits="MyApp.SiteMaster" %>
<!DOCTYPE html>
<html lang="en">
<head runat="server">
    <%= StaticAsset.Css("/css/site.css") %>
    <asp:ContentPlaceHolder ID="head" runat="server" />
</head>
<body class="<%= BodyClass %>">
    <header>...</header>

    <main>
        <asp:ContentPlaceHolder ID="MainContent" runat="server" />
    </main>

    <%= StaticAsset.Script("/js/site.js") %>
    <asp:ContentPlaceHolder ID="scripts" runat="server" />
</body>
</html>

Two key things the master page carries: versioned asset tags (StaticAsset), and a BodyClass property for CSS scoping. Give the master a public string BodyClass { get; set; } that content pages set in Page_Load, defaulting to "".

Page-level forms — use <div> instead:

Don't<form> + type="submit" triggers default browser submit:

<form id="loginForm" onsubmit="event.preventDefault(); doLogin();">
    <input name="username" />
    <button type="submit">Sign in</button>
</form>

Do — plain <div> container + type="button" with explicit onclick:

<div id="loginForm">
    <input name="username" />
    <input type="password" name="password" />
    <button type="button" onclick="doLogin();">Sign in</button>
</div>

<script>
// Preserve Enter-to-submit behavior since we no longer have a <form>.
document.getElementById('loginForm').addEventListener('keydown', function (e) {
    if (e.key === 'Enter') { e.preventDefault(); doLogin(); }
});
</script>

Session & Authentication

AppSession Facade Rule. Code-behind reads session only through a static AppSession facade class, never Session["..."] strings scattered through pages. One central place to change, one place to audit.

if (!AppSession.IsLoggedIn) { Response.Redirect("/Login"); return; }
obUser me = AppSession.LoginUser;

Server-Side Dynamic HTML — PlaceHolder / Literal

For fully static HTML, write it directly in the .aspx.

For one-time dynamic HTML, inject composed strings into PlaceHolder / Literal with zero ViewState overhead. Typical case: search pages with pagination driven by the query string.

Frontend Declaration (.aspx)

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="ClassListEdit.aspx.cs" Inherits="myweb.ClassListEdit" %>

<asp:Content ContentPlaceHolderID="MainContent" runat="server">

    <h2>Class List</h2>

    <!-- PlaceHolder: container for server-composed HTML blocks -->
    <asp:PlaceHolder ID="phClassList" runat="server" />

    <!-- Literal: simpler, single string injection -->
    <asp:Literal ID="litSummary" runat="server" />

</asp:Content>

Backend Composition (.aspx.cs)

protected void Page_Load(object sender, EventArgs e)
{
    if (!AppSession.IsLoggedIn) { Response.Redirect("/Login"); return; }

    Master.BodyClass = "page-classlist";

    RenderClassList();
    RenderSummary();
}

void RenderClassList()
{
    StringBuilder sb = new StringBuilder();

    // Scoped by owner — never "all classes"
    List<ClassItem> classes = ClassRepo.ListForUser(AppSession.LoginUser.Id);

    sb.Append(@"<div class='class-list'>");

    foreach (var c in classes)
    {
        sb.Append($@"
<div class='class-row' data-id='{c.Id}'>
    <span class='class-name'>{HttpUtility.HtmlEncode(c.Name)}</span>
    <span class='class-count'>{c.StudentCount} students</span>
    <button type='button' class='btn-edit-class'>Edit</button>
</div>");
    }

    sb.Append("</div>");

    phClassList.Controls.Add(new LiteralControl(sb.ToString()));
}

void RenderSummary()
{
    int total = ClassRepo.CountForUser(AppSession.LoginUser.Id);
    litSummary.Text = $"<p>Total: <strong>{total}</strong> classes</p>";
}

Note the repeated rows emit data-id and a class, not onclick='editClass({c.Id})'. See Client-Side Dynamic Rendering for why C#-emitted lists use delegation while static .aspx markup may keep inline onclick.

When to Use This Pattern

Strong fit — one-time / page-load dynamic content:

  • Initial page render with database-driven content
  • Reports, summaries, dashboards (read-mostly views)
  • Lists where the entire view is regenerated on full page reload
  • Admin pages where SEO / progressive enhancement matters

Wrong fit — interactive / frequently-updated content:

  • Live filtering, sorting, pagination without full reload
  • Inline editing with save/cancel
  • Anything that updates after the initial render

For interactive cases, use the Fetch API pattern.


Static Asset Cache-Busting (StaticAsset)

A hard-coded /css/site.css ships stale to every returning browser after an edit. Append the file's write time instead:

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 it for shared assets too — the master page emits <%= StaticAsset.Css("/css/site.css") %>, never a hard-coded path. site.css and site.js are the two files most likely to change.
  • Page-specific and component assets go through the same helper, emitted into the head / scripts ContentPlaceHolder.
  • ⚠️ Do not memoize Version() into a static dictionary. Editing a .js file does not recycle the app pool, so a memoized version serves stale URLs during development. The write-time lookup is OS-cached metadata — the cost is negligible.

Client-Side Dynamic Rendering — Two Choices

When content must update after initial page load (filter, sort, edit, refresh without reload), the Fetch API is mandatory. You still choose where the HTML gets built:

  • Server-side rendering (C# StringBuilder + multiline interpolation), returned as an HTML fragment
  • Client-side rendering (JS builds HTML from JSON)

Always prefer server-side rendering — HTML tables, card layouts, <select>/<option> lists. Server-render it; JS just dumps it into the container. The server already has the object state — instead of shipping JSON and asking JavaScript to re-derive the markup, render it once. Exception: state the frontend must hold for other reusable purposes.

For static form inputs, the skeleton HTML already exists — the backend sends a JSON object and JS fills the fields one by one.

JavaScript Organization — 4 Rules

#RuleSpecification
1Data island only inlineServer emits data into a Literal as <script> globals via JsonConvert.SerializeObject(). No functions, no logic.
2Static behavior in external filesAll logic, functions, and listeners live in .js files emitted via StaticAsset.Script().
3Scope decides file• App-wide → site.js (loaded everywhere; must guard every lookup if (!el) return;)
• Page-specific → /js/<Page>.js, one per .aspx, named after it
• Reusable widget → /js/components/<n>.js
4Portable componentsSelf-contained IIFE exposing one constructor on window. Renders into a mount point, receives data via options, scopes queries to this.mount.querySelector(), escapes every value, ships a prefixed /css/components/<n>.css, includes destroy().

The enforceable test for Rules 1 & 2: if a <script> block emitted from code-behind contains the function keyword, Rule 2 has been violated.

Small page glue may stay in the .aspx markup. The threshold: script exceeding roughly one screenful, or defining reusable functions rather than wiring, belongs in a .js file. Static JS inside C# strings costs syntax highlighting, linting, browser caching, and CSP hardening — and needs a DLL rebuild for a one-character fix. Static JS in .aspx markup at least keeps highlighting, but still can't be cached or shared.

Other JS rules:

  • No catch-all files — never misc.js / common2.js. That is site.js bloat wearing a different filename.
  • Page files may assume their page (its DOM ids, its data-island globals). site.js may assume neither.
  • Promotion path: the moment a second page needs a function verbatim, promote it — site.js if small and universal, a component if it owns markup. Copy-pasting between two page files is the signal, not the solution.
  • Inline onclick policy: acceptable in hand-written .aspx markup for one-off buttons (onclick="doLogin();"). Not acceptable in C#-emitted repeated rows — emit data-id + a class and wire one delegated listener in the page's .js. N copies of a handler name inside a StringBuilder loop is N places for it to drift, and it is what blocks a future Content-Security-Policy.
// /js/ClassListEdit.js
document.addEventListener('DOMContentLoaded', function () {
    var list = document.querySelector('.class-list');
    if (!list) return;
    list.addEventListener('click', function (e) {
        var btn = e.target.closest('.btn-edit-class');
        if (!btn) return;
        editClass(btn.closest('.class-row').dataset.id);
    });
});

CSS Organization

  1. App-wide (/css/site.css) — reset, layout, colors, header/footer. Loaded on every page by the master.
  2. Page-specific (/css/<Page>.css) — selectors unique to one page, scoped under the page-root BodyClass (.page-classlist .card { ... }) to prevent leakage.
  3. Component CSS (/css/components/<n>.css) — scoped exclusively by the component prefix (.bookpicker-item). No bare element selectors, no styling outside its own root.
  4. Load order is already correct: the master emits site.css first, then the head placeholder — so page and component stylesheets land after the global one and win ties at equal specificity. Page CSS overrides shared CSS without !important.
  5. ⚠️ Never emit a static <style> block from code-behind — the CSS twin of the Rule 2 anti-pattern: uncacheable, unlintable, needs a rebuild to change.
  6. Dynamic CSS values are a data island too — emit the value as a custom property; the rules stay in the file. Double braces ({{ }}) inside $@".
      litThemeStyle.Text = $@"<style>:root {{ --brand: {HttpUtility.HtmlEncode(theme.BrandColor)}; }}</style>";
      .btn-primary { background: var(--brand, #E0F3EF); }
  7. Promotion signal: a style moves to site.css only once genuinely shared. Two pages needing the same card is the signal; one page anticipating a second is not.
  8. Version page and component CSS with StaticAsset.Css, same as scripts.

Frontend Patterns (.aspx)

1. Data Loading (GET)

const response = await fetch(`${API_URL}?action=get_list&id=${id}`);
const data = await response.json();
if (data.success) {
    // Render data.items to DOM
}

GET is for reads only. A GET must never mutate state.

2. Data Saving (POST with FormData)

Mutating calls use a central apiPost wrapper in site.js:

// site.js
async function apiPost(url, formData) {
    // Do NOT set Content-Type — the browser sets multipart/form-data with the boundary.
    const response = await fetch(url, { method: 'POST', body: formData });
    if (!response.ok && response.status !== 400) {
        throw new Error('Server responded with an error status');
    }
    return response;
}
// Uses async/await — linear flow, ordinary scoping, standard try/catch.
async function doRegister() {

    // Prevent double submit
    const btn = document.getElementById('btnRegister');
    btn.disabled = true;
    btn.textContent = 'Creating account...';

    // Append form fields (or fake fields as honeypot to trap spambots)
    const form = document.getElementById('registerForm');

    const formData = new FormData(form);
    formData.append('action', 'register');
    formData.append('username', username);
    formData.append('email', email);
    formData.append('password', password);
    formData.append('subscribe_newsletter', newsletter);

    try {
        const response = await apiPost(API_URL, formData);
        const data = await response.json();

        if (data.success) {
            btn.textContent = 'Done';
        } else {
            btn.disabled = false;
            btn.textContent = 'Register';
        }
    } catch (error) {
        console.error('Fetch error:', error);
        btn.disabled = false;
        btn.textContent = 'Register';
    }
}

3. Consuming a Server-Rendered HTML Fragment

async function refreshClassList() {
    const fd = new FormData();
    fd.append('action', 'get_list_html');
    const res = await apiPost(API_URL, fd);
    document.getElementById('class-container').innerHTML = await res.text();
}

4. File Upload (XMLHttpRequest with FormData)

const formData = new FormData();
formData.append('action', 'upload');
formData.append('csrf_token', csrfToken());
formData.append('file', fileInput.files[0]);

const xhr = new XMLHttpRequest();
xhr.open('POST', API_URL);
xhr.onload = function() {
    const data = JSON.parse(xhr.responseText);
    // Handle response
};
xhr.send(formData);

5. HTML Escaping in JS

function escapeHtml(str) {
    var d = document.createElement('div');
    d.textContent = str == null ? '' : str;
    return d.innerHTML;
}

Any DB text written into innerHTML from JS must pass through this — the JS twin of HttpUtility.HtmlEncode.


Backend API Pattern

Structure

API front page (.aspx) — delete all markup, leave only the page directive:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiPage.aspx.cs" Inherits="myweb.apiBackup" %>

The code-behind (.aspx.cs)

public partial class SomePageApi : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        try
        {
            // 1. Authentication
            Guard.LoggedIn();

            // 2. Route by action
            string action = (Request["action"] + "").ToLower().Trim();

            switch (action)
            {
                // --- reads ---
                case "get_list":      GetList();      break;
                case "get_list_html": GetListHtml();  break;   // pre-rendered HTML fragment
                case "get_item":      GetItem();      break;
                // --- writes: enforce POST method ---
                case "save":          ApiHelper.RequirePost(); SaveItem();   break;
                case "delete":        ApiHelper.RequirePost(); DeleteItem(); 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(ex.Message, 500); }

        ApiHelper.EndResponse();
    }

⚠️ ex.Message in the response is the development form. In production, log the exception and return a generic message plus a correlation id — stack traces and SQL text belong in the log, not the response body.

API Actions

    #region API Actions

    void GetList()
    {
        int parentId = dp.IntParse(Request["parent_id"]);
        var items = ItemRepo.ListForUser(AppSession.LoginUser.Id, parentId);   // scoped by owner
        ApiHelper.WriteJson(new { success = true, items });
    }

    void GetListHtml()
    {
        var sb = new StringBuilder();
        foreach (var it in ItemRepo.ListForUser(AppSession.LoginUser.Id))
        {
            sb.Append($@"
<div class='item-row' data-id='{it.Id}'>
    <span>{HttpUtility.HtmlEncode(it.Name)}</span>
    <button type='button' class='btn-edit'>Edit</button>
</div>");
        }
        Response.ContentType = "text/html; charset=utf-8";
        Response.Write(sb.ToString());
    }

    void SaveItem()
    {
        int id = dp.IntParse(Request["id"]);
        string name = (Request["name"] + "").Trim();

        // --- validate before any write ---
        if (string.IsNullOrEmpty(name)) { ApiHelper.WriteError("Name is required"); return; }

        int userId = AppSession.LoginUser.Id;

        // --- authorize the row, not just the user ---
        if (id > 0 && !ItemRepo.IsOwnedBy(id, userId)) { ApiHelper.WriteError("Not found", 404); return; }

        if (id <= 0) { ItemRepo.Insert(userId, name); ApiHelper.WriteSuccess("Saved");   }
        else         { ItemRepo.Update(id, name);     ApiHelper.WriteSuccess("Updated"); }
    }

    void DeleteItem()
    {
        int id = dp.IntParse(Request["id"]);
        if (id <= 0) { ApiHelper.WriteError("Invalid id"); return; }

        // Scope the delete in the WHERE clause — not a separate check that can race.
        if (ItemRepo.DeleteOwned(id, AppSession.LoginUser.Id) == 0)
        {
            ApiHelper.WriteError("Not found", 404);
            return;
        }
        ApiHelper.WriteSuccess("Deleted");
    }

    #endregion
}

Data Access Rules

The MySqlExpress / SQLiteExpress README covers the API. Only these rules are architectural:

Note a hard rule, just a strategy option.

  • One save action, not two. id <= 0 → insert; otherwise update.
  • Keep data access out of code-behind (The ItemRepo Strategy). Isolating database logic into plain C# repository classes unlocks unit testing and codebase portability. Because HttpContext.Current is null outside active HTTP requests, plain Repo classes enable rapid, surgical testing (such as executing compiled DLL methods directly via PowerShell) and turn core business logic into a portable, universal engine for the entire application (background jobs, APIs, CLI tools). There are times where putting the code logic all-in-one in HTTP request is a valid choice.

Guarding Write Actions (POST Protection)

State-changing actions (writes/deletes) must be guarded so they are executed strictly via HTTP POST. Use one of the following two strategies:

  • Strategy 1 (Explicit Payload Parsing): Read action strictly from the Request.Form collection for dedicated POST handlers:
    string action = (Request.Form["action"] + "").ToLower().Trim();

    Completely ignores URL query strings, ensuring GET requests cannot trigger write logic.

  • Strategy 2 (Method Assertion via ApiHelper.RequirePost()): If a single API handler parses actions from a combined request (string action = (Request["action"] + "").ToLower().Trim();), explicitly assert that the request method is POST before executing any write action:
    switch (action)
    {
        case "get_list": GetList(); break;
        case "save":     ApiHelper.RequirePost(); SaveItem(); break;   // Enforces POST method
        case "delete":   ApiHelper.RequirePost(); DeleteItem(); break; // Enforces POST method
    }

    If a non-POST request hits a write action, ApiHelper.RequirePost() returns a 405 Method Not Allowed JSON error ("Action requires a POST request") and terminates the response.

The API Helper Class

using Newtonsoft.Json;
using System;
using System.Web;

// callable everywhere without a "using", zero risk collision
namespace System 
{
    public class ApiHelper
    {
        static HttpResponse Response
        {
            get
            {
                if (HttpContext.Current == null)
                    throw new InvalidOperationException("ApiHelper called outside of an HTTP request context.");
                return HttpContext.Current.Response;
            }
        }

        static HttpRequest Request
        {
            get
            {
                if (HttpContext.Current == null)
                    throw new InvalidOperationException("ApiHelper called outside of an HTTP request context.");
                return HttpContext.Current.Request;
            }
        }

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

        public static void EndResponse()
        {
            Response.TrySkipIisCustomErrors = true;   // IIS skips custom errors

            try { Response.Flush(); }
            catch { /* client already disconnected — ignore */ }

            Response.SuppressContent = true;

            // Most reliable way in WebForms / IIS-integrated pipeline
            HttpContext.Current.ApplicationInstance.CompleteRequest();
        }

        public static void WriteJson(object obj)
        {
            // No naming conversion — preserves names exactly as declared
            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 bool IsPostRequest()
        {
            return Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase);
        }

        public static void RequirePost()
        {
            if (!IsPostRequest())
            {
                WriteError("Action requires a POST request", 405);
                EndResponse();
            }
        }
    }
}

Security Baseline

These security rules are mandatory baseline practices for API and page execution.

Guard Implementation

Guard methods are called at the top of API actions to enforce authentication and role authorization:

public static class Guard
{
    public static void LoggedIn()
    {
        if (!AppSession.IsLoggedIn) throw new GuardException("Not signed in", 401);
    }

    public static void Role(string role)
    {
        LoggedIn();
        if (!AppSession.LoginUser.IsInRole(role)) throw new GuardException("Not found", 404);
    }
}

Authorization

  • IsLoggedIn is authentication, not authorization. It answers "who", never "may they".
  • Scope by owner in the query itself (WHERE id = @id AND user_id = @uid) rather than fetch-then-check — one round trip, and no TOCTOU gap.
  • A failed ownership check returns 404, not 403 — a 403 confirms the row exists to someone who shouldn't know.
  • Role checks sit at the top of the action.

⚠️ File Uploads — the sharpest edge in this architecture

A web-accessible folder accepting arbitrary filenames is a remote-code-execution path on IIS the moment someone uploads a handler-mapped extension — and this is a WebForms site, so .aspx is definitely mapped. All five rules apply together:

  1. Store outside the web root (~/App_Data/uploads/ or a non-served disk path) and serve files back through an API action that sets Content-Disposition and an explicit content type. If they must live under the web root, unmap all handlers for that directory in web.config.
  2. Whitelist extensions — an allow-list of what you accept, never a block-list of what you reject.
  3. Generate the stored filename (Guid.NewGuid() + whitelisted extension). Keep the original name as data in the DB for display, never as a path. Path.GetFileName() is not sufficient — it strips directories but happily returns evil.aspx.
  4. Cap the size in both web.config (maxRequestLength, maxAllowedContentLength) and the handler.
  5. Verify the content, at minimum a magic-number sniff for image types. A client-declared content type is worthless.
void UploadFile()
{
    Guard.LoggedIn();

    if (Request.Files.Count == 0)
    {
        ApiHelper.WriteError("No file uploaded");
        return;
    }

    var uploadedFiles = new List<object>();

    for (int i = 0; i < Request.Files.Count; i++)
    {
        HttpPostedFile file = Request.Files[i];
        if (file.ContentLength == 0) continue;

        string original = Path.GetFileName(file.FileName);
        string ext = (Path.GetExtension(original) + "").ToLowerInvariant();

        if (!UploadPolicy.AllowedExtensions.Contains(ext))
        {
            uploadedFiles.Add(new { success = false, file_name = original, message = "File type not allowed" });
            continue;
        }
        if (file.ContentLength > UploadPolicy.MaxBytes)
        {
            uploadedFiles.Add(new { success = false, file_name = original, message = "File too large" });
            continue;
        }

        string stored = Guid.NewGuid().ToString("N") + ext;              // never the client's name
        file.SaveAs(Path.Combine(UploadPolicy.PhysicalRoot, stored));    // outside the web root
        FileRepo.Record(AppSession.LoginUser.Id, original, stored, file.ContentLength);

        uploadedFiles.Add(new { success = true, file_name = original, file_id = stored });
    }

    ApiHelper.WriteJson(uploadedFiles);
}

Other baseline items

  • Route segments (§2) and every Request[...] value are untrusted input.
  • Errors: log the exception, return a generic message.
  • Login: rate-limit, fresh session on success, adaptive password hash (§5).

Background Tasks

Depends on the situation, two choices:

// Primary - ASP.NET tracks it and delays shutdown while it drains
HostingEnvironment.QueueBackgroundWorkItem(ct => DoWork(taskId, ct));

// Secondary - Dies silently if the app pool recycles mid-work
_ = Task.Run(() => DoWork(taskId));

The worker honours the CancellationToken alongside its own RequestCancel flag.

Progress state

Persist task progress in process memory (public static ConcurrentDictionary<string, obState>), a file-based report, or the database. The frontend traces it with a Server-Sent Event stream, a JS timer poll, or a manual GET check.

  • obState: TaskId, PercentComplete, Status, IsCompleted, HasError, ErrorMessage, RequestCancel, IsCancelled, OwnerUserId.
  • Cancel: the stop action sets RequestCancel = true; the worker loop checks it, sets IsCancelled, breaks, and always sets IsCompleted = true on exit.
  • Authorize by OwnerUserId on the status and stop actions — a task id is guessable.
  • ⚠️ In-memory state is process-local and does not survive a recycle. A polling frontend must treat "task id not found" as ended, outcome unknown, not as an error loop. Anything whose result must survive a restart belongs in a DB row.

Default JSON Library

Newtonsoft.Json

using Newtonsoft.Json;

Response.ContentType = "application/json";
Response.Write(JsonConvert.SerializeObject(obj));

JSON naming convention. Direct matching of C# class fields or properties. Use the default standard.

  • It can be PascalCase ("PropertyName").
  • If the fields primarily match MySQL columns, use snake_case ("property_name").
  • Never use camelCase ("propertyName").

API Response Format — all responses follow this structure:

// Success
{ "success": true, "message": "...", "data": {...} }

// Success with list
{ "success": true, "items": [...] }

// Error
{ "success": false, "message": "Error description" }

File Structure & Naming

Master Page as Template

ASP.NET Master Pages (.master) are the shared layout template — <head>, navigation, footer, script references — while each content page supplies markup through ContentPlaceHolder regions. The master carries versioned shared assets and the BodyClass property.

/Global.asax.cs
/engine/
    RouteConfig.cs
    config.cs
    ApiHelper.cs
    Guard.cs                      <-- auth / role guards
    StaticAsset.cs                <-- cache-busting asset tags
    AppSession.cs                 <-- session facade
    Models/
        obMember.cs               <-- data model (ob prefix)
    Repo/
        MemberRepo.cs             <-- data access, testable, no HttpContext
/pages/
    Site.Master
    ClassListEdit.aspx
    ClassListEditApi.aspx
/css/
    site.css
    ClassListEdit.css
    components/book-picker.css
/js/
    site.js
    ClassListEdit.js
    components/book-picker.js
/App_Data/
    uploads/                      <-- outside the web root

Page Naming

Frontend page — HTML, JavaScript and CSS only:

FrontPage.aspx
FrontPage.aspx.cs             <-- almost empty, except login detection + Literal fills
FrontPage.aspx.designer.cs    <-- Literal/PlaceHolder declarations only

API page — primary choice, append Api as a pair to the frontend page:

FrontPageApi.aspx             <-- blank, page directive only
FrontPageApi.aspx.cs          <-- backend C# API handling
FrontPageApi.aspx.designer.cs <-- empty, no server controls

Secondary choice — prefix with api:

apiFrontPage.aspx
apiFrontPage.aspx.cs
apiFrontPage.aspx.designer.cs

Data Model Convention

Models use Binding Mode 1 from the MySqlExpress/SQLiteExpress README: private snake_case fields matching DB columns, public PascalCase properties. Class names prefixed ob.

The snake_case private fields are not cosmetic — they are why Default JSON Library permits snake_case JSON property names.

C# Class Naming

Avoid generic class names that collide with popular built-in framework types or standard libraries (e.g., System.IO.File, System.Action, System.Threading.Tasks.Task).

A class name like public class File creates compilation ambiguity errors when combined with using System.IO;:

Don't — collides with System.IO.File:

using System.IO;
using MyApp;

public void DoSomething()
{
    File.WriteAllText(...);   // Compilation Error: ambiguous reference 'File'
}

Do — intentionally differentiate class names with domain context (e.g., MemberFile, SaveFile, BackupFile, AttachmentFile):

namespace MyApp
{
    public class MemberFile
    {
        // Unique domain name — zero collision risk
    }
}

C# StringBuilder — Multiline String Interpolation

When emitting HTML/JS from code-behind, prefer multiline interpolated verbatim strings ($@"...") over many small sb.Append("...") calls with escaped double quotes.

Use Single-Quote HTML Attributes

HTML accepts both " and '. Single quotes inside $@"..." avoid escaping every double quote as "".

Don't — escape-heavy, one Append per line, concatenation, no encoding:

sb.Append("<div class=\"card\">");
sb.Append("<strong>" + item.Title + "</strong>");
sb.Append("</div>");

Do — one block, HTML shape visible, every hole encoded:

sb.Append($@"
<div class='card' data-id='{item.Id}'>
    <strong>{HttpUtility.HtmlEncode(item.Title)}</strong>
</div>");

Why it matters: source indentation is the emitted HTML indentation, so unclosed tags are visible on sight; single quotes remove the escaping tax entirely; the encoder sits inside the tag it protects, so an unencoded hole is obvious in review; and one Append per fragment means less StringBuilder churn in list loops.

The single-quote convention applies specifically to attributes written inside a C# $@"..." string. Static markup in the .aspx page uses conventional double quotes (ID="litSummary").

Three Approved String Forms

  1. $@"..." — default for any block containing at least one dynamic value.
  2. @"..." — multiline verbatim for fully static HTML; literal braces need no doubling, so use it for blocks carrying JS or CSS.
  3. $"..." — single-line. Only for a single tag emitted conditionally.

Always write $@", never @$" (reversed order requires C# 8.0+). Interpolation holes must be single-line expressions in C# 7.3 — pre-compute ternaries, formatted dates, and conditional class names into locals before the block.

Encoding is Mandatory

ContextEncoder
HTML contentHttpUtility.HtmlEncode(value)
Attribute valueHttpUtility.HtmlAttributeEncode(value)
Inside <script>JsonConvert.SerializeObject(value)

⚠️ A hole is a value, never markup. Never interpolate raw HTML that originated from user input, and never embed pre-built markup from a request. Compose child fragments by appending a helper method's return value.

Avoid Double-Brace Escapes — Split the Block

Inside $@"...", every literal { or } must be doubled as {{ / }} — painful for JavaScript or CSS.

Rule: split output by what each block needs. $@"..." only for blocks needing substitution; @"..." for JS/CSS/JSON.

// Interpolated block: substitutes itemId, itemName
sb.Append($@"
<div class='item' data-id='{itemId}'>
    <span>{HttpUtility.HtmlEncode(itemName)}</span>
    <button type='button' class='btn-edit'>Edit</button>
</div>
");

The matching static editItem function does not belong in a @"..." block here — it belongs in /js/<Page>.js (see Client-Side Dynamic Rendering). Emitting it from code-behind is exactly the Rule 2 violation the function-keyword test catches.

Keep Static JS in the Frontend — Emit Only State

When most JavaScript is static (handlers, functions, logic) and only a small part is dynamic (IDs, state objects), emit only the state into a Literal:

// Code-behind emits only dynamic data
sb.Append($@"
<script>
const editMode = {JsonConvert.SerializeObject(editMode)};
const memberId = {member_id};
const pageState = {JsonConvert.SerializeObject(stateObject)};
</script>");

literalScriptState.Text = sb.ToString();
<!-- State renders here first... -->
<asp:Literal ID="literalScriptState" runat="server"></asp:Literal>

<!-- ...then the static script reads it -->
<%= StaticAsset.Script("/js/MemberEdit.js") %>

Why:

  • Brace-heavy static code never touches an interpolated string, so no {{/}} escaping.
  • JsonConvert.SerializeObject for objects — braces come from the serializer, and values are safely escaped. Never hand-build JSON like {{ name: '{name}' }}; it breaks the moment a value contains a quote, newline, or brace. This applies to plain strings too: '{editMode}' breaks on an apostrophe, SerializeObject(editMode) does not.
  • Place the Literal above the script that reads it, so state exists first. DOMContentLoaded inside the .js file adds a further margin — and is required, since the script tag may sit at the end of <body>.

C# Conventions

Do not use dynamic-typed references.

CostWhy it matters
No IntelliSenseYou lose autocomplete, refactoring, "Find All References" — the IDE goes blind
Runtime errorsTypos like arg.blakc compile fine, crash only when that line executes
SlowerEach .member access goes through the DLR CallSite cache — adds ~microseconds
No null-safetyC# 8+ nullable reference types can't help you
Hard to grepStatic analysis tools, dependency analyzers, and AI assistants struggle
Decompile-hostiledynamic compiles into CallSite plumbing decompilers struggle to fold back

No LINQ in rendering and hot paths — use traditional for / foreach. LINQ allocates delegates and closure classes (<>c__DisplayClass) per call, and decompiles poorly.

Keep Page_Load short. It is synchronous and holds a thread-pool thread for its whole duration. Anything slow belongs in Background Tasks.

Photo by Beyzaa Yurtkuran from Pexels.