Architecture

Compile-time rules, CI gates, automated enforcement.

Architecture decisions are encoded as compiler errors and CI checks. A rule violation fails the build.

Six sections: code analyzers, CI guards, financial bookkeeping, GDPR deletion, search sync, and local orchestration.

Catching bugs before they compile

50 custom code analyzers

These are custom rules written as Roslyn analyzers. They run inside the compiler and flag violations as build errors that stop the build. Here are three examples:

HWK001build error
No manual database saves in message consumers

The messaging outbox handles the save. Doing it manually breaks the atomic guarantee. events can be lost.

await _dbContext.SaveChangesAsync();
// Outbox commits automatically
// Mutations via EF tracked entities
// MassTransit outbox commits on success
HWK002build error
No random IDs inside retry loops

A new ID on each retry means the provider sees each attempt as a different request. causing duplicate charges.

policy.ExecuteAsync(() => {
  var key = Guid.NewGuid(); // new per retry!
});
var key = Guid.NewGuid();
policy.ExecuteAsync(() => {
  Call(key); // same across retries
});
HWK035build error
No hardcoded currency strings

A hardcoded "USD" breaks for customers in other countries. Currency must come from configuration.

var amount = new Money(100, "USD");
// What about GBP, EUR customers?
var amount = new Money(
  100, options.DefaultCurrency
); // From config

Blocking bad merges automatically

159 rules checked on every pull request

Beyond compiler checks, CI runs 159 regex patterns and 12 assembly-level rules against every PR. A single violation blocks the merge. These catch the mistakes that are correct C# but architecturally wrong:

No publishing events without saving first
If the save fails, the event is already sent. downstream services act on data that was rolled back
No manual transactions in message consumers
The messaging framework wraps each consumer in a transaction. A second one conflicts and can lose messages.
No localhost URLs in configuration
Services run in containers and resolve each other by name. Localhost means "myself" inside a container.
No constructor-based event records
The message serializer cannot reconstruct them. The event arrives empty on the other side.
No raw database containers in tests
Each test suite must share a single container. Creating new ones per test exhausts Docker resources in CI.
No generating retry keys inside the retry loop
The key changes every attempt, so the provider thinks each retry is a new request. Charges happen twice.

Tracking every penny

Double-entry bookkeeping for payments

Every payment is recorded as two entries. a debit and a credit — just like real accounting. The database enforces that they always sum to zero. If they don't, the transaction is rejected.

creditSeller Payable
+£39.99Payment received
debitPlatform Escrow
-£39.99Funds reserved
debitSeller Payable
-£39.99Payout disbursed
creditBank (Stripe)
+£39.99Wire initiated
Sum of all entries = £0.00 (enforced by a database constraint)

Deleting user data across 8 services

GDPR right-to-be-forgotten

When a user requests data deletion, a saga coordinates erasure across every service that holds their information. It tracks progress, detects stalls, and produces an audit trail, all within the 7-day legal deadline.

Request
User submits
Orders
PII scrubbed
Payments
PII scrubbed
Identity
Account deleted
Audit
Anonymized
Confirmed
< 7 days

Completes within 7 days · stall detection at 24h · audit records anonymized, not deleted

Keeping search in sync without polling

Database changes stream to search automatically

When a product is updated in Postgres, the change appears in Elasticsearch within seconds. The application does not write to two places. Instead, Postgres's own change log feeds the pipeline:

PostgreSQL
Change log
Debezium
Streams changes
Kafka
Event bus
Elasticsearch
Search index

No dual-write risk. The database change log is the single source of truth.

Testing across service boundaries

13 test suites, from unit to end-to-end

Unit tests verify business logic. Integration tests run against real Postgres and RabbitMQ in containers. Contract tests (Pact) ensure two services agree on message format before deploy. All run in CI on every PR.

13
Test Suites
Unit, integration, contract, E2E
159
Architecture Rules
Checked on every pull request
50
Compiler Rules
Custom Roslyn analyzers

Running the whole platform locally

One command starts everything

.NET Aspire orchestrates all 8 services and their infrastructure (Postgres, RabbitMQ, Redis, Kafka) with a single command. No Docker Compose file to maintain, no manual startup order.

$ dotnet run --project AppHost

13 infrastructure containers
 8 microservices
 1 command

Dashboard: http://localhost:15888

Want to see these patterns in action?

Try the interactive demos →