Tech Series: Web app and API architecture

Part 3 of the inveazy tech series. How inveazy runs one ASP.NET Core host with two contracts: Razor for people, JSON under /api for hub scripts — and why a failed save must stay JSON so the composer is still the composer.

Part 3 of the inveazy tech series. Building a multi-tenant B2B hub usually forces a fork: split the UI and the API into two codebases, or fight the framework when a compose screen needs JSON while marketing pages need HTML. inveazy does neither. One ASP.NET Core host, two contracts: Razor Pages for people, strict JSON under /api for hub scripts. When a save fails, that JSON contract has to stay JSON — or the composer is gone. Part 1 and Part 2 were the catalog. This post is the process that sits on it.

Series:Dacpac publish and data lockdown · Next → Front-end patterns that stay maintainable


The contract in one line: People get HTML pages. Hub scripts get JSON. Failed saves stay JSON — or the composer disappears.

Dual-contract architecture: Razor Pages vs JSON API

A hub compose screen is not a form that posts the whole page. It is a document the person is still looking at, plus scripts that save, list, and validate against JSON. If a slug is already taken, they need a toast on the same screen. If the database is briefly unreachable, they need a sentence they can retry from. What they must not get is a full HTML error document in place of the composer — or a login page stuffed into a fetch that expected JSON. Either way the work on screen is gone, and the operator is staring at a request id instead of the draft they were finishing.

That is not a front-end preference. It is a host contract. People get HTML. Hub scripts get JSON. Authentication challenges, module gates, billing locks, and unhandled exceptions have to honor the same split, or the first layer that forgets it undoes the rest.

Routing under /api

One host matters because a paid customer site is one container and one Azure SQL database. Cookies, module flags, and tenant context stay in one pipeline. You do not pay for a second API farm, and you do not invent CORS for a compose save on the same origin. The cost is discipline: every response has to know whether it is a page or a script.

The inveazy web application is a single ASP.NET Core application on .NET 10. It is not a Razor site plus a separate API farm, and it is not a single-page app that treats the server as a stateless blob of JSON. Razor Pages render the public site and the signed-in hubs. Attribute-routed API controllers return JSON for the JavaScript those hubs call. There is no conventional MVC route table inventing a third way to do the same job.

The split is the path, not content negotiation. If the request starts with /api, cookie challenges return 401 or 403 instead of a login redirect, and unhandled exceptions write JSON instead of re-executing /Error. Mix the two languages and you get the classic bug of a JSON client following a 302 to a login page, or an exception middleware re-executing an HTML error page on an API path. The public site and the hubs share the host on purpose. Marketing, the public blog, and the storefront are documents search engines and guests can read. CRM, inventory, blog compose, files, POS, and admin are signed-in documents. The scripts behind those hubs call /api on the same site, with the same cookies.

Three surfaces

The inveazy host exposes three surfaces. The rest of this post is how one process keeps them honest.

public HTML     marketing, public blog, storefront
signed-in HTML  hubs (CRM, inventory, compose, admin)
/api/…          JSON for hub scripts and webhooks

Public HTML is the company’s face: home, pricing, help, the public blog, the store when it is on. Signed-in HTML is the operating system: dashboards and module hubs. JSON under /api is the machine contract for those hubs — CRM, inventory, POS, blog posts, files, notifications, billing, and the public JSON a storefront or public blog needs. Webhooks sit on that JSON surface too, but they authenticate with signatures, not cookies.

The blog is a useful picture of the split. Readers on the public site get an HTML post. Editors in the hub get a Razor composer. The composer does not post the article as a page handler. It sends JSON to the blog API. A slug conflict comes back as a 409 the script can show in place. A public reader never sees that API. An editor never has their draft swapped for a marketing error layout because a unique index said no.

Page to service to SQL

A typical hub save does not put business rules in the page and SQL in the controller. The Razor page is the shell: layout, permissions to open the screen, the HTML the person reads. JavaScript on that page calls an API controller. The controller authorizes, binds the payload, and hands off to a scoped service. The service sanitizes, enforces uniqueness, talks to SQL, and returns a result the controller can turn into JSON. The page never becomes the transaction.

The save pipeline

One write, five steps. The page stays on screen the whole time.

