Recurring Defect Classes

1. Purpose

This page catalogues defect classes — not individual bugs. Each entry has appeared more than once in this estate, in different services, written by different people, and each shares one property:

The system reports success while doing the wrong thing. Nothing throws, no probe fails, no counter increments. A build is green, a request returns 200, a screen renders. The failure is only visible to someone who already knows to look for it.

That property is what makes them worth writing down. A defect that fails loudly is found by the next test run; these are found months later, usually by accident, and usually in production.

Each section states the mechanism (why it happens), the symptom (what an observer actually sees), and how to avoid or detect it.

Two further classes are documented in full elsewhere and are not repeated here:

  • An enum filter that resolves leniently returns another value’s rows rather than an error or an empty set — see Enum Modelling.

  • A migration that half-applied blocks every later migration, invisibly — see Liquibase Migration Traps.

2. Streaming responses log an error after succeeding

2.1. Mechanism

A controller that returns ResponseEntity<StreamingResponseBody> — or an SseEmitter or ResponseBodyEmitter — commits the status line and headers before the body is written. The body is produced on an async dispatch.

Spring Security re-evaluates the request on that async dispatch. The request-thread SecurityContext does not propagate to it, so a method-level @PreAuthorize fails there. ExceptionTranslationFilter then tries to render an error response, finds the response already committed, and throws instead of rendering. The async dispatch and the subsequent error dispatch each raise it, so the lines come in pairs.

@GetMapping("/{eventId}/feed")
@PreAuthorize("hasAuthority('ROLE_USER')")
public ResponseEntity<StreamingResponseBody> feed(@PathVariable("eventId") Long eventId) {
    StreamingResponseBody body = out -> writeRows(eventId, out);   (1)
    return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(body);
}
1 Status and headers commit on return; this lambda runs later, on a dispatch the security context did not reach.

2.2. Symptom

A successful request log line, immediately followed by error-level lines about a request that was in fact served:

INFO  RequestLoggingFilter : GET /api/events/{id}/feed -> 200
ERROR io.undertow.request  : UT005023: Exception handling request to /api/events/{id}/feed
      jakarta.servlet.ServletException: Unable to handle the Spring Security Exception
      because the response is already committed.
        at ExceptionTranslationFilter.doFilter(...)
ERROR io.undertow.request  : UT005023: ... (same, milliseconds later)

The response itself is correct and complete: the status is 200, the headers are well formed, and the body is not truncated.

2.3. Consequence and detection

The lines are cosmetic for the caller and expensive for everyone else. Anything that alerts on level="ERROR" fires on ordinary, successful use of the endpoint — and fires hardest when the endpoint is used most, which is precisely when a real signal needs to be visible.

  • Reading a report of this: check the request-log line immediately preceding the stack trace. A → 200 there means the caller was served and the error is post-commit noise, not a failed request.

  • Reviewing code: suspect it on any streaming or emitter endpoint that also carries @PreAuthorize.

  • Fixing it: propagate the security context onto the async dispatch, or suppress by request path. Do not lower the alert’s sensitivity — that removes the signal along with the noise.

3. Disabled Angular controls never reach the payload

3.1. Mechanism

Angular excludes disabled controls from FormGroup.value. A component that disables a control for presentation reasons — because another field is meant to derive it, or because it is read-only in this mode — and then reads .value to build a request emits an object with those keys absent, not null.

setIdentityControlState(type: IdentityType): void {
  if (type === IdentityType.NATIONAL) {
    this.form.controls.dateOfBirth.disable();   // derived from the identity number
  } else {
    this.form.controls.dateOfBirth.enable();
  }
}

const wrong = this.form.value;         // { identityType, identityNumber }
const right = this.form.getRawValue(); // { identityType, identityNumber, dateOfBirth }

3.2. Symptom

A field the operator can plainly see filled in — because they typed it, or because a lookup populated it — is silently dropped from the request. The form is valid, the save succeeds, and the value is simply not there afterwards. Where the component is a ControlValueAccessor, the collapse happens at the boundary between child and parent, so neither side looks wrong in isolation.

3.3. Avoiding and detecting it

  • Use getRawValue(), or re-enable before reading, on any form that disables controls.

  • Suspect this class whenever a field the operator clearly filled in fails to persist.

  • Test at the ControlValueAccessor boundary rather than through the DOM: install a spy with registerOnChange(spy), call writeValue(…​), and assert on what the spy received.

