This series is written in the open, from a real production system. This chapter walks the architecture diagram layer by layer and explains why every choice is deliberately boring. [All chapters and diagrams live in the public repo.]
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
The architecture, rendered live: one Caddy edge → two .NET API containers + workers, each with its own Postgres, deployed by GitHub Actions.
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, plus
/ VoidQuote 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.
Reading this and want to see the real thing under the hood? Create a free workspace at
foxyinvoice.com, then redeem founding code
U8B4Z8S87X on the Upgrade page — 6 months of Pro, free, no card. If anything
breaks, there's a feedback button in the app. I read every one.
Next: Chapter 4 — Multi-tenancy & data security: the vault.
Top comments (0)