๐ŸฆŠ FoxyInvoice

Chapter 03 โ€” Architecture: one codebase, two products, boring on purpose

Every architecture decision in this system answers one question: can one tired person run this at 2 a.m.? Not "could it scale to a million users" (it can, further than you'd think), not "is it fashionable" (it is not). One operator, no on-call rotation, a billing product where downtime means someone can't get paid. That constraint produced a system that is deliberately boring โ€” and the chapter explains why each boring choice earns its keep.

The picture

flowchart LR
    subgraph Client["Browser / PWA"]
        SPA["Angular 22 SPA
(installable, offline shell)"] end subgraph Edge["Edge โ€” one Caddy, TLS auto"] CADDY["Caddy reverse proxy
+ static SPA + access logs
+ X-Robots-Tag hygiene"] end subgraph Apps["Docker Compose on one VPS"] FOXAPI["fox-api (.NET 10)
freemium deployment"] TAXAPI["tax-api (.NET 10)
enterprise deployment"] WORKER["tax/fox worker
outbox ยท reminders ยท recurring"] FOXDB[("fox Postgres")] TAXDB[("tax Postgres")] end subgraph Cloud["External services"] SES["SES SMTP
(email send/receive)"] S3["S3-compatible storage
(PDFs, feedback screenshots)"] STRIPE["Stripe
(payment links + subscriptions)"] GH["GitHub Actions
(CI/CD self-hosted runners)"] end SPA --> CADDY CADDY -->|foxyinvoice.com| FOXAPI CADDY -->|invoices.seolith.com| TAXAPI FOXAPI --> FOXDB TAXAPI --> TAXDB WORKER --> FOXDB & TAXDB WORKER --> SES FOXAPI & TAXAPI --> S3 FOXAPI --> STRIPE GH -->|SSH deploy| Apps

Walk it left to right. A browser loads the Angular SPA from Caddy's static files. Every /api/* call is reverse-proxied to a .NET API container. The APIs talk to their own Postgres instances; the worker containers handle everything slow; email leaves through SES, files through S3, money through Stripe. GitHub Actions deploys the whole thing over SSH. That's the entire system โ€” five moving parts and a proxy.

Now the why behind each part.

The three layers, and why they're separate

The SPA (Angular 22 + Material, installable PWA). One codebase compiled to static files, served by Caddy straight off disk โ€” no node server to babysit. It's a PWA: service worker, offline app shell, per-deployment branded manifest (foxyinvoice.com and invoices.seolith.com serve the same bundle with different branding resolved at runtime). Why Angular over React/Svelte/Vue? Honestly: strong-opinioned batteries included (forms, router, Material, i18n primitives) and it's what the one operator knew. Framework choice is a burn-rate decision, not an identity.

The API (.NET 10). One process per deployment, stateless, fat with the domain logic. Why .NET for a solo project? Boring, enormously documented, superb tooling, and the type system catches the class of bug this domain cannot afford (adding dollars to euros โ€” you'll see the Money type refuse it below).

The worker. Anything slower than ~100ms or that must retry leaves the request path: email dispatch, payment-reminder scans, recurring invoice generation, the HN/Reddit lead radar. Same codebase as the API (a different entrypoint), running as its own container so a hung email send can never block an invoice save.

The pattern that keeps the API tidy: one operation, one handler

Controllers here do almost nothing โ€” parse the request, hand a command or query object to an in-process dispatcher (Mediator, a single-file MIT library), return the result. Every business operation is a record plus one handler class:

public sealed record ExportInvoicesQuery(string Format, int? Year)
    : IRequest<ExportResultDto>;

internal sealed class ExportInvoicesHandler
    : IRequestHandler<ExportInvoicesQuery, ExportResultDto>
{
    // dependencies injected: DbContext, current-user service
    // Handle(): load rows โ†’ format CSV/IIF/Tally โ†’ return
}

Why bother, solo? Three dividends: every operation is unit-testable without a web server; the request envelope is a type the compiler checks; and cross-cutting behavior (validation, logging, transactions) lives in the pipeline, not pasted into sixty controllers. A war-story footnote we'll fully dissect in Chapter 13: this pipeline used to be MediatR 12, our platform packages needed 14, and the version diamond crash-looped production until a dependency bumped it into a wall. The migration to the tiny MIT alternative took an afternoon. Architecture includes your dependency graph.

The outbox: email that cannot be lost by a success page

The classic bug this design deletes: "user clicked submit, page said OK, email never arrived." If sending email happens inside the request and the SMTP server hiccups, you must either fail the request (user thinks their invoice wasn't saved) or swallow the error (email vanishes). We do neither. The request writes the business row and an outbox_messages row in one database transaction. A worker picks up unsent messages on a loop, sends with retries, marks Sent or records the error. The user's action is durable the millisecond the transaction commits; delivery is a background guarantee, not a promise made by a page. Every email in the product โ€” invoice sends, reminders, feedback notifications (with Reply-To set to the reporter) โ€” flows through it.

The domain model: five tables that matter

Strip the features and the spine is:

tenants โ”€โ”€< users                    (a workspace and its people)
tenants โ”€โ”€< clients                  (the businesses you invoice)
clients  โ”€โ”€< invoices โ”€โ”€< line_items (header + rows)

An invoice is a header (number, client, dates, status, totals) plus line items (description, qty, unit price, discount %, tax jurisdiction, and engine-computed line total + tax). The status machine is deliberately small: Draft โ†’ Sent โ†’ Paid / Partial / Overdue / Void, plus Quote as a type (quotes convert to invoices by cloning โ€” the client-facing accept button you'll meet in Chapter 12 does exactly that server-side). Totals are never trusted from the client; the API recomputes every total from the lines on each mutation. The SPA shows a preview labeled "server-confirmed on save."

Money is a value object, and it's rude on purpose

public readonly record struct Money(decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        RequireSameCurrency(other);   // throws "USD vs EUR"
        return this with { Amount = Amount + other.Amount };
    }
}

Every amount in the system โ€” invoice totals, line prices, payments โ€” is a (decimal, currency) pair. decimal, because binary floats cannot represent 0.10 and accountants add thousands of numbers (Chapter 01's lesson, now enforced by the type system). The pair, because "100" means nothing until you say dollars or rupees โ€” and Add/Subtract throw across currencies instead of guessing an exchange rate. In the database each Money becomes two columns (numeric(18,2) + char(3)), mapped as an EF Core complex type. When a report once crashed with Currency mismatch: "" vs USD, the type had done its job: turned silent corruption into a loud stack trace.

Two products, one codebase (the trick that pays the rent)

foxyinvoice.com (freemium, fox branding, Stripe on) and invoices.seolith.com (enterprise, SEOlith branding, our own books) are the same Docker images, deployed twice with different environment config. Branding resolves per deployment from the API; the SPA bundle is literally one set of files both domains serve. The dividends: every fix lands for both audiences in one deploy, the enterprise twin dogfoods the platform with real money daily, and the cost of the second product is one more docker compose project. The one discipline it demands: multi-tenant from day one โ€” which is Chapter 04's entire subject.

What crawlers see: prerendering instead of SSR

A client-side-rendered SPA is a blank page to GPTBot, ClaudeBot, and PerplexityBot โ€” they don't execute JavaScript, and Google mostly tolerates it. Full server-side rendering would mean an SSR server to run (violating 2 a.m. boringness) for pages that are 95% app-shell. The middle path we shipped: a build-time prerender step. When the SPA compiles, a script generates static, crawler-ready copies of the public marketing routes โ€” per-template titles, descriptions, canonical tags, JSON-LD, even full article body copy โ€” and Caddy's try_files serves those files to anything that fetches the URL. Real browsers get the app; crawlers get real content; no new server exists. (Full anatomy in Chapter 11.)


Recap. Static SPA + stateless API + worker for the slow stuff, commands as types, email through a transactional outbox, money as a rude value object, one codebase deployed twice with branding resolved per deployment, and prerendered HTML where crawlers need it. Nothing here would surprise a 2015 enterprise architect โ€” that's the point.

Next: Chapter 04 โ€” Multi-tenancy & data security: the crown jewels โ€” how a thousand businesses share one database and never see each other's data, with tests that prove it.