// ARCHITECTURE
Seven services, one spine.
The site is seven independent .NET microservices behind one Nginx gateway. They share no database and no contracts library — they cooperate through domain events on a RabbitMQ bus. Here's why, and what one event looks like end to end.
Why seven, not one?
A portfolio app could be a single .NET project. This one isn't, for a specific reason: I wanted the seams to be visible. Each service is drawn around a volatility — a part of the system likely to change for its own reasons — rather than around a noun like "User" or a verb like "Manage". That's Juval Löwy's IDesign rule of thumb, and it's the only decomposition I've found that survives feature growth.
Finance owns the rules of money. Household owns the people who share it. Forum owns public speech. Identity owns who you are. None of them reach into another's database. The only way they cooperate is by publishing domain events to a RabbitMQ spine — and the consumers bind to the publisher's real event type, not a sanitized integration contract. Less indirection, less drift.
Each service is drawn around what's likely to change together — not around a noun. They never touch each other's tables. The bus is the only crossing.
One event, end to end
A single tap on the "Add expense" button travels through seven stages. The user's HTTP request returns before the bus even hears about it — the publish is decoupled by the transactional outbox.
You tap “Add expense”
The browser fires an HTTP POST to /api/expenses. Nginx forwards it to the Finance service — no other service is involved in the request thread.
Finance writes in one transaction
The handler inserts the Charge aggregate AND an outbox row (the serialized ChargeCreated event) in the same Postgres transaction. If either fails, both roll back — the bus never sees a phantom event.
OutboxPublisher dispatches
A background poller in Finance picks up unpublished outbox rows and hands them to RabbitMQ. The publish is decoupled from the user request — the user already got their 201.
RabbitMQ fans out
A topic exchange routes the message to every queue bound to finance.charge.created. Each consuming service has its own queue, prefixed with the service name so they don’t compete.
Finance posts its own ledger
Finance’s own LedgerPostingConsumer takes ChargeCreated back off the bus and posts the double-entry accrual (Dr Expense / Cr Vendor Payable) into the group’s ledger. The originating service reacts to its own event — the books are never written inside the request thread, and a failed posting redelivers instead of being lost.
Household mirrors it to the home
Household’s consumer checks HouseholdId; if the charge is household-scoped, it mirrors the bill into the shared calendar and the activity feed, marking the event processed (idempotency table). If not, it drops the message.
Notifications fans out to members
Notifications looks up every member of the household, inserts a Notification row per member, and emits a push. No shared library — every consumer declares matching types in Finance.Domain.Events.
Bounded contexts, one bus
Five services participate in the messaging story. Geography (weather) and Math (unit conversion) are pure utility services and live off-bus — they don't publish or consume events.
Volatility, not function
Each box is drawn around a thing that's likely to change together — not a noun. Finance owns the rules of money; Household owns the people who share it. They never touch each other's tables.
One database per context
Every service has its own Postgres schema and its own outbox. Cross-service reads are forbidden — if Forum needs a display name, it keeps a projection populated by Identity events.
No integration-event layer
Consumers bind to the publisher's own domain event types (e.g. Finance.Domain.Events.ChargeCreated) — no shared contracts NuGet, no mapping shim. The bus carries the real thing.
The stack
- Services
- .NET 8ASP.NET CoreMediatREF CorePostgres 16
- Messaging
- RabbitMQMassTransitTransactional outboxIdempotency table
- Frontend
- Next.js 14TypeScriptReact QueryTailwindRadix
- Edge & infra
- Nginx gatewayDocker ComposeAWSGitHub Actions