Refactor LOVE.NET into event-driven microservices#5
Open
iwanmitowski wants to merge 9 commits into
Open
Conversation
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-snapshotbranch. Full design rationale lives in docs/ARCHITECTURE.md.Why
The monolith could not scale past one process, verified in code before the split:
[Authorize]— sender identity was client-asserted.Dictionaryand SignalR had no backplane — a second instance splits every room.ApplicationUsermega-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:
IMessageStoreseam (Cassandra/ScyllaDB swap path), room provisioning, history API, REST image upload[Authorize]d SignalR hub, Redis backplane + Redis presence,MatchReceivedpush; statelessKey patterns (details in ARCHITECTURE.md):
profile.user-updatedis enriched with geo/avatar/images/flags)./healthper 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 disconnecteduseChatinstance).Verification
dotnet test LoveNet.sln— 37/37 tests across five services (ported/adapted from the monolith suite).docker compose up -d --build→ 15 containers;scripts/smoke-test.ps1— 20-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
FOR UPDATE SKIP LOCKED(documented, not built).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