All projects

SkillSync AI

Live

Turns a campus project idea into a staffed team and a learning plan.

Problem and users

2.1 What students actually experience

A student with an idea faces four questions in sequence:

  1. Who is available? There is no reliable inventory of students who want to build something this semester.
  2. Who is capable? Self-reported skills are noisy. A GitHub profile shows code but not intent, availability, or role fit.
  3. Who is compatible? Skill overlap is necessary but not sufficient. Communication style, ambition, and prior delivery matter.
  4. What happens after the match? Most tools stop at introduction. The team still needs a workspace, a plan, and a way to close its own skill gaps.

The existing tools each solve one slice:

ToolStrengthWhy it does not solve campus team formation
WhatsApp / DiscordReach and immediacyNo structured skills, roles, capacity, or search; history is unsearchable
LinkedInProfessional identityOptimized for permanent jobs, not transient campus projects; heavy cold-outreach friction
GitHubEvidence of codeDoes not express availability, role preference, or team intent
University portalsAdministrative dataBuilt for records, not collaboration
Generic AI chatIdeationCan suggest a team, but cannot execute the workflow or persist state

2.2 Design requirements

The product requirements follow directly from those gaps:

  1. A trust boundary. Matching should happen inside a college community, not across the entire internet. This improves relevance and reduces cold-start risk.
  2. Structured demand. A project must express roles, headcount, and required skills — not just a paragraph of text.
  3. Evidence-based supply. Profiles should combine self-reported skills, resume-extracted skills, achievements, and prior project history.
  4. Two-sided discovery. Owners need candidate recommendations; students need project recommendations. Both directions must exist.
  5. Explainable ranking. A score alone is not enough. The user needs matched skills, a confidence level, and a rationale.
  6. A post-match workspace. Chat, notes, notifications, and task tracking keep the team operating after the introduction.
  7. A growth loop. The project's missing skills should become a learning plan, not a dead end.

SkillSync is designed around those seven requirements. The rest of this document explains how.


PersonaJob to be doneWhat SkillSync gives them
The student builder"I want to join a project that uses my skills and stretches me."A profile, AI-ranked project recommendations, applications, invitations, and a roadmap for gaps
The project owner"I have an idea and need specific roles filled."Structured project creation, role/skill modeling, ranked candidates, invitations, and team management
The team member"I want to collaborate without losing context."Project chat, direct messages, notes, notifications, and shared task visibility
The career-focused learner"I want to know what to learn next."Resume-derived skill evidence, AI insights, a curriculum, and checkpoints converted into todos
The college community"I want stronger collaboration without administration overhead."College-scoped discovery and a shared skill graph (multi-college support is architecturally present but not yet product-complete — see §17)

The primary user is the student builder. The primary buyer, in a campus context, would be the college or student organization. The product is deliberately not a job board: the unit of value is a project team, not a permanent placement.


What I built

Finding a teammate for a college project is a coordination problem disguised as a people problem. Students know the skills their team is missing, but they do not know who has those skills, whether those people are available, whether they have shipped anything before, or whether they will follow through. The search happens across WhatsApp groups, Discord servers, hallway conversations, and spreadsheets. The result is predictable: strong students cluster, weaker students get excluded, and projects stall before they start.

SkillSync treats the problem as an orchestration problem. It gives students a structured professional identity, gives project owners a structured model of demand (roles, headcount, required skills), and puts a ranking system between the two. The ranking system is deliberately layered: deterministic filters first, an LLM second, deterministic business policy last. The team, once formed, does not disappear into a group chat — it gets project messaging, direct messages, notes, notifications, and a learning plan tied to the work.

The platform is built as two deployable applications:

  • SkillSyncWeb — a React 19 single-page application with feature-based modules, TanStack Query for server state, Axios interceptors for authentication, STOMP for chat, and SSE for live notifications.
  • skillsync-ai — a Spring Boot 4 / Java 21 modular monolith organized into 13 domain modules, with PostgreSQL for persistence, Redis for OTP/rate-limit state, Kafka for durable AI work, Spring Modulith events for in-process domain reactions, and two AI providers with different roles.

The central engineering bet: matching quality is a data-modeling and ranking problem before it is a model problem. Sending every student to an LLM is expensive, slow, and unexplainable. Scoping the candidate pool to the same college, filtering by skill overlap, and sending only a bounded shortlist to a single batched model call produces a system that is cheaper, faster, and easier to defend. The model supplies semantic judgement; the application supplies eligibility, invariants, and policy.

The project is a working MVP, not a demo shell. Resume ingestion, matching, invitations, applications, team capacity, chat, notifications, roadmap generation, and task tracking are implemented end to end. It also has real rough edges — dormant subsystems, stubbed scoring inputs, and a few frontend links that drift from the route table. Those are documented rather than hidden, because knowing where a system is unfinished is part of engineering it.


This is the implemented flow, not an aspirational one. Technical notes are included because the product experience depends on them.

Step 1 — Create an account and verify identity

A student registers with email and password. The backend creates an inactive user, assigns the deployment's active college, and sends a six-digit OTP by email. The OTP is stored as a SHA-256 hash with a five-minute TTL and a 45-second resend cooldown. Verification activates the account. Name and profile details are captured later during onboarding. Alternatively, Google OAuth2 completes the same identity flow without a password.

Why it matters: the verified email is the first trust signal. The OTP design avoids storing plaintext codes and prevents resend abuse.

Step 2 — Build an evidence-bearing profile

Onboarding captures the full name, graduation year, and professional bio. The profile editor then adds headline, department, social links, avatar, skills with proficiency levels, achievements, and previous projects. A profile-completion tracker turns the empty state into a sequence of concrete actions.

Why it matters: the matching pipeline can only rank what the profile expresses. The completion tracker is a product-level fix for cold-start data quality.

Step 3 — Upload a resume and let the system extract skills

The browser requests backend-signed Cloudinary upload parameters, uploads the PDF directly to Cloudinary, then sends the resulting URL to the backend. The backend publishes a ResumeUploadedEvent to Kafka topic resume-uploaded-topic. The AI module downloads the PDF, extracts text with Apache PDFBox, sends it to Gemini, parses the structured JSON, persists an analysis result, and publishes ResumeAnalysisCompletedEvent to resume-analyzed-topic.

Two consumers react:

  • The user-profile module upserts the extracted skills onto the student's profile and stores the AI-generated resume summary.
  • The notification module persists a notification and pushes it over SSE, which the SPA renders as a live toast and a profile refresh.

Why it matters: the expensive, failure-prone work is asynchronous and durable. The user is not holding an HTTP connection open while a PDF is parsed and a model responds.

Step 4 — Understand strengths and gaps

The profile includes an AI insights tab. Gemini receives the profile context and returns a structured analysis of strengths, skill gaps, and suggested focus areas. The result is stored on the profile with an insightsOutdated flag so it can be refreshed when the profile changes.

Step 5 — Create a project with real structure

The project wizard creates a project in DRAFT status. The owner adds roles; each role has a title, headcount, and required skills. The backend enforces:

  • the sum of role headcounts plus the owner cannot exceed maxTeamSize;
  • a project must have at least one role before it can be published;
  • publishing aggregates all role skills into the project's required-skill set and moves the project to RECRUITING.

The status enum is explicit: DRAFT, RECRUITING, IN_PROGRESS, COMPLETED, ARCHIVED, CANCELLED. The transitions implemented in the current service layer are DRAFT → RECRUITING → IN_PROGRESS; the remaining states are defined in the model but are not yet reachable through the current endpoints.

Why it matters: a free-text project post cannot be matched reliably. Roles and headcount turn an idea into a queryable specification.

Step 6 — Discover matches in both directions

The owner opens the AI matchmaker and requests candidates. The student opens AI discovery and requests projects. Both requests enter the same three-stage funnel, described in §9. The result is a ranked list with a final score, semantic score, confidence, matched skills, and a human-readable rationale.

Generation is manual, not automatic on publish. The auto-matchmaking listener exists but is intentionally disabled in the current code, so AI spend happens only when a user asks for it.

Step 7 — Evaluate before committing

A recommendation is not a black box. The UI shows the score, the skills that matched, the confidence band, and the rationale. The owner can open the candidate's public profile to inspect skills, achievements, previous projects, and AI insights. The student can inspect the project's roles and owner.

Why it matters: trust in a ranking system comes from inspectability. A percentage without an explanation is worse than a shortlist with one.

Step 8 — Invite, apply, and form the team

Owners can invite a recommended student to a specific role. Students can apply to a project with a cover message. The backend prevents duplicate pending invitations and duplicate pending applications, and it prevents a student from applying to a project they already own or have been invited to.

When an application or invitation is accepted, a TeamMemberAcceptedEvent is published. The project module:

  • rejects duplicate membership idempotently;
  • verifies project capacity;
  • verifies role capacity (filledCount < headcount);
  • creates the active team member;
  • increments the role's filled count;
  • transitions the project to IN_PROGRESS when the team reaches maxTeamSize.

Step 9 — Collaborate in the workspace

The team gets project chat over STOMP/WebSocket, one-to-one direct messages, a notes dashboard with AI summarization, and persisted notifications. Project chat is broadcast to a project topic; direct messages are routed to a user queue. Read receipts are handled through REST.

Step 10 — Close the loop with a learning plan

The AI roadmap page lets a student describe a target role, current knowledge, and perceived gaps. The backend combines that with platform skills, project history, and the last 50 chat messages, asks Gemini for a curriculum, and generates tasks for the first topic. Each checkpoint becomes a todo. Completing a todo marks its checkpoint complete; when every checkpoint is complete, the roadmap records the current topic, selects the next topic, and generates the next set of tasks automatically.

This is the growth loop: project work reveals a gap → the gap becomes a roadmap → the roadmap becomes tasks → completed tasks become evidence for the next match.


CapabilityUser-facing outcomeImplementationState
AuthenticationRegister, verify email, log in, Google sign-inJWT, OTP, OAuth2 exchange code, rate limitingVerified
Profile and evidenceA professional student profileProfile, skills, achievements, prior projects, social links, avatarVerified
Resume intelligenceSkills appear automatically after uploadCloudinary → Kafka → PDFBox → Gemini → Kafka → profileVerified
AI profile insightsStrengths, gaps, and focus areasGemini profile analysis stored as JSONVerified
Project modelingRoles, headcount, required skills, lifecycleProject, ProjectRole, TeamMember; capacity rulesVerified
Owner → candidate matchingRanked candidates with explanationsCollege scope → overlap → Groq batch → policyVerified, manual trigger
Student → project matchingRanked projects with explanationsCollege scope → overlap → Groq batch → policyVerified, manual trigger
Team formationInvitations, applications, accepts, capacityApplication/Invitation workflows; idempotent membershipVerified
Project collaborationChat, DMs, read receiptsSTOMP/WebSocket, REST history, user queuesVerified
NotificationsLive in-app updatesPersisted notifications + SSEVerified
AI roadmapCurriculum, checkpoints, mentor chatGemini curriculum + topic tasks + tool-calling chatVerified (custom path)
Roadmap → tasksCheckpoints become todos; completion advances the topicModulith events, todo dedupe, checkpoint cascadeVerified
NotesCreate, tag, pin, summarizeNotes CRUD + asynchronous Gemini summaryVerified
AnalyticsDashboard metricsAggregation DTO with cache annotationPartial — endpoint exists, current dashboard does not call it
Public exploreBrowse without an accountStatic placeholder gridGap — authenticated /discover is the real experience
Auto-match on publishRecommendations appear without a clickListener intentionally disabledPartial
Gap-based roadmapRoadmap derived from a project skill gapCode exists; producer is currently dormantPartial

The capability map is intentionally blunt. A portfolio case study that claims everything is finished is less credible than one that distinguishes "working" from "wired" from "planned."


Architecture

6.1 Four loops around one identity graph

SkillSync is easiest to understand as four loops that share a single identity and evidence graph.

flowchart TD
    A["Profile + resume evidence"] --> B["Structured project + roles"]
    B --> C["Deterministic shortlist"]
    C --> D["LLM semantic ranking"]
    D --> E["Team formation"]
    E --> F["Collaboration + delivery"]
    F --> G["Skill gap"]
    G --> H["Roadmap + tasks"]
    H --> A
  • Identity loop: registration, verification, profile, resume extraction. The output is a machine-readable picture of what a student can do.
  • Matching loop: structured demand meets structured supply, narrowed deterministically and ranked semantically. The output is a shortlist with explanations.
  • Execution loop: team formation, chat, direct messages, notes, notifications, task status. The output is delivery and evidence.
  • Growth loop: skill gap → curriculum → checkpoints → todos → completed work → stronger evidence. The output is a better profile and a better next match.

The loops are not marketing language; they map to actual subsystems and events. The growth loop, in particular, is implemented as an event chain rather than a static recommendation list.

6.2 Deterministic shell, probabilistic core

The most important architectural decision is the separation of deterministic and probabilistic responsibilities.

ConcernDeterministic shellProbabilistic core
Eligibility and trustCollege scope, ownership checks, role eligibility, capacity—
RankingSkill-overlap shortlist, candidate caps, policy adjustments, thresholdSemantic score, confidence, matched skills, rationale
State and invariantsStatus machines, idempotency, expiry, membership rules—
Content generation—Resume extraction, skill gap, curriculum, mentor chat, note summaries
Failure handlingExplicit FAILED states, retries, fallbacksTimeouts, null responses, malformed JSON

The rule is simple: the model never owns a business invariant. It can suggest that a candidate is a 78% fit. It cannot decide that a team has capacity, that an invitation is still valid, or that a project is published. Those decisions stay in application code, where they can be tested and reasoned about.

This is also why the matching pipeline is a funnel rather than a single prompt. The deterministic stages are not a preprocessing detail; they are the part of the system that makes the AI affordable and auditable.

6.3 Fast path, slow path, push path

PathExamplesMechanismUser experience
FastLogin, profile read, project feed, todo toggle, notes CRUDSynchronous HTTP + PostgreSQLImmediate response
SlowResume analysis, batch matching, roadmap generation, note summarizationKafka or Modulith events, async executors, status recordsProgress state, polling, or notification
PushChat messages, notificationsSTOMP/WebSocket, SSEReal-time update

The slow path is where most of the engineering difficulty lives. The rest of this document focuses there.

6.4 Design principles that emerge from the code

  1. Narrow before you prompt. College scope and skill overlap reduce the model's input before a token is spent.
  2. Batch the expensive call. One Groq request scores a shortlist; it does not score the college one student at a time.
  3. Every async workflow has a status. Resume analysis, roadmaps, notes, and recommendations all have explicit states.
  4. Persist before push. Notifications and messages are written to the database before being broadcast, so a dropped connection does not lose data.
  5. Events carry IDs, not entity graphs. Cross-module events reference users, projects, roles, and checkpoints by ID, which keeps module boundaries intact.
  6. Boundaries need a test. Spring Modulith's ApplicationModules.verify() runs in the test suite and fails the build if a module imports another module's internals.

7.1 Topology

flowchart LR
    subgraph Browser["Browser"]
        SPA["React 19 SPA<br/>Vite · TypeScript · Tailwind v4"]
    end

    subgraph Edge["Hosting"]
        Vercel["Vercel<br/>static SPA"]
        Render["Render<br/>Docker · Spring Boot 4 · Java 21"]
    end

    subgraph App["Spring Boot modular monolith"]
        SEC["Security chain<br/>rate limit · JWT · OAuth2"]
        REST["20 controllers<br/>95 REST route mappings"]
        STOMP["STOMP / WebSocket"]
        SSE["SSE emitter registry"]
        MOD["13 domain modules"]
        EVT["Spring Modulith<br/>domain events"]
    end

    subgraph Infra["Managed infrastructure"]
        PG[("PostgreSQL")]
        REDIS[("Redis / Cacheify")]
        KAFKA[("Kafka")]
        CLOUD["Cloudinary"]
    end

    subgraph AI["AI providers"]
        GEM["Google Gemini"]
        GROQ["Groq<br/>Llama 3.1 70B"]
    end

    SPA --> Vercel
    SPA -->|"HTTPS / REST"| Render
    SPA <-->|"WSS / STOMP"| Render
    SPA <-->|"SSE"| Render
    Render --> SEC --> REST
    Render --> STOMP
    Render --> SSE
    REST --> MOD
    STOMP --> MOD
    MOD --> PG
    MOD --> REDIS
    MOD --> EVT
    EVT --> KAFKA
    MOD --> CLOUD
    MOD --> GEM
    MOD --> GROQ

7.2 Backend modules

The backend is a modular monolith with 13 packages under com.skillsync.skillsync_ai. Each package is a Spring Modulith module with its own controllers, services, entities, repositories, and DTOs.

ModuleResponsibilityRepresentative classes
authenticationUsers, colleges, JWT, OTP, OAuth2, rate limiting, pluggable storesAuthService, JwtService, OtpService, RateLimitFilter, OAuth2AuthenticationSuccessHandler
userprofileProfiles, skills, achievements, prior projects, resume URL, Cloudinary signatures, AI insightsUserProfileService, SkillManagementService, AchievementService, ResumeAnalysisListener
projectProjects, roles, members, lifecycle, ownership, capacityProjectManagementService, Project, ProjectRole, TeamMember
matchingRecommendations, applications, invitations, scoring, dashboardsRecommendationOrchestrator, RecommendationScoringService, TeamMatchingService
aiGemini/Groq gateways, resume analysis, skill gap, roadmaps, checkpoints, agent chat, note summariesGeminiApiService, GroqApiService, BatchSkillGapAnalyzer, RoadmapService, AiChatOrchestrator
todoTodos, subtasks, status, roadmap event consumptionTodoService, TodoController
notesNotes CRUD, tags, pinning, summary state machineNoteService, NoteSummarizationListener
messagingProject chat, direct messages, STOMP configuration, inbox, read receiptsWebSocketConfig, AuthChannelInterceptor, ProjectChatService, DirectMessageService
notificationPersisted notifications, SSE registry, event listenersNotificationEventListener, SseConnectionService
chatAI conversation threads and message historyChatService, Conversation, ChatMessage
analyticsDashboard aggregation DTOAnalyticsService, AnalyticsController
commonShared events, API response envelope, exceptions, enums, Kafka configApiResponse, GlobalExceptionHandler, KafkaConfig, event records
configAsync executor and cross-cutting configurationAsyncConfig

Why a modular monolith? The domain has strong transactional invariants: a team cannot exceed capacity, a role cannot be overfilled, an invitation cannot be accepted twice. A monolith keeps those invariants inside one database transaction and one deployable. At the same time, the module boundaries, gateway interfaces, and event bus make future extraction possible. The architecture is a bet that clear boundaries matter more than process isolation at this stage.

