← Home · All projects

Application Template

Turning hard-won lessons from three production applications into a starting point for the next one

11+ weeks · 20+ working sessions

ArchitectureVertical Slice.NETKnowledge Capture
1 / 1

The Application Template is a working project skeleton plus a pattern library, covering the solution structure we use for internal web applications, the authentication and authorization approach, database access conventions, deployment steps, and a growing set of documented pitfalls. A new application starts from this, not from an empty folder. It exists because, by the time three internal applications had shipped, the same problems were being solved for the third time, and worse, the same mistakes were being made for the second time, each lesson stranded in the history of the project that learned it. Consolidating that into one place was less about saving setup time and more about stopping repeat failures.

The distinguishing property is that almost none of it is theory. Each pattern and recorded pitfall goes back to a real incident in a particular project, and the symptom sits recorded next to the fix. The clearest example: one project’s background retry mechanism had no memory of prior failures, so it retried a permanently broken operation every few seconds for more than five weeks, and by the time anyone noticed it had written about 1.3 million log rows and around 41 gigabytes, and nothing ever crashed. The bounded backoff pattern that came out of it counts consecutive failures for each item and slows the retry interval down to a low but never-zero rate, slow enough that the logs stop flooding while the problem stays visible.

The template now covers the user interface, the database layer, authentication, data access, warehouse conventions, integrations, and deployment, each as a separate reference document, plus a verification methodology added late in the project, once requirements reviews on two applications already in production had proved the practices deserved keeping. The pitfalls table has grown into the most valuable part of it, since each row represents an outage or a debugging session that will not need repeating.

Actively maintained and updated whenever a project surfaces something worth carrying forward. The convention I follow is that a lesson from one project stays uncaptured until it lands here, since no future project will look anywhere else for it.

The technical detail

The Application Template is the working skeleton and pattern library every new internal application starts from. Before it existed, each application had independently worked out how to derive its base path under a virtual directory, how to configure the web server for a long-running workload, and how to structure role-based authorization, and the same mistakes were being repeated because each lesson lived in one project’s history where the next project would never look.

What separates this from a generic starter

Almost nothing in this template is theoretical. Each pattern and documented pitfall traces to a specific incident in a named project, with the symptom recorded alongside the fix. That property is what makes it worth maintaining, and it is also what makes the pitfalls table the most valuable part of it.

The structure

Solution
├── BlazorApp/              Web layer
│   ├── Features/           Vertical slices: page, code-behind, API, client, DTOs
│   ├── Shared/             Layout, nav, authorization wrapper, filter bar
│   └── Program.cs          Dependency registration, middleware
├── ServerLibrary/          Business logic, user security service
├── DALLibrary/             Data access implementations, EF context
├── SharedLibrary/          Models, DTOs, interfaces. No external packages.
└── UnitTests/

Dependencies run one direction only, web layer to server library to data access to shared library, with the web layer also referencing shared directly for types. No circular references anywhere.

The organizing principle is vertical slice: a feature’s page, its code-behind, its business logic entry point, its data access client, and its data transfer objects live together instead of being distributed across folders organized by technical layer. Finding everything related to one feature means opening one folder.

What is in it

The template is seven reference documents plus a working skeleton. The documents are the substance, and they are split by concern, not combined, so a specific question has one place to be answered:

DocumentCovers
Interface patternsComponent library setup, the page and code-behind pattern with strict member ordering, shared components, inline edit and confirmation patterns, build quality standards, and a production-learned gotchas section
Data patternsDependency registration, entity and DTO patterns, the interface pair for business logic and data access, context configuration, anti-patterns, and vertical slice architecture in full
Authentication patternsPackage requirements, the user allowlist table, the layout-level auth gate, directory lookup for admin screens, impersonation, and virtual directory hosting
Database patternsQuery conventions, bounded retry backoff, connection string rules, and the scheduling-metadata permission recipe
Data warehouse patternsMedallion layering, transformation tooling, scheduling agent usage, and target-mode strategy
IntegrationsThird-party service patterns, the largest of the documents
Setup and deployEnvironment configuration, publish sequence, and the pitfalls table

The gotchas section, which is the part I would keep if I could keep only one

Every entry below is a production incident that cost real debugging time, and every one shares a property: the code looks correct and the failure is silent.

