Skip to main content

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:

  1. Identifies the employee from the authenticated session, not from client-supplied identity fields.
  2. Validates the request.
  3. Enriches it with department and manager data from User Service.
  4. Selects an approval policy.
  5. Creates sequential approval tasks.
  6. Routes the request through those approvers.
  7. 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 role MANAGER or FINANCE_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, and roles.
  • 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_REIMBURSEMENT
  • PURCHASE_REQUEST
  • VENDOR_PAYMENT
  • BUDGET_ALLOCATION

Approval Workflow — Implemented​

  • Select an active matching policy at submission time.
  • Snapshot policyId and policyVersion on the request.
  • Create one approval task per policy step.
  • Mark step 1 as PENDING and later steps as WAITING.
  • 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-events and payment-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.

RequirementWhy it matters for FincoreCurrent posture
SecurityFinancial 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.
AvailabilityEmployees and managers need to create and decide on requests during business hours.Single-instance Docker Compose locally. No HA topology is implemented.
ScalabilityRequest volume can grow with company size.Stateless JVM services can be replicated. Databases are currently single-instance.
ReliabilityA 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 consistencyApproval state and request status must stay aligned.Same-database transaction for request + approvals + outbox. No 2PC across services.
AuditabilityFinance 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.
MaintainabilityPolicy changes should not require code deploys.Policy-driven workflow instead of hardcoded amount thresholds.
Low latencyInteractive UI (create, list, approve).Synchronous REST. No cache layer. User Service is on the submit path.
Fault isolationA 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.

AssumptionValue used in this documentNotes
Company size10,000 employeesMid-size enterprise.
Active requesters8,000Some roles rarely create requests.
Requests per requester per month4Mix of expenses and purchases.
Peak factor5× averageMorning and month-end spikes.
Average request payload2 KB JSONMetadata only; attachments live in Document Service.
Kafka events per submitted request~6–10 envelopesSubmit, approval-required, decisions, payment requested/completed, request completed.
Average attachment size0.5 MBDesign assumption; max upload is 10 MB.
Approval steps per submitted request2Depends on policy; tests use 1–3.
Retention7 yearsTypical finance retention; not implemented as a job.
Read/write mix~10 reads per writeList, detail, and queue views dominate.
JWT lifetime3,600 secondsMatches 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 / sub is 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

ComponentPortRole
React frontend5173UI. JWT in sessionStorage. Not in Compose.
API Gateway8080CORS, JWT validation, path routing.
Auth Service8082Login and JWT issuance.
User Service8081Employees and departments.
Financial Request Service8083Requests, policies, approvals, outbox producer, payment-completed consumer. Owns the request-document workflow and calls Document Service over Feign.
Notification Service8084Idempotent Kafka consumer. Logging sender (not a real ESP).
Audit Service8085Append-only audit trail.
Payment Service8086Sandbox payment processor + outbox.
Reconciliation Service8087Detects request vs payment projection mismatches.
Analytics Service8088MySQL event projections (not MongoDB).
Document Service8089Document metadata and storage. Not on the public request-document path. Local disk today; S3 is future.
MySQL 83307→3306One database per service.
Kafka9092At-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​

ServiceResponsibilityOwns Data
API GatewaySingle public entry. CORS. JWT authentication for all routes except login and health. Path-based routing.None
Auth ServiceValidate email/password. Generate HS256 JWT.auth_accounts in MySQL auth_platform
User ServiceEmployee and department master data, manager relationships, roles, status.employees, departments in MySQL financial_platform
Financial Request ServiceRequest 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 ServiceConsume request/payment events; persist in-app notifications; log “email” in development.notifications, processed_events in notifications
Audit ServiceImmutable audit trail from Kafka.audit_events, processed_events in audits
Payment ServiceSandbox charge after PAYMENT_REQUESTED; outbox PAYMENT_COMPLETED / PAYMENT_FAILED.payments, outbox_events, processed_events in payments
Reconciliation ServiceCompare request vs payment projections; expose mismatches.reconciliation_records, processed_events in reconciliation
Analytics ServiceCounters and request facts from events.analytics_request_facts, analytics_counters, processed_events in analytics
Document ServiceOwns 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).
FrontendScreens 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, expenseDate
  • status
  • policyId, 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.

