Skip to content
Home / Skills / Self Learning / Lessons Learned
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_REFUSED when 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 the environment: block of every service in docker-compose.yml that uses a non-default port. Spring Boot automatically reads this env var via server.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.yml for any Spring Boot service with a non-8080 port, always include SERVER_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_healthy deadlocks.
  • 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 equal SERVER_PORT.
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8086/actuator/health"]  # matches SERVER_PORT
    
  • Prevention Rule: Health check port and SERVER_PORT must 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 the baseURL, producing baseURL + '/' + params. The resulting URL has the form /api/v1/disputes/?userId=....
  • Fix: Never use empty string as path. Use a named sub-path:
    // ✅ Use explicit path
    disputeClient.get('/list', { params: { userId } })
    offersClient.get('/all', { params: userId ? { userId } : {} })
    
    // ❌ Empty string generates trailing slash
    disputeClient.get('', { params: { userId } })
    
    And update the corresponding Spring Boot @GetMapping annotations 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 @GetMapping with 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 /list and /all resolved the issue

L-004 · frontend-axios · Axios response interceptor unwraps ApiResponse — do not double-wrap API function return types

  • Symptom: API calls return undefined data or components receive { success: true, data: {...} } objects instead of the raw domain type. Accessing .availablePoints returns undefined because the object is actually ApiResponse<RewardsBalanceResponse>.
  • Root Cause: client.ts has a response interceptor that automatically unwraps ApiResponse<T> and replaces response.data with response.data.data. If an API function then also declares its return type as Promise<ApiResponse<T>> and calls await 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:
    1. Return Promise<T> (not Promise<ApiResponse<T>>)
    2. Type the axios call as client.get<T>(...) (not client.get<ApiResponse<T>>(...))
    3. Use const { data } = await client.get<T>(...) and return 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;
    }
    
  • Prevention Rule: When client.ts has a response interceptor that unwraps ApiResponse<T>, all API functions must use Promise<T> return types and client.verb<T>() type parameters. Import ApiResponse only in client.ts and ledgerApi.ts type 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:
    [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.
    
    Build fails at PostCSS step when @apply is 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 opacity scale: 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100. The value 12 is not in this scale, so the class bg-emerald-500/12 does not exist and cannot be used with @apply.
  • Fix: Replace non-standard opacity values with the nearest valid multiple of 5:
    /* ✅ 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 ...; }
    
    Alternatively, extend the opacity scale in tailwind.config.js:
    theme: { extend: { opacity: { 12: '0.12' } } }
    
  • Prevention Rule: When using @apply in CSS files, only use opacity modifiers that are multiples of 5 (5, 10, 15, 20, 25…) OR add the custom value to tailwind.config.js opacity extension. Never use /12, /7, /3, etc. in @apply directives.
  • Tags: tailwind, postcss, @apply, opacity, build-failure, css
  • Date: 2026-02-22
  • Verified: ✅ Confirmed — replacing /12 with /10 resolved 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/60 work in JSX but fail if the sapphire palette 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 or 0 for 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 returns undefined rather 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 or grep the 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: HotelBookingRequest interface did not include userId because the backend reads it from the X-User-Id header, not the request body. Including userId in the object literal passed to axios.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/list get proxied as /list to the upstream service instead of /api/v1/disputes/list, causing 404.
  • Root Cause: When proxy_pass includes a trailing slash (proxy_pass http://service:port/;), nginx strips the location prefix. 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_pass does NOT have a trailing slash when you want the full path forwarded.
  • Prevention Rule: In nginx proxy_pass directives, 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 @GetMapping with 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/disputes no longer matches /api/v1/disputes/.
  • Fix: Either:
    1. Use an explicit sub-path (@GetMapping("/list")) — preferred
    2. Or re-enable trailing slash matching in WebMvcConfigurer:
      @Override
      public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.setUseTrailingSlashMatch(true);
      }
      
  • 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: onSuccess callback inside useQuery options does not fire, or TypeScript reports it as not a valid option in TanStack Query v5.
  • Root Cause: TanStack Query v5 removed onSuccess, onError, and onSettled from useQuery options. Side effects must be handled in useEffect watching the data value, or in useMutation callbacks.
  • 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/onError inside useQuery. Use useEffect watching data/error for side effects. onSuccess is still valid inside useMutation.
  • 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:pom or dependency resolution failures when building a specific module.
  • Root Cause: In a multi-module Maven project, each module’s pom.xml references the parent pom.xml and sibling modules. The Docker build context must have all pom.xml files present before running mvn -pl <module> -am package so 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.xml and all sibling module pom.xml files 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

DomainCountLast Updated
docker-spring-boot22026-02-22
frontend-axios22026-02-22
tailwind-css22026-02-22
typescript-types22026-02-22
nginx-proxy12026-02-22
spring-boot-config12026-02-22
react-query12026-02-22
build-pipeline12026-02-22
Total122026-02-28