Geospatial social meetup platform — post events, discover nearby activities, join with a request workflow, and get promoted off the waitlist automatically.
- Overview
- Architecture
- Tech stack
- Local setup
- How it works
- Directory layout
- Engineering decisions
- What I learned
Nexus lets users post events, discover what is happening nearby, and send join requests. The system enforces per-event capacity, promotes waitlisted users automatically when a slot opens, and notifies participants in real time. The backend is a single Spring Boot monolith; the frontend is a React SPA with Leaflet maps and geolocation.
React SPA (Vite · Zustand · Leaflet)
│ HTTP :8080
┌───────▼──────────────────────────────────────────┐
│ Spring Boot Monolith │
│ │
│ JWT filter → auth / event / group / notification│
│ │
│ • PostGIS spatial queries (ST_DWithin) │
│ • Caffeine in-memory cache │
│ • Direct in-process calls (no Kafka/HTTP) │
│ • Flyway migrations │
└──────────────────────────┬───────────────────────┘
│
┌────────▼────────┐
│ PostgreSQL 15 │
│ + PostGIS 3.3 │
└─────────────────┘
Key flows
- Discovery —
ST_DWithinon a PostGIS spatial index, Caffeine-cached by radius + category. - Join request — capacity check via direct service call; full events go to
WAITLISTED; leaving a member triggerspromoteFromWaitlistin the same@Transactionalmethod. - Notifications — created by direct method call from EventService / GroupService / JoinRequestService inside the same transaction, no messaging broker needed.
- Auth — stateless JWT (15 min) validated by a
OncePerRequestFilter; refresh tokens stored hashed (SHA-256), rotated on every use.
| Layer | Technology |
|---|---|
| Backend | Java 21 · Spring Boot 3.2 · Maven |
| Frontend | React 18 · Vite · Zustand · React Query v5 |
| Database | PostgreSQL 15 · PostGIS 3.3 (single DB) |
| Cache | Caffeine (in-memory, no external server) |
| Auth | JWT (access 15 min · refresh 7 days, rotated on each use) |
| ORM | JPA / Hibernate Spatial · Flyway migrations · MapStruct |
| Maps | Leaflet · OpenStreetMap · Nominatim geocoding |
| Hosting | Railway (app + PostgreSQL) · Vercel (frontend) |
- Java 21 and Maven 3.9+
- Docker Desktop
- Node.js 20+
git clone https://github.com/mars-alien/nexus.git
cd nexus/backend
# Start PostgreSQL + the Spring Boot app
docker compose up --buildThe API is available at http://localhost:8080.
To run the app without Docker (requires a local PostgreSQL with PostGIS):
mvn spring-boot:runCopy .env.example to .env and adjust values if needed.
cd nexus/frontend
npm install
npm run dev # http://localhost:5173Set VITE_API_URL=http://localhost:8080 in frontend/.env.development if not already set.
1) Geospatial discovery
Event locations are stored as geometry(Point, 4326) with a PostGIS GiST spatial index. The query uses ST_DWithin against a geography cast for accurate metre-level radius filtering without a full table scan. Results are paged and cached in Caffeine keyed by lat · lng · radiusKm · category · page with a 5-minute TTL.
2) Join request and waitlist
- A user sends
POST /api/v1/events/{id}/join-requests. JoinRequestServicecallsEventService.getCapacity()directly (in-process, same JVM) to check current capacity.- If full, the request is saved as
WAITLISTEDwith a queue position. - When any member leaves (
DELETE /api/v1/events/{id}/members/me),promoteFromWaitlistruns inside a@Transactionalmethod: it computes available slots, fetches the earliestWAITLISTEDrequests, promotes them toAPPROVED, and updatescurrentMemberCount— all in one DB transaction.
3) Notifications
Instead of a Kafka consumer, NotificationService.createNotification() is called directly by EventService, GroupService, and JoinRequestService within the same transaction that triggers the relevant state change. This guarantees notifications are never lost without requiring a message broker or outbox pattern.
4) JWT auth and token rotation
JwtAuthenticationFilter validates the JWT on every request and populates the SecurityContext with a UserPrincipal record. Access tokens are stateless and expire in 15 minutes. Refresh tokens are hashed with SHA-256 before storage and rotated on every use: the old token is revoked atomically before the new pair is issued.
.
├── .env.example
├── backend/
│ ├── Dockerfile
│ ├── docker-compose.yml # postgres + app (local dev)
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/nexus/
│ │ ├── NexusApplication.java
│ │ ├── auth/ # User, RefreshToken, JWT, AuthService
│ │ ├── event/ # Event, PostGIS queries, scheduler
│ │ ├── group/ # GroupMember, JoinRequest, waitlist
│ │ ├── notification/ # Notification entity + service
│ │ ├── security/ # JwtFilter, SecurityConfig, UserPrincipal
│ │ ├── common/ # BaseEntity, ApiResponse, PageResponse
│ │ └── exception/ # GlobalExceptionHandler + custom exceptions
│ └── resources/
│ ├── application.yml
│ └── db/migration/
│ └── V1__init.sql # single Flyway migration for all tables
└── frontend/
├── src/
│ ├── api/ # axios instance + endpoint constants
│ ├── components/ # shared UI components
│ ├── features/
│ │ ├── auth/ # login / register forms + auth API
│ │ ├── events/ # event cards, map, form, API
│ │ └── groups/ # join-request button, members list, API
│ ├── pages/ # route-level page components
│ ├── store/ # Zustand auth store
│ ├── styles/ # CSS custom properties + component styles
│ └── utils/ # formatters, validators
└── index.html
Monolith over microservices — five Spring Boot services connected by Kafka, Redis, and Feign clients were collapsed into one. The domain boundaries are preserved as packages (auth, event, group, notification) but services communicate via direct method calls in the same transaction. This eliminates the operational overhead of Kafka, multiple databases, and an API gateway while keeping the code organised.
Caffeine over Redis — geospatial query results are cached in-process with Caffeine (max 500 entries, 5-minute TTL). No external cache server means one less Railway service and zero network latency on cache hits.
Real foreign keys — the monolith uses a single database, so events.creator_id is a proper REFERENCES users(id) constraint. The microservice version stored only UUIDs across service boundaries and could not enforce referential integrity at the DB level.
Denormalised creatorUsername on Event — event listings include the creator's username without a join on every read. Username changes are rare and out of scope; the trade-off is intentional.
Flyway over ddl-auto: create — ddl-auto: validate is used in all environments. Schema is owned by a single versioned V1__init.sql migration, making every environment reproducible with a clear upgrade path.
Spring Security 6 path ordering — GET /api/v1/events/my must be declared before GET /api/v1/events/{id} in the security filter chain. Spring Security 6's {id} pattern matches any literal segment (including "my"), so first-match-wins ordering is required.
-
Spatial queries with PostGIS and JTS.
ST_DWithinon ageographycast uses the GiST index for accurate radius searches without a full table scan. JTS uses(x=longitude, y=latitude)coordinate ordering — opposite of most geographic APIs — which caused subtle bugs until caught. -
JWT refresh token rotation. Short-lived access tokens keep the stateless API fast. Storing refresh tokens hashed with SHA-256 and rotating on every use means a stolen token can only be used once before the legitimate client invalidates it.
-
Waitlist auto-promotion. Concurrent member departures could over-promote. Computing available slots and promoting inside a
@Transactionalmethod makes the operation atomic without an explicit row lock. -
Spring Data JPA with ManyToOne traversal. When
Event.creatoris a@ManyToOne, the derived query becomesfindByCreator_IdAndDeletedAtIsNull(underscore traversal), notfindByCreatorIdAndDeletedAtIsNull. MapStruct source paths also change:source = "creator.id"instead ofsource = "creatorId". -
Monolith vs microservices trade-offs. Microservices give independent deployability and per-service data stores at the cost of network latency, distributed transactions, and operational complexity. Collapsing to a monolith removed Kafka, Redis, four extra databases, and an API gateway — all without losing domain separation.