ServiceEngineDatabaseSchema management
Auth ServiceMySQL 8auth_platformFlyway + ddl-auto=validate
User ServiceMySQL 8financial_platformFlyway + ddl-auto=validate
Financial Request ServiceMySQL 8financial_requestsHibernate ddl-auto=update for legacy tables; Flyway for outbox_events and processed_events
Notification ServiceMySQL 8notificationsFlyway + ddl-auto=validate
Audit ServiceMySQL 8auditsFlyway + ddl-auto=validate
Payment ServiceMySQL 8paymentsFlyway + ddl-auto=validate
Reconciliation ServiceMySQL 8reconciliationFlyway + ddl-auto=validate
Analytics ServiceMySQL 8analyticsFlyway + ddl-auto=validate
Document ServiceMySQL 8documentsFlyway + 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:

ColumnTypeNotes
idCHAR(36) PKUUID
employee_idCHAR(36) UNIQUEUser Service employee
emailVARCHAR(255) UNIQUELogin key; index idx_auth_accounts_email
password_hashVARCHAR(255)BCrypt
roleVARCHAR(32)UserRole
enabledBOOLEANDefault true
created_at / updated_atTIMESTAMP(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​

ColumnNotes
idUUID PK, CHAR
request_numberUNIQUE, length 50
employee_idNOT NULL, indexed
department_idnullable until enrich
manager_idnullable
request_typeenum string
amountNUMERIC(19,4)
currencyCHAR/VARCHAR 3
category100
description2000
expense_datedate
statusenum string, indexed
policy_idindexed
policy_versioninteger
submitted_at / completed_attimestamptz
created_at / updated_atJPA auditing
versionoptimistic lock

approvals​

ColumnNotes
idUUID PK
request_idFK to financial_requests, NOT NULL
approver_idindexed
stepinteger
statusindexed
decisionnullable until decided
comments2000
created_at / decided_at
versionoptimistic lock

Unique constraint uk_approval_request_step on (request_id, step).

approval_policies​

ColumnNotes
idUUID PK
name100
request_typeindexed
min_amount / max_amountNUMERIC(19,4); max nullable = unbounded
department_idindexed; null = global policy
activeindexed
prioritylower is selected first
versioninteger, incremented on update
effective_from / effective_to
timestamps

approval_policy_steps​

ColumnNotes
idUUID PK
policy_idFK, indexed
step_number
approver_typeMANAGER, FINANCE, FINANCE_MANAGER, DIRECTOR
is_requiredboolean
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​

StatusMeaningWho transitionsAllowed operationsRestrictions
DRAFTSaved, not in workflowAuthenticated creator (ID from JWT)Update, submit, cancel, viewNo policy, no approvals yet. Enrichment happens on submit, not on draft create.
PENDING_APPROVALIn sequential workflowAssigned approver for the PENDING stepApprove, reject, request-changes, view, cancelNot editable. Only the assigned approverId may decide. Later steps stay WAITING.
CHANGES_REQUESTEDSent back to requesterApprover requested changesUpdate, submit, cancel, viewTreated like an editable draft. Resubmit re-enriches, re-matches policy, and rebuilds approval tasks.
APPROVEDAll steps approved; payment requestedSystem after last approveViewNot cancellable. completedAt set. Outbox emits PAYMENT_REQUESTED.
REJECTEDA step rejectedSystem after rejectViewNot cancellable. completedAt set. Remaining waiting steps are not activated.
CANCELLEDWithdrawnAuthenticated caller of cancel APIViewNot allowed from APPROVED, REJECTED, COMPLETED, or CANCELLED.
SUBMITTEDEnum only——Not used at runtime.
COMPLETEDPayment succeededFRS Kafka consumerViewSet only from APPROVED when PAYMENT_COMPLETED arrives.

Approval task statuses​

StatusMeaning
WAITINGFuture step; not yet actionable
PENDINGCurrent step; assigned approver may act
APPROVED / REJECTED / CHANGES_REQUESTEDDecision recorded
DELEGATED / ESCALATEDEnum placeholders. No APIs.

12. Create Request Flow​

Creating a draft does not call User Service and does not select a policy.

What actually happens:

  1. Authentication. Gateway and Financial Request Service both require a valid JWT except for health. CurrentUserProvider reads userId, falling back to sub.
  2. Validation. Bean validation on CreateFinancialRequest: type, amount > 0, ISO currency, optional category/description/expenseDate. There is no employeeId field on the DTO.
  3. Employee identification. entity.setEmployeeId(currentUserProvider.getCurrentUserId()). If the UI also sends employeeId / departmentId, Jackson ignores unknown properties. The backend does not use them.
  4. No User Service lookup on draft create. departmentId and managerId stay null until submit.
  5. Persistence. Status DRAFT, unique requestNumber, 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 draftSubmit
HTTPPOST /api/v1/requests or create-and-submit POST /api/v1/requests/submitPOST /api/v1/requests/{id}/submit
Status after successDRAFTPENDING_APPROVAL
User ServiceNot calledRequired (enrich + possibly approver resolve)
PolicyNoneMatching active policy required
ApprovalsNoneOne row per policy step
Employee IDJWTJWT again (overwrites)

Create-and-submit is draft mapping + submit in one transaction.

Submit steps in code (FinancialRequestService.submit):

  1. Load request (or build from create DTO).
  2. Ensure status is DRAFT or CHANGES_REQUESTED.
  3. Set employeeId from JWT.
  4. Enrich from User Service: employee must be ACTIVE and have a department; department must be active; manager if present must be ACTIVE and MANAGER or FINANCE_MANAGER.
  5. Validate amount, currency, type, employee, department.
  6. Match policy: active, same requestType, amount in [min, max], department null or equal, lowest priority first.
  7. Snapshot policy id/version.
  8. Clear previous approvals if resubmitting, then create new tasks.
  9. Set PENDING_APPROVAL and submittedAt.
  10. 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):

  1. findByRequestTypeAndActiveTrueOrderByPriorityAsc
  2. Keep policies where departmentId is null or equals the request department (null department on the policy = company-wide).
  3. Keep policies where amount >= minAmount and (maxAmount is null or amount <= maxAmount).
  4. 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​

