Hank Karpinenfull-stack-engineer

// 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.
// DESIGN_PHILOSOPHY · IDESIGN × DDD

One event, end to end

// DIAGRAM · the request thread & the background dispatch

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.

Lifecycle of a ChargeCreated domain eventA user tap travels through Nginx to the Finance service, which writes a Charge and an outbox row in one Postgres transaction. The OutboxPublisher dispatches the event to RabbitMQ, which fans out to Finance's own double-entry ledger consumer, Household, and Notifications.No. 01No. 02No. 03No. 04No. 05 / 06 / 07BROWSERyouHTTPNGINX:443ROUTEFINANCEPOST /expensesOUTBOX TXOUTBOXpublisher (poll)PUBLISHRABBITMQtopic exchangeLEDGERfinance posts its own eventHOUSEHOLDcalendar + activity feedNOTIFICATIONSfanout consumerPOSTGREScharge + outboxINSERT (one tx)poll unsentfinance.charge.createdFinance.Domain.Events.ChargeCreated<-- request thread --><-- background dispatch --><-- independent consumers -->
Lifecycle of one ChargeCreated event, end to end. Step annotations below.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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

// DIAGRAM · who owns what, what they publish

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.

Bounded contexts and the RabbitMQ spineFive services arranged around a central RabbitMQ bus. Identity and Household across the top; Finance and Forum across the bottom; Notifications hanging off the bus as a pure consumer. Each service shows its aggregates and the events it publishes.Five bounded contexts · one message spineIdentityOwnsUserUserRoleContactdrawn around who you areHouseholdOwnsHouseholdMembershipChoreCalendarEventdrawn around people who share a homeR A B B I T M QpublishesUserRegisteredUserProfileUpdatedUserBannedpublishesMemberJoinedMemberLeftOwnershipTransferredFinanceOwnsCharge · AllocationLedger · JournalEntryIncomeSourceFinancialConnectiondrawn around moneyForumOwnsThread · Comment · VoteCommunity · MembershipModerationLog · Reportdrawn around public speechpublishesChargeCreatedAllocationCreatedSettlementRecordedpublishesThreadCreatedCommentCreatedModeratorAppointedNOTIFICATIONSConsumes onlyNo aggregates. A pure projector —fans out push notifications fromevery other service's events.consumesSolid arrows publish · dashed arrows consume · two more services (Geography, Math) live off-bus

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

// READ_THE_REST

The code behind these diagrams.

Each service is its own .NET project · The full bio lives on the about page · Or just get in touch.

// SOURCE · GitHub · Seven .NET 8 services · One Next.js shell · Back to home