A parent-component test written with .overrideTemplate(SomeComponent, '') never instantiates the child, so the accessor never runs and never normalises. A green test of that shape proves nothing about this defect — it is a harness artefact, not behaviour.

4. A mandatory association over a nullable column

4.1. Mechanism

A JPA association is declared mandatory while the underlying foreign-key column still accepts NULL:

@ManyToOne(optional = false)                          (1)
@JoinColumn(name = "sanctioning_organisation_id")     (2)
private Organisation sanctioningOrganisation;
1 The mapping asserts the association is always present.
2 The column is nullable, so nothing in the database enforces that.

Rows created through the REST API are safe, because the DTO carries its own @NotNull. Rows created by direct SQL are not. Any such row is one Hibernate cannot materialise, permanently.

4.2. Symptom

Every symptom points somewhere other than the real cause:

Operation What the caller sees

GET on a child entity

500 — a generic data-access failure

POST creating a child

400 "EventRaceType not found with id: N" — although the row exists and was just created. The lookup by id succeeded; loading the parent’s association is what failed.

DELETE of a child

204 with the row still present. The response is written before the transaction commits, so the commit-time failure never reaches the caller.

GET on the parent

Works perfectly — the DTO mapper does not traverse the association, so the row looks healthy right up until something hangs off it.

4.3. Avoiding and detecting it

  • When inserting by SQL, populate every association the entity declares non-optional. Where there is no external body, self-reference — set the sanctioning organisation to the organiser.

    -- Wrong: the column accepts NULL, the mapping does not
    INSERT INTO event (name, organiser_id)
    VALUES ('Example Event', @org_id);
    
    -- Right: with no external sanctioning body, the event self-sanctions
    INSERT INTO event (name, organiser_id, sanctioning_organisation_id)
    VALUES ('Example Event', @org_id, @org_id);
  • The durable fix is to close the gap: make the column NOT NULL so the mapping and the schema agree. Until then, a nullable column under an optional = false mapping is a latent landmine for anyone who later hangs a child record off one of those rows.

  • Audit for the class rather than the instance: find every optional = false association whose column is nullable, then count the offending rows.

Why SQL at all, and a probe lesson. This class arises where a config endpoint is unreachable to the credential in hand, so a row gets created by SQL instead. When probing whether a credential is authorised, note that bean validation runs before method security: a POST with an empty {} body returns 400, not 403. A 400 does not prove the caller is authorised. Probe with a structurally valid body, or read the @PreAuthorize.

5. Endpoints behind a development-only profile

5.1. Mechanism

A controller annotated @Profile("stub") serves endpoints only when that profile is active, and stub is reachable only through the dev profile group. Stage and production run the prod posture, where the bean is never created, so every endpoint it serves returns 404. See Spring Bootstrap for how profiles select posture rather than environment.

5.2. Symptom

