System design interview guide
Financial Request Management Platform
A 45–60 minute walkthrough of the real architecture: employees create financial requests, policies route approvals, and outbox events fan out to notification, audit, payment, reconciliation, analytics, and documents — without trusting the browser.
1. Problem Statement
FRM (Financial Request Management) is an internal Financial Request Management Platform. Employees create financial requests such as expense reimbursements, purchase requests, vendor payments, and budget allocations. The system then:
- Identifies the employee from the authenticated session, not from client-supplied identity fields.
- Validates the request.
- Enriches it with department and manager data from User Service.
- Selects an approval policy.
- Creates sequential approval tasks.
- Routes the request through those approvers.
- Tracks the request until it is approved, rejected, sent back for changes, or cancelled.
The business problem is simple: money should not move on a handshake. A manager should not invent the approval chain. An employee should not pick their own approver. Finance should not apply a different rule for two similar requests of the same type and amount. Fincore encodes those rules in the backend so the request lifecycle is consistent, auditable, and independent of the UI.
Synchronous request/approval APIs remain the source of truth for the workflow. After the last required approval succeeds, the request becomes APPROVED and a PAYMENT_REQUESTED outbox event is written in the same transaction. A Payment Service then processes that event. On PAYMENT_COMPLETED, Financial Request Service moves the request to COMPLETED. This is not a card network, bank ledger, or production payment rail.
2. Functional Requirements
User Management — Implemented (User Service)
- Create, read, update, and list employees.
- Soft-deactivate an employee (
INACTIVE). - Store employee number, name, email, role, status, department, and manager.
- Create, read, update, and list departments.
- Soft-deactivate a department (
active = false). - Enforce unique employee number and email.
- Enforce that a manager exists, is
ACTIVE, and has roleMANAGERorFINANCE_MANAGER. - Prevent an employee from being their own manager.
Authentication — Implemented (Auth Service)
- Login with email and password.
- Issue a signed JWT with
userId,sub, androles. - Store passwords as BCrypt hashes.
- Disable login for accounts with
enabled = false.
Financial Requests — Implemented (Financial Request Service)
- Create a draft.
- Update a draft or a request in
CHANGES_REQUESTED. - Submit an existing draft.
- Create and submit in one call.
- Get a request by ID.
- List requests with optional filters (
status,employeeId,requestType) and pagination. - Cancel a request that is not in a terminal status.
Supported request types:
EXPENSE_REIMBURSEMENTPURCHASE_REQUESTVENDOR_PAYMENTBUDGET_ALLOCATION
Approval Workflow — Implemented
- Select an active matching policy at submission time.
- Snapshot
policyIdandpolicyVersionon the request. - Create one approval task per policy step.
- Mark step 1 as
PENDINGand later steps asWAITING. - Resolve the approver from the policy step's
approverType. - List the current user's pending approvals.
- List approvals for a request.
- Approve, reject, or request changes.
Policy Management — Implemented
- Create a policy with amount range, optional department, priority, and sequential steps.
- Get and list policies.
- Update a policy only if no financial request already references it.
- Activate and deactivate a policy.
- Soft-delete via deactivate (
DELETE /api/v1/policies/{policyId}).
Policy mutations (create, update, activate, deactivate, delete) require ADMIN or FINANCE_MANAGER.
Frontend — Implemented (React + Vite)
- Login.
- Dashboard.
- Create request (draft or submit).
- My requests / all requests (role-dependent filter in the UI).
- Request details (including payment status, attachments, and audit trail).
- Approval queue.
- Policy administration for allowed roles.
- Notifications inbox.
- Analytics dashboard (finance/admin/auditor).
- Reconciliation results (finance/admin/analyst).
Event-driven capabilities — Implemented
- Transactional outbox in Financial Request Service and Payment Service.
- Kafka topics
financial-request-eventsandpayment-events. - Notification Service (in-app rows + development log sender).
- Audit Service (append-only event trail).
- Payment Service (sandbox provider).
- Reconciliation Service (event projections, mismatch detection).
- Analytics Service (MySQL counters and request facts).
Documents are not Kafka. Request attachments are a synchronous FRS → OpenFeign → Document Service call. Document Service owns metadata and ObjectStorage (local disk implemented; S3 future).
3. Non-Functional Requirements
These are design goals, not measured SLAs. The repository does not contain production latency, availability, or throughput numbers.
| Requirement | Why it matters for Fincore | Current posture |
|---|---|---|
| Security | Financial requests and approvals must not be spoofable from the browser. | JWT at the gateway and Financial Request Service. Backend derives employee identity. Approval actions check assigned approverId. Policy writes are role-gated. |
| Availability | Employees and managers need to create and decide on requests during business hours. | Single-instance Docker Compose locally. No HA topology is implemented. |
| Scalability | Request volume can grow with company size. | Stateless JVM services can be replicated. Databases are currently single-instance. |
| Reliability | A submit that creates a request but fails to create approvals would be unsafe. | Submit and approval decisions run in one local transaction, including the outbox row. Kafka publish happens after commit. |
| Data consistency | Approval state and request status must stay aligned. | Same-database transaction for request + approvals + outbox. No 2PC across services. |
| Auditability | Finance needs to know who approved what, and which policy applied. | Request stores policyId / policyVersion. Approvals store decision, comments, decidedAt, approverId. Audit Service also appends an event-driven trail keyed by eventId. |
| Maintainability | Policy changes should not require code deploys. | Policy-driven workflow instead of hardcoded amount thresholds. |
| Low latency | Interactive UI (create, list, approve). | Synchronous REST. No cache layer. User Service is on the submit path. |
| Fault isolation | A User Service outage should not take down Auth login, but it will block submit/enrichment. | Separate processes and databases. Submit is still synchronously coupled to User Service. |
4. Assumptions
Fincore is treated as an internal enterprise system. Employees are internal users, not external customers.
| Assumption | Value used in this document | Notes |
|---|---|---|
| Company size | 10,000 employees | Mid-size enterprise. |
| Active requesters | 8,000 | Some roles rarely create requests. |
| Requests per requester per month | 4 | Mix of expenses and purchases. |
| Peak factor | 5× average | Morning and month-end spikes. |
| Average request payload | 2 KB JSON | Metadata only; attachments live in Document Service. |
| Kafka events per submitted request | ~6–10 envelopes | Submit, approval-required, decisions, payment requested/completed, request completed. |
| Average attachment size | 0.5 MB | Design assumption; max upload is 10 MB. |
| Approval steps per submitted request | 2 | Depends on policy; tests use 1–3. |
| Retention | 7 years | Typical finance retention; not implemented as a job. |
| Read/write mix | ~10 reads per write | List, detail, and queue views dominate. |
| JWT lifetime | 3,600 seconds | Matches JWT_EXPIRATION_SECONDS default. |
Additional design assumptions used when explaining the architecture:
- One employee maps to one auth account via
employee_id. - The JWT
userId/subis the User Service employee UUID. - The browser is untrusted. Identity, manager, department, policy, approver, and status are backend-controlled.
- Local deployment is Docker Compose. There is no Kubernetes, Terraform, or AWS topology in this repository.
5. Capacity Estimation
Interview assumptions — not Fincore production metrics. Formulas are shown so the numbers can be recalculated in an interview.
Requests
Requests / month
= 8,000 active requesters × 4 requests/month
= 32,000 requests/month
Requests / day (assume 22 working days)
= 32,000 / 22
≈ 1,455 requests/day
Average create/submit writes per second
= 1,455 / 86,400
≈ 0.017 writes/sec
This is a low-write system compared with consumer products. The interesting load is not tweet-scale ingestion. It is correctness under concurrent approvals, User Service dependency on submit, and queue queries.
Peak
Peak request writes
= 0.017 × 5
≈ 0.09 writes/sec
Even with a more aggressive assumption (50,000 employees, 8 requests/month):
Requests / day ≈ 18,180
Average writes/sec ≈ 0.21
Peak writes/sec ≈ 1.05
Fincore does not fail because of write QPS. It fails if two approvers can both decide, if submit cannot reach User Service, or if the pending-approval query becomes a full table scan.
Approval actions
Approval actions / submitted request ≈ 2
Approval actions / day ≈ 1,455 × 2 ≈ 2,910
Average approval writes/sec ≈ 0.034
Reads
Reads / day ≈ 10 × (request writes + approval writes)
≈ 10 × (1,455 + 2,910)
≈ 43,650 reads/day
Average read RPS ≈ 0.5
Peak read RPS ≈ 2.5
Dashboard, my-requests, and my-pending are the read hot spots.
Storage (Financial Request DB)
Request row ≈ 1 KB
Approval rows ≈ 2 × 0.4 KB = 0.8 KB
Policy snapshot fields are on the request row
Storage / day ≈ 1,455 × 1.8 KB ≈ 2.6 MB/day
Storage / year ≈ 0.95 GB/year
7-year retention ≈ 7 GB (requests + approvals only)
Policies are small configuration data. Employee and department tables live in User Service (MySQL) and remain tiny at 10k employees.
API traffic
Average total RPS ≈ 0.5 reads + 0.05 writes ≈ 0.55 RPS
Peak total RPS ≈ 3 RPS
A single Financial Request Service instance and a single MySQL primary are enough at this scale. Scaling conversation in an interview should focus on correctness, isolation, and future event fan-out, not thousands of servers.
Kafka events (design assumption)
Events / submitted request ≈ 8
Events / day ≈ 1,455 × 8 ≈ 11,640
Average event RPS ≈ 0.13
Peak event RPS ≈ 0.7
At this volume a single Kafka broker in Compose is enough. Partitioning is an interview talking point, not a current operational need.
Document storage (design assumption)
Assume 30% of requests have one 0.5 MB attachment
New object storage / day ≈ 1,455 × 0.30 × 0.5 MB ≈ 218 MB/day
≈ 80 GB/year (local volume today; S3 is future)
- Attachments, OCR, and payment files would change storage dramatically if every request stored large binaries; the current cap is 10 MB per file on local disk.
6. High-Level Architecture
Current architecture — Implemented
React Frontend (:5173)
|
v
API Gateway (:8080)
|
+--------------------+--------------------+
| | |
v v v
Auth Service User Service Financial Request
:8082 :8081 Service :8083
|
v
Financial DB
|
v
Outbox
|
v
Kafka
|
+-------------------+-------------------+------------------+
| | | |
v v v v
Notification Audit Service Payment Service Analytics Service
:8084 :8085 :8086 :8088
|
v
Reconciliation :8087
Financial Request Service
|
| OpenFeign (sync)
v
Document Service :8089
|
v
Object storage (local disk today; S3 future)
Financial-request documents are not an async Kafka side effect. The browser talks to the gateway, the gateway talks to Financial Request Service, and FRS calls Document Service over OpenFeign. Document Service owns metadata and bytes. FRS does not store files and does not talk to S3/disk itself.
Current runtime components
| Component | Port | Role |
|---|---|---|
| React frontend | 5173 | UI. JWT in sessionStorage. Not in Compose. |
| API Gateway | 8080 | CORS, JWT validation, path routing. |
| Auth Service | 8082 | Login and JWT issuance. |
| User Service | 8081 | Employees and departments. |
| Financial Request Service | 8083 | Requests, policies, approvals, outbox producer, payment-completed consumer. Owns the request-document workflow and calls Document Service over Feign. |
| Notification Service | 8084 | Idempotent Kafka consumer. Logging sender (not a real ESP). |
| Audit Service | 8085 | Append-only audit trail. |
| Payment Service | 8086 | Sandbox payment processor + outbox. |
| Reconciliation Service | 8087 | Detects request vs payment projection mismatches. |
| Analytics Service | 8088 | MySQL event projections (not MongoDB). |
| Document Service | 8089 | Document metadata and storage. Not on the public request-document path. Local disk today; S3 is future. |
| MySQL 8 | 3307→3306 | One database per service. |
| Kafka | 9092 | At-least-once event bus. |
Gateway routes actually configured
/api/v1/auth/**→ Auth Service/api/v1/users/**→ User Service/api/v1/requests/**,/api/v1/approvals/**,/api/v1/policies/**→ Financial Request Service/api/v1/notifications/**→ Notification Service/api/v1/audits/**→ Audit Service/api/v1/payments/**→ Payment Service/api/v1/reconciliations/**→ Reconciliation Service/api/v1/analytics/**→ Analytics Service
The designed financial-request document path is Gateway → Financial Request Service → Document Service (Feign). Document Service should not be called by the gateway for that flow.
Current wiring (not the designed path): Compose/gateway still has /api/v1/documents/** → Document Service, and the React client still posts to /api/v1/documents. FRS has no document controller and no Document Feign client yet. Treat that as leftover public routing, not the architecture above.
There is no gateway route for /api/v1/departments/**.
Delivery guarantee (implemented): at-least-once Kafka delivery with idempotent consumers (processed_events / unique keys). This is not exactly-once end-to-end.
7. Service Responsibilities
| Service | Responsibility | Owns Data |
|---|---|---|
| API Gateway | Single public entry. CORS. JWT authentication for all routes except login and health. Path-based routing. | None |
| Auth Service | Validate email/password. Generate HS256 JWT. | auth_accounts in MySQL auth_platform |
| User Service | Employee and department master data, manager relationships, roles, status. | employees, departments in MySQL financial_platform |
| Financial Request Service | Request lifecycle, policy matching, approvals, transactional outbox, consume PAYMENT_COMPLETED. Associates documents with a request and calls Document Service synchronously (OpenFeign). | financial_requests, approvals, approval_policies, approval_policy_steps, outbox_events, processed_events. Stores document IDs only, not file bytes. |
| Notification Service | Consume request/payment events; persist in-app notifications; log “email” in development. | notifications, processed_events in notifications |
| Audit Service | Immutable audit trail from Kafka. | audit_events, processed_events in audits |
| Payment Service | Sandbox charge after PAYMENT_REQUESTED; outbox PAYMENT_COMPLETED / PAYMENT_FAILED. | payments, outbox_events, processed_events in payments |
| Reconciliation Service | Compare request vs payment projections; expose mismatches. | reconciliation_records, processed_events in reconciliation |
| Analytics Service | Counters and request facts from events. | analytics_request_facts, analytics_counters, processed_events in analytics |
| Document Service | Owns document metadata and object storage. Internal collaborator of FRS for the request-document flow. | documents in documents. Files via ObjectStorage (local disk implemented; S3 future). |
| Frontend | Screens including notifications, audit, payment, documents, analytics, reconciliation. | Browser sessionStorage for the JWT only |
Auth Service
Authentication only. It does not serve employee profiles. The JWT userId is the employee UUID that User Service and Financial Request Service use.
User Service
Organizational source of truth: who the employee is, which department they belong to, who their manager is, and whether they are active.
Financial Request Service
Domain owner for money requests and approval. It stores IDs for employee, department, manager, and approver. It does not own employee rows and does not create JPA relationships to User Service entities.
It also owns whether a document belongs on a financial request. Upload/list/delete for that workflow is a synchronous FRS API. FRS then calls Document Service over OpenFeign. FRS does not write the documents database and does not talk to S3 or the local volume.
Vendor Service
vendor-service remains a stub (application class only). Treat it as Future / Planned.
8. Why Microservices?
Fincore is split along domain ownership, not along “we needed infinite scale.”
Reasons that match the current code
Independent data ownership. Auth credentials, org chart, and financial requests are different consistency boundaries. Auth lives in auth_platform. Employees live in financial_platform. Requests live in financial_requests. Additional event-driven services each have their own MySQL database. Financial Request Service cannot join to employees with JPA.
Independent change velocity. Approval policy and request state machine change for finance reasons. Employee master data changes for HR reasons. Login changes for security reasons.
Fault isolation (partial). Auth can issue tokens even if Financial Request Service is down. A financial_requests database outage does not take down employee reads. A User Service outage does block submit, because enrichment is synchronous.
Clear API contracts. Financial Request Service talks to User Service through OpenFeign (/api/v1/users/{id}, department, list-by-role). The same pattern is the designed contract for Document Service: FRS is the only public caller of the request-document flow. That User Service client is implemented. A Document Feign client on FRS is not in the repository yet.
Team scalability (organizational argument). Different teams could own Auth, User, and Requests without sharing a deploy artifact.
The alternative: modular monolith
A modular monolith would have been a reasonable first version:
- One deployable.
- One database (or one DB with schemas).
- Local method calls instead of Feign.
- One Spring Security filter chain.
- Easier transactions spanning “load employee + create request.”
It would have been simpler for a small team and a ~1,500 requests/day workload.
Honest trade-off
Microservices added operational cost: multiple MySQL databases, a gateway, Feign failure modes, Kafka, and a User Service that currently has no Spring Security of its own. For this traffic level, a modular monolith with module boundaries and separate schemas would have been simpler. Microservices are a domain/ownership choice here, not a scale necessity.
9. Domain Model
Employee (User Service)
An employee is a person in the company. Fields include employee number, name, email, departmentId, optional managerId, role, status, and @Version.
Roles: EMPLOYEE, MANAGER, FINANCE_ANALYST, FINANCE_MANAGER, ADMIN, AUDITOR.
Statuses: ACTIVE, INACTIVE, TERMINATED.
Department (User Service)
A department has name, unique code, optional managerId, and active. The department manager is used when a policy step has approverType = DIRECTOR.
AuthAccount (Auth Service)
Login identity. Links email → employeeId, stores password_hash, role, and enabled. Roles mirror employee roles so the JWT can carry ROLE_* authorities.
FinancialRequest (Financial Request Service)
The aggregate root for a money request. It stores:
- Generated
requestNumber(FR-yyyyMMdd-######) employeeId,departmentId,managerId(IDs only)requestType,amount,currency,category,description,expenseDatestatuspolicyId,policyVersion(set at submit)submittedAt,completedAt@Version- Child
approvals
ApprovalPolicy
Configurable routing rule: name, request type, min/max amount, optional department, active, priority (lower number wins), version, effective dates, and ordered steps.
ApprovalPolicyStep
One step in a policy: stepNumber, approverType (MANAGER, FINANCE, FINANCE_MANAGER, DIRECTOR), and required.
required is validated and persisted. The workflow currently still creates a task for every step.
Approval
A task for one step of one request: approverId, step, status, decision, comments, decidedAt, @Version. Unique on (request_id, step).
Relationships
Cross-service links are UUIDs, not JPA associations. That is intentional: Financial Request Service cannot load an Employee entity from another database.
10. Database Architecture
Fincore uses database-per-service at the schema level.
| Service | Engine | Database | Schema management |
|---|---|---|---|
| Auth Service | MySQL 8 | auth_platform | Flyway + ddl-auto=validate |
| User Service | MySQL 8 | financial_platform | Flyway + ddl-auto=validate |
| Financial Request Service | MySQL 8 | financial_requests | Hibernate ddl-auto=update for legacy tables; Flyway for outbox_events and processed_events |
| Notification Service | MySQL 8 | notifications | Flyway + ddl-auto=validate |
| Audit Service | MySQL 8 | audits | Flyway + ddl-auto=validate |
| Payment Service | MySQL 8 | payments | Flyway + ddl-auto=validate |
| Reconciliation Service | MySQL 8 | reconciliation | Flyway + ddl-auto=validate |
| Analytics Service | MySQL 8 | analytics | Flyway + ddl-auto=validate |
| Document Service | MySQL 8 | documents | Flyway + ddl-auto=validate |
Services share a MySQL server in Docker Compose, but not a database. That is still database-per-service. There are no cross-service foreign keys.
The diagram shows logical neighbors, not joins. Cross-service references are UUIDs in application payloads.
Auth: auth_accounts
From Flyway V1__create_auth_accounts.sql:
| Column | Type | Notes |
|---|---|---|
id | CHAR(36) PK | UUID |
employee_id | CHAR(36) UNIQUE | User Service employee |
email | VARCHAR(255) UNIQUE | Login key; index idx_auth_accounts_email |
password_hash | VARCHAR(255) | BCrypt |
role | VARCHAR(32) | UserRole |
enabled | BOOLEAN | Default true |
created_at / updated_at | TIMESTAMP(6) |
User: departments and employees
After V4__convert_uuid_to_char.sql, UUIDs are CHAR(36).
departments: id, name, code UNIQUE, manager_id, active, timestamps, version. Indexes on manager_id and active. FK manager_id → employees(id).
employees: id, employee_number UNIQUE, names, email UNIQUE, department_id NOT NULL, manager_id, role, status, timestamps, version. FKs to departments and employees (self). Indexes on department, manager, role, status.
User Service uses @Version on both entities.
Financial Request Service (from JPA entities + Flyway)
Legacy request/approval/policy tables are still created/updated by Hibernate (ddl-auto=update). Flyway owns outbox_events (V1) and processed_events (V2) and is enabled with baseline-on-migrate=true.
financial_requests
| Column | Notes |
|---|---|
id | UUID PK, CHAR |
request_number | UNIQUE, length 50 |
employee_id | NOT NULL, indexed |
department_id | nullable until enrich |
manager_id | nullable |
request_type | enum string |
amount | NUMERIC(19,4) |
currency | CHAR/VARCHAR 3 |
category | 100 |
description | 2000 |
expense_date | date |
status | enum string, indexed |
policy_id | indexed |
policy_version | integer |
submitted_at / completed_at | timestamptz |
created_at / updated_at | JPA auditing |
version | optimistic lock |
approvals
| Column | Notes |
|---|---|
id | UUID PK |
request_id | FK to financial_requests, NOT NULL |
approver_id | indexed |
step | integer |
status | indexed |
decision | nullable until decided |
comments | 2000 |
created_at / decided_at | |
version | optimistic lock |
Unique constraint uk_approval_request_step on (request_id, step).
approval_policies
| Column | Notes |
|---|---|
id | UUID PK |
name | 100 |
request_type | indexed |
min_amount / max_amount | NUMERIC(19,4); max nullable = unbounded |
department_id | indexed; null = global policy |
active | indexed |
priority | lower is selected first |
version | integer, incremented on update |
effective_from / effective_to | |
| timestamps |
approval_policy_steps
| Column | Notes |
|---|---|
id | UUID PK |
policy_id | FK, indexed |
step_number | |
approver_type | MANAGER, FINANCE, FINANCE_MANAGER, DIRECTOR |
is_required | boolean |
| timestamps |
Unique constraint uk_approval_policy_step on (policy_id, step_number).
11. Request Lifecycle
States in code
DRAFT
|
| submit
v
PENDING_APPROVAL
|
+------> REJECTED (terminal; completedAt set)
|
+------> CHANGES_REQUESTED (editable; resubmit returns to PENDING_APPROVAL)
|
+------> CANCELLED (terminal)
|
v
APPROVED (all steps approved; completedAt set; PAYMENT_REQUESTED emitted)
|
v
COMPLETED (after PAYMENT_COMPLETED is consumed)
SUBMITTED exists on RequestStatus but is never assigned. After the last approval, status becomes APPROVED and completedAt is set. COMPLETED is assigned later by PaymentCompletedHandler when a PAYMENT_COMPLETED Kafka event arrives for an APPROVED request.
Create-and-submit skips persisting a long-lived draft: the entity is built in memory as DRAFT, then submitted in the same transaction, so the first persisted status is typically PENDING_APPROVAL.
State meanings and rules
| Status | Meaning | Who transitions | Allowed operations | Restrictions |
|---|---|---|---|---|
DRAFT | Saved, not in workflow | Authenticated creator (ID from JWT) | Update, submit, cancel, view | No policy, no approvals yet. Enrichment happens on submit, not on draft create. |
PENDING_APPROVAL | In sequential workflow | Assigned approver for the PENDING step | Approve, reject, request-changes, view, cancel | Not editable. Only the assigned approverId may decide. Later steps stay WAITING. |
CHANGES_REQUESTED | Sent back to requester | Approver requested changes | Update, submit, cancel, view | Treated like an editable draft. Resubmit re-enriches, re-matches policy, and rebuilds approval tasks. |
APPROVED | All steps approved; payment requested | System after last approve | View | Not cancellable. completedAt set. Outbox emits PAYMENT_REQUESTED. |
REJECTED | A step rejected | System after reject | View | Not cancellable. completedAt set. Remaining waiting steps are not activated. |
CANCELLED | Withdrawn | Authenticated caller of cancel API | View | Not allowed from APPROVED, REJECTED, COMPLETED, or CANCELLED. |
SUBMITTED | Enum only | — | — | Not used at runtime. |
COMPLETED | Payment succeeded | FRS Kafka consumer | View | Set only from APPROVED when PAYMENT_COMPLETED arrives. |
Approval task statuses
| Status | Meaning |
|---|---|
WAITING | Future step; not yet actionable |
PENDING | Current step; assigned approver may act |
APPROVED / REJECTED / CHANGES_REQUESTED | Decision recorded |
DELEGATED / ESCALATED | Enum placeholders. No APIs. |
12. Create Request Flow
Creating a draft does not call User Service and does not select a policy.
What actually happens:
- Authentication. Gateway and Financial Request Service both require a valid JWT except for health.
CurrentUserProviderreadsuserId, falling back tosub. - Validation. Bean validation on
CreateFinancialRequest: type, amount > 0, ISO currency, optional category/description/expenseDate. There is noemployeeIdfield on the DTO. - Employee identification.
entity.setEmployeeId(currentUserProvider.getCurrentUserId()). If the UI also sendsemployeeId/departmentId, Jackson ignores unknown properties. The backend does not use them. - No User Service lookup on draft create.
departmentIdandmanagerIdstay null until submit. - Persistence. Status
DRAFT, uniquerequestNumber, auditing timestamps, version starts at 0/1 depending on Hibernate.
Draft create is available even if User Service is down. Submit is not.
13. Submit Request Flow
Draft vs submit
| Create draft | Submit | |
|---|---|---|
| HTTP | POST /api/v1/requests or create-and-submit POST /api/v1/requests/submit | POST /api/v1/requests/{id}/submit |
| Status after success | DRAFT | PENDING_APPROVAL |
| User Service | Not called | Required (enrich + possibly approver resolve) |
| Policy | None | Matching active policy required |
| Approvals | None | One row per policy step |
| Employee ID | JWT | JWT again (overwrites) |
Create-and-submit is draft mapping + submit in one transaction.
Submit steps in code (FinancialRequestService.submit):
- Load request (or build from create DTO).
- Ensure status is
DRAFTorCHANGES_REQUESTED. - Set
employeeIdfrom JWT. - Enrich from User Service: employee must be
ACTIVEand have a department; department must be active; manager if present must beACTIVEandMANAGERorFINANCE_MANAGER. - Validate amount, currency, type, employee, department.
- Match policy: active, same
requestType, amount in[min, max], department null or equal, lowestpriorityfirst. - Snapshot policy id/version.
- Clear previous approvals if resubmitting, then create new tasks.
- Set
PENDING_APPROVALandsubmittedAt. - Save.
If no policy matches, submit fails with 400 and nothing is left half-submitted (same @Transactional method).
14. Approval Workflow
This is the core of the system.
Financial Request (type, amount, department)
|
v
Policy selection (active, type, amount range, department, lowest priority)
|
v
Approval Policy (id + version snapshotted on the request)
|
v
Approval Policy Steps (1..N, approverType)
|
v
Approval tasks (step 1 PENDING, rest WAITING, approverId resolved)
Policy selection
PolicyService.findMatchingPolicy(requestType, amount, departmentId):
findByRequestTypeAndActiveTrueOrderByPriorityAsc- Keep policies where
departmentIdis null or equals the request department (null department on the policy = company-wide). - Keep policies where
amount >= minAmountand (maxAmountis null oramount <= maxAmount). - Take the first remaining row.
There is no extra “more specific department beats global” rule beyond priority ordering. A global policy with priority 0 beats a department policy with priority 1. Operators must set priorities deliberately.
effectiveFrom / effectiveTo are stored but not used in the matcher. Matching uses active = true and the filters above.
Policy version
On submit, the request stores policyId and policyVersion. If a policy is later updated, version increments, but in-use policies cannot be updated (existsByPolicyId → conflict). The intended change process is: deactivate, create a new policy. In-flight requests keep the approval rows already created.
Approver types — Implemented
ApproverType | Resolution |
|---|---|
MANAGER | request.managerId if already enriched; otherwise employee's managerId. Fails if missing. Manager must be active with role MANAGER or FINANCE_MANAGER. |
FINANCE | First ACTIVE employee with role FINANCE_ANALYST (GET /api/v1/users?role=FINANCE_ANALYST&status=ACTIVE&size=1). Not department-scoped. |
FINANCE_MANAGER | Same pattern with role FINANCE_MANAGER. |
DIRECTOR | Department's managerId (department of the employee). |
If resolution fails, submit fails and the transaction rolls back.
Sequential activation
Only one approval is PENDING at a time. Approving step n moves the next WAITING step to PENDING. If none remain, the request becomes APPROVED.
15. Approval Decision Flow
Approve
Optional comments. Marks the current approval APPROVED / APPROVE. Activates the next waiting step or completes the request as APPROVED.
Reject
Comments required. Marks approval REJECTED / REJECT. Request becomes REJECTED. completedAt is set. Later steps stay WAITING and are never opened.
Request changes
Comments required. Marks approval CHANGES_REQUESTED. Request becomes CHANGES_REQUESTED. The employee may edit (with version) and resubmit, which rebuilds the workflow.
Delegation / escalation
ApprovalDecision.DELEGATE / ESCALATE and ApprovalStatus.DELEGATED / ESCALATED exist as enums. There are no endpoints or service methods. Future / Planned.
Backend authorization — the UI is not trusted
ApprovalService.loadActionableApproval:
- Request must exist.
- Approval must belong to that request (
findByIdAndRequest_Id). approverIdmust equalCurrentUserProvider.getCurrentUserId().- Approval status must be
PENDING(already decided →409). - Request status must be
PENDING_APPROVAL.
A manager cannot approve a task assigned to finance by changing a hidden form field. A pending list is also queried by approverId = currentUserId, not by a client-supplied ID.
Frontend canAccessApprovals only hides the nav item. The API is the real control.
16. User Service Integration
Financial Request Service
|
| OpenFeign (user-service.base-url)
v
User Service
UserServiceClient (@FeignClient):
| Feign method | User Service API |
|---|---|
getEmployee | GET /api/v1/users/{id} |
getEmployeeDepartment | GET /api/v1/users/{id}/department |
getDepartment | GET /api/v1/departments/{id} |
listEmployees | GET /api/v1/users?departmentId&role&status&page&size |
Called from UserLookupService during enrichment and approver resolution.
Why User Service exists
Employee master data would otherwise be duplicated inside the request database. Duplication drifts: someone changes manager in HR and finance still routes to the old manager on new submits. Historical requests keep the IDs captured at submit time.
What is stored vs fetched
Stored on the request: employeeId, departmentId, managerId.
Fetched at submit: status, role, department activity, manager validity, finance-role lookup.
If User Service fails
UserServiceFeignConfig maps 404 → ResourceNotFoundException, 400 → BadRequestException, 5xx / 502–504 → ExternalServiceException (FR-400, HTTP 502). Connection failures surface as 502 as well.
Submit and workflow initialization fail. Draft create and login still work. There is no cache fallback and no circuit breaker today.
Data ownership principle
| Data | Owner | Others may |
|---|---|---|
| Password hash, login email | Auth Service | Store employee_id only |
| Name, org, manager, role | User Service | Store UUIDs + snapshot at submit |
| Amount, policy, approvals | Financial Request Service | User Service does not know about requests |
| File bytes and document metadata | Document Service | FRS stores document IDs / request association only. FRS does not own S3 or the local volume. |
17. Authentication and Authorization
User
|
v
API Gateway (permit POST /api/v1/auth/login)
|
v
Auth Service (BCrypt verify → HS256 JWT)
|
v
JWT in sessionStorage (tokenType Bearer, expiresIn)
|
v
Subsequent calls: Gateway JWT resource server
|
v
Financial Request Service JWT resource server
|
v
CurrentUserProvider + @PreAuthorize / approverId checks
Login
POST /api/v1/auth/login. Public. Invalid credentials → 401 with a generic message. JWT claims:
sub= employee UUIDuserId= same UUIDroles=[account.role]e.g.EMPLOYEE,MANAGERiat/exp(default 3600s)- Signed with shared
JWT_SECRET(HMAC SHA-256)
Gateway, Auth, and Financial Request Service share that secret via environment. Auth Service does not expose a JWKS endpoint.
JWT validation
Both Gateway and Financial Request Service use Spring OAuth2 resource server with a symmetric key. Roles become ROLE_EMPLOYEE, ROLE_ADMIN, etc.
Authentication vs authorization
Authentication: Is this a valid logged-in user? (JWT signature and expiry.)
Authorization: Is this user allowed to do this action?
Example — approval:
Authenticated as user 1111-... → authentication succeeded
Approval.approverId = 2222-... → authorization fails
→ ApprovalNotAllowedException (FR-201, 403)
"Approval action not allowed for the current user"
Policy write example:
@PreAuthorize("hasAnyRole('ADMIN', 'FINANCE_MANAGER')")
POST /api/v1/policies
An authenticated EMPLOYEE receives 403.
Frontend role helpers (canAccessPolicies, canCreateRequest, …) only change navigation. They are not security.
Roles in the JWT / Auth / User models
EMPLOYEE, MANAGER, FINANCE_ANALYST, FINANCE_MANAGER, ADMIN, AUDITOR.
18. API Design
All Financial Request Service and user/auth APIs below exist in controllers. Gateway authentication: login is public; everything else requires JWT (plus OPTIONS and /actuator/health).
Auth APIs
| Method | Endpoint | Purpose | Authentication | Authorization |
|---|---|---|---|---|
| POST | /api/v1/auth/login | Issue JWT | Public | Enabled account + password |
User APIs (User Service)
Gateway exposes users, not departments.
| Method | Endpoint | Purpose | Authentication | Authorization |
|---|---|---|---|---|
| POST | /api/v1/users | Create employee | Gateway JWT | None in User Service |
| GET | /api/v1/users/{id} | Get employee | Gateway JWT | None in service |
| GET | /api/v1/users/{id}/manager | Get manager | Gateway JWT | None in service |
| GET | /api/v1/users/{id}/department | Get department | Gateway JWT | None in service |
| GET | /api/v1/users | List / filter | Gateway JWT | None in service |
| PUT | /api/v1/users/{id} | Update | Gateway JWT | None in service |
| DELETE | /api/v1/users/{id} | Soft deactivate | Gateway JWT | None in service |
| POST | /api/v1/departments | Create department | Not routed on gateway | None in service |
| GET | /api/v1/departments/{id} | Get department | Direct to :8081 or future route | None in service |
| GET | /api/v1/departments | List | Direct | None in service |
| PUT | /api/v1/departments/{id} | Update | Direct | None in service |
| DELETE | /api/v1/departments/{id} | Soft deactivate | Direct | None in service |
Request APIs
| Method | Endpoint | Purpose | Authentication | Authorization |
|---|---|---|---|---|
| POST | /api/v1/requests | Create draft | JWT | Any authenticated user; employee = token |
| GET | /api/v1/requests/{requestId} | Get request | JWT | Authenticated; no owner check |
| GET | /api/v1/requests | List, paginated | JWT | Filters optional; no enforced scope |
| PUT | /api/v1/requests/{requestId} | Update draft / changes-requested | JWT | State check + version; no owner check |
| POST | /api/v1/requests/{requestId}/submit | Submit existing | JWT | Submittable state; identity from token |
| POST | /api/v1/requests/submit | Create and submit | JWT | Identity from token |
| POST | /api/v1/requests/{requestId}/cancel | Cancel | JWT | Not terminal; no owner check |
Approval APIs
| Method | Endpoint | Purpose | Authentication | Authorization |
|---|---|---|---|---|
| GET | /api/v1/approvals/my-pending | My pending queue | JWT | Rows for token userId |
| GET | /api/v1/requests/{requestId}/approvals | List steps | JWT | Authenticated |
| GET | /api/v1/requests/{requestId}/approvals/{approvalId} | Get one | JWT | Must match request |
| POST | .../approvals/{approvalId}/approve | Approve | JWT | Must be assigned approver; status PENDING |
| POST | .../approvals/{approvalId}/reject | Reject | JWT | Same + comments required |
| POST | .../approvals/{approvalId}/request-changes | Request changes | JWT | Same + comments required |
Policy APIs
| Method | Endpoint | Purpose | Authentication | Authorization |
|---|---|---|---|---|
| POST | /api/v1/policies | Create | JWT | ADMIN or FINANCE_MANAGER |
| GET | /api/v1/policies/{policyId} | Get | JWT | Authenticated |
| GET | /api/v1/policies | List | JWT | Authenticated |
| PUT | /api/v1/policies/{policyId} | Update if unused | JWT | ADMIN or FINANCE_MANAGER |
| DELETE | /api/v1/policies/{policyId} | Deactivate | JWT | ADMIN or FINANCE_MANAGER |
| POST | /api/v1/policies/{policyId}/activate | Activate | JWT | ADMIN or FINANCE_MANAGER |
| POST | /api/v1/policies/{policyId}/deactivate | Deactivate and return body | JWT | ADMIN or FINANCE_MANAGER |
Default page size is 20 (@PageableDefault).
Event-driven service APIs (Implemented)
| Method | Endpoint | Service | Authorization |
|---|---|---|---|
| GET | /api/v1/notifications/me | Notification | Authenticated (own inbox) |
| GET | /api/v1/audits/request/{requestId} | Audit | Owner/actor or finance/admin/auditor |
| GET | /api/v1/payments/request/{requestId} | Payment | Owner or finance/admin |
| GET | /api/v1/reconciliations | Reconciliation | ADMIN, FINANCE_MANAGER, FINANCE_ANALYST |
| GET | /api/v1/reconciliations/request/{requestId} | Reconciliation | same |
| POST | /api/v1/reconciliations/request/{requestId}/reprocess | Reconciliation | same |
| GET | /api/v1/analytics/summary | Analytics | finance/admin/auditor |
| GET | /api/v1/analytics/requests-by-type | Analytics | same |
| GET | /api/v1/analytics/requests-by-department | Analytics | same |
| GET | /api/v1/analytics/volume | Analytics | same |
Document APIs
Designed financial-request document flow (synchronous):
React → API Gateway → Financial Request Service → OpenFeign → Document Service → storage
The browser should not call Document Service for this flow. FRS authorizes the request (same JWT identity rules as other request APIs), then Feigns to Document Service. Document Service APIs are an internal contract.
Document Service endpoints that exist today (POST/GET/DELETE /api/v1/documents…) are the storage service’s own API. They are not the public request-document API.
Current vs designed
| Item | Status |
|---|---|
Document Service upload/list/get/delete + local ObjectStorage | Implemented |
Gateway /api/v1/documents/** and frontend calling those URLs | Current leftover. Not the designed path. |
| FRS document endpoints + Document Feign client | Not implemented |
| S3 | Future. Document Service owns the storage abstraction; local disk is current. |
19. Transaction Management
Financial Request Service uses Spring @Transactional on service methods. Request, policy snapshot fields, and approval children are in one MySQL database (financial_requests), so they share a local transaction.
submit / approve / reject / request-changes
|
v
@Transactional (FinancialRequestService or ApprovalService)
|
+--> financial_requests row
+--> approvals rows (cascade ALL on the request aggregate)
+--> outbox_events row (same TX; unpublished until the publisher succeeds)
Submit/approve still fail atomically if policy matching or User Service enrichment fails.
Approve loads the approval, mutates request + current/next approval, saves, then appends outbox events before commit.
Why distributed transactions are avoided
Submit calls User Service before relying on the DB commit for enrichment data, but it does not write to User Service. There is no 2PC across financial_platform and financial_requests. If User Service succeeds and the request database commit fails, no org data was mutated. If we later published Kafka from the same process without an outbox, that would be a dual-write problem. The outbox in section 24 is the implemented mitigation.
Auth login is @Transactional(readOnly = true). User Service writes are their own MySQL transactions.
20. Concurrency
Optimistic locking is implemented on:
FinancialRequest.versionApproval.versionEmployee.versionandDepartment.version(User Service)
Updates to drafts require the client to send version. Mismatch → ConflictException (FR-202, HTTP 409) before Hibernate fails.
Concurrent approval of the same pending row:
User A reads approval version 5 (status PENDING)
User B reads approval version 5 (status PENDING)
User A approves
status → APPROVED, version → 6
request may become APPROVED or next step PENDING
User B approves the same row at version 5
→ OptimisticLockingFailureException
→ HTTP 409 FR-202 "Resource was modified by another user"
GlobalExceptionHandler maps OptimisticLockingFailureException to that 409.
Why this matters: two finance users must not both think they approved the same step, and must not activate the next step twice. Unique (request_id, step) also prevents duplicate steps.
There is no pessimistic SELECT FOR UPDATE. There is no distributed lock. For this QPS, optimistic locking is the right default.
Draft updates use an explicit version check in addition to @Version. Approval actions currently rely on the entity @Version plus the PENDING status guard.
21. Error Handling
Financial Request Service uses ErrorCode + GlobalExceptionHandler and JSON ErrorResponse.
Format (Financial Request Service)
{
"timestamp": "2026-09-07T09:00:00Z",
"status": 404,
"errorCode": "FR-001",
"error": "Not Found",
"message": "Financial request not found: ...",
"path": "/api/v1/requests/...",
"fieldErrors": [
{ "field": "amount", "message": "Amount must be greater than zero", "rejectedValue": 0 }
]
}
fieldErrors is present for bean validation failures.
Codes
| Code | HTTP | When |
|---|---|---|
| FR-001 … FR-004 | 404 | Request / approval / policy / user-service resource missing |
| FR-100 | 400 | Validation |
| FR-101 | 400 | Malformed body |
| FR-102 | 400 | Bad query/path argument |
| FR-200 | 409 | Illegal state (not editable, already decided, policy in use, …) |
| FR-201 | 403 | Not the assigned approver |
| FR-202 | 409 | Optimistic lock |
| FR-203 | 409 | Data integrity / duplicate |
| FR-300 | 401 | Unauthenticated |
| FR-301 | 403 | Access denied (@PreAuthorize) |
| FR-400 | 502 | User Service down or 5xx |
| FR-500 | 500 | Unexpected |
Auth and User Service use a similar JSON shape without errorCode.
Mapping to architecture questions
| Failure | Handling |
|---|---|
| Validation | 400 FR-100 / FR-101 |
| Not found | 404 FR-00x |
| Invalid state | 409 FR-200 |
| Authorization | 401 / 403 |
| User Service | 404 or 502 FR-400 |
| DB constraint | 409 FR-203 |
| Optimistic lock | 409 FR-202 |
22. Security
Implemented
- JWT HS256 issued by Auth Service; validated by Gateway and every JWT-protected service except User Service (Auth, FRS, Notification, Audit, Payment, Reconciliation, Analytics, Document).
- BCrypt password hashes (
BCryptPasswordEncoder). - Stateless sessions (
SessionCreationPolicy.STATELESS). CSRF disabled because there are no cookie sessions. - RBAC for policy mutations (
@PreAuthorize). - Backend approval authorization via
approverId == currentUserId. - Identity from token, not from create DTO.
- Input validation (Jakarta Validation) on amounts, currency, step numbers, comments length.
- CORS allow-list (
CORS_ALLOWED_ORIGINS, defaulthttp://localhost:5173) with credentials. - Soft delete for employees/departments/policies rather than hard deleting history casually.
- Secrets via environment variables (
JWT_SECRET, DB passwords). Defaults exist for local Docker and are not production-safe.
Gaps (document honestly)
| Topic | Current | Future / Planned |
|---|---|---|
| HTTPS | Not terminated in Compose | Reverse proxy / load balancer TLS |
| User Service auth | Unsecured application | JWT or mTLS on the service |
| Feign auth | No token | Propagate JWT or use a client credential |
| Gateway departments | Unrouted | Add route + authz |
| Row-level security on requests | Missing | Owner / finance / auditor rules in FRS |
| Refresh tokens / logout denylist | Access token only; stored in sessionStorage | Refresh + revocation |
| Secret rotation / vault | Env vars | Dedicated secrets manager |
| DB encryption / network isolation | Local Docker network | Private subnets, IAM, KMS |
| Audit log of every mutation | Request/approval fields plus Audit Service Kafka trail | Immutable store with legal hold / 7-year job |
| Rate limiting | None | Gateway rate limits on login |
Password hashing is implemented. Do not claim a threat model, WAF, or KYC stack as current — those live only in other docs, not in this codebase’s runtime.
23. Scalability
Currently implemented
- Stateless services. Gateway, Auth, User, FRS hold no HTTP session. Horizontal replicas are possible behind a load balancer.
- Pagination. Lists default to 20 rows.
- Indexes. Employee, status, policy, approver, request type, active flag, as declared on entities / Flyway.
- Database per service. Request load does not lock employee tables.
- Optimistic concurrency instead of global locks.
24. Outbox Pattern and Kafka — Implemented
User-facing writes stay synchronous REST. Cross-service side effects use the transactional outbox. FRS does not call kafka.send() inside the request transaction.
Business operation
↓
DB Transaction
├── Update Financial Request / Approval state
└── Insert Outbox Event
↓
Transaction Commit
↓
Outbox Publisher (poll, claim, retry)
↓
Kafka (at-least-once)
↓
Idempotent consumers
Topics
| Topic | Producers | Consumers |
|---|---|---|
financial-request-events | FRS outbox | Notification, Audit, Payment (PAYMENT_REQUESTED only), Reconciliation, Analytics |
payment-events | Payment outbox | Notification, Audit, FRS (PAYMENT_COMPLETED), Reconciliation, Analytics |
Event types actually published
REQUEST_SUBMITTED, APPROVAL_REQUIRED, APPROVAL_COMPLETED, REQUEST_APPROVED, REQUEST_REJECTED, REQUEST_CHANGES_REQUESTED, REQUEST_CANCELLED, PAYMENT_REQUESTED, REQUEST_COMPLETED, PAYMENT_COMPLETED, PAYMENT_FAILED.
Payloads are versioned JSON envelopes (eventId, eventType, eventVersion, occurredAt, aggregateType, aggregateId, payload). JPA entities are not serialized.
Concurrency: publisher claims a row (NEW/FAILED → PUBLISHING). A second instance that loses the claim skips the event. Stale PUBLISHING rows are reclaimed after claim-timeout-ms so a crash cannot lose events forever.
Not implemented: CDC (Debezium), a dedicated dead-letter topic, exactly-once Kafka transactions.
Notification Service — Implemented
Consumes financial-request-events and payment-events. Creates an in-app row and calls LoggingNotificationSender (logs the message; not a real email provider).
| Event | Recipient |
|---|---|
REQUEST_SUBMITTED, REQUEST_APPROVED, REQUEST_REJECTED, REQUEST_CHANGES_REQUESTED | Employee |
APPROVAL_REQUIRED | Assigned approver |
PAYMENT_COMPLETED | Employee |
Idempotency: unique (event_id, recipient_id, notification_type) plus processed_events. Duplicate Kafka deliveries do not send a second notification.
API: GET /api/v1/notifications/me — authenticated user sees only their inbox.
Audit Service — Implemented
Append-only consumer. Each Kafka envelope becomes an audit_events row (event_id unique). Other services cannot write this database.
API: GET /api/v1/audits/request/{requestId}. ADMIN / FINANCE_MANAGER / AUDITOR / FINANCE_ANALYST see all rows for that request. Other users see the trail only if they appear as actor or employee on a recorded event.
Payment Service — Implemented
Last approval succeeds
↓
FRS TX: status APPROVED + outbox PAYMENT_REQUESTED
↓
Outbox publisher → Kafka
↓
Payment Service (idempotent on eventId and unique requestId)
↓
INITIATED → PROCESSING → COMPLETED or FAILED
↓
Payment outbox → PAYMENT_COMPLETED / PAYMENT_FAILED
↓
FRS consumes PAYMENT_COMPLETED → status COMPLETED
Implemented: unique requestId, statuses INITIATED / PROCESSING / COMPLETED / FAILED (CANCELLED exists on the enum if unused), provider reference SANDBOX-….
Future production integration: a real payment network, webhooks, settlement files, and PCI scope. Amounts whose fractional cents equal 99 fail on purpose so the failure path is demonstrable.
Payment Service does not update the Financial Request database. FRS updates itself when it consumes PAYMENT_COMPLETED.
Reconciliation — Implemented
Event-driven projections, not 2PC and not bank-statement matching.
Mismatch examples actually coded:
- Payment
FAILEDbut requestCOMPLETED - Payment
COMPLETEDbut requestREJECTEDorCANCELLED
IN_SYNC when payment COMPLETED and request is APPROVED or COMPLETED. Otherwise PENDING until both sides have arrived.
APIs: list, get by request, POST .../reprocess (re-evaluates stored projections; does not replay Kafka). Roles: ADMIN, FINANCE_MANAGER, FINANCE_ANALYST.
Future: bank statement / settlement-file reconciliation.
Analytics Service — Implemented (MySQL, not MongoDB)
Consumes the same topics and writes request facts plus counters. Read APIs: /summary, /requests-by-type, /requests-by-department, /volume. Roles: finance/admin/auditor.
MongoDB was considered and not introduced; the volume in section 5 does not require it.
Document Service — storage owner; FRS owns the request association
Documents are synchronous, like User Service enrichment. They are not published through the outbox or Kafka.
React
→ API Gateway
→ Financial Request Service
→ OpenFeign
→ Document Service
→ ObjectStorage (local disk implemented; S3 future)
| Responsibility | Owner |
|---|---|
| Is this user allowed to attach a file to this request? | Financial Request Service |
| Validate size/type, persist metadata, store bytes | Document Service |
| S3 bucket / local volume | Document Service only. FRS never calls S3. |
Implemented today: Document Service (10 MB cap; pdf/png/jpeg/csv/xlsx; LocalFileObjectStorage; MySQL documents). Authorization on Document Service is uploader or finance/admin/manager — it does not call FRS back to prove request ownership.
Not implemented: FRS Feign client and FRS-facing document APIs. S3. The current UI still hits /api/v1/documents through the gateway.
25. Reliability and Failure Scenarios
Current behavior
| Failure | What happens today |
|---|---|
| User Service unavailable | Submit, create-and-submit, and approver resolution fail with 502 FR-400. Draft create, login, and existing GET/approve of already loaded data can still work if they do not call User Service. Approve itself does not call User Service. |
| Financial Request DB unavailable | Request/approval/policy APIs fail (500/DB exception). Auth and User still up. |
| API Gateway unavailable | Browser cannot reach any API (VITE_API_BASE_URL is the gateway). Direct service ports exist in Compose but the UI does not use them. |
| Auth Service unavailable | New logins fail. Existing JWTs still work until expiry (gateway validates locally with the shared secret). |
| Kafka unavailable | User-facing submit/approve still succeed. Outbox rows stay NEW/FAILED and retry when Kafka returns. Consumers pause. |
| Document Service unavailable | Submit/approve do not call Document Service. On the designed FRS→Feign upload path, attach/list/download would fail without blocking the request state machine. Today the UI calls Document Service through the gateway, so those calls fail independently. |
26. Observability
Currently Implemented: Spring/SLF4J logs and Spring Boot Actuator health on the services (/actuator/health). Kafka consumers log skip/duplicate/processing failures. Payment and reconciliation log mismatch and sandbox outcomes.
Logs
Structured fields: timestamp, service, requestId, userId, path, errorCode. Correlation id generated at the gateway and passed downstream.
Distributed tracing (recommended)
Gateway → Financial Request Service → User Service
Gateway → Financial Request Service → Document Service (designed document path)
Gateway → Payment / Notification / Audit query APIs
Outbox → Kafka → Notification / Audit / Payment / Reconciliation / Analytics
Actuator health is permitted unauthenticated on gateway and the JWT services that expose it. User Service exposes health via management.endpoints.web.exposure.include=health.
27. Architectural Trade-offs
| Decision | Chosen approach | Alternative | Why |
|---|---|---|---|
| Service split | Auth + User + FRS + Gateway | Modular monolith | Clear data ownership and independent deploys. Cost: Feign, extra DBs, ops. Monolith would be simpler at this QPS. |
| DB per service | Separate MySQL databases on one server | One shared schema | Prevents JPA joins to employees. Cross-service consistency is via IDs + API. A shared schema would make “microservices” fake. |
| MySQL for every service | Same engine, separate databases | Postgres for requests | One operational skill set. Polyglot persistence is not required at this scale. |
| OpenFeign | Declarative HTTP client from FRS | Browser → Document Service via gateway | User lookup and request-document calls stay inside the FRS process. The UI should not pick the storage service. Cost: Document Service down blocks upload, not submit. User Feign is implemented; Document Feign is not. |
| Sync User lookup | Enrich on submit | Replicate employees into FRS | Always fresh org data; submit depends on User Service. Replication would need events and lag. |
| JWT (symmetric) | Shared secret HS256 | Opaque sessions or RS256 | Simple for a few services. Rotation and “who issued this” are weaker than RS256/JWKS. |
| IDs not JPA associations | UUID columns | Cross-DB JPA | Physically impossible without shared DB. Correct microservice boundary. |
| Policy-driven workflow | DB policies + steps | Hardcoded if/else on amount | Finance can change chains without a release. Risk: priority misconfiguration. |
| Optimistic locking | @Version | SELECT FOR UPDATE | Low contention; no lock wait. Callers must retry 409. |
| Sequential approvals | One PENDING step | Parallel approvals | Simpler state machine. Slower cycle time for multi-step requests. |
| Outbox + Kafka | Same TX as business write | Direct kafka.send() | Avoids dual-write. Cost: lag until publisher runs. Delivery is at-least-once. |
| Sandbox payment | In-process mock | Card network | Safe for local demo. Amounts ending in .99 fail by design. |
| Analytics in MySQL | Same operational skill set | MongoDB | Document-oriented analytics is not required at this volume. |
| Document storage behind Document Service | ObjectStorage + metadata DB | Files in FRS / MySQL BLOBs / gateway→S3 | FRS never owns the bucket. Local disk is implemented. S3 is future on the same abstraction. |
| No Redis yet | Direct DB / Feign | Cache-aside | Extra moving part not justified at current RPS. First cache candidate is employee+policy. |
| Gateway JWT + service JWT | Validate twice | Gateway-only auth | Defense in depth if someone hits :8083 directly. User Service does not do this. |
2-minute version
- Problem. Employees need to request money and get it approved under consistent rules.
- Architecture. React → Gateway → Auth / User / Financial Request plus event-driven services. MySQL databases include
auth_platform,financial_platform,financial_requests,notifications,audits,payments,reconciliation,analytics, anddocuments. Kafka is in Compose. - Request flow. Draft is local to FRS. Submit enriches from User Service, matches a policy, writes approvals and an outbox row in one transaction, status
PENDING_APPROVAL. - Approval workflow. Policies are ordered by priority; steps resolve
MANAGER,FINANCE,FINANCE_MANAGER, orDIRECTOR. Step 1 isPENDING, the restWAITING. Approve advances; reject ends; request-changes returns the request to the employee. Last approve emitsPAYMENT_REQUESTED. - Authentication. Email/password → BCrypt → HS256 JWT with
userIdandroles. Gateway and JWT-protected services validate the token. Approvals compareapproverIdto the token. - Database. No cross-service JPA. IDs only. Optimistic
@Versionon requests and approvals. - Scaling. Stateless services, indexes, pagination. QPS is small; correctness matters more than shard maps.
- Events. Outbox + Kafka into Notification, Audit, Payment, Reconciliation, Analytics. Documents are synchronous (Gateway → FRS → Feign → Document Service → storage), not Kafka. Future: Redis, S3, real ESP/payment rails, User Service JWT, FRS Document Feign client.
5-minute deep dive
Start with trust boundaries. The create DTO cannot carry employee id. Submit always sets employee from JWT, then overwrites department and manager from User Service. Policy is selected server-side. That is the difference between a form and a financial control system.
Then consistency. Submit is one MySQL transaction on financial_requests: request + approvals. We call User Service first (read-only). We do not use 2PC. When we add email or payment, we will not kafka.send() next to repository.save(); we will write an outbox row.
Then concurrency. Two approvers clicking Approve on the same pending step: version check + PENDING guard → 409. Unique (request_id, step) protects the model.
Then trade-offs. Microservices gave us independent databases and a hard boundary against joining employees. A modular monolith would have been easier to operate. OpenFeign is a pragmatic sync call; the cost is User Service as a SPOF on submit. JWT shared secret is simple and weak for rotation compared with RS256. Policy-as-data beats hardcoded thresholds but requires careful priority design — department-specific policies do not automatically beat global ones.
Close with what I would do next: Flyway on FRS, authenticate User Service, composite index on the pending queue, outbox before any consumer, and row-level authorization on GET/cancel.
Speak this as a conversation, not a slide dump. Draw the three boxes under the gateway, then zoom into submit, then zoom into approve.
28. System Design Interview Questions
Why did you use microservices?
To give Auth, User, and Requests separate data ownership and deploy cycles. Not because of Twitter-scale QPS. A modular monolith was a valid alternative and would have been simpler operationally.
Why is User Service separate?
Org chart is a different domain and a different database. FRS must not own employee rows. It stores UUIDs and fetches live data at submit.
Why don't you create a JPA relationship to Employee?
Employee is in MySQL financial_platform. FRS uses MySQL financial_requests. A @ManyToOne would require a shared database or a fake entity. IDs plus Feign are the contract.
Why use OpenFeign?
FRS is a Spring MVC service making typed REST calls. Feign maps 404/5xx through an ErrorDecoder. WebClient would be more natural if FRS were reactive; it is not. The same client style is how FRS should reach Document Service. Putting document upload on the gateway would skip request-workflow authorization in FRS.
Why use JWT?
Stateless gateway routing without sticky sessions. The token carries userId and roles. Cost: shared HMAC secret, no refresh/revocation in v1.
How do you prevent unauthorized approvals?
The API loads the approval, then requires approverId equals the JWT user id and status PENDING. The UI role check is not sufficient.
What happens if User Service is down?
Submit returns 502 FR-400. Drafts and login still work. No retry/circuit breaker yet.
How do you prevent two managers from approving the same request?
Only one step is PENDING. That step has one approverId. Concurrent updates hit @Version → 409. Unique constraint on (request_id, step).
Why use optimistic locking?
Contention is low; we prefer 409 + retry over holding row locks. Especially important when two tabs approve the same task.
How would you scale the approval queue?
It is already filtered by approver_id + status. Add a composite index, paginate (already), eventually archive completed approvals, cache counts.
Why would you introduce Kafka?
For side effects: email, audit, analytics, payment — not for the approval decision itself.
Why do you need the Outbox pattern?
So the event is committed with the request. Direct Kafka publish can diverge from the database (dual write).
What happens if Kafka publication fails?
Outbox publisher retries (NEW/FAILED → PUBLISHING). The business transaction already committed. Consumers are idempotent via processed_events. There is no dedicated DLQ topic today; poison payloads stay failed/retried on the outbox row.
How would you implement idempotency?
Idempotency-Key on create/submit stored in FRS (future). Event consumers key on event id (implemented). Approvals are already naturally idempotent via status + version (second approve is 409).
How would you handle millions of requests?
They still may not require app-tier sprawl. Partition or archive old requests, read replicas for lists, keep submit on primary, cache policies/employees, consider partitioning approvals by created_at. Million rows is a DB design problem, not a 128-server problem.
How would you add notifications?
Implemented. Outbox event APPROVAL_REQUIRED → Notification Service. Do not send email inside ApprovalService.approve. Production would swap LoggingNotificationSender for an ESP.
How would you audit every financial action?
Implemented as Kafka → Audit Service (append-only). Do not treat application logs as the audit trail.
How would you design reconciliation?
Implemented as event projections: request status vs payment status. Mismatch if payment failed but request completed, or payment completed but request rejected/cancelled. Bank-statement matching is future.
What would you change if traffic increased 100×?
Still likely tens of RPS. Add replicas, connection pools, Redis for employee/policy, read replicas, stricter indexes. If submit fan-out to User Service hurts, cache and bulk lookup. Kafka is for decoupled consumers, not for scaling the request API.
Why can a global policy beat a department policy?
Matcher sorts by priority ascending, then department/amount filters. Specificity is not automatic. Call this out; it is a product footgun.
Is SUBMITTED part of the lifecycle?
The enum exists; the service sets PENDING_APPROVAL on submit. Do not draw DRAFT → SUBMITTED → PENDING_APPROVAL as implemented. COMPLETED is used after sandbox PAYMENT_COMPLETED.
Does the frontend send employeeId?
Yes, but CreateFinancialRequest does not include it. The server uses the JWT. That is the right talking point: never trust the client.
Implementation snapshot
| Item | Reality in repo |
|---|---|
| Frontend | React 19, Vite, Axios, sessionStorage JWT |
| Gateway | Spring Cloud Gateway WebFlux, JWT, CORS |
| Auth | Login, BCrypt, JJWT HS256, Flyway MySQL |
| User | Employees/departments, Flyway MySQL, no security config |
| FRS | Requests, policies, approvals, User Service Feign, JWT, outbox. Document Feign / request-document APIs not implemented. |
| Kafka | Compose service; topics financial-request-events, payment-events |
| Outbox | FRS and Payment Service |
| Notification / Audit / Payment / Reconciliation / Analytics | Implemented (see §6–7, §24) |
| Document Service | Implemented (metadata + local storage). Designed path is FRS → Feign. Gateway /api/v1/documents is leftover. S3 not implemented. |
| Vendor | Empty Spring Boot stub |
| Redis / Prometheus / S3 / real ESP / card network / K8s | Not implemented |
Use this file as a 45–60 minute script: architecture on the whiteboard, then submit + policy + approve, then failure modes and evolution.