Skip to content

Refactor LOVE.NET into event-driven microservices#5

Open
iwanmitowski wants to merge 9 commits into
mainfrom
feature/microservices
Open

Refactor LOVE.NET into event-driven microservices#5
iwanmitowski wants to merge 9 commits into
mainfrom
feature/microservices

Conversation

@iwanmitowski

Copy link
Copy Markdown
Owner

Refactors LOVE.NET from a layered ASP.NET Core monolith into an event-driven microservices system built for a realtime chat workload that can scale horizontally. The pre-refactor monolith is preserved on the monolith-snapshot branch. Full design rationale lives in docs/ARCHITECTURE.md.

Why

The monolith could not scale past one process, verified in code before the split:

  • The SignalR hub had no [Authorize] — sender identity was client-asserted.
  • Room presence lived in an in-memory singleton Dictionary and SignalR had no backplane — a second instance splits every room.
  • The hub broadcast before persisting — a DB failure lost messages clients already saw.
  • Matches were recomputed from mutual likes on every request; chat authorization string-parsed the room-id convention.
  • One SQL Server + one ApplicationUser mega-aggregate coupled every feature to every table.

What this PR contains

7 services (each with its own PostgreSQL database, or Redis for Realtime), an async backbone on Kafka (KRaft), and a YARP gateway on the monolith's old port so the SPA keeps its base URL:

Service Owns
Gateway (YARP, :8080) routing incl. websockets, CORS, per-IP rate limits (tighter on credentials), admin stats fan-out with timeout/retry/circuit-breaker
Identity (:8081) ASP.NET Identity credentials, refresh-token rotation, HS512 JWT issuance, moderation/ban
Profile (:8082) profile aggregate, images (Cloudinary), geo reference data, admin user list
Matching (:8083) likes + persisted matches (new table; monolith derived them), swipe deck from a local read model
Chat (:8084) messages in a monthly range-partitioned table behind an IMessageStore seam (Cassandra/ScyllaDB swap path), room provisioning, history API, REST image upload
Realtime (:8085) [Authorize]d SignalR hub, Redis backplane + Redis presence, MatchReceived push; stateless
Notifications (:8086) SendGrid email via events, templates, audit log

Key patterns (details in ARCHITECTURE.md):

  • Transactional outbox → Kafka → inbox dedupe on every boundary: at-least-once delivery, effectively-once processing, DLQ after bounded retries.
  • Durable-before-display chat: the hub awaits the Kafka ack (acks=all, keyed by roomId for per-room ordering) before broadcasting.
  • Event-carried read models instead of cross-service joins (profile.user-updated is enriched with geo/avatar/images/flags).
  • Serilog + OpenTelemetry (optional Jaeger via compose profile), correlation ids across HTTP and Kafka hops, real /health per service.

