π¦ Ditos Manifest Service β Backend Guide
A friendly, in-depth tour of the Java backend β written so a newcomer can understand what the project does, how the code is organized, and why each piece exists. Read it top-to-bottom the first time; later, jump straight to the subsystem you care about.
On this page
- 1. What is this project? (the 30-second version)
- 2. The technology stack
- 3. The big picture
- 4. How the code is organized
- 5. The request lifecycle (this is the heart of it)
- 6. Shared building blocks (
common/) - 7. Authentication & authorization deep-dive (
auth/) - 8. The Unicommerce integration (
unicommerce/) - 9. Subsystem tour
- 10. The βtwo-gateβ pattern (important!)
- 11. The scheduler (
ManifestScheduler) - 12. Configuration & secrets
- 13. Running it locally
- 14. Cross-cutting conventions (the βhouse styleβ)
- 15. Mini-glossary (for newcomers)
- 16. Suggested reading path for a new developer
1. What is this project? (the 30-second version)
This is a single-client integration backend that sits between a retailerβs systems and Unicommerce (a warehouse / order-management SaaS).
It does five practical jobs:
| # | Job | Plain-English description |
|---|---|---|
| 1 | Shipping manifests | Pull βwhat shipped todayβ data out of Unicommerce on a schedule and store it. |
| 2 | Catalog sync | Push product item types and categories into Unicommerce. |
| 3 | Inventory | Push stock adjustments to Unicommerce, and bulk-import full inventory snapshots back. |
| 4 | Mall feeds | Let external βmallsβ (landlords/partners) securely pull a storeβs sales transactions. |
| 5 | Admin console | A web UI (React) for staff to manage users, credentials, outlets, toggles, and view logs. |
Everything is exposed as a REST API. A separate React frontend talks to it.
2. The technology stack
| Layer | Choice | Why it matters |
|---|---|---|
| Language | Java 25 | Modern Java (records, pattern matching, text blocks). |
| Framework | Spring Boot 4.0.5 | Web, Security, Data JPA, Validation, Actuator. |
| Database | PostgreSQL | Relational store; also used for fast bulk COPY loads. |
| Migrations | Flyway | Versioned SQL files own the schema (V1__...sql, V2__...sql, β¦). |
| Auth | JWT (jjwt) + Spring Security | Stateless tokens for the admin UI. |
| Secrets | AWS Secrets Manager | DB credentials loaded at boot in prod. |
| Analytics source | Google BigQuery | Mall transactions are read from BigQuery. |
| API docs | springdoc / Swagger UI | Auto-generated at /swagger-ui.html. |
| Boilerplate | Lombok | @Getter, @RequiredArgsConstructor, etc. |
Mental model: Spring Boot wires everything together. You write small focused classes (controllers, services, repositories) and annotate them; Spring creates and connects them at startup.
3. The big picture
βββββββββββββββββββββββββββββββββββββββββββββββ
β React Admin UI β
β (browser, JWT in Authorization) β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββ
β HTTPS /api/**
External malls ββX-API-Keyβββ β βββββ Client POS ββ X-Secret-Token
βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β Spring Boot backend β
β ββββββββββββββββββββββββββββββββββββββββββ β
β β Servlet filter chain (auth gates) β β
β ββββββββββββββββββββββββββββββββββββββββββ β
β Controllers β Services β Repositories β
βββββββββ¬ββββββββββββββββββββββββ¬ββββββββββββββ
β β
βββββββββββββΌββββββββββ βββββββββββΌβββββββββββ ββββββββββββββββ
β PostgreSQL β β Unicommerce β β BigQuery β
β (own schema, JPA) β β (REST + OAuth) β β (read-only) β
βββββββββββββββββββββββ ββββββββββββββββββββββ ββββββββββββββββ
There are three βfront doorsβ, each with its own way of proving who you are:
| Door | URL prefix | Who uses it | How it authenticates |
|---|---|---|---|
| π οΈ Admin | most /api/** | staff via the React UI | JWT (Authorization: Bearer β¦) |
| π€ Client | /api/client/** | the retailerβs POS / systems | X-Secret-Token header + IP allowlist |
| π¬ Mall | /api/mall/** | external partners | X-API-Key header + per-mall IP allowlist + rate limit |
4. How the code is organized
Everything lives under com.ditos. Each business area is its own package β a βvertical sliceβ holding its own controller, service, entity, repository, and DTOs together.
com.ditos
βββ DitosManifestApplication.java β main() entry point
βββ common/ π§± shared building blocks (ApiResponse, exceptions, BaseEntity, AES, error handler)
βββ config/ βοΈ security, CORS, OpenAPI, AWS Secrets, IP allowlist
βββ security/ π‘οΈ cross-cutting filters (trace id, login rate limit)
βββ auth/ π users, login, JWT, password rules, first-boot admin
βββ client/ π€ /api/client/** gate (token + IP) for the retailer
βββ mall/ π¬ /api/mall/** partner feeds (token + IP + rate limit + XML)
βββ unicommerce/ π OAuth token lifecycle + two HTTP clients to Unicommerce
βββ manifest/ π shipping-manifest fetch, storage, scheduler
βββ catalog/ π·οΈ push item types & categories to Unicommerce (with call logs)
βββ inventory/ π stock adjustments + bulk snapshot import pipeline
βββ bigquery/ π read transactions/analytics from BigQuery
βββ outlet/ πͺ store/facility master data
βββ operations/ π feature toggles (turn jobs on/off without redeploy)
Where do I start reading? Pick a feature, open its package, and read in this order: Controller (the HTTP shape) β Service (the logic) β Repository/Entity (the data).
5. The request lifecycle (this is the heart of it)
Every HTTP request walks through a chain of servlet filters before it reaches a controller. Think of filters as a series of gates; each can let the request pass or stop it cold.
HTTP request
β
βΌ
[1] RequestTraceFilter β stamps a traceId (X-Trace-Id) for log correlation
β
[2] RateLimitFilter β throttles POST /api/auth/login per IP (anti brute-force)
β
βββββ Spring Security chain ββββββββββββββββββββββββββββββββββββββββββ
β [3] JwtAuthenticationFilter β if Bearer token valid, set the user β
β [4] IpAllowlistFilter β optional global IP allowlist β
β [5] ClientApiAuthFilter β guards /api/client/** (token + IP) β
β [6] MallAuthFilter β guards /api/mall/** (token+IP+rate) β
β [7] MustChangePasswordFilter β blocks all but pwd-change if forced β
β [8] authorizeHttpRequests β "is this path public or protected?" β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
Controller β Service β Repository β (DB / Unicommerce / BigQuery)
β
βΌ
Response wrapped in ApiResponse { success, message, data, timestamp }
Defined in config/SecurityConfig.java. Key facts:
- Stateless:
SessionCreationPolicy.STATELESSβ no server-side sessions; the JWT is the session. - Public paths (
PUBLIC[]):/api/auth/login,/api/client/**,/api/mall/**,/api/manifest/**(β οΈ temporary public test trigger), health, and Swagger. βPublic to Spring Securityβ means JWT is not required β the client/mall filters still guard their paths. - Security headers: frame deny, content-type-options, HSTS (1 year), referrer policy.
- CORS: driven by
app.cors.allowed-origins(*in dev, locked-down list in prod).
6. Shared building blocks (common/)
These are used everywhere, so learn them once.
6.1 ApiResponse<T> β the uniform envelope
Every JSON response has the same shape:
{ "success": true, "message": "optional note", "data": { ... }, "timestamp": "2026-..." }
Helpers: ApiResponse.ok(data), ApiResponse.ok("msg", data), ApiResponse.error("msg"). Null fields are dropped, so error bodies stay small.
6.2 ApiException + GlobalExceptionHandler β one place for errors
Throw new ApiException(HttpStatus.NOT_FOUND, "No such outlet") anywhere in a service. GlobalExceptionHandler (a @RestControllerAdvice) catches it β and validation errors, bad credentials, 404s, etc. β and turns each into the right HTTP status + an ApiResponse.error(...). The catch-all logs the stack trace and returns a generic 500 so internals never leak.
6.3 BaseEntity β audit + soft-delete for free
Most entities extend BaseEntity, which adds: id, createdBy/createdDate, updatedBy/lastModifiedDate, version (optimistic locking), and soft-delete via is_deleted + @SQLRestriction("is_deleted = false") β so βdeletedβ rows are simply filtered out of every query. active is a separate enable/disable flag.
6.4 AesStringConverter β encrypt sensitive columns at rest
Annotate a field with @Convert(converter = AesStringConverter.class) and JPA transparently AES-GCM-encrypts it on write and decrypts on read. The 256-bit key is derived (SHA-256) from the APP_ENCRYPTION_KEY env var. Used for things like stored third-party credentials.
APP_ENCRYPTION_KEY must be a real OS env var β thereβs a loud dev fallback you must never ship.
7. Authentication & authorization deep-dive (auth/)
This is the admin/staff login system.
7.1 The players
| Class | Role | |ββ-|ββ| | User / UserRepository | the account record + DB access | | Role | enum: PLATFORM_ADMIN (manages everything) or OPERATOR (uses features) | | AuthService | login + change-password logic | | JwtService | issues & verifies HS256 JWTs | | JwtAuthenticationFilter | reads Bearer token on each request, sets the security context | | MustChangePasswordFilter | forces a password reset before anything else | | AuthBootstrap | creates the first admin on first boot | | AuthController / UserController | the HTTP endpoints |
7.2 Login flow
POST /api/auth/login { username, password }
β AuthService.login()
β’ find user, check active, check BCrypt password
β’ JwtService.issue(user) β signs a token with claims:
subject=username, role, mustChangePassword, exp(+8h)
β { token, username, role, mustChangePassword }
On later requests the browser sends Authorization: Bearer <token>. JwtAuthenticationFilter parses it, and if valid puts a ROLE_<role> authority into the SecurityContext. Method-level rules like @PreAuthorize("hasRole('PLATFORM_ADMIN')") then apply.
7.3 First-boot admin (no seeded password in SQL!)
AuthBootstrap runs once when the user table is empty: it creates admin with a random 16-char password printed once to the logs under a FIRST-BOOT: initial admin created banner, with mustChangePassword = true.
If you ever canβt log in locally, this banner in the backend startup log is where the password is.
7.4 Forced password change
The JWT carries mustChangePassword. While thatβs true, MustChangePasswordFilter blocks every endpoint with 403 except /api/auth/change-password, /me, /logout. This makes the rule authoritative even if someone bypasses the UI.
8. The Unicommerce integration (unicommerce/)
Most features ultimately call Unicommerce. All the auth plumbing lives here so feature code stays simple.
8.1 Two HTTP clients, one host
UnicommerceClientConfig defines two RestClient beans β neither hard-codes a base URL (callers pass absolute URLs):
| Bean | Purpose | Auth | Read timeout |
|---|---|---|---|
unicommerceAuthRestClient | the OAuth token endpoint | none (it fetches the token) | 30s |
unicommerceApiRestClient | all authenticated API calls | auto-injects Authorization: Bearer β¦ | 60s |
They resolve to the same host because UnicommerceTokenService.getApiBaseUrl() derives scheme://host[:port] from the stored credentialsβ token URL.
8.2 Token lifecycle (UnicommerceTokenService)
- Caches a valid access token in memory and persists it (so restarts donβt re-auth).
getAccessToken()returns a non-expired token, refreshing 60s before expiry.- Refresh strategy: try the refresh-token grant, fall back to the password grant.
- Concurrency: a
ReentrantLockensures only one refresh happens at a time.
8.3 The βre-login on 401β pattern β withRetryOn401
Feature clients wrap calls like this:
tokenService.withRetryOn401(() -> apiRestClient.post().uri(base + PATH).body(x).retrieve().body(String.class));
If Unicommerce returns 401 (token revoked server-side), the helper invalidates the cached token and retries once β which transparently re-authenticates. Youβll see this in every Unicommerce client (UnicommerceItemTypeClient, UnicommerceCategoryClient, UnicommerceInventoryClient, etc.).
8.4 Test proxy (dev only)
UnicommerceTestProxyInterceptor can route calls through a whitelisted relay so local dev machines (without a whitelisted IP) can still reach Unicommerce. Controlled by app.unicommerce.test-proxy.enabled.
9. Subsystem tour
9.1 π Manifests (manifest/)
Pulls shipping-manifest data from Unicommerce and stores it as ShippingManifest β ManifestPackage β ManifestLineItem.
ManifestFetchServiceβ the workhorse. Resolves a time window using a gapless high-water-mark cursor (continues from the previous runβswindowEnd; default 30-minute window), then fetches for active outlets in a 3-phase concurrent pattern.ManifestRunServiceβ records each run (ManifestRun) for the admin history.ManifestSchedulerβ an in-process scheduler (see Β§11).ManifestControllerβ β οΈ a temporary publicPOST /api/manifest/runmanual trigger.
9.2 π·οΈ Catalog (catalog/)
Pushes products into Unicommerce with forensic logging + manual replay β a pattern worth learning because inventory copies it too.
Two near-identical stacks:
| Feature | Client endpoint | Unicommerce path | Log table |
|---|---|---|---|
| Item types | POST /api/client/catalog/itemType/createOrEdit | β¦/catalog/itemType/createOrEdit | catalog_item_type_log |
| Categories | POST /api/client/catalog/category/addOrEdit | β¦/product/category/addOrEdit | catalog_category_log |
The logging pattern (in ItemTypeService / CategoryService):
- Save a
PENDINGlog row before the HTTP call (so a crash mid-call still leaves a trace). - Call Unicommerce via the client (with
withRetryOn401). - Update the row to
SUCCESS/FAILEDwith the request payload + raw response. - Never auto-retry. An admin reviews failures and replays them (
POST /api/catalog/logs/{id}/retry) β safe because these operations are idempotent upstream.
Admin views: GET /api/catalog/logs and GET /api/catalog/category/logs (PLATFORM_ADMIN only).
9.3 π Inventory (inventory/)
Two distinct things:
(a) Adjustments β POS pushes stock changes: POST /api/client/inventory/adjust β InventoryService (same PENDINGβSUCCESS/PARTIAL/FAILED log pattern as catalog) β UnicommerceInventoryClient β β¦/inventory/adjust/bulk (sets a Facility header).
(b) Snapshot import pipeline (inventory/snapshot/) β admin batch job that imports the full inventory matrix using a clever 3-phase, memory-flat design:
Phase 1 CREATE-ALL : ask Unicommerce to create an export job per outlet (export/job/create)
Phase 2 POLL-ALL : poll all jobs concurrently until COMPLETE (export/job/status)
Phase 3 INGEST-ALL : stream-download each CSV and bulk-load via Postgres COPY
StreamingCsvReaderparses CSV one row at a time (constant memory, RFC-4180-ish).InventorySnapshotCsvIngestorstreams rows straight into a PostgresCOPY, tags each with the run id, then atomically deletes the previous runβs rows for that facility (full-replace per facility).InventorySnapshotRunServicerecords the batch outcome.
Why COPY? Per-row INSERT of ~100k rows would be painfully slow; COPY streams them in one shot.
9.4 π¬ Mall (mall/)
Lets external partners pull a storeβs sales transactions (sourced from BigQuery).
MallAuthFilterguards/api/mall/**: readsX-API-Key, resolves client IP, callsMallService.verify(ip, token)(token is SHA-256 hashed and looked up; per-mall IP allowlist checked unlessapp.mall-auth.ip-check-enabled=false), then applies a per-mall rate limit (MallRateLimiter, fixed 60s window; 429 +Retry-Afterwhen exceeded).MallTransactionControllerβMallTransactionService: validates the requestedstore_codemaps to the mallβs outlet, queries BigQuery, returns JSON or XML (MallTransactionXml). Supports incremental pulls via a stored cursor (lastBilledMicros,lastInvoiceId).MallAdminController(/api/malls, admin): CRUD malls, manage per-mall IPs and tokens. A token is shown once at generation (only its hash is stored) β same one-time-secret idea as client tokens.
9.5 π€ Client access (client/)
The token+IP system behind /api/client/**:
ClientAccessService.verify(ip, token)β token (SHA-256) must match an active credential; the IP allowlist applies unlessapp.client-auth.ip-check-enabled=false(token-only mode).ClientAccessController(admin) manages allowlisted IPs and one-time secret tokens.TokenSupportβ generates high-entropy tokens and hashes them.
9.6 π BigQuery (bigquery/)
Read-only analytics access. BigQueryCredentialService stores a service-account JSON (encrypted), BigQueryClientProvider builds the client, BigQueryService runs parameterized queries. The mall feeds are the main consumer.
9.7 πͺ Outlets & π Operations
outlet/β master data for stores/facilities (facility codes used across manifests/inventory/malls).operations/β feature toggles stored in the DB (operation_toggle), e.g.MANIFEST_FETCH,INVOICE_PUSH. Lets an admin turn a job on/off without a redeploy.
10. The βtwo-gateβ pattern (important!)
Background jobs are guarded by two independent switches:
- Config flag (env / Secrets Manager) β is this capability deployed/enabled at all? e.g.
MANIFEST_SCHEDULER_ENABLED=true. - DB operation toggle (
operations/) β should it actually run right now? e.g. theMANIFEST_FETCHrow beingenabled=true.
Both must be on. The config flag is an ops/deploy decision; the DB toggle is a day-to-day admin switch.
11. The scheduler (ManifestScheduler)
An in-process scheduler (@Scheduled, enabled by @EnableScheduling on the main class):
@Component
@ConditionalOnProperty(prefix="app.manifest.scheduler", name="enabled", havingValue="true")
class ManifestScheduler {
@Scheduled(fixedDelayString="${app.manifest.scheduler.interval-ms:1800000}", // 30 min
initialDelayString="${app.manifest.scheduler.initial-delay-ms:60000}")
void tick() { /* skip if MANIFEST_FETCH toggle off, else fetch + record */ }
}
- Runs only if the config flag is on (
MANIFEST_SCHEDULER_ENABLED) and theMANIFEST_FETCHDB toggle is on (the two-gate pattern). fixedDelay= waits for the previous run to finish, then waits the interval (no overlap).- β οΈ This only works on a long-lived JVM (e.g. EC2). On AWS Lambda the process freezes between invocations, so youβd use EventBridge to trigger a run instead.
12. Configuration & secrets
12.1 application.yml (defaults) + application-prod.yml (prod overrides)
Settings use ${ENV_VAR:default} so the same jar runs locally and in prod by changing env vars. Key knobs:
| Property | Env var | What it does |
|---|---|---|
server.port | SERVER_PORT | HTTP port (default 8080). |
app.jwt.secret | JWT_SECRET | HS256 signing key (β₯32 bytes). |
app.cors.allowed-origins | CORS_ALLOWED_ORIGINS | lock down in prod. |
app.manifest.scheduler.enabled | MANIFEST_SCHEDULER_ENABLED | turn the 30-min job on. |
app.client-auth.ip-check-enabled | CLIENT_IP_CHECK_ENABLED | token-only client mode when false. |
app.mall-auth.ip-check-enabled | MALL_IP_CHECK_ENABLED | token-only mall mode when false. |
app.ip-allowlist.trust-forwarded-headers | IP_TRUST_FORWARDED | trust X-Forwarded-For behind a proxy. |
12.2 AWS Secrets Manager (SecretsManagerInitializer)
On non-dev profiles, before the Spring context starts, this loads a JSON secret (ditos-middleware/<profile>/db) and injects DB credentials so application-prod.yml can resolve ${DATABASE_*}. On EC2 the instance-profile IAM role authenticates automatically.
The whitelist trap (read this!): the initializer only injects a hard-coded list of keys. If you add a new secret key (e.g. a new toggle) and forget to add it to this class, it is silently ignored. Whenever a βI set it in Secrets Manager but nothing changedβ bug appears, check this file first.
12.3 Behind nginx β the client-IP gotcha
When the app runs behind nginx, request.getRemoteAddr() is nginxβs 127.0.0.1, not the real client IP. The real IP arrives in X-Forwarded-For / X-Real-IP, which the auth filters only trust when trust-forwarded-headers=true. Only enable that if port 8080 is not directly reachable (otherwise the header can be spoofed).
13. Running it locally
# 1. Start PostgreSQL and create the DB (default: ditos_manifest on localhost:5432)
# 2. From backend/:
mvn spring-boot:run # Flyway auto-runs migrations on startup
# 3. Watch the logs for the first-boot admin banner:
# FIRST-BOOT: initial admin created username: admin password: <random>
# 4. API is on http://localhost:8080 ; Swagger UI at /swagger-ui.html
- Health check:
GET http://localhost:8080/actuator/healthβ{"status":"UP"}. - The dev profile skips AWS Secrets Manager and uses the plaintext defaults in
application.yml.
14. Cross-cutting conventions (the βhouse styleβ)
- Controller β Service β Repository. Controllers are thin (HTTP shape + validation); services hold logic and
@Transactional; repositories are Spring Data JPA interfaces. - DTOs are Java
records, grouped in aβ¦Dtosholder per area, with Bean Validation annotations (@NotBlank,@Size) and@JsonInclude(NON_NULL). - Every response is an
ApiResponse; every error path goes throughGlobalExceptionHandler. - Secrets never hard-coded β env vars or Secrets Manager only; sensitive columns use
AesStringConverter. - External call logs (catalog/inventory): persist PENDING first, never auto-retry, allow manual replay.
- Tokens (client & mall): high entropy, stored hashed, shown in plaintext exactly once.
- Trace everything: each request has an
X-Trace-Idin logs and the response header.
15. Mini-glossary (for newcomers)
| Term | Meaning |
|---|---|
| Bean | An object Spring creates and manages for you (controllers, services, etc.). |
| Filter | Code that runs on every request before the controller β used here for auth gates. |
| JWT | A signed token proving who you are; sent as Authorization: Bearer <token>. |
| DTO | βData Transfer Objectβ β the request/response shape, separate from DB entities. |
| Entity | A Java class mapped to a DB table (JPA). |
| Repository | An interface giving you DB queries without writing SQL. |
| Flyway migration | A versioned .sql file that evolves the schema in order. |
| Soft delete | Marking a row is_deleted=true instead of removing it. |
| Idempotent | Safe to run twice with the same effect (why manual replay is safe). |
| High-water-mark cursor | Remembering βwhere I stoppedβ so the next run continues gaplessly. |
16. Suggested reading path for a new developer
DitosManifestApplication.javaβconfig/SecurityConfig.java(how requests are gated).common/ApiResponse,ApiException,GlobalExceptionHandler,BaseEntity(the conventions).auth/end-to-end (login β JWT β filter β controller).- One simple feature slice:
outlet/(clean ControllerβServiceβRepository example). unicommerce/UnicommerceTokenService+ one client (catalog/UnicommerceCategoryClient).- A βlogged + replayableβ feature:
catalog/CategoryService. - The advanced pipeline:
inventory/snapshot/(concurrency + Postgres COPY).
Once these click, every other package follows the same shapes.