SE
Lessons Learned
Self Learning adaptive v1.0.0
Lessons Learned — Institutional Memory
Auto-managed file. Updated by the Self-Learning Agent and
scripts/capture-lesson.py. Read in full by the Self-Learning Agent before every response. Lessons are ordered newest-first within each domain.
How to Read This File
Each lesson block:
### L-NNN · [Domain] · Short title
- **Symptom**: What was observed
- **Root Cause**: Why it happened
- **Fix**: What resolved it
- **Prevention Rule**: Generalised rule applied automatically going forward
- **Tags**: searchable keywords
- **Date**: when learned
- **Verified**: confirmed in production/CI
Domain: docker-spring-boot
L-001 · docker-spring-boot · Spring Boot ignores docker ports: for internal listening port
- Symptom:
ERR_CONNECTION_REFUSEDwhen nginx proxy_pass tries to reach a service on its mapped port (e.g. 8084, 8085, 8086, 8087). Service starts successfully but is unreachable on the configured port. - Root Cause: Docker’s
ports: - '8086:8086'only maps the host→container network tunnel. It does not instruct Spring Boot to listen on port 8086. Without an explicit configuration, Spring Boot always binds to port 8080 inside the container. - Fix: Add
SERVER_PORT: <PORT>to theenvironment:block of every service indocker-compose.ymlthat uses a non-default port. Spring Boot automatically reads this env var viaserver.port=${SERVER_PORT:8080}resolution.# ✅ Correct dispute-service: ports: - '8086:8086' environment: SERVER_PORT: 8086 # ← required # ❌ Wrong — Spring Boot still listens on 8080 inside container dispute-service: ports: - '8086:8086' environment: SPRING_DATASOURCE_URL: ... - Prevention Rule: When writing
docker-compose.ymlfor any Spring Boot service with a non-8080 port, always includeSERVER_PORT: <PORT>in the environment block. This applies to lounge (8084), benefits (8085), dispute (8086), offers (8087), and any future service. - Tags: spring-boot, docker, docker-compose, ports, SERVER_PORT, ERR_CONNECTION_REFUSED, nginx
- Date: 2026-02-22
- Verified: ✅ Confirmed — restarting services with SERVER_PORT restored connectivity
L-002 · docker-spring-boot · Health check port must match SERVER_PORT
- Symptom: Services with custom SERVER_PORT pass container startup but health checks fail, causing
depends_on: condition: service_healthydeadlocks. - Root Cause: Health check commands were written against the custom port before SERVER_PORT was set, so they were checking the wrong port when the service was still on 8080.
- Fix: The health check
test:command port must equalSERVER_PORT.healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8086/actuator/health"] # matches SERVER_PORT - Prevention Rule: Health check port and
SERVER_PORTmust always match. When adding SERVER_PORT to a service, verify the healthcheck port is identical. - Tags: spring-boot, docker, healthcheck, actuator, depends_on
- Date: 2026-02-22
- Verified: ✅
Domain: frontend-axios
L-003 · frontend-axios · Empty string path in axios produces trailing slash URL
- Symptom:
GET http://localhost/api/v1/disputes/?userId=...— trailing slash before query params. Spring Boot 6 (Spring MVC 6+) does not match trailing slash by default, returning 404. - Root Cause:
axiosClient.get('', { params: {...} })— when the path is an empty string'', axios appends a/relative to thebaseURL, producingbaseURL + '/' + params. The resulting URL has the form/api/v1/disputes/?userId=.... - Fix: Never use empty string as path. Use a named sub-path:
And update the corresponding Spring Boot// ✅ Use explicit path disputeClient.get('/list', { params: { userId } }) offersClient.get('/all', { params: userId ? { userId } : {} }) // ❌ Empty string generates trailing slash disputeClient.get('', { params: { userId } })@GetMappingannotations to match:@GetMapping("/list") // matches /api/v1/disputes/list @GetMapping("/all") // matches /api/v1/offers/all - Prevention Rule: Never pass
''(empty string) as the axios path argument. Always use an explicit path like'/','/list', or'/all'. When the backend uses@GetMappingwith no value (root mapping), add an explicit sub-path to both client and controller. - Tags: axios, trailing-slash, spring-boot-6, GetMapping, URL, 404
- Date: 2026-02-22
- Verified: ✅ Confirmed — renamed to
/listand/allresolved the issue
L-004 · frontend-axios · Axios response interceptor unwraps ApiResponse — do not double-wrap API function return types
- Symptom: API calls return
undefineddata or components receive{ success: true, data: {...} }objects instead of the raw domain type. Accessing.availablePointsreturnsundefinedbecause the object is actuallyApiResponse<RewardsBalanceResponse>. - Root Cause:
client.tshas a response interceptor that automatically unwrapsApiResponse<T>and replacesresponse.datawithresponse.data.data. If an API function then also declares its return type asPromise<ApiResponse<T>>and callsawait client.get<ApiResponse<T>>(...), the data is double-handled: the interceptor unwraps once, but the calling code tries to unwrap again with.data. - Fix: All API functions must:
- Return
Promise<T>(notPromise<ApiResponse<T>>) - Type the axios call as
client.get<T>(...)(notclient.get<ApiResponse<T>>(...)) - Use
const { data } = await client.get<T>(...)andreturn data
// ✅ Correct — interceptor handles unwrapping export async function getRewardsBalance(userId: string): Promise<RewardsBalanceResponse> { const { data } = await rewardsClient.get<RewardsBalanceResponse>(`/balance/${userId}`); return data; } // ❌ Wrong — double-wrapping, data arrives as ApiResponse object export async function getRewardsBalance(userId: string): Promise<ApiResponse<RewardsBalanceResponse>> { const { data } = await rewardsClient.get<ApiResponse<RewardsBalanceResponse>>(`/balance/${userId}`); return data; } - Return
- Prevention Rule: When
client.tshas a response interceptor that unwrapsApiResponse<T>, all API functions must usePromise<T>return types andclient.verb<T>()type parameters. ImportApiResponseonly inclient.tsandledgerApi.tstype aliases — nowhere else in API function signatures. - Tags: axios, interceptor, ApiResponse, typescript, generic-types, double-unwrap
- Date: 2026-02-22
- Verified: ✅
Domain: tailwind-css
L-005 · tailwind-css · Arbitrary opacity modifiers (e.g. /12) cause PostCSS build failure
- Symptom:
Build fails at PostCSS step when[postcss] The `bg-emerald-500/12` class does not exist. If `bg-emerald-500/12` is a custom class, make sure it is defined within a `@layer` directive.@applyis used with an opacity modifier that is not a multiple of 5. - Root Cause: Tailwind CSS v3 only generates opacity modifier classes for values defined in its
opacityscale:0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100. The value12is not in this scale, so the classbg-emerald-500/12does not exist and cannot be used with@apply. - Fix: Replace non-standard opacity values with the nearest valid multiple of 5:
Alternatively, extend the opacity scale in/* ✅ Valid */ .status-success { @apply bg-emerald-500/10 ...; } .status-warning { @apply bg-amber-500/10 ...; } .status-error { @apply bg-red-500/10 ...; } .status-info { @apply bg-sapphire-500/10 ...; } /* ❌ Invalid — causes build failure */ .status-success { @apply bg-emerald-500/12 ...; }tailwind.config.js:theme: { extend: { opacity: { 12: '0.12' } } } - Prevention Rule: When using
@applyin CSS files, only use opacity modifiers that are multiples of 5 (5, 10, 15, 20, 25…) OR add the custom value totailwind.config.jsopacity extension. Never use/12,/7,/3, etc. in@applydirectives. - Tags: tailwind, postcss, @apply, opacity, build-failure, css
- Date: 2026-02-22
- Verified: ✅ Confirmed — replacing
/12with/10resolved the build
L-006 · tailwind-css · Custom colour palettes must use numeric scale keys for opacity modifiers to work
- Symptom: Custom colour classes like
bg-sapphire-800/60work in JSX but fail if thesapphirepalette uses string keys or non-standard values. - Root Cause: Tailwind generates opacity modifier utilities only for colours defined as flat key-value maps with numeric keys (50, 100, 200 … 950).
- Fix: Define custom palettes as
{ 50: '#...', 100: '#...', ... , 950: '#...' }with full numeric scale. - Prevention Rule: Custom colour palettes must use the standard 50–950 numeric key scale for full Tailwind utility generation including opacity modifiers.
- Tags: tailwind, custom-colors, opacity, palette
- Date: 2026-02-22
- Verified: ✅
Domain: typescript-types
L-007 · typescript-types · Always verify field names against actual TypeScript interfaces before using in JSX
- Symptom: Dashboard widgets showing
—or0for all values despite API returning data. TypeScript compilation succeeds but runtime values are undefined. - Root Cause: Field names used in JSX (
balance?.currentBalance,history?.transactions) did not match the actual interface definitions (RewardsBalanceResponse.availablePoints,PointsHistoryData.content). TypeScript compiles without error because optional chaining (?.) on a non-existent field returnsundefinedrather than throwing a type error when the interface isn’t strictly enforced at the access site. - Fix:
// Actual interface: RewardsBalanceResponse // { availablePoints, pendingPoints, expiringPoints, tier } // ✅ Correct balance?.availablePoints balance?.pendingPoints balance?.expiringPoints history?.content // PointsHistoryData.content: PointsTransactionResponse[] // ❌ Wrong — compiles but returns undefined at runtime balance?.currentBalance balance?.lifetimeEarned balance?.pointsExpiringSoon history?.transactions - Prevention Rule: Before writing JSX that accesses API response fields, always read the actual TypeScript interface in
types/api.ts. Never guess field names from naming conventions. Use IDE autocomplete orgrepthe interface definition. - Tags: typescript, interfaces, field-names, runtime-undefined, JSX, optional-chaining
- Date: 2026-02-22
- Verified: ✅
L-008 · typescript-types · Request body interfaces must not include fields sent as headers
- Symptom:
TS2353: Object literal may only specify known properties, and 'userId' does not exist in type 'HotelBookingRequest' - Root Cause:
HotelBookingRequestinterface did not includeuserIdbecause the backend reads it from theX-User-Idheader, not the request body. IncludinguserIdin the object literal passed toaxios.post<HotelBookingRequest>(...)causes a TypeScript error. - Fix: Pass user identification via header, not in the request body:
// ✅ Correct — userId as header bookingClient.post<{bookingId: string}>('/hotels', request, { headers: { 'X-User-Id': userId } }); // ❌ Wrong — userId not in HotelBookingRequest interface bookingClient.post<{bookingId: string}>('/hotels', { ...request, userId }); - Prevention Rule: When a Spring Boot controller reads user identity from
@RequestHeader("X-User-Id"), the frontend must pass it as an axios header config, never in the request body/type. - Tags: typescript, TS2353, request-body, headers, X-User-Id, axios
- Date: 2026-02-22
- Verified: ✅
Domain: nginx-proxy
L-009 · nginx-proxy · Trailing slash in location block matters for proxy_pass path rewriting
- Symptom: Requests to
/api/v1/disputes/listget proxied as/listto the upstream service instead of/api/v1/disputes/list, causing 404. - Root Cause: When
proxy_passincludes a trailing slash (proxy_pass http://service:port/;), nginx strips thelocationprefix. When it does not include a trailing slash, nginx forwards the full original path.# Forwards /api/v1/disputes/list → http://dispute-service:8086/api/v1/disputes/list ✅ location /api/v1/disputes/ { proxy_pass http://dispute-service:8086; } # Forwards /api/v1/disputes/list → http://dispute-service:8086/list ❌ (strips prefix) location /api/v1/disputes/ { proxy_pass http://dispute-service:8086/; } - Fix: Ensure
proxy_passdoes NOT have a trailing slash when you want the full path forwarded. - Prevention Rule: In nginx
proxy_passdirectives, omit the trailing slash to preserve the full request URI path. - Tags: nginx, proxy_pass, trailing-slash, location, path-rewriting
- Date: 2026-02-22
- Verified: ✅
Domain: spring-boot-config
L-010 · spring-boot-config · Spring Boot 6 disables trailing slash URL matching by default
- Symptom:
GET /api/v1/disputes/returns 404 even though@GetMappingwith no value is present on the controller. Works in Spring Boot 5/2.x but not Spring Boot 3.x/6. - Root Cause: Spring Framework 6 (used by Spring Boot 3.x) removed default trailing slash matching. A controller mapped to
/api/v1/disputesno longer matches/api/v1/disputes/. - Fix: Either:
- Use an explicit sub-path (
@GetMapping("/list")) — preferred - Or re-enable trailing slash matching in
WebMvcConfigurer:@Override public void configurePathMatch(PathMatchConfigurer configurer) { configurer.setUseTrailingSlashMatch(true); }
- Use an explicit sub-path (
- Prevention Rule: In Spring Boot 3.x projects, never rely on trailing slash tolerance. Always map to explicit paths. Avoid bare
@GetMapping(no value) for list/collection endpoints — use@GetMapping("/list")or@GetMapping("/all"). - Tags: spring-boot-3, spring-framework-6, trailing-slash, GetMapping, 404, URL-matching
- Date: 2026-02-22
- Verified: ✅
Domain: react-query
L-011 · react-query · TanStack Query v5 changed onSuccess callback signature and removal
- Symptom:
onSuccesscallback insideuseQueryoptions does not fire, or TypeScript reports it as not a valid option in TanStack Query v5. - Root Cause: TanStack Query v5 removed
onSuccess,onError, andonSettledfromuseQueryoptions. Side effects must be handled inuseEffectwatching thedatavalue, or inuseMutationcallbacks. - Fix:
// ✅ v5 correct — use useEffect for side effects const { data } = useQuery({ queryKey: [...], queryFn: ... }); useEffect(() => { if (data) { setSomething(data.thing); } }, [data]); // ❌ v4 pattern — onSuccess removed in v5 useQuery({ queryKey: [...], queryFn: ..., onSuccess: (data) => setSomething(data.thing) }); - Prevention Rule: In TanStack Query v5+, never use
onSuccess/onErrorinsideuseQuery. UseuseEffectwatchingdata/errorfor side effects.onSuccessis still valid insideuseMutation. - Tags: tanstack-query, react-query-v5, onSuccess, useEffect, mutation
- Date: 2026-02-22
- Verified: ✅
Domain: build-pipeline
L-012 · build-pipeline · Maven multi-module Docker build must COPY all sibling pom.xml files before source
- Symptom: Maven build inside Docker fails with
Could not find artifact com.gaganbank:parent:pomor dependency resolution failures when building a specific module. - Root Cause: In a multi-module Maven project, each module’s
pom.xmlreferences the parentpom.xmland sibling modules. The Docker build context must have allpom.xmlfiles present before runningmvn -pl <module> -am packageso Maven can resolve the dependency graph. - Fix:
# ✅ Copy ALL module pom.xml files first (enables Docker layer caching) COPY pom.xml pom.xml COPY ledger-service/pom.xml ledger-service/pom.xml COPY rewards-service/pom.xml rewards-service/pom.xml COPY booking-service/pom.xml booking-service/pom.xml COPY lounge-service/pom.xml lounge-service/pom.xml COPY benefits-service/pom.xml benefits-service/pom.xml COPY dispute-service/pom.xml dispute-service/pom.xml COPY offers-service/pom.xml offers-service/pom.xml # Then copy only this module's source COPY dispute-service/src dispute-service/src RUN mvn -pl dispute-service -am -DskipTests -q package - Prevention Rule: Every service Dockerfile in a multi-module Maven project must COPY the root
pom.xmland all sibling modulepom.xmlfiles before copying source. When adding a new module, update every existing Dockerfile to include the new module’s pom.xml. - Tags: maven, multi-module, docker, dockerfile, COPY, pom.xml, build-cache
- Date: 2026-02-22
- Verified: ✅
Lesson Statistics
| Domain | Count | Last Updated |
|---|---|---|
| docker-spring-boot | 2 | 2026-02-22 |
| frontend-axios | 2 | 2026-02-22 |
| tailwind-css | 2 | 2026-02-22 |
| typescript-types | 2 | 2026-02-22 |
| nginx-proxy | 1 | 2026-02-22 |
| spring-boot-config | 1 | 2026-02-22 |
| react-query | 1 | 2026-02-22 |
| build-pipeline | 1 | 2026-02-22 |
| Total | 12 | 2026-02-28 |