ApproverTypeResolution
MANAGERrequest.managerId if already enriched; otherwise employee's managerId. Fails if missing. Manager must be active with role MANAGER or FINANCE_MANAGER.
FINANCEFirst ACTIVE employee with role FINANCE_ANALYST (GET /api/v1/users?role=FINANCE_ANALYST&status=ACTIVE&size=1). Not department-scoped.
FINANCE_MANAGERSame pattern with role FINANCE_MANAGER.
DIRECTORDepartment'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:

  1. Request must exist.
  2. Approval must belong to that request (findByIdAndRequest_Id).
  3. approverId must equal CurrentUserProvider.getCurrentUserId().
  4. Approval status must be PENDING (already decided → 409).
  5. 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 methodUser Service API
getEmployeeGET /api/v1/users/{id}
getEmployeeDepartmentGET /api/v1/users/{id}/department
getDepartmentGET /api/v1/departments/{id}
listEmployeesGET /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​

DataOwnerOthers may
Password hash, login emailAuth ServiceStore employee_id only
Name, org, manager, roleUser ServiceStore UUIDs + snapshot at submit
Amount, policy, approvalsFinancial Request ServiceUser Service does not know about requests
File bytes and document metadataDocument ServiceFRS 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 UUID
  • userId = same UUID
  • roles = [account.role] e.g. EMPLOYEE, MANAGER
  • iat / 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​

