Backend · Multi-tenant · AWS S3 · 2025 – 2026
RealEstate CRM
A multi-tenant CRM for property developers: projects broken down to individual flats, the enquiry-to-booking pipeline, disbursements, and project documents kept private in S3.
- Role
- Backend engineer on a two-person team, YSM Infosolution
- Stack
- Java 21Spring Boot 3Spring SecurityPostgreSQLAWS S3React 18DockerNginx
5
Hierarchy levels
17
REST controllers
10 min
Download-link lifetime
Company project: the code is private, so this page covers architecture and behaviour rather than source.
01 / The challenge
A deep, shared hierarchy
Everything hangs off an organization's projects: wings, floors and flats, plus enquiries, follow-ups, bookings, clients and the bank disbursement schedule.
Private files, easy access
Agreements, IDs and letterheads must never sit in a public bucket, but staff still need to view and share them in a click.
Two systems, one operation
Creating a project writes dozens of rows and uploads files. The database can roll back; S3 can't.
02 / Architecture journey
Follow a project from creation to a buyer's booking, and the files that travel with it.
React SPA
Vite · Tailwind
Nginx
/api proxy
Spring Boot
JWT · role checks
projects
wings → flats
enquiries
→ bookings
disbursements
bank info
documents
S3 storage
PostgreSQL
scheduler
01:00 daily
AWS S3
private bucket
01 / 06 · One origin, no CORS
01
One origin, no CORS
The React SPA is served by Nginx, which also proxies /api/* to the Spring Boot container. The browser only ever talks to one origin.
Security is stateless JWT with access and refresh tokens, and endpoints are gated by role. Every authenticated user carries their organization id, which the services use to scope data to that tenant.
02
Create a project in one request
An admin submits the whole project at once: details, a MahaRERA number, wings with their floors and flats, approved banks, amenities, documents and the disbursement schedule. Project names and MahaRERA numbers are checked for uniqueness first.
It all runs in one @Transactional method, so the rows commit together or not at all.
03
Files go to S3, keys go to the database
The letterhead and project documents upload to a private bucket under {orgId}/{projectName}/LetterHead/ and /Documents/. Only the object key is stored in PostgreSQL, never bytes or a public URL.
Because the uploads happen mid-transaction and S3 can't roll back, a sub-step that reports failure triggers a compensating delete: list everything under the project's prefix, batch-delete it, then throw so the database rolls back too.
04
Short-lived links instead of a public bucket
Viewing or sharing a document asks the API for a presigned GET URL for that one object, valid for ten minutes. Access control stays in the application; the bucket stays private.
05
From enquiry to booking
Sales staff log enquiries against a project and track them through follow-ups. A booking ties three things together: the enquiry it came from, the specific flat, and the client buying it.
06
A to-do list that writes itself
At 01:00 every day a scheduler generates the follow-up tasks due, so each employee starts the morning with a current queue instead of yesterday's leftovers.
03 / System components
- ProjectServiceImpl
- Creates a project and its whole hierarchy in one transaction.
- hardDeleteProjectRecursive
- Compensation: S3 prefix delete plus rows, used to undo a failed create.
- S3StorageService
- Upload, download and batch prefix delete with AWS SDK v2.
- S3PresignedUrlService
- Single-object GET links that expire after 10 minutes.
- DailyTaskScheduler
- Generates the day's follow-up tasks at 01:00.
- Nginx
- Serves the SPA and proxies /api to the backend container.
- JWT security
- Stateless access + refresh tokens, role-gated endpoints.
- Docker Compose
- PostgreSQL, backend and the Nginx frontend as one stack.
04 / Now you drive it
Step 3's failure handling, replayed. Make any step fail, either by reporting failure or by throwing, and compare what's left in the database and in the bucket.
- // no rows
- // no objects
- // Pick a failure, then send the request.
05 / What I'd change next
Compensate on exceptions too
The clean-up only runs when a step returns false. An exception skips it, so files uploaded earlier in the request are left in S3. Registering the S3 clean-up as an after-rollback hook would cover both paths.
Page through large prefixes
A single listObjectsV2 call returns at most 1,000 keys, and the prefix delete reads one page. A project with more files would be only partly cleaned.
Key objects by id, not name
Keys use the project's name. Renaming a project would separate it from its files; a stable project id in the prefix wouldn't.
06 / Deep dives from this project