A screen that works perfectly in development and fails only where it matters. Two shapes have occurred:

  1. A bootstrap call. A route guard calls a session endpoint, gets 404, and the caller’s catchError) ⇒ of(null turns that into "no session" — so every user is redirected to the landing page, on stage and production only.

  2. An aggregate load. A screen’s ngOnInit runs Promise.all over six calls, three of them stub-only. One 404 rejects the whole aggregate, ngOnInit throws, the data signal stays null, and the template renders its loading state forever. The user sees a permanent, faint "Loading…" — not a blank page and not an error.

5.3. Avoiding and detecting it

  • Sweep the callers. Every SPA reference to a stub-only path prefix is a candidate:

    grep -rn "/api/admin-portal/" src/main/webapp/app --include='*.ts' | grep -v '\.spec\.'
  • Confirm the posture before blaming data or configuration. The active profile list is the ground truth:

    curl -s http://127.0.0.1:PORT/management/info
    # {"git":{},"activeProfiles":["prod","api-docs","otlp"]}  -> no stub, those endpoints 404
  • Anything load-bearing must be served unconditionally by a real controller, not by a profile-gated one.

  • Never aggregate optional panels with Promise.all. It is all-or-nothing, so one optional panel takes down the whole screen. Wrap each non-load-bearing call in its own error handler that degrades to an empty value.

  • The same gating also shapes the published API description — see the profile-gated endpoint discussion in OpenAPI Contract Flow.

6. Controller bindings with no explicit name

6.1. Mechanism

The Maven build does not enable the -parameters compiler flag anywhere in the POM hierarchy. The root parent is a standalone POM that does not inherit from spring-boot-starter-parent, so the <parameters>true</parameters> that Spring Boot applications normally receive by inheritance was never picked up.

Spring Framework 6.1 removed the bytecode debug-information fallback for discovering parameter names, and the services run a later version than that. There is no fallback. A binding that omits its name therefore has no name at all at runtime.

// Fails at request time with HTTP 500
@GetMapping("/{id}")
public ResponseEntity<ThingDTO> get(@PathVariable Long id) { … }

// Correct — and the house convention
@GetMapping("/{id}")
public ResponseEntity<ThingDTO> get(@PathVariable("id") Long id) { … }

6.2. Symptom

HTTP 500 on every call to the endpoint, with:

IllegalArgumentException: Name for argument of type [java.lang.Long] not specified,
and parameter name information not available via reflection.
Ensure that the compiler uses the '-parameters' flag.

Binding throws before handler logic runs, so an otherwise trivially correct happy-path endpoint returns 500 for every request.

6.3. Why it survives review and CI

The failure is runtime-only, and only for un-named bindings on endpoints actually exercised over HTTP. Most controllers already name their bindings, so they are safe and the class stays invisible. Service-layer integration tests never exercise HTTP binding at all — so a controller with bare bindings compiles, passes its suite, ships green, and returns 500 in production.

6.4. Avoiding and detecting it

  • Give every @PathVariable and @RequestParam an explicit name. Treat a bare binding as a defect in review, not a style preference.

  • Only a MockMvc- or HTTP-level test catches it. When adding a controller test, exercise the binding rather than the service beneath it.

  • The durable, estate-wide fix is <parameters>true</parameters> on the shared parent’s maven-compiler-plugin configuration, matching what spring-boot-starter-parent does — see Maven POM Conventions.

7. Double log export, and the fix that silences everything

7.1. Mechanism

With both the OpenTelemetry agent’s Logback instrumentation and an explicitly declared OpenTelemetryAppender active, every statement is exported twice. The obvious fix is the property that disables the agent’s capture.

That same property name also gates the OpenTelemetry Spring Boot starter’s OpenTelemetryAppender.install() call, which Spring resolves from the environment — including system properties. Setting it as a JVM flag or environment variable therefore disables both: the duplicate is gone, and so is the appender’s SDK.

7.2. Symptom

Two distinct symptoms, one on either side of the naive fix:

  • Before: every log record appears twice in the backend.

  • After: zero records are exported. The appender buffers a bounded number of pre-install records, then discards everything — with no error, no failing health check, and a service that starts and serves traffic normally.

7.3. Avoiding and detecting it

  • Install the appender from application code, unconditionally, rather than relying on the starter’s conditional installation.

  • Do not enable Logback configuration rescanning on a service carrying an OTLP appender. A rescan rebuilds the appenders without an SDK and produces the same silent total loss.

  • The no-op case is detectable in code: the global OpenTelemetry instance is the no-op instance when no agent is attached. Log a warning on that condition rather than asserting that export works.

The full configuration, including the conformance checklist, is in OpenTelemetry Configuration.

8. Generated-client allow-lists that are not closed under reference

8.1. Mechanism

Generated API clients restrict their output with an explicit modelsToGenerate allow-list. The generator honours that list literally: when a listed model `$ref`s an unlisted one, it still emits the reference — into the type map, the docblocks and the setter hints — and never emits the class.

Nothing objects. Generation succeeds, dependency installation succeeds, and a syntax check passes.

8.2. Symptom

The symptom depends entirely on how the target language resolves types:

Client How it surfaces

Statically typed

A missing import, at compile time, on the next build

Dynamically typed

A lazy class lookup, the first time a payload populates that property — potentially months later, in production

The dynamic case is the damaging one, because the failure lands on the deserialisation of a successful response. The remote call applied; the client throws while reading the reply; the operator is told the operation may not have happened.

8.3. Avoiding and detecting it

Closure is a property of the specification plus the allow-list, so it is checkable statically before anything is published. Gate that check in the generation workflow ahead of the commit step. When adding a resource, do not hand-pick the models it needs — add the obvious ones and let the closure computation find the transitive set.

The mechanism, the detection script, and the CI wiring are documented in OpenAPI Contract Flow.

9. A published client that is silently months stale

9.1. Mechanism

Client regeneration is triggered by a repository dispatch from the service whose specification changed. Nothing watches whether the triggered run succeeded. The dispatching pipeline goes green either way — it only records that it fired the event — and the package registry keeps serving the last version that published successfully.

The observed failure mode is an expired credential taking out the very first step of the triggered workflow:

fatal: could not read Username for 'https://github.com': terminal prompts disabled

Every run after that fails identically, forever, with nothing downstream reporting it.

9.2. Symptom

A DTO that plainly exists in the service is absent from the published client. Consumers compile against a contract that has not moved for months, and the gap is invisible from the service side.

9.3. Avoiding and detecting it

Before assuming a DTO is available to a consumer, check the client repository directly:

gh run list --repo <owner>/<client-repo> --limit 10
git -C <client-worktree> log --oneline origin/main -3   # "sync OpenAPI spec from <service>@<sha>"
gh secret list --repo <owner>/<client-repo>             # last-updated date exposes a stale credential

A secret listing shows each secret’s last-updated date without its value, which is how a long-expired token is spotted — a date years behind its counterpart in the dispatching repository is the tell.

A long sync gap makes the next regeneration wide: many schemas move at once. Diff the fields of the types the consumer actually imports, and prove the upgrade is additive, before merging it.

10. Technique: auto-increment gaps as evidence

This is not a defect class. It is the diagnostic technique that cracks several of them, and it belongs beside them because it is the tool of choice when a failure left no log.

10.1. Mechanism

A rolled-back INSERT still consumes its AUTO_INCREMENT value. The value is never reused. Every failed request that got as far as the insert therefore leaves a permanent, dated gap in the table’s primary-key sequence — a record of the attempt that survives the rollback.

When a request fails with no server-side log, that sequence stands in for the log.

10.2. Recipe

-- 1. List the ids actually present across the window of interest
SELECT ID FROM wp_users WHERE ID BETWEEN <lo> AND <hi> ORDER BY ID;
-- diff against the dense range to find the gaps

-- 2. Confirm the gaps are rolled-back inserts, not deletions
SELECT COUNT(*) FROM wp_users_delete WHERE ID IN (<gaps>);      -- expect 0
SELECT COUNT(*) FROM wp_usermeta     WHERE user_id IN (<gaps>); -- expect 0

10.3. What it tells you beyond the count

Timing. Bracket each gap by the registration timestamps of the ids either side of it. Clusters reveal an operator retrying rather than distinct users failing.

Where in the call path it failed. A consumed id proves execution reached the insert. If an earlier step in the same method allocates a different resource first, then a failure upstream of the insert consumes no id at all — so the presence or absence of a gap discriminates between candidate throw sites that the stack trace cannot distinguish.

Whether orphans were left behind. A helper that joins the caller’s transaction rolls back cleanly and leaves only the gap. One that starts its own transaction, or a path that returns 200 and therefore commits, leaves a real orphan row per attempt — a materially different clean-up job.

Other rolled-back inserts against the same table consume ids too. Corroborate the gap count against an independent signal — a request count, an error metric — before treating it as exact.

11. The common thread

Every class above is silent, and the silences share three causes. They are worth naming, because they generalise past this list.

A success is reported before the work is finished. The streaming response commits before its body is written. The DELETE responds before its transaction commits. The dispatching pipeline goes green on firing an event, not on the event succeeding.

A safety net is removed along with a nuisance. One property disables both the duplicate exporter and the appender that was meant to replace it. A catchError written to absorb a missing optional panel absorbs a broken required one identically.

A contract is asserted in one layer and unenforced in the next. optional = false over a nullable column. A model allow-list the generator will not validate. A parameter name the compiler was never asked to keep.

The corresponding review habits: never treat absence of errors as evidence of success; verify the thing you care about directly, at the boundary where it is consumed; and when a green result comes from a check that could not have failed, treat it as no result at all.

13. Change History

Date Change

2026-09-07

Initial version. Nine defect classes plus the auto-increment-gap diagnostic technique.