MethodEndpointPurposeAuthenticationAuthorization
POST/api/v1/auth/loginIssue JWTPublicEnabled account + password

User APIs (User Service)​

Gateway exposes users, not departments.

MethodEndpointPurposeAuthenticationAuthorization
POST/api/v1/usersCreate employeeGateway JWTNone in User Service
GET/api/v1/users/{id}Get employeeGateway JWTNone in service
GET/api/v1/users/{id}/managerGet managerGateway JWTNone in service
GET/api/v1/users/{id}/departmentGet departmentGateway JWTNone in service
GET/api/v1/usersList / filterGateway JWTNone in service
PUT/api/v1/users/{id}UpdateGateway JWTNone in service
DELETE/api/v1/users/{id}Soft deactivateGateway JWTNone in service
POST/api/v1/departmentsCreate departmentNot routed on gatewayNone in service
GET/api/v1/departments/{id}Get departmentDirect to :8081 or future routeNone in service
GET/api/v1/departmentsListDirectNone in service
PUT/api/v1/departments/{id}UpdateDirectNone in service
DELETE/api/v1/departments/{id}Soft deactivateDirectNone in service

Request APIs​

MethodEndpointPurposeAuthenticationAuthorization
POST/api/v1/requestsCreate draftJWTAny authenticated user; employee = token
GET/api/v1/requests/{requestId}Get requestJWTAuthenticated; no owner check
GET/api/v1/requestsList, paginatedJWTFilters optional; no enforced scope
PUT/api/v1/requests/{requestId}Update draft / changes-requestedJWTState check + version; no owner check
POST/api/v1/requests/{requestId}/submitSubmit existingJWTSubmittable state; identity from token
POST/api/v1/requests/submitCreate and submitJWTIdentity from token
POST/api/v1/requests/{requestId}/cancelCancelJWTNot terminal; no owner check

Approval APIs​

MethodEndpointPurposeAuthenticationAuthorization
GET/api/v1/approvals/my-pendingMy pending queueJWTRows for token userId
GET/api/v1/requests/{requestId}/approvalsList stepsJWTAuthenticated
GET/api/v1/requests/{requestId}/approvals/{approvalId}Get oneJWTMust match request
POST.../approvals/{approvalId}/approveApproveJWTMust be assigned approver; status PENDING
POST.../approvals/{approvalId}/rejectRejectJWTSame + comments required
POST.../approvals/{approvalId}/request-changesRequest changesJWTSame + comments required

Policy APIs​

MethodEndpointPurposeAuthenticationAuthorization
POST/api/v1/policiesCreateJWTADMIN or FINANCE_MANAGER
GET/api/v1/policies/{policyId}GetJWTAuthenticated
GET/api/v1/policiesListJWTAuthenticated
PUT/api/v1/policies/{policyId}Update if unusedJWTADMIN or FINANCE_MANAGER
DELETE/api/v1/policies/{policyId}DeactivateJWTADMIN or FINANCE_MANAGER
POST/api/v1/policies/{policyId}/activateActivateJWTADMIN or FINANCE_MANAGER
POST/api/v1/policies/{policyId}/deactivateDeactivate and return bodyJWTADMIN or FINANCE_MANAGER

Default page size is 20 (@PageableDefault).

Event-driven service APIs (Implemented)​

