Backend · Multi-tenant SaaS · Event-driven · 2026 – present
Generic WorkFlow
A multi-tenant, metadata-driven CRM: workspaces design their own tables, forms, permissions and automations at runtime, without a schema migration or a redeploy.
- Role
- Main backend engineer, YSM Infosolution
- Stack
- Java 21Spring Boot 4Spring ModulithSpring SecurityPostgreSQLKafkaRedisReact 19Docker
67
Permission keys
5
Grant scopes
33
After-commit listeners
Company project: the code is private, so this page covers architecture and behaviour rather than source.
01 / The challenge
Custom data, no migrations
Each workspace defines its own record groups (tables), form fields (columns) and automations from the UI. The backend can't know any of that schema in advance.
Access that follows a tree
Record groups nest. Who may see or change what has to respect that hierarchy, with roles, per-user overrides and owner/admin rules, and still be cheap enough to check on every request.
Twenty-plus modules, one deployable
Auth, workspaces, records, forms, automations, billing, notifications and more live in one Spring Boot app. They must not collapse into a web of direct calls.
02 / Architecture journey
Follow one request, a user creating a record in a record group, from the browser to the side effects it sets off.
React SPA
React Query
Spring Boot
JWT · @PreAuthorize
identity
AuthorizationApi
form
field validation
record
RecordService
subscription
PolicyService
Redis
4 caches
PostgreSQL
rows + outbox
events
after commit
Kafka
+ DLQ topics
schedulers
cron + outbox
consumers
email · DLQ · retry
01 / 07 · The request arrives
01
The request arrives
The SPA sends POST /records with a bearer access token. The refresh token never touches JavaScript: it lives in an HttpOnly, SameSite=Strict cookie scoped to /auth, and its hash is stored server-side so it can be revoked.
A stateless Spring Security filter verifies the JWT and puts an AuthenticatedUser, including the current workspace, on the request.
02
Who may do this?
RecordService asks one question of one API: does this user hold record:create on this record group? No module reads permission tables directly; they all go through AuthorizationApi.
Owners bypass everything, admins everything except billing actions. Everyone else gets the union of their role's grants and their personal overrides, evaluated by scope against the record-group tree. The user's grants are loaded once into a request-scoped snapshot, so a page full of checks doesn't mean a page full of queries.
03
Can the plan afford it?
Next, the subscription module's PolicyService: is the subscription active, is this record group locked (downgrades lock resources over the new plan's limits), and is the workspace under its MAX_RECORDS_PER_RECORD_GROUP quota for the current period?
Subscription status, entitlements, locked resources and plans sit in four named Redis caches with their own TTLs, so the common case never reaches the database.
04
Validate against a form nobody coded
Each submitted value is validated and normalised by its FormField definition: TEXT, NUMBER, EMAIL, DATE, SELECT or CURRENCY, with required flags and JSONB-configured rules like min, max and regex. Required fields that are missing fail the whole request.
Values are stored as record_values rows keyed by field, which is what lets a workspace add a column from the UI without anyone writing a migration.
05
Commit first, then tell everyone
The service publishes a RecordCreatedEvent and increments the period's usage counter in a single SQL UPDATE. Nothing else happens inline.
33 @TransactionalEventListener(AFTER_COMMIT) handlers across the modules react only once the transaction has committed: the activity feed, audit log, dashboards and automations. A rolled-back request can't trigger an email about a record that doesn't exist, and modules stay decoupled, which ModulithStructureTest enforces in the build.
06
Fast actions inline, slow ones on Kafka
Automations matching the CREATE trigger run next. Each action executor declares how it runs: ADD_NOTE and UPDATE_FIELD are synchronous, SEND_EMAIL is enqueued on the automation-actions Kafka topic so SMTP latency never sits in a user's request.
Every run is logged in automation_logs with a retry count. Failures go to a dead-letter topic, and a scheduler re-runs failed actions every five minutes until they hit their max_retries.
07
Housekeeping on a clock
The rest happens on schedules: subscription renewals at 01:00, trash purging and public-link expiry at 02:00, upload clean-up every five minutes, and an outbox poller every minute that confirms pending events and handles retry accounting.
03 / System components
- AuthorizationApi
- The single entry point for every authorization decision in the system.
- GrantEvaluator
- Pure evaluation of grants: GLOBAL, NODE, SUBTREE, GROUP_NODE, GROUP_SUBTREE.
- Permission snapshot
- A user's grants, loaded once and cached for the life of the request.
- PolicyService
- Active subscription, locked resources and per-period quotas.
- Usage counters
- Per-workspace, per-feature, per-period counts, incremented in SQL.
- Form engine
- Typed, validated fields that act as runtime-defined columns.
- AutomationActionDispatcher
- Routes each action inline or onto Kafka, per its executor annotation.
- Kafka topics
- automation-actions and notification-emails, each with a DLQ.
- Retry scheduler
- Every 5 minutes: re-runs failed actions below their retry limit.
- Redis caches
- subscriptionStatus, subscriptionEntitlements, lockedResources, subscriptionPlans.
- Outbox poller
- Confirms pending outbox events every minute.
- ModulithStructureTest
- Fails the build on an illegal dependency between modules.
04 / Now you drive it
The authorization rules from step 2, running in your browser. Load a scenario or build your own grants, then click a record group: the panel shows the decision and every check the evaluator made to reach it.
Who is asking
Asking for
Record groups (click one to check it)
ALLOW
- ·hasPermission(record:create) on “Shortlisted”
- ·Not owner or admin: evaluate grants
- ·1 grant(s) for record:create: 1 from role, 0 user override(s). Union, allow-only.
- ✓GROUP_NODE grant on “Shortlisted”?
Grants
- role record_group:view SUBTREE @ Admissions
- role record:create GROUP_NODE @ Shortlisted
Overrides only ever add access; there are no deny rules. NODE / SUBTREE apply to the group itself, GROUP_NODE / GROUP_SUBTREE to what's inside it.
05 / Deep dives from this project
- Read →
Counting usage under concurrent requests
The first version counted usage by reading a counter, adding one and saving it, so concurrent requests lost updates. Moving the increment into SQL fixed the counting. The quota check still runs in Java first, so requests that arrive at the limit together can still both get in. The next fix puts the limit inside the UPDATE itself.
- Read →
Breaking circular module dependencies
An architecture test started flagging circular dependencies between domains. They worked fine at runtime but broke the system's core promise: domains talk through events, not direct calls.
- Read →
Reviewing agent-generated code before it reached users
An AI coding agent merged two related but different task types into one system. Reading its output line by line against the real permission and visibility rules surfaced 17 defects before release.
- Read →
The server that died every 5.4 days
Every five or six days the production server stopped accepting SSH, the API returned 500s and even monitoring went dark. A reboot fixed it, until it came back. The cause was a Kafka health check quietly leaking one process every 15 seconds.