Step Component Input Output
1 Razor page User action HTML shell + JS
2 JavaScript PUT/POST to /api/… JSON request
3 API controller Request payload Service call
4 Service Sanitized data SQL result or error
5 Response Service result 200 + JSON, or 409
GET  hub compose page          Razor shell stays on screen
PUT  /api/…                    JSON controller
     service → published SQL
409  { title, detail }         toast, not /error

That layering exists because the same write has more than one caller. A blog post can be saved from compose, listed on a grid, and rendered on the public site. Inventory quantities show up on a hub document and on a scan gun. If each screen owned its own INSERT, you would get four slightly different ideas of a slug, a tenant filter, and a soft delete. The service is the product rule. The API is the door. The page is the room.

SQL is the catalog from Part 1 and Part 2. Entity Framework Core opens connections as a reader of that published schema — never as an author of new tables or migrations. It retries transient Azure SQL faults: dropped connections, deadlocks, and brief throttling. Hot lists and writes that must match an index use parameterized SQL and DTOs — row shapes, not “load the whole document to draw a card.” There is no Database.Migrate() at startup. A missing column is a failed dacpac release, not a reason for the first HTTP request to run DDL.

Opening a connection also sets SQL Server session context to the workspace id. Session context is a key/value bag the engine keeps for that connection — the equivalent of telling SQL “this request belongs to workspace 42” for the life of the connection:

EXEC sp_set_session_context N'TenantId', @tid;

Row-level security is SQL Server’s built-in predicate on the table. It reads that session value so every query is filtered by workspace in the engine. A WHERE clause in C# is a safety net, not the primary gate. That mechanism is Part 5. This post only needs the boundary: the web host consumes a published database. It does not invent one.

Cookies, roles, and module gates

Authentication: two schemes, one host

Staff sign in with a cookie and work in a workspace. Store customers sign in with a different cookie on the storefront. Those are two schemes on purpose. A clerk who also buys from the store must not become one principal with two jobs mashed together. Store middleware can authenticate the buyer cookie without replacing the staff user the hubs already know.

The important API rule is the challenge. If a page is anonymous, the pipeline may redirect to login. If a /api call is anonymous, the pipeline returns 401 or 403 as status codes. A fetch that follows a redirect and parses a login form as JSON is how a session timeout looks like a “malformed save.” Same-origin cookies with a conservative SameSite setting are the CSRF story for hub JSON: the browser will not send them on a cross-site POST the way a naive API key in local storage would. Webhooks do not use that cookie path; they verify a signature.

Enabled is not the same as allowed

Two permission layers: site-wide (the module is deployed) and per-user (the role may use it).

The organization site profile says which modules are deployed on this site. The person’s roles say which of those modules they may open. If inventory is off for the whole site, the inventory hub should not advertise itself — a page request looks like a missing route, not a tease. If inventory is on and this user is not in a role that may use it, that is 403 on the page and 403 on the APIs. A script must not succeed on a module the HTML is hiding.

Roles are coarse on purpose: organization admin, workspace admin, everyday user, plus specialists such as a blog editor. Fine-grained custom role JSON exists for sites that need it. The blog is the exception that proves the rule. When the module is on, any signed-in person can read in the hub. Write APIs and the composer are for editors and admins. POS can call a slice of catalog APIs a clerk needs at the counter without granting the full products hub. The permission model is not the URL. The URL is how you get there after the gates agree.

Why a failed save must stay JSON

When someone hits Save in a hub composer, the page is still the document they are editing. Validation, a slug already in use, a unique-index conflict — those are known failures. The controller returns structured JSON (ProblemDetails or a title and detail) so the script can toast or highlight a field. The workspace state stays put. What must not happen is a fallback to the HTML error page. That wipes the composer. Unhandled exceptions used to leak that way. The handler below is how inveazy keeps the two languages apart. The split is the /api path prefix, not Accept headers. Cookie challenges use the same prefix: 401 or 403 on /api, a login redirect on pages.

Unhandled exceptions stay in the caller’s language