Script tags in component markup never execute. When the framework updates part of a page over its persistent connection, it diffs the DOM. Script tags introduced by that diff are ignored by the browser; only scripts present in the initial HTML response ever run. Anything that must run on page load needs registering in the application root, after the framework’s own scripts. There is no error. The script does not exist as far as the browser is concerned.

A table’s sort resets on every render if its data source is a computed property. Binding a table component to a property that returns a freshly computed sequence gives the component a new collection reference on each render, so it discards its internal sort state. Clicking a sort header appears to do nothing at all. The fix is to write the filtered results into a plain field, updated only when the source data or the filter changes, and bind to that field.

A loading spinner cannot appear before the operation it describes. Setting a loading flag does not update the interface. The framework defers re-rendering until the first await point, so if the first await is the slow database call itself, the spinner never renders before the wait begins. The fix needs two steps, not one: force a render, then yield so the browser paints, and only then start the blocking call.

An interpolated string inside a quoted attribute breaks the parser. The inner quote terminates the attribute. This produces a compile error instead of a silent failure, which makes it the friendliest entry here, and the fix is to extract the logic into a named method; do not try to escape your way out.

A background service registered only as a hosted service cannot be injected. The standard registration creates an internal instance that dependency injection cannot resolve by its concrete type. A component that injects it does not error; it hangs, showing an infinite spinner, because the circuit never resolves the dependency. The fix registers the same instance twice, once as a singleton and once as a hosted service resolving to it.

A page with a route parameter goes stale when navigated to from itself. The framework reuses the component instance, and the initialization method fires only on first load, so changing the parameter leaves the previous data on screen. The documented fix tracks both an initialized flag and a copy of the last parameter value, detects the change in the parameter-set lifecycle method, and reloads, with care taken not to double-load on the first render.

Interactivity is configured once, globally, never per page. A page missing its interactivity directive renders as static output, meaning every button on it silently does nothing. The first fix attempted was per-page, which is the wrong fix; the correct one sets it on the root component so new pages inherit working controls automatically. Adding it per page is redundant, and a nested boundary can throw at runtime.

Non-serializable services do not cross an interactivity boundary. Passing a service down the component tree works in the older hosting model and returns null in the newer one, because the value cannot cross into an interactive island. The parent layout keeps working, which is what makes this confusing to diagnose. Direct injection is the documented rule.

Anti-patterns, stated as prohibitions

The template also records things not to do, which is a category most starters omit, and which prevents a specific class of quiet bug.

No default row limits on data access methods. A fetch method with a limit parameter defaulting to five silently returns five rows to every caller that forgets to override it. This was not hypothetical: a certifications tab in one application was hiding every record past the fifth most recent, and nobody noticed because five records looked like a plausible amount of data. If a caller needs a limit, it can apply one; a hidden default is a page that lies.

No mixed concerns in a partial class split. Import statements do not transfer between partial files. Each file needs its own imports for every type it references, including project namespaces and any file-scoped type aliases, which must be repeated, never inherited.

Zero build warnings, as a policy and not an aspiration. The document covers how to legitimately suppress the two categories of noise that otherwise make the policy impossible: platform warnings on deliberately Windows-only services, and nullable warnings generated by the templating engine, not by hand-written code. Warnings you have decided to tolerate are warnings you stop reading, and then a real one hides among them.

Patterns earned from specific incidents

Bounded backoff on per-item retries. One project’s background service retried correctly and had no memory of prior failures, so a permanently broken item was retried every few seconds and logged at error level every time. It ran more than five weeks, producing roughly 1.3 million log rows and about 41 gigabytes of log data. Nothing crashed; the existing guidance already prevented a bad tick from taking down the host and did nothing about this. The pattern that went into the template tracks consecutive failures per item and steps the interval down to a low but deliberately non-zero rate, low enough to stop flooding logs, non-zero so the problem stays visible instead of being silently suppressed.

Explicit certificate trust on every connection string, documented directly alongside the backoff pattern because an unset value here can fail silently forever if the connection sits inside a retry loop, which is the combination that produced the incident above.

The scheduling-metadata permission recipe. Building a job monitoring page surfaced a two-part gap. The built-in roles grant access to a job view and execute rights on the helper procedures, but not direct select rights on the history, activity, or session tables. Separately, granting permissions to the documented login was insufficient, because the connection used integrated security and the real connecting principal was therefore a machine account, not the login anyone would have expected. Both halves are documented together, including the step of confirming which principal is connecting before granting anything, and the rule to query the job view, never the underlying table.