MethodEndpointServiceAuthorization
GET/api/v1/notifications/meNotificationAuthenticated (own inbox)
GET/api/v1/audits/request/{requestId}AuditOwner/actor or finance/admin/auditor
GET/api/v1/payments/request/{requestId}PaymentOwner or finance/admin
GET/api/v1/reconciliationsReconciliationADMIN, FINANCE_MANAGER, FINANCE_ANALYST
GET/api/v1/reconciliations/request/{requestId}Reconciliationsame
POST/api/v1/reconciliations/request/{requestId}/reprocessReconciliationsame
GET/api/v1/analytics/summaryAnalyticsfinance/admin/auditor
GET/api/v1/analytics/requests-by-typeAnalyticssame
GET/api/v1/analytics/requests-by-departmentAnalyticssame
GET/api/v1/analytics/volumeAnalyticssame

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

ItemStatus
Document Service upload/list/get/delete + local ObjectStorageImplemented
Gateway /api/v1/documents/** and frontend calling those URLsCurrent leftover. Not the designed path.
FRS document endpoints + Document Feign clientNot implemented
S3Future. 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.version
  • Approval.version
  • Employee.version and Department.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​

CodeHTTPWhen
FR-001 … FR-004404Request / approval / policy / user-service resource missing
FR-100400Validation
FR-101400Malformed body
FR-102400Bad query/path argument
FR-200409Illegal state (not editable, already decided, policy in use, …)
FR-201403Not the assigned approver
FR-202409Optimistic lock
FR-203409Data integrity / duplicate
FR-300401Unauthenticated
FR-301403Access denied (@PreAuthorize)
FR-400502User Service down or 5xx
FR-500500Unexpected

Auth and User Service use a similar JSON shape without errorCode.

Mapping to architecture questions​

FailureHandling
Validation400 FR-100 / FR-101
Not found404 FR-00x
Invalid state409 FR-200
Authorization401 / 403
User Service404 or 502 FR-400
DB constraint409 FR-203
Optimistic lock409 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, default http://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)​

TopicCurrentFuture / Planned
HTTPSNot terminated in ComposeReverse proxy / load balancer TLS
User Service authUnsecured applicationJWT or mTLS on the service
Feign authNo tokenPropagate JWT or use a client credential
Gateway departmentsUnroutedAdd route + authz
Row-level security on requestsMissingOwner / finance / auditor rules in FRS
Refresh tokens / logout denylistAccess token only; stored in sessionStorageRefresh + revocation
Secret rotation / vaultEnv varsDedicated secrets manager
DB encryption / network isolationLocal Docker networkPrivate subnets, IAM, KMS
Audit log of every mutationRequest/approval fields plus Audit Service Kafka trailImmutable store with legal hold / 7-year job
Rate limitingNoneGateway 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

TopicProducersConsumers
financial-request-eventsFRS outboxNotification, Audit, Payment (PAYMENT_REQUESTED only), Reconciliation, Analytics
payment-eventsPayment outboxNotification, 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).

EventRecipient
REQUEST_SUBMITTED, REQUEST_APPROVED, REQUEST_REJECTED, REQUEST_CHANGES_REQUESTEDEmployee
APPROVAL_REQUIREDAssigned approver
PAYMENT_COMPLETEDEmployee

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 FAILED but request COMPLETED
  • Payment COMPLETED but request REJECTED or CANCELLED

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)
ResponsibilityOwner
Is this user allowed to attach a file to this request?Financial Request Service
Validate size/type, persist metadata, store bytesDocument Service
S3 bucket / local volumeDocument 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​

FailureWhat happens today
User Service unavailableSubmit, 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 unavailableRequest/approval/policy APIs fail (500/DB exception). Auth and User still up.
API Gateway unavailableBrowser 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 unavailableNew logins fail. Existing JWTs still work until expiry (gateway validates locally with the shared secret).
Kafka unavailableUser-facing submit/approve still succeed. Outbox rows stay NEW/FAILED and retry when Kafka returns. Consumers pause.
Document Service unavailableSubmit/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.

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​

DecisionChosen approachAlternativeWhy
Service splitAuth + User + FRS + GatewayModular monolithClear data ownership and independent deploys. Cost: Feign, extra DBs, ops. Monolith would be simpler at this QPS.
DB per serviceSeparate MySQL databases on one serverOne shared schemaPrevents JPA joins to employees. Cross-service consistency is via IDs + API. A shared schema would make “microservices” fake.
MySQL for every serviceSame engine, separate databasesPostgres for requestsOne operational skill set. Polyglot persistence is not required at this scale.
OpenFeignDeclarative HTTP client from FRSBrowser → Document Service via gatewayUser 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 lookupEnrich on submitReplicate employees into FRSAlways fresh org data; submit depends on User Service. Replication would need events and lag.
JWT (symmetric)Shared secret HS256Opaque sessions or RS256Simple for a few services. Rotation and “who issued this” are weaker than RS256/JWKS.
IDs not JPA associationsUUID columnsCross-DB JPAPhysically impossible without shared DB. Correct microservice boundary.
Policy-driven workflowDB policies + stepsHardcoded if/else on amountFinance can change chains without a release. Risk: priority misconfiguration.
Optimistic locking@VersionSELECT FOR UPDATELow contention; no lock wait. Callers must retry 409.
Sequential approvalsOne PENDING stepParallel approvalsSimpler state machine. Slower cycle time for multi-step requests.
Outbox + KafkaSame TX as business writeDirect kafka.send()Avoids dual-write. Cost: lag until publisher runs. Delivery is at-least-once.
Sandbox paymentIn-process mockCard networkSafe for local demo. Amounts ending in .99 fail by design.
Analytics in MySQLSame operational skill setMongoDBDocument-oriented analytics is not required at this volume.
Document storage behind Document ServiceObjectStorage + metadata DBFiles in FRS / MySQL BLOBs / gateway→S3FRS never owns the bucket. Local disk is implemented. S3 is future on the same abstraction.
No Redis yetDirect DB / FeignCache-asideExtra moving part not justified at current RPS. First cache candidate is employee+policy.
Gateway JWT + service JWTValidate twiceGateway-only authDefense in depth if someone hits :8083 directly. User Service does not do this.

2-minute version​

  1. Problem. Employees need to request money and get it approved under consistent rules.
  2. 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, and documents. Kafka is in Compose.
  3. 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.
  4. Approval workflow. Policies are ordered by priority; steps resolve MANAGER, FINANCE, FINANCE_MANAGER, or DIRECTOR. Step 1 is PENDING, the rest WAITING. Approve advances; reject ends; request-changes returns the request to the employee. Last approve emits PAYMENT_REQUESTED.
  5. Authentication. Email/password → BCrypt → HS256 JWT with userId and roles. Gateway and JWT-protected services validate the token. Approvals compare approverId to the token.
  6. Database. No cross-service JPA. IDs only. Optimistic @Version on requests and approvals.
  7. Scaling. Stateless services, indexes, pagination. QPS is small; correctness matters more than shard maps.
  8. 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​

ItemReality in repo
FrontendReact 19, Vite, Axios, sessionStorage JWT
GatewaySpring Cloud Gateway WebFlux, JWT, CORS
AuthLogin, BCrypt, JJWT HS256, Flyway MySQL
UserEmployees/departments, Flyway MySQL, no security config
FRSRequests, policies, approvals, User Service Feign, JWT, outbox. Document Feign / request-document APIs not implemented.
KafkaCompose service; topics financial-request-events, payment-events
OutboxFRS and Payment Service
Notification / Audit / Payment / Reconciliation / AnalyticsImplemented (see §6–7, §24)
Document ServiceImplemented (metadata + local storage). Designed path is FRS → Feign. Gateway /api/v1/documents is leftover. S3 not implemented.
VendorEmpty Spring Boot stub
Redis / Prometheus / S3 / real ESP / card network / K8sNot implemented

Use this file as a 45–60 minute script: architecture on the whiteboard, then submit + policy + approve, then failure modes and evolution.