Frontend (minimal diff): JWT on the hub connection (accessTokenFactory), upload-then-send chat images (no more base64 over the websocket), repointed account/admin endpoints, plus two inherited bug fixes (refresh-interceptor clobbering stored auth; ChatModal's disconnected useChat instance).

Verification

  • dotnet test LoveNet.sln37/37 tests across five services (ported/adapted from the monolith suite).
  • docker compose up -d --build → 15 containers; scripts/smoke-test.ps120-step end-to-end run, PASSED: health ×7 → register/login via gateway → identity→profile→matching event propagation → mutual like → persisted match → room provisioned from the match event → authenticated websocket join/send/echo through the gateway → message durably persisted via Kafka → seeded chatrooms → admin stats aggregation + 403 for non-admins.

Key decisions & accepted limitations

  • Shared HS512 key across services keeps token issuance byte-compatible with the monolith; production path is RS256 + JWKS (config-level change, documented).
  • Eventual consistency windows (match toast before room row; profile edits reaching the deck) are bounded by consumer lag; deterministic room-id fallback authorization covers the chat gap.
  • Ordering is per-partition only (roomId/userId keys) — nothing depends on global order.
  • Outbox publisher is single-instance per service; scaling it out needs FOR UPDATE SKIP LOCKED (documented, not built).
  • Not built on purpose: sagas (no flow needs compensation yet), DLQ replay tooling, Kubernetes manifests, schema registry, read receipts/typing.

Commits are one-per-phase for reviewability (scaffolding → identity/profile → matching → chat/realtime → notifications → gateway/compose/smoke → docs/monolith removal → code-smell pass).

🤖 Generated with Claude Code

ivanmitovski and others added 9 commits July 13, 2026 10:27
- LoveNet.sln with 4 BuildingBlocks libs, 6 service APIs, YARP gateway host,
  5 NUnit test projects (net8.0, nullable, implicit usings via src props)
- Contracts: EventEnvelope, KafkaTopics, event records for all 7 topics
- EventBus: Confluent.Kafka idempotent producer, transactional outbox +
  polling publisher, inbox dedupe, consumer base with bounded retry + DLQ
- Web: shared HS512 JWT validation (incl. SignalR query token), correlation
  id middleware, Serilog + OpenTelemetry defaults, health checks, validation
  attributes, Result type
- Media: Cloudinary ImagesService extracted from the monolith
- docker-compose: 5x postgres:16, Kafka KRaft + topic init (auto-create off),
  Redis, optional Jaeger profile; replaces monolith mssql compose
- NuGet.config pinning nuget.org (machine had no online source)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Identity (lovenet_identity, port 8081):
- Credentials-only ApplicationUser + refresh-token rotation (HttpOnly cookie),
  HS512 JWT issuance ported from the monolith IdentityService
- register/login/logout/refreshToken + email flows (token generation stays
  here; delivery becomes notifications.email-requested outbox events)
- admin moderation (lockout ban) publishing identity.user-banned
- user_profiles_lite read model from profile.user-updated so the login
  response keeps carrying avatar/location for the SPA
- seeder replays demo Users.json through the real event pipeline

Profile (lovenet_profile, port 8082):
- user_profiles/images aggregate + countries/cities/genders reference data
  (CSV seeders), account GET/PUT, admin user listing
- consumes identity.user-registered/user-banned, publishes enriched
  profile.user-updated (geo names, lat/lng, avatar, images, isBanned, isAdmin)

Both: transactional outbox + inbox dedupe, Npgsql migrations, health checks.
Verified end-to-end against compose infra: seeded users flow
Identity -> Kafka -> Profile -> Kafka -> Identity read model.

Tests: 20 NUnit tests ported/adapted (login/register/moderate/outbox,
profile edit image diff, admin paging/search, geo services).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- likes + persisted matches table (roomId keeps the sorted-GUID convention
  Chat/Realtime authorize against); unique pair index prevents double-likes
- candidate_profiles read model from profile.user-updated (no cross-service
  joins; kills the monolith UsersRepository.WithAllInformation mega-include)
- swipe deck / like / matches endpoints with identical response shapes;
  GetMatches now reads the matches table instead of recomputing mutual likes
  and returns a correct pre-pagination TotalMatches (monolith counted the page)
- reciprocal like inserts the match row + matching.match-created outbox event
  in one transaction; admins excluded from the deck via IsAdmin on events
- 9 NUnit tests ported from DatingServiceTests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chat (lovenet_chat, port 8084):
- messages table range-partitioned monthly by created_on (hand-written
  migration SQL; DEFAULT partition + startup partition maintenance), index
  (room_id, created_on DESC), behind IMessageStore so Cassandra/ScyllaDB can
  be swapped in later
- persists via chat.message-sent consumer (idempotent by hub-assigned id);
  provisions rooms from matching.match-created and publishes
  chat.room-provisioned (also for seeded public chatrooms)
- history API with rooms/chatrooms/deterministic-roomId authorization;
  POST api/chat/images replaces base64-over-WebSocket uploads

Realtime (port 8085, stateless, Redis only):
- [Authorize]d SignalR hub /chat; sender identity from JWT claims (closes the
  client-asserted-identity hole); Redis backplane for multi-instance fan-out
- Redis presence replaces the in-memory UsersGroupService singleton;
  OnDisconnectedAsync cleanup fixes the ghost-user leak; LeaveRoom now
  actually removes the group registration
- durable-before-display: SendMessage awaits the Kafka ack (acks=all,
  key=roomId) before broadcasting ReceiveMessage
- pushes MatchReceived to both users on matching.match-created

Frontend: accessTokenFactory on the hub connection, upload-then-send image
flow, account endpoints -> /profile, admin endpoints -> /admin/users +
/admin/moderate, refresh-interceptor bug fix (kept clobbering the stored
auth object with a bare token string).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- consumes notifications.email-requested (verify/reset templates ported from
  the monolith wwwroot), matching.match-created (match emails to both users),
  and the user events for a contacts read model
- SendGrid sender ported; skips gracefully with a warning when no API key is
  configured (local dev); email_log audit table; inbox dedupe so at-least-once
  delivery never double-sends
- 4 NUnit tests (template substitution, dedupe, both-recipients, upsert)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gateway (port 8080, the monolith's old port so the SPA base URL is unchanged):
- YARP routes for all services incl. websocket proxying of the SignalR hub;
  /api/admin/dashboard served locally by an aggregation controller that fans
  out to each service's /internal/stats with timeout+retry+circuit-breaker
  (Microsoft.Extensions.Http.Resilience) and degrades to zeros per service
- rate limiting: global 200/10s per IP, auth 10/60s (login/register),
  likes token bucket 5/s; single CORS policy for the SPA origin

Infra:
- multi-stage Dockerfiles per service; compose now runs the full stack
  (gateway + 6 services + 5 Postgres + Kafka/KRaft + Redis + frontend)
- healthchecks-topic added to kafka-init (broker auto-create is off and the
  Kafka health check produces a probe message)
- Cloudinary client now constructed lazily inside ImagesService so keyless
  local runs work end-to-end for image-less flows
- OpenTelemetry OTLP exporter bumped to 1.15.3 (GHSA-4625-4j76-fww9)

scripts/smoke-test.ps1 + scripts/hub-smoke.js: 20-step end-to-end proof --
health x7, register/login via gateway, identity->profile->matching event
propagation, mutual like -> persisted match, room provisioning from the match
event, authenticated websocket join/send/echo through the gateway, Kafka
message persistence, seeded chatrooms, admin stats aggregation + 403.
Full run PASSED against the compose stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- delete the monolith projects (Data/, Services/ incl. dead
  LOVE.NET.Services.Data, Web/, LOVE.NET.Common, LOVE.NET.Services.Tests,
  Tests/ template leftovers incl. Sandbox), old solution, dcproj, and the
  StyleCop config the new tree does not use; everything remains available on
  the monolith-snapshot branch
- docs/ARCHITECTURE.md: why the monolith could not scale, service map with
  mermaid diagram, topic table, key flows, the patterns applied (outbox/inbox,
  delivery semantics, ordering, consistency spectrum, stateless tiers,
  resiliency, observability), partitioned chat storage and its Cassandra swap
  seam, HA story, security hardening path, and what was deliberately not built
- README rewritten for the new stack with quickstart + smoke test

LoveNet.sln builds and all 37 tests pass after the removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings from a dedicated review of src/ and the touched frontend files:
- hoist the Npgsql legacy-timestamp switch (duplicated in 5 Program.cs files)
  into AddLoveNetDefaults with its rationale comment
- replace the ParentAssistantApi from-address copy-pasted from another
  project with config-driven Email:FromAddress/FromName (LOVE.NET defaults)
- drop dead contract/API surface: EmailRequestedEvent.ToUserName (never
  consumed), ImageModel.IsDeleted in Matching + Profile responses (always
  false / never read), ClaimsPrincipalExtensions.GetEmail (no callers),
  UserConnectionModel.UserId/UserName (server ignores them by design)
- delete weatherforecast launchUrl template leftovers from all 6
  launchSettings.json
- ChatModal no longer spins up a second disconnected useChat() instance for
  hasMoreMessagesToLoad (always-true bug inherited from the monolith); the
  value now flows down as a prop from the owning hook in Matches.js
- remove narrate-the-next-line comments in the two chat components

Also in this pass earlier: OTLP exporter bumped to 1.15.3 (vulnerability),
lazy Cloudinary construction, deleted template .http files and an unused
constant. Full solution builds; 37/37 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README back to the original emoji-sectioned structure (About / Getting
  Started with the feature blocks / Database Placement / Tests / Built Using),
  updated for the microservices stack
- docs/images: terminal-style captures rendered from the live stack (psql \dt
  across the five service databases incl. the partitioned messages table,
  dotnet test summary, docker compose ps)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants