All backend services live here as sibling folders, sharing one Postgres instance (docker-compose.yml, root) and one Redis instance for caching and async events. The .NET services are unified under Vettly.slnx and reference the shared class library for common DTOs, event contracts, and JWT middleware.
Two communication patterns are used throughout:
- Synchronous HTTP between services when the caller needs the result immediately (e.g. candidate-service calling screening-service).
- Async Redis pub/sub on a single channel,
vettly.events, for fire-and-forget notifications. Events are wrapped in aDomainEventEnvelope<T>(shared/DTOs/Events/) with aType,OccurredAt, andDatapayload. Current event types:application_received,stage_changed,offer_ready,document_signed.
JWT auth, refresh tokens, and Google/GitHub OAuth. Source of truth for user identity — no outbound calls to other services.
POST /api/auth/register,POST /api/auth/login,POST /api/auth/refresh,POST /api/auth/logout,GET /api/auth/meGET /api/auth/google,GET /api/auth/github,GET /api/auth/oauth/callbackGET /api/internal/users/{id}/name— internal lookup used by other services to resolve a display name- Model:
User(email, password hash, role, name, active flag) - Refresh tokens and the logout blacklist are stored in Redis via
ITokenService
Candidate profiles, applications, resumes (stored in Cloudflare R2).
api/candidates/applications:POST /(apply),GET /,GET /{id},GET /{id}/summary,POST /preview-scores(batch AI score preview across jobs)api/candidates: profile CRUD (GET/POST/PUT /profile,POST /profile/avatar), experience/education/skills sub-resources, resume upload/list/delete/set-primaryapi/internal/applications:PATCH /{id}/screening-result(callback target for screening-service),GET /{id}/participants(used by messaging-service and notification-service)- Models:
CandidateProfile(has manyExperience,Education,Skill,Resume,Application);Application(job/resume refs,Status,AiScore,MatchScore,BiasFlagged,SkillGap) - Calls out to: job-service (
POST /api/jobs/{jobId}/pipeline/applicationsto register a new application) and screening-service (POST /screen-resume,POST /batch-preview) - Publishes
application_receivedto Redis on new application
Job postings and pipeline stage management.
api/jobs: publicGET /,GET /{id}; recruiter-onlyGET /my-jobs,GET /my-jobs/stats,POST /,PUT /{id},PATCH /{id}/status,DELETE /{id}api/jobs/{jobId}/pipeline:GET /(filter by stage),POST /move,POST /applications(candidate-service registers here),GET /application/{applicationId},PATCH /application/{applicationId}/notes- Models:
JobPosting(type, experience level, salary range, status draft/open/closed/archived, org info, has manyJobSkill/PipelineStage);PipelineStage(applied → screening → matched → interview → offer → hired/rejected) - Calls out to: organization-service (
GET /api/organizations/mine, to attach org info when creating a job) - Publishes
stage_changedto Redis when a pipeline stage moves
Recruiter organizations and membership.
api/organizations:POST /(create),GET /mine,GET /{id}(public),PUT /mine,GET /search,POST /join(by join code),POST /mine/join-code/regenerate(owner only)- Models:
Organization(profile fields, join code, has manyOrganizationMember);OrganizationMember(role within org) - No outbound calls; consumed by job-service
Real-time chat plus in-app notifications, and the main consumer of the Redis event stream.
api/messaging/conversations:GET /,POST /(get-or-create by application),GET /{id}/messages(paginated),POST /{id}/messages,POST /{id}/readapi/messaging:GET /notifications,POST /notifications/read,GET /unread-summaryMessagingHub(SignalR) — adds each connection to auser:{userId}group for real-time push- Models:
Conversation(per-side unread counts, has manyMessage);Notification(typed: application_received/stage_changed/offer_ready/message_reply/document_signed) RedisEventSubscriberbackground service handles all four event types, calling job-service/candidate-service to resolve participants before creating an in-appNotification
Offer letter generation and e-signatures, documents stored in R2.
api/esign/documents:POST /(create),GET /,GET /{id},GET /{id}/download(presigned URL),POST /{id}/sign- Every action logs an
AuditTrailEntry(action, actor, IP, user agent) - Models:
Document(type, salary/start date, S3 key + signed S3 key, status pending/signed, SHA-256Hash, has oneSignature, manyAuditTrailEntry) - Calls out to: candidate-service, job-service, auth-service to enrich document data
- Publishes
offer_readyon document creation anddocument_signedafter signing
Interview scheduling between recruiter and candidate.
api/interviews:POST /(recruiter creates),GET /(role-aware list),GET /{id},DELETE /{id}(cancel)- Model:
Interview(recruiter/candidate refs, scheduled time, duration, status, meeting link) - Calls out to: job-service and candidate-service to validate/enrich data on creation. No events published.
Resume screening and bias detection.
GET /healthPOST /screen-resume→202 Accepted, runs as a background task: fetches the job from job-service, downloads the resume from R2, extracts text, scores it, runs bias detection, then PATCHes the result back to the caller-suppliedcallback_url(candidate-service's internal screening-result endpoint)POST /batch-preview→ scores one resume against multiple jobs synchronously, no callback- Scoring:
sentence-transformers(all-MiniLM-L6-v2) cosine similarity between resume and job description forai_score;match_scoreblendsai_score(70%) with required/optional skill match ratio (30%) - Bias detection: flags a
gender_signal(via name-based gender inference) andage_signal(birth/graduation year implying age > 40) — these are heuristics for surfacing potential bias in downstream screening decisions, not ground truth
Semantic candidate↔job ranking and skill gap analysis, both recruiter-only.
POST /api/matching/rank-candidates— embeds job + candidate profiles and ranks by cosine similarity (fitScore); candidates whose profile can't be fetched are returned withstatus: "skipped"POST /api/matching/skill-gap— matches each required job skill against the candidate's skills above a similarity threshold, returns matched/missing skills and a match percentage- Forwards the recruiter's JWT when calling candidate-service and job-service
Pure Redis subscriber — no HTTP endpoints. Subscribes to vettly.events and sends transactional email (via SMTP/Mailtrap) for each event type:
| Event | Email sent to |
|---|---|
application_received |
Recruiter — "New application from {candidate}" |
stage_changed (ignores applied) |
Candidate — "You were moved to {stage}" |
offer_ready |
Candidate — "Your offer for {job} is ready to sign" |
document_signed |
Recruiter — "{candidate} accepted the offer" |
Resolves job/user/participant details via plain HTTP GET to job-service, candidate-service, and auth-service's internal endpoints. Failures are logged and recovered so one bad message never kills the subscribe loop.
The single public entry point in front of every other backend service — a reverse proxy with centralized auth, rate limiting, and CORS.
- Static route table (
routes.go), matched by longest path prefix:/api/auth,/api/candidates,/api/jobs,/api/organizations,/api/messaging+/hubs/messaging,/api/esign,/api/interviews,/api/matching— each proxied to its backend's internal URL viahttputil.ReverseProxy(which handles WebSocket upgrades natively, so the messaging SignalR hub works transparently through the gateway). screening-serviceand every/api/internal/*route are not exposed through the gateway — internal/service-to-service only.- JWT validation (
middleware_auth.go): local HMAC verification using the sameJWT_SECRET/issuer/audience every other service already validates against — fast-fails protected routes with 401, forwards the originalAuthorizationheader unchanged. A handful ofauth-serviceroutes are public (register/login/refresh/OAuth); everything else requires a valid token. - Per-IP rate limiting (
middleware_ratelimit.go) — in-memory token bucket, configurable viaRATE_LIMIT_RPS/RATE_LIMIT_BURST. - CORS is centralized here (
CORS_ALLOWED_ORIGIN) rather than duplicated per service. GET /healthis handled directly by the gateway (public, bypasses auth) for platform health checks.
render.yamlat the repo root defines all 11 services as free-tier Render web services for a zero-cost deployment path (Neon for Postgres, Upstash/Redis Cloud for Redis instead of the docker-compose containers)..github/workflows/deploy.ymlbuilds and pushes every service's image to GHCR on manual trigger.