In production, the host uses an exception handler. If the path is not /api, the request re-executes the HTML error page and shows a request id a human can read to support. If the path is /api, the handler writes a small JSON envelope at 500 and stops. It does not re-execute the error page. That is the whole trick. Everything else — billing locks, cancelled requests, store tenant not ready — is the same idea applied to a specific status code.

if path does not start with /api:
    return false   // page pipeline re-executes /Error
write 500 application/json
{ "title": "Server error", "detail": "The request failed. Try again." }
return true

A billing hold on a platform-billed site is a 402 JSON on APIs and a billing page for HTML — not a 500 that looks like the save pipeline exploded. A tab closed in the middle of a query is a cancellation, not an unhandled fault. If the store cannot resolve its tenant, store JSON says service unavailable as JSON; store HTML says it as a page. Hub scripts still strip a stray HTML document if a proxy ever returns one, so a leaked error page becomes a toast instead of getting painted into the composer. Defense in depth, same contract.

Scale-out is part of this story even though it looks like DevOps. Auth cookies and antiforgery are encrypted with ASP.NET Core data protection. Keys live on a shared volume with a stable application name so a new container can still read the previous instance’s cookies. An empty key ring after deploy is how “everyone got signed out and every POST died” shows up as random 400s. Part 6 returns to environments. The application fact is: the host is allowed to scale only if it can still decrypt what it issued.

How this sets the process apart

A typical app process grows a page that posts itself, then an API added later for a mobile client, then a SPA that talks to a third host. Errors become whatever the last middleware author remembered. Login redirects leak into XHR. Schema migrates on first request because the ORM is already in the web project. Two instances race DDL. A save failure is an HTML document because that is the default exception page.

inveazy makes the split explicit and then repeats it. Razor is for people. /api is for scripts. Services own writes. The published catalog owns shape — Part 1 and Part 2. Cookies challenge APIs with status codes. Modules and roles gate both HTML and JSON. Exceptions keep API failures in JSON. That is slower than “add a controller and return a view.” It is the speed you want when an operator is in the middle of an invoice, a blog post, or a till receipt and the network blips.

The host has two response languages. Every layer has to speak the one the caller asked for.

Questions we get about this shape

Why not a separate API host?

A customer site is one app and one database. Cookies, module flags, and tenant context stay in one pipeline. A second host would duplicate those gates and invent a CORS and token story the hub does not need. Public JSON and hub JSON can share the process without sharing the HTML error page.

Why not let Entity Framework migrate on startup?

Two app instances must not race DDL, and a hub page must not become the upgrade tool. Schema is a dacpac applied as an administration event. EF Core is the client: connections, retries, a small entity set, parameterized SQL for hot paths. Part 2 is who is allowed to apply the package.

What happens if an API throws?

In production the exception handler returns JSON on /api and the HTML error page on everything else. Known conflicts still come from the controller as 409 or 400 with a detail the hub can show. The composer stays the composer.

Do store customers use the same login as staff?

No. Staff cookies open hubs. Customer cookies open the storefront account. The pipeline can authenticate a buyer without replacing the staff principal. APIs on each surface follow that identity, not a merged one.

What this contract keeps in place

One ASP.NET Core host serves public pages, signed-in hubs, and JSON under /api. Razor Pages are documents. API controllers are the machine door. Services own sanitizing, uniqueness, and SQL. Entity Framework does not migrate at startup and does not own DDL. Staff and store customers use separate cookies. Anonymous API calls get 401 or 403, not a login redirect. Modules that are off do not advertise themselves; roles that cannot use a module get 403 on pages and APIs. Known failures return structured JSON. Unhandled API exceptions stay JSON. Page exceptions stay a page with a request id. Billing locks and store outages keep the same split. Data-protection keys are shared so a scaled-out host can still read its own cookies. The web application still does not apply schema when a page loads.

Part 4 is the scripts and CSS that live on those Razor shells: hub JavaScript against /api, the CSS and icon build, and rich text only where compose actually needs it. This post is the host those scripts are allowed to assume.

People get a page. Scripts get JSON. A failed save is not a new document.


What’s next

Next in the series: Front-end patterns that stay maintainable — Razor first, hub JavaScript calling /api, the CSS and icon pipeline, and Quill only on compose surfaces. We will link that post here when it is live. Operator walkthroughs live in the separate how-to series.