Warm up after every recycle, not only at first start. The setting that warms an application on startup applies only to the pool’s initial start. A recycle from a health check, configuration change, or scheduled restart leaves the new worker cold, and an application with no regular interactive traffic has nothing arriving to warm it. Named as a symptom-and-fix entry after a production incident.

Never verify a string inside a compiled binary with pattern matching. Reading a binary and searching for a known string is a valid way to confirm a deployment. Doing that search with a regular expression is not, because binary encoding produces false negatives even when the string is present. Three successful deployments were each reported as failed for this reason, costing a full debugging round on a problem that did not exist.

Stage an offline marker before publishing. The web server holds the application binary open while running, so publishing over a live application fails after repeated retries and can fail in a way that leaves the previous version on disk while appearing to succeed.

Two structures, not one

A late addition corrected an over-generalization in the template itself. The five-project structure is right for applications with real domain logic, and it is unnecessary ceremony for a job runner or a read-only portal that has no domain of its own.

Instead of forcing every project into the same shape, the template documents a single-project variant for those cases. A template that only offers one answer gets used where it does not fit, and the resulting project is worse than one built without a template at all, because the mismatch is now load-bearing.

Reusable patterns that came from one project and generalized

Two entries began as project-specific work and were rewritten as general patterns:

The dry-run pattern for scheduled jobs. Originally built for one audit job, the document now states it as a general rule with the full wiring on both the job side and the interface side, including the preview email structure and an explicit note on why it is a general pattern rather than a special case for the job that prompted it.

Directory typeahead for email recipient selection. A free-text recipient field was replaced with a lookup against the corporate directory, and the pattern is documented with the fields, methods, and markup needed to reproduce it, not as a description of what one application does.

The verification methodology, added whole

The largest single addition came late in the project as its own document, written after requirements reviews on two running applications proved the practices worth carrying forward. Four of them:

Alongside these came guidance for moving an existing scheduled job into the shared Job Scheduler, which is a different task from writing a new job there, and the document warns in as many words against copy the class, register it, delete the original.

Two deployment pitfalls joined the gotchas list in the same period. A database-scoped configuration in one SQL tool cannot see a second database on the same physical server even when permissions are correct, and it presents as a missing database, not a denied one. And one shell mangles UNC file locations passed as arguments to native command-line tools, so the tool receives a location that does not exist. Both entries carry their specific workaround.

A documentation convention that is not fussiness

One convention covers padding markdown table cells to their column’s maximum width. This looks like pure pedantry and is not.

These documents are read in two very different contexts: rendered through a viewer, where alignment is irrelevant, and as raw text in a diff or a plain editor, which is how they are read during review and how an AI assistant loads them as context. Unpadded tables are close to unreadable in the second case, and the second case is the one these files exist for.

Timeline and effort

Work on the template spans roughly 11 weeks, with close to 20 working sessions recorded, the worklog having been reconstructed in a single pass from git history. It is not a project with a completion date; entries accumulate whenever another project surfaces something worth carrying forward.

Technical reference

Documented pattern areas. User interface patterns, database patterns, authentication, data access, data warehouse conventions, third-party integrations, and setup and deployment, each maintained as its own reference document instead of one combined file, so each can be referenced independently.

Data warehouse conventions were added as a separate document, not folded into the general database patterns, since medallion layering, transformation tooling, scheduling agent usage, pointer-based pivoting, and target-mode strategy form a large enough topic to stand alone.

Parameterized routes need a reload pattern. Any page whose route includes a parameter and can be navigated to from itself must handle the parameter changing without re-initialization, since the framework reuses the component instance. Tracking the previously loaded value and reloading on change is documented as a required pattern, not a suggestion, because the failure mode is a stale page rather than an error.

Where it stands now

Actively maintained and used as the starting point for new internal applications. The working convention I hold to is that a lesson learned on one project is not captured until it lands here, because this is the only place a future project will encounter it. A lesson recorded in the project where it happened is a lesson recorded for someone who already knows it.

Keep reading

Next project: On-Premises AI Evaluation →

Have a comment on this page? Send it to me →

Home · All projects