7.3 Boundaries in practice

The code uses three boundary mechanisms:

  1. Gateway interfaces for cross-module reads. For example, UserProfileGateway, ProjectGateway, TodoGateway, ChatGateway, AiGateway, and AiMatchingGateway expose narrow, intention-revealing methods instead of leaking repositories across modules.
  2. Domain events for cross-module writes. A module publishes an event; interested modules react. The publisher does not know who listens.
  3. A Modulith verification test. ModulithVerificationTest calls ApplicationModules.of(...).verify(), which fails if a module imports another module's internal classes. This turns architecture diagrams into an enforceable build rule.

The result is not microservices purity. It is a pragmatic monolith with seams that are visible, testable, and cheap to change.

7.4 API surface

All routes except /api/auth/** and the WebSocket handshake require a valid JWT. The API is stateless.

GroupBase pathRepresentative operations
Auth/api/authRegister, resend OTP, verify, login, OAuth2 exchange, ping
Profile/api/v1/profile/meGet profile, update basic info, social links, resume, avatar, search, public profile
Skills/api/v1/skillsSearch skills, list mine, add, remove
Achievements/api/v1/userprofile/achievementsCreate, update, delete
Prior projects/api/v1/profile/me/projectsList, create, update, delete
Uploads/api/uploadsCloudinary upload signature
Projects/api/v1/projectsCreate, update, delete, publish, close, feed, roles, members, leave, transfer ownership
Matching/api/v1/matching/dashboardMy applications, invitations, recommendations; generate; project-side views
Applications/api/v1/applicationsApply, decide, withdraw, list by project, list mine
Invitations/api/v1/invitationsInvite, respond, revoke, list by project, list mine
AI/api/v1/aiResume analysis status, skill gap for project
Roadmaps/api/v1/roadmapsGenerate from gap, generate custom, active roadmap, chat, checkpoint status
Todos/api/v1/todosList, create, update, status, subtask toggle, delete
Notes/api/v1/notesList, get, create, update, delete, summarize
Messaging/api/v1/messages, /api/v1/projects/{id}/messagesInbox, direct history, mark read, project history
Notifications/api/v1/notificationsStream (SSE), list, unread count, mark read, mark all read
Analytics/api/v1/analyticsDashboard metrics
WebSocket/wsSTOMP handshake, project topics, user queues

7.5 Data model highlights

The schema is split into domain schemas (auth, profile, project, matching, ai, todo, notes, messaging, notification, chat). Cross-module references are IDs, not JPA relationships, which keeps module ownership clear.

AggregateKey fieldsRelationships
User / CollegeEmail, password hash, role, active flag, collegeA user belongs to a college
StudentProfileName, headline, bio, department, graduation year, resume URL, summary, AI insights, visibilityOne per user; owns skills and achievements
Skill / StudentSkillCanonical skill name, category; proficiency level, sourceStudentSkill joins profile to skill
Project / ProjectRoleTitle, description, status, max team size, required skills; role title, headcount, filled countA project has roles and members
TeamMemberUser ID, role, status, joined/left timestampsActive membership is unique by business rule
Application / InvitationProject, user, role, status, message, expiryPending duplicates are rejected in service logic
ProjectRecommendationProject, user, semantic score, final score, confidence, matched skills, rationale, prompt version, expiryUnique on (project_id, user_id); optimistic @Version
SkillAnalysisResultResume URL, extracted skills, summary, status, errorOne analysis record per upload
SkillGapAnalysis / SkillGapItemTarget role, matched skills, missing skills, match percentageFeeds the gap-based roadmap path
Roadmap / CheckpointTitle, curriculum, current topic, completed topics, status; title, description, order, subtasks, statusA roadmap has ordered checkpoints
TodoTitle, status, priority, due date, source title, related entity ID, subtasksRoadmap checkpoints map to todos via relatedEntityId
NoteTitle, content, tags, pinned, AI summary, summary statusOwned by a user; optional project link
ProjectMessage / DirectMessageSender, content, timestamp, read stateProject topic or direct user queue
NotificationType, title, message, read flag, related entity IDPersisted before SSE push

11.1 Two event buses, two jobs

BusScopeDurabilityUsed for
Spring Modulith eventsIn-process, same applicationTransactional outbox via JPADomain reactions that should be consistent with the local transaction
KafkaExternal brokerDurable, replayableExpensive AI work that should survive restarts and be decoupled from the request

Using both is a deliberate split. Modulith events keep the monolith cohesive; Kafka keeps the slow, failure-prone AI pipeline from blocking the user-facing transaction and provides a path to extract the AI worker later.

11.2 Resume ingestion pipeline

Browser
  → GET /api/uploads/signature
  → direct upload to Cloudinary
  → PUT /api/v1/profile/me/resume (URL)
  → Kafka: resume-uploaded-topic
      → AI module:
          download PDF
          PDFBox text extraction
          Gemini structured analysis
          persist SkillAnalysisResult (COMPLETED / FAILED)
          Kafka: resume-analyzed-topic
              → userprofile module: upsert skills + summary
              → notification module: persist notification + SSE push

Why this shape:

  • The upload is direct-to-Cloudinary, so the backend never proxies large files.
  • PDF extraction and model inference are off the request thread.
  • The analyzed event has two independent consumers, so profile updates and notifications do not block each other.
  • Failures are persisted on the analysis record.

11.3 Roadmap → todo → checkpoint loop

This is the most interesting event chain in the product because it creates a self-continuing workflow.

POST /api/v1/roadmaps/generate-custom
  → deactivate existing active roadmaps (and their todos)
  → gather profile, skills, projects, last 50 chat messages
  → Gemini: curriculum for target role
  → persist Roadmap (ACTIVE, curriculum, currentTopic)
  → Gemini: tasks for first topic
  → persist Checkpoints (PENDING, ordered, with subtasks)
  → publish RoadmapGeneratedEvent
      → Todo module creates Todos with relatedEntityId = checkpointId
          (deduplicated against existing related IDs)

User completes a Todo
  → TodoCompletedEvent
      → AI module marks the Checkpoint COMPLETED
      → if all checkpoints are complete:
          mark current topic completed
          select next topic from curriculum
          Gemini: tasks for next topic
          persist new Checkpoint
          publish RoadmapGeneratedEvent
              → Todo module creates the next tasks

The key design choices:

  • The todo is the user's unit of work; the checkpoint is the roadmap's unit of progress. relatedEntityId links them without a JPA relationship across modules.
  • Idempotency is explicit. findExistingRelatedEntityIds prevents duplicate todos if an event is replayed.
  • The roadmap is a state machine, not a static document. currentTopic, completedTopics, and checkpoint status drive the next generation.
  • Deactivation is part of the flow. Creating a new roadmap retires the old one and its outstanding todos, so the user is not pursuing two conflicting plans.

11.4 Team formation event chain

Owner accepts application / Student accepts invitation
  → TeamMemberAcceptedEvent
      → Project module:
          duplicate membership check
          project capacity check
          role capacity check
          create TeamMember (ACTIVE)
          increment role.filledCount
          if team full and RECRUITING → IN_PROGRESS
      → Notification module:
          persist welcome notification
          SSE push

The event carries projectId, projectRoleId, and userId — only IDs. The project module re-loads the aggregates it needs and re-validates capacity at the moment of acceptance. That is important: a recommendation is a suggestion, not a reservation.

11.5 Notes summarization state machine

User clicks Summarize
  → status = PROCESSING (locks duplicate requests)
  → NoteSummarizationRequestedEvent
      → AI module: Gemini summary
      → NoteSummarizationCompletedEvent / FailedEvent
          → Notes module: set summary, status, timestamp

Editing a note clears the AI summary and resets the status to IDLE. That prevents a stale summary from being displayed against changed content — a small detail that matters for trust.

11.6 Idempotency and duplicate protection

WorkflowDuplicate riskProtection
Roadmap → todosKafka/event replay creates duplicate tasksfindExistingRelatedEntityIds by relatedEntityId
Team membershipDouble accept or duplicate eventActive-membership check before insert
Role capacityTwo accepts race for the last slotfilledCount >= headcount check at acceptance
ApplicationsStudent applies twicePending-application existence check
InvitationsOwner invites twicePending-invitation existence check
RecommendationsRepeated generation creates duplicatesUnique (project_id, user_id) + upsert + optimistic @Version
Note summaryDouble-click summarizePROCESSING state lock

Idempotency is not theoretical here. Kafka and Modulith listeners can both redeliver, and users double-click. The code handles the common cases explicitly.


12.1 Stateless JWT

  • The API is stateless; no HTTP session is created.
  • JwtAuthenticationFilter extracts the bearer token, validates it, and populates the security context.
  • Tokens carry the user ID as a custom claim and the email as the subject.
  • Default expiry is 24 hours, configurable through app.security.jwt.expiration.
  • SecurityConfig permits only /api/auth/**, /api/v1/dev/**, /ws/**, and CORS preflight; everything else requires authentication.
  • Unauthenticated requests receive a 401 from a dedicated entry point.

12.2 OTP verification

ControlImplementation
StorageSHA-256 hash, never the plaintext code
TTL5 minutes, enforced by the store
Resend cooldown45 seconds, enforced by the store
ComparisonMessageDigest.isEqual for constant-time comparison
NormalizationEmail lower-cased/trimmed before hashing and lookup
Single useHash deleted on successful verification
Abuse controlAuth endpoints rate-limited per IP

The OtpServiceTest verifies the hash is 64 hex characters, is not the plaintext code, accepts case-insensitive email, rejects wrong codes, consumes the key, and delegates cooldown correctly.

12.3 OAuth2 one-time exchange code

Putting a JWT directly in an OAuth2 redirect URL leaks it into browser history, referrer headers, and server logs. SkillSync avoids that:

  1. The OAuth2 success handler mints the JWT server-side.
  2. It generates a random UUID exchange code.
  3. It stores the token under that code for 30 seconds.
  4. It redirects the browser with only ?code=<uuid>.
  5. The SPA calls /api/auth/oauth2/exchange?code=... to exchange the code for the token.
  6. The code is single-use and expires quickly.

This is a small piece of design with a large security payoff. The token never appears in the URL.

12.4 Rate limiting

  • Applies to /api/auth/**.
  • Limit: 5 requests per minute per IP.
  • Store is pluggable:
    • Redis + Bucket4j — distributed token bucket; default.
    • Cacheify — atomic INCR + PEXPIRE fixed-window counter.
  • Rejection returns 429 with application/problem+json and a Retry-After header.
  • Client IP is taken from X-Forwarded-For when present, otherwise the remote address.

The pluggable store is a real deployment consideration: Redis is not always available, and the abstraction lets the same security behavior run over an HTTP key-value service.

12.5 WebSocket authentication

The STOMP handshake at /ws is permitted through the HTTP security chain because the authentication happens at the messaging layer. AuthChannelInterceptor validates the JWT on the STOMP CONNECT frame and binds the principal. Project chat broadcasts to /topic/projects/{id}; direct messages route to /user/queue/messages. The principal is the user's identity, so user destinations are addressed by the authenticated principal rather than a client-supplied ID.

12.6 SSE authentication trade-off

The browser's native EventSource cannot set an Authorization header. The current frontend therefore appends the JWT as a query parameter:

/api/v1/notifications/stream?token=<jwt>

The backend's JWT filter accepts a bearer token from either the Authorization header or a token query parameter. The SPA uses the query form only for SSE, but the filter's acceptance is global, so any endpoint can be authenticated that way. This works, but query-string tokens can be captured by proxies, logs, and analytics. The honest assessment is that this is a known compromise, not a best practice. The next step is a short-lived, single-use stream ticket (or a same-site cookie session for the stream endpoint) so the long-lived JWT never appears in a URL.

12.7 Authorization and ownership checks

Authentication answers "who are you"; authorization answers "can you do this." The code checks ownership and role at the service layer:

  • Only a project owner can generate recommendations, publish, close, add roles, remove members, or transfer ownership.
  • Only the invited student can respond to an invitation.
  • Only the applicant can withdraw an application.
  • Only the roadmap owner can update a checkpoint or chat with the roadmap.
  • Only the note owner can read, update, delete, or summarize a note.
  • Profile visibility controls public search and public profile access.

These checks are not left to the frontend. The frontend hides controls for UX, but the backend enforces the rule.

12.8 Security posture summary

Strong points:

  • Hashed OTPs with constant-time comparison and TTL.
  • One-time OAuth2 exchange code instead of a token in the URL.
  • Stateless JWT with a filter and explicit 401 handling.
  • Per-IP rate limiting on the auth surface.
  • Ownership checks on every mutating domain operation.
  • College-scoped queries for matching.
  • CORS restricted to local, Vercel, and Render origins.

Hardening backlog:

  • No refresh-token rotation; a 24-hour access token is the only credential.
  • Access token stored in localStorage, which is readable by any script on the origin.
  • SSE token in the query string (see above).
  • A development fallback signing key exists for local runs; production must override it.
  • Rate limiting is scoped to auth endpoints; other endpoints rely on authentication and business limits.

These are appropriate for an MVP but should be addressed before handling sensitive data at scale.


13.1 Chat over STOMP

The messaging module uses Spring's STOMP support:

  • Endpoint: /ws.
  • Simple broker: /topic and /queue.
  • Application prefix: /app.
  • User destination prefix: /user.

Project chat is broadcast to a project topic. Direct messages are sent to a user queue. The STOMP interceptor authenticates the CONNECT frame and binds the principal, so the server knows who is sending and who should receive.

Messages are persisted through the service layer before or alongside the push, so a disconnected client can load history through REST:

  • GET /api/v1/projects/{projectId}/messages
  • GET /api/v1/messages/inbox
  • GET /api/v1/messages/direct/{otherUserId}
  • POST /api/v1/messages/direct/{otherUserId}/read

13.2 Notifications over SSE

Notifications are a different problem from chat: they are server-initiated, one-way, and need to reach a user who may not have an open WebSocket. SSE is a good fit.

The flow:

  1. A domain event occurs (resume analyzed, application submitted, invitation sent, team member accepted, certificate uploaded).
  2. NotificationEventListener persists a Notification row.
  3. SseConnectionService.sendEventToUser looks up the user's emitter and sends a named notification event.
  4. The SPA updates the React Query cache, increments the unread count, and shows a toast.
  5. If the notification is RESUME_ANALYZED, the SPA invalidates the profile query so the new skills appear.

The emitter registry is a ConcurrentHashMap<UUID, SseEmitter> with a one-hour timeout and cleanup callbacks for completion, timeout, and error. A connected event is sent immediately to confirm the stream.

Scale caveat: one emitter per user means one active stream per user. Multiple tabs or devices compete for the same entry. A multi-instance deployment would need Redis pub/sub or a message broker to fan out events across nodes. This is documented in §17.

13.3 Cold-start-aware connection status

The backend is deployed on Render's free tier, which sleeps after inactivity. During wake-up, the platform returns 502 or 503, which looks like an outage to a user.

ConnectionStatus handles this with a health probe:

  • It calls /api/auth/ping with a cache-busting timestamp.
  • 502/503 → "Waking Server..." (amber, spinner).
  • Any other HTTP response → "Connected" (green).
  • Network error → "Server Offline" (red).
  • Polling interval: 5 seconds while connecting/disconnected, 30 seconds once connected.

This is a small UX component with a real operational purpose: it converts an ambiguous failure into an understandable wait.


14.1 Feature-based organization

The frontend is organized by feature rather than by technical type:

FeatureResponsibility
authLogin, register, OTP verification, OAuth callback, auth context, schemas
userprofileProfile view/edit, skills, achievements, prior projects, resume upload, AI insights
projectsProject feed, creation wizard, details, applications, recommendations, discovery
matching(Consumed through project and dashboard APIs)
messagingProject chat, direct messages, inbox, STOMP hooks
notificationsSSE connection, notification list, unread count, toasts
notesNotes dashboard, editor, AI summary
roadmapRoadmap API, generation, chat, checkpoint display
todoTodo list, subtask toggling, status updates

This structure keeps a feature's API calls, components, hooks, and schemas close together. It also makes ownership obvious: a change to notifications does not require traversing a global components/ and services/ tree.

14.2 Server state vs client state

TanStack Query owns server state:

  • Query keys are feature-scoped (["profile"], ["todos"], ["notifications"], ["projectRecommendations", projectId]).
  • Mutations invalidate the relevant queries.
  • The notification provider writes directly into the query cache for instant UI updates.
  • Polling is used where the backend is asynchronous (recommendation generation).

Local React state is reserved for transient UI concerns: modal visibility, form input, chat draft text, and generation spinners.

14.3 API layer and interceptors

src/lib/api/axios.ts creates a single Axios instance. attachInterceptors adds:

  • Request: reads the token from tokenStorage and sets Authorization: Bearer <token>.
  • Response: on 401 for non-auth endpoints, clears the token and invokes a registered unauthorized handler.

The unauthorized handler is registered by AuthProvider, which avoids importing React state into the HTTP layer. Auth endpoints are explicitly excluded from the global logout behavior so a failed login or wrong OTP can be shown inline instead of redirecting the user.

The base URL comes from VITE_API_BASE_URL, with a production fallback. The SSE context and some URLs use hardcoded production hosts, which is a portability gap documented in §17.

14.4 Routing

The route table is explicit in App.tsx:

  • Public: /, /explore, /about, /contact, /privacy-policy, /terms-of-service, /cookie-policy, /oauth2/callback.
  • Auth layout: /login, /register, /verify.
  • Protected dashboard layout: /dashboard, /profile, /profiles/:userId, /search, /onboarding, /roadmap, /projects, /projects/new, /projects/:projectId, /discover, /messages, /notes.
  • Fallback: * → /dashboard.

The dashboard layout is the protected shell; the auth layout is the pre-login shell. The route structure is conventional and easy to reason about.

14.5 Design system and interaction

The UI is built on Tailwind CSS v4 with Radix primitives and shadcn-style local components. The visual language is deliberate:

  • A warm off-white canvas with a strong primary/accent palette.
  • Handwritten accent typography on landing and dashboard headings, which gives the product a human, campus-oriented feel.
  • Framer Motion for reveal and page transitions.
  • Lenis for smooth scrolling on the landing page.
  • Three.js/OGL for hero visuals.
  • Recharts for data visualization.
  • Sonner for toasts.
  • Responsive layouts across mobile, tablet, and desktop.

Radix primitives provide the accessibility baseline: focus management, keyboard navigation, and ARIA semantics for dialogs, menus, tabs, and popovers. That is preferable to hand-rolling interactive components and fixing accessibility later.

14.6 Forms and validation

react-hook-form handles form state and submission; zod defines the validation schemas. The auth forms, profile editor, project wizard, and onboarding flow use this pairing. Validation is enforced on the client for UX and on the server for correctness — the client is never the only line of defense.

14.7 UX details that matter

  • Profile completion tracker: converts an empty profile into a checklist.
  • Empty states: dashboard, notes, projects, and recommendations all have explicit empty-state copy rather than a blank screen.
  • Loading states: skeletons and spinners are used during queries and AI generation.
  • Optimistic feedback: notification read state and todo toggles update the cache immediately.
  • Error toasts: mutations surface backend messages rather than failing silently.
  • Cold-start pill: explains Render wake-up instead of showing a generic error.
  • AI progress copy: "AI is analyzing...", "This usually takes 15–30 seconds", and a 180-second timeout set expectations.

14.8 Frontend gaps

The frontend is polished in the core flows but has some honest rough edges:

  • /explore is a placeholder grid; the real discovery experience is the authenticated /discover page.
  • ProjectRecommendationsModal opens /profile/${userId}, while the route table defines /profiles/:userId. That is a broken-link bug in the recommendations flow.
  • The dashboard contains some static widget copy (for example, "You have 3 active projects") rather than live counts.
  • Some production URLs are hardcoded instead of derived from configuration.
  • There is no frontend test suite.

These are listed in §17 with next steps.


15.1 Topology

  • Frontend: Vercel static hosting. vercel.json rewrites every path to index.html, so client-side routes work on refresh.
  • Backend: Render Docker container. The image is built in two stages: Maven + JDK 21 for the build, JRE 21 Alpine for the runtime.
  • Database: managed PostgreSQL.
  • Cache/state: managed Redis (with the Cacheify HTTP alternative).
  • Events: managed Kafka with SSL. The build copies ca.pem into the runtime image and sets KAFKA_TRUST_STORE_LOCATION.
  • Media: Cloudinary. The backend signs uploads; the browser uploads directly.
  • AI: Gemini and Groq over HTTPS.

15.2 Docker details

The Dockerfile reflects real deployment constraints:

  • Multi-stage build keeps the runtime image small.
  • -Xmx300m caps the JVM heap for a memory-constrained free instance.
  • -Djava.net.preferIPv4Stack=true avoids IPv6 resolution issues in the hosting environment.
  • -Dio.netty.transport.noNative=true avoids native transport problems on Alpine.
  • The Kafka CA certificate is copied into the runtime image.
  • The container exposes port 8080.

These are not theoretical flags. They are the kind of adjustments that come from deploying a JVM service on a small cloud instance and debugging why it fails to start or connect.

15.3 Configuration and secrets

Configuration is environment-driven. The repository includes a Docker Compose file that references an external env file; the env file itself is ignored. The backend uses Spring Dotenv for local development. This document deliberately does not inspect or reproduce secret values.

15.4 Operational behavior

  • Cold starts: Render free-tier instances sleep. The frontend's connection pill and retry behavior make this visible and tolerable.
  • AI latency: 60-second provider timeouts and two retries; the UI polls for up to 180 seconds for recommendations.
  • Async execution: a dedicated ThreadPoolTaskExecutor (core 5, max 10, queue 100) handles async work.
  • Logging: the AI and matching services log stage boundaries ("Stage 1 completed", "Prompt length", "Groq returned", "Successfully persisted"), which makes it possible to follow a request through the pipeline.
  • Failure persistence: analysis, roadmap, and note states are stored, not just logged.

15.5 What is missing operationally

  • No metrics/tracing dashboard (Micrometer/OpenTelemetry would be the next step).
  • No dead-letter queue policy for Kafka consumers (failures are logged and state is marked FAILED, but there is no replay tooling).
  • No automated backup/restore runbook in the repository.
  • No load testing or capacity plan.

These are normal for an MVP and are called out in §17.


Architecture graph

Nodes

  • React SPA
  • Vercel
  • Render
  • Security chain
  • REST controllers
  • STOMP and WebSocket
  • SSE
  • Domain modules
  • Domain events
  • PostgreSQL
  • Redis and Cacheify
  • Kafka
  • Cloudinary
  • Google Gemini
  • Groq

Edges

  • React SPA to Vercel — static hosting
  • React SPA to Render — HTTPS and REST
  • React SPA to Render — WSS and STOMP
  • React SPA to Render — SSE
  • Render to Security chain — request pipeline
  • Security chain to REST controllers — rate limit and JWT
  • Render to STOMP and WebSocket — real-time
  • Render to SSE — streaming
  • REST controllers to Domain modules — module calls
  • STOMP and WebSocket to Domain modules — real-time events
  • Domain modules to PostgreSQL — persistent state
  • Domain modules to Redis and Cacheify — cache and limits
  • Domain modules to Domain events — in-process events
  • Domain events to Kafka — durable transport
  • Domain modules to Cloudinary — media
  • Domain modules to Google Gemini — resume parsing
  • Domain modules to Groq — ranking

Key decisions and tradeoffs

This section is deliberately explicit. A credible portfolio project should show not only what works, but what is unfinished and why.

17.1 Matching and AI

AreaCurrent state (verified)ImpactNext step
Activity bonusisUserHighlyActive returns trueEvery candidate gets +5; effective threshold is 35Feed from analytics/todo activity
Completion penaltyhasLowCompletionRate returns falseNo candidate is penalizedCompute from project/member history
Auto-match on publishListener intentionally disabledRecommendations only on explicit requestAdd a queue + budget guard before enabling
Gap-based roadmapCode exists; CandidateBatchReadyForAiEvent producer is disabledThe UI uses the custom roadmap path; gap-based path is dormantRe-wire the producer or remove the dead path
Skill-gap endpointReturns a stored analysis; no active producer in the current flowMay 404/500 for projects without an analysisConnect the gap analyzer to project creation or remove from the UI
Recommendation read pathPer-row getUserProfile lookupN+1 at page sizeBatch profile lookup by user ID set
Matching prompt versionStored as v1.0-batch-skillsGood foundation for experimentsAdd evaluation harness and compare versions
Stale comment"GEMINI AI" comment where Groq is usedMisleading for maintainersFix the comment / rename the stage

17.2 Product and frontend

AreaCurrent state (verified)ImpactNext step
Public /explorePlaceholder gridUnauthenticated visitors see an incomplete pageBuild public discovery or hide the route
Recommendation profile linkOpens /profile/:userId; actual route is /profiles/:userIdBroken link from the AI matchmakerFix the path and add a route test
Dashboard widgetsSome copy is static ("3 active projects")Numbers can be wrongBind widgets to analytics/project queries
Analytics endpointImplemented, cached annotation presentCurrent dashboard does not call itSurface metrics or remove the endpoint
College selectionRegistration assigns the first active collegeMulti-college isolation is architectural, not product-completeAdd college selection/verification
Profile creationLazy auto-create ("New User")A user can exist without completing onboardingMake onboarding mandatory before dashboard access
Hardcoded URLsSSE host and API fallback are hardcodedEnvironment portability issueCentralize in config/env
Error boundariesNot consistently usedA rendering error can blank a routeAdd route-level error boundaries

17.3 Security

AreaCurrent state (verified)ImpactNext step
Refresh tokensNot implementedUsers re-authenticate after 24 hoursAdd rotating refresh tokens
Token storagelocalStorageXSS can read the tokenMove to httpOnly, secure, same-site cookies
SSE tokenQuery parameter; the JWT filter accepts ?token= globallyToken may appear in logs/proxies and on non-SSE routesShort-lived stream ticket or cookie
JWT fallback secretDevelopment fallback existsProduction risk if not overriddenFail fast when the secret is absent in production
Rate limiting scopeOnly /api/auth/**Other endpoints rely on auth/business limitsAdd per-user limits for expensive AI endpoints
CORSRestricted patternsGood baselineTighten to exact production origins

17.4 Configuration and operations

AreaCurrent state (verified)ImpactNext step
Scheduling@Scheduled exists, but no @EnableScheduling was foundInvitation expiry may not run automaticallyEnable scheduling and add a test
Caching@Cacheable exists, but no @EnableCaching was foundAnalytics caching is inertEnable caching or remove the annotation
Kafka DLQNo dead-letter policyFailed events are logged/state-marked but not replayableAdd DLQ + replay tooling
Metrics/tracingLogging onlyHard to diagnose latency across async stagesMicrometer + OpenTelemetry
Tests5 backend test files; no frontend/e2e suiteRegressions in UI and workflows are not caughtAdd Playwright + targeted integration tests
DeploymentFree-tier single instanceCold starts; no horizontal scalePaid instance / autoscaling when needed

17.5 Configuration gaps that are easy to miss

Two annotations are present without their enabling annotations in the source:

  • @Scheduled in TeamMatchingService.expireOldInvitations — Spring requires @EnableScheduling (or an equivalent configuration) to process it.
  • @Cacheable in AnalyticsService.getDashboardData — Spring requires @EnableCaching (or an equivalent configuration) to create the caching proxy.

These are exactly the kinds of issues that code review catches and runtime testing confirms. They are listed here because the document is meant to be an honest engineering audit, not a sales page.

17.6 README drift

The frontend README claims TanStack Router and describes a migration from React Router. The actual code uses React Router 7 (react-router-dom 7.18.1, Routes/Route in App.tsx). The backend README is closer to the code but is more optimistic about automatic matching and the skill-gap flow than the current implementation.

The lesson is general: documentation drifts unless it is generated from or tested against the code. For a portfolio project, the code and this case study are the reliable sources.


Hard parts

9.1 Two directions, one funnel

SkillSync matches in both directions:

  • Owner → candidates: given a project and its roles, find students who can fill them.
  • Student → projects: given a student's skills and achievements, find recruiting projects that fit.

The directions share the same philosophy but use different shortlist sizes: up to 50 candidates per project and up to 10 projects per student. The asymmetry reflects the economics: a project owner needs a deeper bench; a student needs a focused list.

9.2 The funnel

StageOperationCostOutput
0Authorization and ownership checkCheapReject non-owners / invalid students
1College isolation at the SQL levelCheapEligible student or recruiting-project population
2Deterministic skill-overlap shortlistCheapTop 50 candidates / top 10 projects
3Batched semantic scoringOne LLM callSemantic score, confidence, matched skills, rationale
4Deterministic business policyCheapFinal score, status, expiry
5Persistence and read modelsCheapRanked recommendations with explanations

9.3 Stage 1 — College isolation

For the owner → candidate direction, the orchestrator loads all users by collegeId, filters to STUDENT, and excludes the owner. For the student → project direction, it loads projects by collegeId and RECRUITING, and excludes projects the student owns.

This is both a product decision and a cost decision. The college is the trust boundary; it is also the first and largest reduction in the candidate set. In a single-college deployment this is a simple filter. In a multi-college deployment it becomes the tenant boundary (see §17 for the current product gap).

9.4 Stage 2 — Deterministic skill overlap

The shortlist is built with case-insensitive, bidirectional substring matching:

candidate has skill S if S contains required skill R
                     or R contains S

This tolerates real-world variants such as React vs React.js and Node vs Node.js. It also over-matches short tokens, which is an accepted trade-off at the shortlist stage: the LLM sees the shortlist and can correct the semantic judgement, while the deterministic stage remains cheap and transparent.

For candidates, the overlap score is:

max(overlap against the project's overall required skills,
    overlap against each individual role's required skills)

Taking the maximum against a single role is important. A candidate who is an excellent fit for one role should not be penalized for lacking skills required by a different role.

The orchestrator then:

  • sorts by overlap descending;
  • keeps the top 50 candidates (or top 10 projects);
  • fetches skills and achievements in batch using projections (findSkillsForUsers, findAchievementsForUsers) rather than per-candidate queries;
  • maps the result into AI input records (CandidateProfileForAi, ProjectProfileForAi).

If a project has no required skills at all, generation aborts with a warning rather than sending an empty prompt.

9.5 Stage 3 — Batched semantic scoring with Groq

The shortlist is sent to Groq in a single batched call using llama-3.1-70b-versatile. The prompt contains:

  • the project's overall required skills;
  • each role's title and required skills;
  • for each candidate: skills with levels, and achievements (title, type, organization).

The model returns structured JSON:

{
  "matches": [
    {
      "userId": "uuid",
      "semanticScore": 82.5,
      "confidence": "HIGH",
      "matchedSkills": ["React", "TypeScript"],
      "rationale": "Strong frontend overlap and relevant project evidence."
    }
  ]
}

The prompt is versioned (v1.0-batch-skills) and the version is persisted on every recommendation. That makes it possible to compare ranking behavior across prompt changes instead of silently drifting.

Why Groq here? The matching task is high-volume, latency-sensitive classification. It benefits from a fast, inexpensive model and a single batched request. Gemini is reserved for tasks that need longer generation: resumes, curricula, mentor chat, and summaries.

Naming drift: one comment in RecommendationOrchestrator says "DEEP SEMANTIC ANALYSIS (GEMINI AI)", while the implementation calls BatchSkillGapAnalyzer and Groq. The behavior is Groq; the comment is stale. This is noted in §17 as a code-hygiene item.

9.6 Stage 4 — Deterministic business policy

The model's semantic score is not the final score. RecommendationScoringService applies a policy layer:

finalScore = clamp(semanticScore + activityBonus − completionPenalty, 0, 100)
status     = finalScore ≥ 40 ? GENERATED : LOW_MATCH
expiresAt  = now + 14 days

The intended policy is:

  • +5 for a highly active user;
  • −10 for a low project-completion rate;
  • clamp to [0, 100];
  • a 40-point threshold for a usable recommendation;
  • 14-day expiry to keep the feed fresh.

The persistence model stores both semanticScore and finalScore, plus confidence, matched skills, rationale, prompt version, and expiry. That separation is deliberate: it preserves the model's raw judgement while keeping the business policy auditable.

Current-state honesty: the two policy hooks are placeholders in the code — isUserHighlyActive returns true and hasLowCompletionRate returns false. In practice, every candidate receives the +5 bonus and no one receives the penalty, which effectively lowers the threshold to 35. The architecture for policy-based adjustment is in place; the data feeds are not. This is documented in §17 rather than presented as finished.

9.7 Generation is manual, and that is a feature

Recommendations are generated through explicit endpoints:

  • POST /api/v1/matching/dashboard/projects/{projectId}/generate
  • POST /api/v1/matching/dashboard/me/recommendations/generate

The listener that would auto-generate candidates when a project is published exists but is intentionally disabled (ProjectMatchmakingPreparationListener logs "AI matchmaking is disabled per user request" and the body is commented out). This avoids surprise LLM spend, avoids a thundering herd when many projects are published, and keeps the user in control of when analysis runs.

The frontend reflects the asynchronous nature of generation:

  • it starts the mutation;
  • shows an "AI is analyzing" state;
  • polls the recommendation query every 5 seconds;
  • stops after 36 attempts (180 seconds) with a timeout message;
  • stops early when recommendations appear.

9.8 What the ranking system optimizes for

The design is instrumentable around a clear set of metrics:

  • Precision at k: how many of the top-ranked candidates are actually invited or accepted.
  • Time to team: how long a project takes to fill its roles.
  • Invitation acceptance rate: whether the ranking is trusted by owners and students.
  • Cost per formed team: tokens and requests divided by teams formed.
  • Score calibration: whether 80% matches are accepted more often than 40% matches.

Those metrics are not yet instrumented. The point is that the architecture exposes the right seams for them: recommendation records, status transitions, invitation outcomes, and prompt versions are all persisted.


10.1 Provider routing

CapabilityProviderTriggerOutput
Resume parsingGeminiKafka resume-uploaded-topicSummary + extracted skills with confidence and inferred level
Skill gapGeminiSkill-gap request (currently dormant producer)Missing skills, matched skills, match percentage
Curriculum generationGeminiCustom roadmap requestRecommended role + ordered topic list
Topic task generationGeminiRoadmap creation / next-topic transitionCheckpoints with subtasks and descriptions
Roadmap mentor chatGemini + tool loopUser messageConversational reply, optionally grounded in tools
Profile insightsGeminiUser actionStrengths, gaps, focus areas (JSON)
Note summarizationGeminiUser actionShort summary stored on the note
Candidate/project semantic matchingGroqExplicit generation requestBatched JSON scores with rationale

10.2 Why two providers

A single provider would be simpler, but the workloads are different:

  • Gemini is used where the task is generative, long-context, or schema-heavy: resumes, curricula, mentor chat, summaries.
  • Groq is used where the task is batch classification with a short, structured output: score these 50 candidates against this project.

Splitting providers also creates a real abstraction boundary. AiGateway, AiMatchingGateway, and AiChatGateway are interfaces; the implementations can be swapped without rewriting the domain modules. That is useful for cost experiments and for avoiding a hard dependency on one vendor's availability.

10.3 The agent loop and its safeguards

The roadmap mentor is not a single prompt. It is a bounded tool-calling loop in AiChatOrchestrator:

  1. Build a Gemini request with the conversation history and available tools.
  2. Execute the model call.
  3. If the response is plain text, return it.
  4. If the response is a function call, execute the tool and append the result to the conversation.
  5. Repeat.

The loop has three safeguards:

  • Maximum 5 iterations. The agent cannot run indefinitely.
  • 25-second wall-clock budget. Even if iterations remain, the loop returns a graceful message when time is up.
  • Repeated-tool guard. If the model calls the same tool twice in a row, the loop stops with "I'm having trouble retrieving that information properly right now." This catches a common hallucination pattern where the model ignores a tool result and retries the same call.

Available tools are intentionally narrow:

  • get_user_todos — fetch the user's current tasks.
  • get_latest_skill_gap — fetch the most recent skill-gap analysis.

The tools are read-only. The mentor can explain, prioritize, and encourage, but it cannot mutate the user's todo list or roadmap. That is a deliberate safety boundary: the model advises; the application owns state.

10.4 Structured-output hardening

LLM output is treated as untrusted input. The code includes several defensive layers:

Failure modeHandling
JSON wrapped in Markdown fencesFences are stripped before parsing
Missing fieldsNull-safe DTO mapping and defaults
Invented enum valuevalueOf(...) is wrapped; falls back to LOW confidence
Empty provider responseExplicit error path; analysis or roadmap marked FAILED
Malformed roadmap stepsParsing failure marks the roadmap FAILED and logs the cause
Duplicate note summarizationPROCESSING state acts as a lock; duplicate requests are rejected
Provider timeout60-second WebClient timeout; two retries with 2-second exponential backoff

The broader lesson is that an LLM integration needs the same rigor as any external API: typed boundaries, timeouts, retries, fallbacks, and explicit failure states. A try/catch around the whole call is not enough; each item in a batch is processed defensively so one bad record does not discard the rest.

10.5 Prompt design

Prompts are centralized in PromptFactory and RecommendationPromptFactory rather than scattered across services. The important behaviors:

  • The resume prompt asks for a summary and skills with confidence and inferred level.
  • The curriculum prompt avoids topics the user has already completed.
  • The topic-task prompt generates concrete checkpoints and subtasks for the current topic.
  • The mentor prompt explicitly tells the model it cannot manually modify the todo list; the system generates the next tasks automatically.
  • The batch-match prompt asks for per-candidate scores, matched skills, confidence, and rationale in a fixed JSON shape.

Centralizing prompts makes them reviewable and versionable. The matching prompt carries a version string; the others should follow the same pattern as the system matures.

10.6 Failure behavior is a product feature

When AI fails, the product does not pretend it succeeded:

  • A resume analysis record moves to FAILED with an error message.
  • A roadmap moves to FAILED if checkpoint generation fails.
  • A note summary moves to FAILED, and the user can retry.
  • A matching request returns no recommendations, and the UI times out with a clear message.

This is more trustworthy than silent empty states. It also makes operational debugging possible: the failure is persisted, not just logged.


This section is the engineering core of the case study. Each item describes a real problem in the build, the root cause, the solution, and the lesson.

16.1 LLM cost would grow linearly with the user base

Problem: the naive matching design sends every student in the college to an LLM for every project. That is expensive, slow, and produces huge prompts.

Root cause: treating the model as the first filter rather than the last.

Solution: a three-stage funnel. College-scoped SQL reduces the population; deterministic skill overlap reduces it again; only the top 50 candidates (or top 10 projects) reach a single batched Groq call. The model never sees the full college.

Result: the expensive stage is bounded by a constant, not by the number of students. One request per generation instead of one request per candidate.

Lesson: the cheapest token is the one you never send. Rank before you prompt.

16.2 LLM output is not a reliable API contract

Problem: models wrap JSON in Markdown fences, omit fields, invent enum values, and occasionally return prose instead of JSON. A naive objectMapper.readValue fails the whole batch.

Root cause: treating generated text as if it were a typed API response.

Solution: defensive parsing at every boundary:

  • strip ```json fences before parsing;
  • map into typed records;
  • null-check each field and apply defaults;
  • parse enums in a try/catch with a LOW fallback;
  • process each candidate independently so one bad record does not discard the batch;
  • persist FAILED states with error messages when the response is unusable.

Lesson: an LLM is an untrusted external dependency. Validate it like one.

16.3 AI latency does not fit an HTTP request

Problem: resume analysis and roadmap generation can take tens of seconds. Holding the request open causes timeouts and a poor UX.

Root cause: coupling a slow, retryable workflow to a synchronous user action.

Solution: move the work behind Kafka or Modulith events, persist a status record, and notify the user when it finishes. Resume analysis uses Kafka; roadmap generation persists ACTIVE or FAILED; note summarization uses a PROCESSING state; recommendations are polled by the frontend.

Lesson: asynchronous work needs a state machine, not just a background thread.

16.4 Cross-module coupling was turning the monolith into a ball of mud

Problem: services in one module needed data from another. Direct repository imports would create hidden coupling and make future extraction impossible.

Root cause: a monolith without enforced boundaries.

Solution: gateway interfaces for cross-module reads (UserProfileGateway, ProjectGateway, TodoGateway, ChatGateway, AiGateway, AiMatchingGateway) and domain events for cross-module writes. A ModulithVerificationTest runs ApplicationModules.verify() and fails the build on illegal imports.

Lesson: architecture boundaries that are not tested are suggestions.

16.5 Duplicate delivery created duplicate work

Problem: Kafka is at-least-once; Modulith listeners can redeliver; users double-click. The same event could create two todos, two memberships, or two recommendations.

Root cause: assuming exactly-once delivery in a system that does not provide it.

Solution: idempotency at the domain level:

  • todo creation deduplicates by relatedEntityId;
  • membership checks for an existing active member;
  • role capacity is re-checked at acceptance;
  • pending applications and invitations are uniqueness-checked;
  • recommendations are unique on (project_id, user_id) and upserted with optimistic locking;
  • note summarization uses a PROCESSING lock.

Lesson: idempotency belongs in the domain model, not in the message broker configuration.

16.6 Team capacity is a race condition

Problem: two students accept invitations at the same time. Both requests read "one slot left" and both insert a member. The team exceeds maxTeamSize, and the role exceeds its headcount.

Root cause: check-then-act without a transactional guarantee at the database boundary.

Solution: capacity is re-validated inside the acceptance transaction; role filledCount is incremented only after the checks; duplicate membership is rejected; the project transitions to IN_PROGRESS only when the count reaches capacity. The recommendation record is explicitly treated as a suggestion, not a reservation.

Honest caveat: without a database-level unique constraint or pessimistic lock on team membership, a true concurrent race is still theoretically possible. The next step is a unique (project_id, user_id) constraint for active members and a row lock around role capacity. The current service-level checks handle the common cases and are a clear improvement over the naive flow.

Lesson: "check then act" is only safe if the check and the act share a transaction and the database enforces the invariant.

16.7 OAuth2 tokens leaked through redirect URLs

Problem: the conventional OAuth2 flow redirects the browser with the JWT in the query string. The token ends up in browser history, referrer headers, and access logs.

Root cause: treating the redirect as a trusted channel.

Solution: the backend mints the JWT, stores it under a single-use UUID exchange code for 30 seconds, and redirects with only the code. The SPA exchanges the code server-to-server.

Lesson: a URL is a public place. Never put a long-lived credential in one.

16.8 EventSource cannot send an Authorization header

Problem: SSE is the right transport for notifications, but the browser's native EventSource does not support custom headers. The backend requires a JWT.

Root cause: a mismatch between the browser API and the stateless auth design.

Solution: the frontend appends the token as a query parameter, and the backend accepts it for the stream endpoint. The connection is authenticated and the notification stream works.

Honest caveat: query-string tokens can be logged. The proper fix is a short-lived stream ticket or a same-site cookie for the stream endpoint. This is documented as a security trade-off, not presented as ideal.

Lesson: sometimes the practical solution is a documented compromise; the important thing is to know exactly what you traded away.

16.9 WebSocket principal identity vs domain UUIDs

Problem: STOMP user destinations are addressed by principal, but the domain uses UUIDs. A mismatch causes messages to be routed to the wrong user or dropped.

Root cause: two identity representations — security principal and domain identifier — without an explicit mapping.

Solution: the STOMP interceptor validates the JWT on CONNECT and binds a principal backed by the user identity. Project chat uses project topics; direct messages use /user/queue/messages; REST history is keyed by the domain UUID.

Lesson: identity is a cross-cutting concern. Decide early which identifier the security layer and the domain layer share, and make the mapping explicit.

16.10 Render cold starts looked like outages

Problem: the free-tier backend sleeps. The first request returns 502/503, the SPA shows a network error, and users assume the product is broken.

Root cause: infrastructure behavior leaking into the product experience.

Solution: a ConnectionStatus component that probes /api/auth/ping, treats 502/503 as "waking", treats any HTTP response as "connected", and polls every 5 seconds while waking and every 30 seconds when healthy. The copy says "Waking Server..." instead of "Error."

Lesson: deployment constraints are UX constraints. Design for the infrastructure you actually have.

16.11 The agent loop could spin forever

Problem: a tool-calling model can repeatedly call the same tool, ignore the result, and loop. Without a bound, the request hangs and burns tokens.

Root cause: unbounded autonomy in an LLM agent.

Solution: a maximum of 5 iterations, a 25-second wall-clock budget, a repeated-tool guard, and graceful fallback messages. The tools are read-only, so the agent cannot mutate state even if it misbehaves.

Lesson: agentic systems need circuit breakers, not just prompts.

16.12 N+1 queries in the matching path

Problem: loading skills and achievements per candidate would produce hundreds of queries for a 50-candidate shortlist.

Root cause: mapping over entities with lazy relationships.

Solution: batch projections (findSkillsForUsers, findAchievementsForUsers) fetch all skills and achievements for the candidate ID set in one query each, then group in memory.

Honest caveat: the scoring path is batched, but the recommendation response mapping still calls getUserProfile per row. That is a remaining N+1 on the read path, acceptable at page size but worth fixing with a batch profile lookup.

Lesson: batch the hot path first; measure before optimizing the read path.

16.13 A static roadmap would stop after the first topic

Problem: generating one set of tasks for one topic creates a dead end. The user finishes the tasks and has no next step.

Root cause: treating the roadmap as a document rather than a state machine.

Solution: the roadmap stores a curriculum, a current topic, and completed topics. Each topic's checkpoints become todos. Completing all checkpoints marks the topic complete, selects the next topic, generates new tasks, and publishes a new RoadmapGeneratedEvent. Creating a new roadmap deactivates the old one and its outstanding todos.

Lesson: a learning product must model progress, not just content.

16.14 Stale AI summaries undermined trust

Problem: a note's AI summary remained visible after the note content changed, so the summary described text that no longer existed.

Root cause: treating the summary as a static field rather than a derived artifact.

Solution: editing a note clears aiSummary, resets summaryStatus to IDLE, and clears the timestamp. The next summarize action regenerates it.

Lesson: derived data must be invalidated when its source changes.


Results

18.1 Competency map

CompetencyEvidence in this project
Backend architecture13-module Spring Modulith monolith with gateway interfaces, domain events, and boundary verification
API design95 route mappings across 20 controllers, consistent ApiResponse envelope, DTO boundaries, pagination
AI engineeringProvider routing, batched inference, structured-output hardening, prompt versioning, bounded agent loops
Distributed workflowsKafka ingestion, Modulith events, idempotency, status machines, async failure handling
SecurityJWT, hashed OTP, OAuth2 exchange code, rate limiting, ownership checks, STOMP authentication
Real-time systemsSTOMP/WebSocket chat, SSE notifications, persisted message history, read receipts
Data modelingSchema-per-domain, aggregate boundaries, unique constraints, optimistic locking, projections
Frontend engineeringReact 19 + TypeScript, feature modules, TanStack Query, Axios interceptors, accessible Radix UI
DeploymentMulti-stage Docker, Render/Vercel, managed Postgres/Redis/Kafka, Cloudinary, cold-start UX
Product thinkingTwo-sided matching, explainable scores, profile completeness, learning loop, honest empty states

18.2 Resume-ready bullets

  • Designed a three-stage, cost-bounded matching pipeline (college-scoped SQL → deterministic skill-overlap shortlist → one batched Groq call) that caps model input at 50 candidates per project and 10 projects per student, with persisted scores, confidence, matched skills, rationale, prompt version, and a 14-day expiry.
  • Built a 13-module Spring Modulith monolith (262 Java files, 95 route mappings) with gateway interfaces for cross-module reads, domain events for writes, and an automated ApplicationModules.verify() test that fails the build on boundary violations.
  • Implemented an event-driven resume pipeline (Cloudinary signed upload → Kafka → PDFBox extraction → Gemini structured analysis → Kafka → profile skill upsert + SSE notification) with explicit PENDING/COMPLETED/FAILED states.
  • Engineered a self-continuing learning loop where roadmap checkpoints become todos, todo completion advances the topic, and the next topic's tasks are generated automatically — with idempotent todo creation and roadmap deactivation.
  • Hardened authentication with SHA-256-hashed OTPs (5-minute TTL, 45-second cooldown, constant-time comparison), a single-use 30-second OAuth2 exchange code, and 5 requests/minute/IP rate limiting backed by Redis/Bucket4j or a Cacheify HTTP store.
  • Delivered real-time collaboration through STOMP/WebSocket project chat and direct messages plus persisted SSE notifications, with JWT-authenticated channels and read receipts.
  • Reduced cold-start confusion on Render with a health-aware connection indicator that distinguishes 502/503 wake-up from a true outage, polling at 5s/30s and using explicit "Waking Server" / "Connected" / "Server Offline" states.
  • Made AI failures observable by persisting FAILED states with error messages for resume analysis, roadmaps, and note summaries instead of silently returning empty results.

18.3 How to talk about it in an interview

"What is the hardest part of this project?" The matching pipeline. The hard part is not calling an LLM; it is deciding what the model is allowed to decide. The architecture keeps eligibility, capacity, state, and policy in deterministic code, and uses the model only for semantic judgement. That is what makes the system affordable and explainable.

"Why a monolith?" The domain has strong transactional invariants — team capacity, role headcount, invitation state, recommendation uniqueness. A monolith keeps those invariants in one transaction and one deployable. The module boundaries and events make extraction possible later without paying the operational cost now.

"What would you do next?" Replace the scoring stubs with real activity/completion data, add refresh-token rotation and httpOnly cookies, move the SSE token to a short-lived stream ticket, add database-level constraints for membership races, and build an evaluation harness for the matching prompt.

"What are you least happy with?" The gap between the README and the code in a few places, and the dormant skill-gap path. Both are documented in §17. The honest answer is better than pretending the system is more finished than it is.


Stack

8.1 Backend

TechnologyVersionRoleWhy it fits
Java21RuntimeRecords, pattern matching, virtual-thread-ready ecosystem
Spring Boot4.1.0Application frameworkOpinionated wiring, mature security/data/web support
Spring Modulith2.1.0Module boundaries + eventsEnforces modular monolith architecture; transactional event handling
Spring SecurityBoot-managedAuthN/AuthZFilter chain, OAuth2 client, method security
Spring Data JPA / HibernateBoot-managedPersistenceAggregate-oriented modeling; schema generation
PostgreSQLManagedPrimary databaseRelational integrity, JSON columns, schema separation
Spring WebMVCBoot-managedREST APISynchronous request/response
Spring WebFlux / WebClientBoot-managedAI provider callsNon-blocking HTTP client with timeouts and retries
Spring WebSocket + STOMPBoot-managedReal-time chatTopic/queue semantics, user destinations
SSE (SseEmitter)Boot-managedLive notificationsOne-way server push over plain HTTP
Apache KafkaManagedDurable async pipelineDecouples resume ingestion and AI analysis; replayable
RedisManagedOTP + rate-limit stateTTL semantics, atomic counters, distributed token buckets
Bucket4j8.19.0Rate limitingToken bucket over Redis; pluggable store abstraction
JJWT0.12.5JWT issuance/validationStateless authentication
Apache PDFBox3.0.1PDF text extractionResume parsing without an external service
Cloudinary SDK1.36.0Media storageDirect browser uploads via signed requests
Spring Dotenv4.0.0Local configLoads environment values without hardcoding
LombokBoot-managedBoilerplate reductionConstructors, getters, builders, logging
MavenWrapperBuildReproducible builds
DockerMulti-stagePackagingSmall runtime image, deployment parity
JUnit 5 / Modulith TestBoot-managedTestsOTP unit tests, store integration tests, module verification

Notable dependency choices:

  • WebMVC + WebFlux together. The API is servlet-based; the AI calls use WebClient. This is a deliberate hybrid: MVC keeps the CRUD surface simple, while the reactive client gives non-blocking calls, timeouts, and retry composition for slow external providers.
  • Redis as an interface, not a hard dependency. OTP storage, exchange codes, and rate limiting are behind OtpStore, ExchangeCodeStore, and RateLimitStore. The default implementation uses Redis; a Cacheify HTTP implementation exists for environments where Redis is unavailable. This is a real portability decision, not an abstraction for its own sake.
  • Kafka alongside Modulith events. Modulith events handle in-process domain reactions; Kafka handles durable, potentially replayable AI work. Using both is a conscious trade-off between simplicity and resilience.

8.2 Frontend

TechnologyVersionRoleWhy it fits
React19.2.0UI runtimeComponent model, concurrent rendering
TypeScript5.8.3Type safetyCompile-time contracts across API and UI
Vite8.0.16Build/dev serverFast HMR, small production bundles
Tailwind CSS4.2.1StylingUtility-first design system with theme tokens
Radix UIMultiple primitivesAccessible componentsKeyboard/focus behavior handled correctly
shadcn-style componentsLocal components/uiDesign systemOwned primitives, not a black-box library
React Router7.18.1RoutingNested layouts, protected routes, dynamic params
TanStack Query5.101.1Server stateCaching, invalidation, polling, mutation lifecycle
Axios1.18.1HTTP clientInterceptors for bearer token and 401 handling
React Hook Form7.71.2FormsPerformant form state
Zod3.24.2ValidationShared schema validation on the client
STOMP.js7.3.0WebSocket messagingSTOMP over native WebSocket
Framer Motion13.0.0MotionPage and component transitions
Lenis1.3.25Smooth scrollLanding-page feel
Three.js / OGL0.185.1 / 0.0.11WebGL visualsHero and landing effects
Recharts2.15.4ChartsAnalytics/visualization primitives
Sonner2.0.7ToastsNotification and mutation feedback
jwt-decode4.0.0Token decodingClient-side user identity hydration

README drift, verified: the frontend README states that the app uses TanStack Router and even notes a migration from React Router. The actual package.json and src/App.tsx use React Router 7. The README is aspirational or stale on this point. This document follows the code.

8.3 Infrastructure and external services

ServiceRoleOperational note
VercelStatic SPA hostingvercel.json rewrites all paths to index.html for client-side routing
RenderBackend container hostingDocker image; free-tier instances sleep and return 502/503 during wake-up
PostgreSQLPrimary datastoreManaged instance; schema-per-domain organization
RedisOTP, cooldown, exchange codes, rate limitingTTL-based state; Bucket4j distributed buckets
KafkaResume and certificate event streamsManaged broker; SSL truststore configured in the image
CloudinaryResume and avatar storageBrowser uploads via backend-signed requests
Google GeminiExtraction and generationResume parsing, skill gap, curriculum, chat, summaries, insights
GroqBatch semantic matchingllama-3.1-70b-versatile; one request per shortlist

8.4 Test coverage, verified

The test suite is small but deliberate:

  • OtpServiceTest — hashing, normalization, wrong-code rejection, single-use consumption, cooldown delegation.
  • HttpOtpStoreTest — HTTP store semantics, including 404-as-empty and TTL handling.
  • CacheifyLiveIntegrationTest — live integration against the Cacheify store.
  • ModulithVerificationTest — module boundary verification.
  • SkillsyncAiApplicationTests — application context load.

There is no broad end-to-end suite and no frontend test suite. That is a real gap, documented in §17.