Let's talk

← all posts

One App, Two Audiences: Building a Customer Intake Backend with a Personal ERP Inside It

5 min read
Blazor.NETArchitectureCase Study
One App, Two Audiences: Building a Customer Intake Backend with a Personal ERP Inside ItOne App, Two Audiences: Building a Customer Intake Backend with a Personal ERP Inside It

Above: the process boundary between the public intake app and the personal ERP inside it, and the append-only event log that replaced a plain status field.

My newest project isn't for a client — it's for me. customer-intake-backend is a small .NET app that does two jobs: it's the public lead-intake system for two different businesses (my dev-consulting work and music-booking inquiries from the bands I play in), and it's also home to a private personal ERP I use to track my own freelance pipeline. Those two things share a process and a repo. They do not share a data boundary — and how I enforced that, not just declared it, is most of this post.

One Leads Table, Two Businesses

The public-facing half is straightforward: a shared Leads table tagged by Source, so a booking inquiry from the band's site and a dev-services inquiry from this one land in the same schema instead of two one-off tables that happen to look identical. Both dougrosenbergmusic and Guacamayo's site are static (Astro on Cloudflare Workers) and can't run C#, so this exists as its own deployable service they call into over HTTP — POST /api/leads, CORS-restricted to the sites that are allowed to post to it, rate-limited, with the unauthenticated GET locked to development only.

Hard Problem #1: Keeping a Private ERP Inside a Public-Facing App

The personal ERP needed somewhere to live, and the obvious-sounding answer — a fully separate app — turned out to be the wrong default once I actually weighed it. A second repo means a second deployment, second auth, second CI/CD, all for a single-user internal tool. What the sensitivity of the data (real client pricing, an active pipeline) actually requires isn't process isolation, it's data isolation. So the personal ERP lives in the same repo, same deployment, same login — but on its own DbContext and its own database, with nothing shared at the EF Core layer.

Declaring a boundary and enforcing one are different things, so I didn't stop at the design doc. There's an architecture test (NetArchTest.Rules) that fails the build if the public-facing Components.Pages namespace ever references the personal ERP's types, or if the personal ERP ever reaches for the public CustomerIntakeDbContext. And I didn't just trust that the test worked — I deliberately introduced a violation, watched the build fail, then reverted it. If I ever copy-paste the wrong query, or an AI-assisted edit reaches for the wrong context, the build catches it before it ships. That's the realistic failure mode I was actually guarding against — not an attacker, just an ordinary solo-dev mistake at 11pm.

The honest gap: none of this protects against the process itself being compromised — a dependency exploit gets an attacker both connection strings regardless of any C#-level type rule. That's accepted, not overlooked, and the upgrade path if the risk profile ever changes is narrowing network reachability, not re-architecting into a separate app.

Hard Problem #2: A Status Field Can't Tell You When

The original Leads table tracks pipeline state with a single mutable Status enum — New, Contacted, Converted, Closed. Every change overwrites the last one. There's no record of when a lead actually moved from New to Contacted, or what happened in between. Fine for a low-volume, single-admin intake flow — but designing the personal ERP's client pipeline from scratch, I didn't have to inherit that limitation.

So ClientLead.CurrentStatus is a cached projection, not the source of truth. The real source of truth is LeadEvent: one row per thing that actually happened — outreach sent, a gig played, a proposal sent — each with its own date. Current status gets derived from the latest event instead of written to directly. The payoff is that questions like "how long between outreach and conversion" are answerable for free, straight from the schema, with no extra tracking bolted on. The cost is real too: every status change now means writing an event row instead of setting a field, and if something ever writes to CurrentStatus directly instead of going through the event-append path, it can drift from the log. That's a discipline I'm holding by hand for now, not something the database enforces yet.

Hard Problem #3: Azure Assumed My Zip File Was Source Code

Deployment was the least interesting problem to design and the most annoying to actually execute. The plan was ordinary — Azure App Service plus Azure SQL, reusing an existing free-tier plan for near-zero incremental cost. Getting there wasn't ordinary. The CLI path (az webapp deploy, both locally and from Cloud Shell) kept invoking a server-side build step that failed with "Couldn't detect a version for the platform 'dotnet'" — because the zip I was uploading was already-published output (compiled DLLs), not source, and the build tool had nothing buildable left to detect. It wasn't looking for what I'd actually given it.

What worked was going around that path entirely: Visual Studio's built-in Publish, targeting the existing App Service over Web Deploy, which hits the classic deployment endpoint directly and skips the server-side build step altogether. Not the CI/CD story I eventually want (that's still explicitly deferred), but it got real code onto a real server without fighting a build system that was solving a problem I didn't have.

The Stack

Backend: ASP.NET Core 10 minimal API + Blazor Server, one process
Data: EF Core 10 + Azure SQL (two separate databases: intake + personal ERP)
UI: MudBlazor, restyled to this site's navy/teal/Montserrat tokens
Auth: ASP.NET Core Identity, Admin/Client roles
Testing: unit tests on the service layer (SQLite in-memory), architecture tests enforcing the ERP boundary at build time
Hosting: Azure App Service, Web Deploy

What's Actually Live

As of this post: real auth is in place (Admin and Client roles, though the client-facing /portal is currently a stub, not wired to real data). Leads and Companies have full CRUD with notes and an event log. The personal ERP's client-pipeline tracking is live with its own boundary-tested database. The whole thing is deployed and reachable — the custom subdomain and a proper CI/CD pipeline are the two pieces still ahead of it.

What's Next (and Not Built Yet)

I've been sketching a custom MCP server on top of the personal ERP's data — list_leads, get_lead, add_event-shaped tools sitting on a domain model that's already close to tool-shaped. Microsoft co-maintains an official MCP C# SDK, so it stays in the same skill-building lane as the rest of this project rather than being a detour into a different stack. But I want to be straight about where it actually stands: it's a plan, not a build. It's sequenced deliberately after auth and deployment — both now done — specifically so there's something real and access-controlled to attach it to before any MCP client can reach personal data over the network. When it's built, it gets its own post.

The Takeaway

None of this was hard in the "clever algorithm" sense. It was hard in the more common way real projects are hard: deciding where a boundary actually belongs, then proving — not just declaring — that it holds; picking a data shape that survives a question you haven't asked yet; and occasionally getting stopped cold by a deploy tool that assumed something about your inputs that wasn't true. If you're building something similar — a side project sharing infrastructure with something more sensitive — the architecture test is the part I'd steal first. A comment saying "don't reach across this boundary" survives until someone forgets. A build that fails does not.

Related Posts