bmad-method-quarkus 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bmm-skills/agents/bmad-quarkus-build/SKILL.md +25 -5
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-error-handling-i18n/SKILL.md +6 -2
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-hexagonal-core/SKILL.md +106 -38
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-kafka-messaging/SKILL.md +42 -12
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-observability-otel/SKILL.md +191 -13
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-openapi-tmforum/SKILL.md +7 -8
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-security-standards/SKILL.md +132 -0
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-sql-jdbc-agroal/SKILL.md +30 -13
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-temporal-workflows/SKILL.md +616 -0
- package/src/commands/quarkus-all.md +6 -5
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: quarkus-observability-otel
|
|
3
|
-
description: Observability standard for Quarkus native services using OpenTelemetry — distributed tracing with W3C Trace Context (traceparent), trace_id/span_id in every log line (JSON logs + MDC), OTLP export, metrics
|
|
3
|
+
description: Observability standard for Quarkus native services using OpenTelemetry — distributed tracing with W3C Trace Context (traceparent), trace_id/span_id in every log line (JSON logs + MDC), OTLP export, MANDATORY Micrometer metrics (`quarkus-micrometer-registry-prometheus` in every service), custom spans, and trace propagation across REST, gRPC, and Kafka. Also covers build identity in telemetry (service.version/service.instance.id, build_info gauge), the export pipeline (batch span processor, graceful-shutdown flush budget, span attribute/event/link limits) and propagation safety (propagators, baggage, untrusted inbound traceparent). Use this skill whenever the user mentions OpenTelemetry, OTel, tracing, trace_id, span_id, traceparent, distributed tracing, logging standard, log correlation, metrics, Micrometer, quarkus-micrometer, MeterRegistry, counter/timer/gauge, `@Timed`/`@Counted`, /q/metrics, scrape, dashboards or alerts, Grafana/Tempo/Jaeger/Prometheus, baggage, propagators, sampling, service.version or build metadata, telemetry lost on shutdown/SIGTERM/terminationGracePeriodSeconds, span limits or telemetry volume/cost, or debugging requests across services. Includes span, metric, logger and audit-bean naming conventions for the vertical-slice layout (one span per Handler.process()).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# OpenTelemetry Observability Standard (Quarkus)
|
|
7
7
|
|
|
8
8
|
Every request must be traceable end-to-end: REST → Handler → JDBC → outbox → Kafka → consumer → gRPC. `trace_id`/`span_id` appear in every log line and propagate across every transport via W3C Trace Context. Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, explicit instructions) override these defaults.
|
|
9
9
|
|
|
10
|
-
Extensions: `quarkus-opentelemetry` (
|
|
10
|
+
Extensions — **all three are mandatory in every service**, all native-compatible: `quarkus-opentelemetry` (traces), `quarkus-micrometer-registry-prometheus` (metrics — see "Metrics"), `quarkus-logging-json` (structured logs). Traces, metrics and logs are one contract; a service that ships two of the three is not observable.
|
|
11
11
|
|
|
12
12
|
## Baseline configuration
|
|
13
13
|
|
|
14
14
|
```properties
|
|
15
15
|
quarkus.application.name=iam-users # becomes service.name
|
|
16
16
|
quarkus.otel.exporter.otlp.endpoint=http://otel-collector:4317
|
|
17
|
-
quarkus.otel.resource.attributes=deployment.environment=${ENV:dev},service.namespace=alva
|
|
18
17
|
|
|
19
|
-
#
|
|
20
|
-
|
|
18
|
+
# Identity of the running artifact — see "Build identity in every signal"
|
|
19
|
+
quarkus.otel.resource.attributes=service.version=${quarkus.application.version},\
|
|
20
|
+
service.instance.id=${HOSTNAME:unknown},\
|
|
21
|
+
deployment.environment=${ENV:dev},\
|
|
22
|
+
service.namespace=alva
|
|
23
|
+
|
|
24
|
+
# Propagation: W3C Trace Context only. Add `baggage` ONLY for a documented
|
|
25
|
+
# cross-service need — see "Propagation and baggage". Never B3 unless mandated.
|
|
26
|
+
quarkus.otel.propagators=tracecontext
|
|
21
27
|
|
|
22
28
|
# Sampling: head-based ratio in prod, always_on elsewhere
|
|
23
29
|
%prod.quarkus.otel.traces.sampler=parentbased_traceidratio
|
|
@@ -29,6 +35,82 @@ quarkus.otel.traces.suppress-non-application-uris=true
|
|
|
29
35
|
|
|
30
36
|
Export to an **OTel Collector** (agent/sidecar/gateway), never directly to the vendor backend — keeps services vendor-neutral.
|
|
31
37
|
|
|
38
|
+
### Build identity in every signal
|
|
39
|
+
|
|
40
|
+
"Which build produced this?" is the first question of every incident, and it must be answerable from a trace, a log line and a dashboard *without* cross-referencing a deploy log. `service.version` is an OTel semantic-convention resource attribute; `quarkus.application.version` resolves it from the Maven project version at build time, so it is correct by construction and never hand-maintained. `service.instance.id` from `${HOSTNAME}` is the pod name under Kubernetes — it is what separates "the whole service is broken" from "one replica is broken".
|
|
41
|
+
|
|
42
|
+
Each signal carries it differently, and the difference matters:
|
|
43
|
+
|
|
44
|
+
| Signal | How the version travels | Cost |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Traces | `service.version` resource attribute (above) | none — resource attributes are sent once per batch, not per span |
|
|
47
|
+
| Logs | an additional JSON field (see "Logs" below) | none |
|
|
48
|
+
| Metrics | a dedicated **info gauge**, never a common tag | one extra series per version |
|
|
49
|
+
|
|
50
|
+
**Do not add `version` as a Micrometer common tag.** It looks tidy and it is the standard trap: every metric in the service gets a new time series on every deploy, so cardinality grows with release frequency forever and any dashboard grouped without an explicit `sum by (…)` splits in two during a rolling deploy. The Prometheus idiom is a separate info metric pinned to 1, joined at query time (`… * on(instance) group_left(version) iam_build_info`):
|
|
51
|
+
|
|
52
|
+
```java
|
|
53
|
+
// common/observability
|
|
54
|
+
@ApplicationScoped
|
|
55
|
+
public class BuildInfoMetric {
|
|
56
|
+
|
|
57
|
+
@Inject MeterRegistry registry;
|
|
58
|
+
|
|
59
|
+
@ConfigProperty(name = "quarkus.application.version")
|
|
60
|
+
String version;
|
|
61
|
+
|
|
62
|
+
void onStart(@Observes StartupEvent event) {
|
|
63
|
+
Gauge.builder("iam_build_info", () -> 1)
|
|
64
|
+
.description("Always 1; carries the running build's identity as tags")
|
|
65
|
+
.tag("version", version)
|
|
66
|
+
.register(registry);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`<module>_build_info` is a **sanctioned exception** to the `<module>_<entity>_<action>_total|_seconds` naming rule below — `_info` is the established Prometheus convention for this pattern and dashboards expect it. If CI injects a commit SHA (as `BUILD_COMMIT`, per the quarkus-security-standards skill's `${VAR}` rule), add it as a second tag here and as a `service.build_id` resource attribute; it is bounded by the same one-series-per-deploy budget.
|
|
72
|
+
|
|
73
|
+
## Export pipeline: batching, shutdown and hard limits
|
|
74
|
+
|
|
75
|
+
Spans are **buffered in memory** by the batch span processor and exported asynchronously. Two failure modes follow from that, and neither announces itself: a pod that dies before the buffer flushes loses the telemetry of the requests that mattered most, and an unbounded attribute turns one bad `setAttribute` call into an export-size incident.
|
|
76
|
+
|
|
77
|
+
```properties
|
|
78
|
+
# Batch span processor — the in-memory buffer
|
|
79
|
+
quarkus.otel.bsp.schedule.delay=2S # default 5S; smaller = less at risk on a hard kill
|
|
80
|
+
quarkus.otel.bsp.max.queue.size=2048 # spans buffered; overflow is DROPPED, silently
|
|
81
|
+
quarkus.otel.bsp.max.export.batch.size=512
|
|
82
|
+
quarkus.otel.bsp.export.timeout=10S # must fit the shutdown budget below
|
|
83
|
+
|
|
84
|
+
quarkus.otel.exporter.otlp.timeout=5S
|
|
85
|
+
|
|
86
|
+
# Hard caps — the backstop for a bad attribute (mirror the OTEL_* spec env vars)
|
|
87
|
+
quarkus.otel.span.attribute.value.length.limit=4096
|
|
88
|
+
quarkus.otel.span.attribute.count.limit=128
|
|
89
|
+
quarkus.otel.span.event.count.limit=128
|
|
90
|
+
quarkus.otel.span.link.count.limit=128
|
|
91
|
+
|
|
92
|
+
# Graceful shutdown — drain in-flight requests before the SDK flushes
|
|
93
|
+
quarkus.shutdown.timeout=15S
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### The shutdown budget (this is arithmetic, not a preference)
|
|
97
|
+
|
|
98
|
+
On `SIGTERM` Quarkus stops accepting requests, waits up to `quarkus.shutdown.timeout` for in-flight ones, shuts down beans, and only then does the OTel SDK flush its buffer — up to `bsp.export.timeout`. If the orchestrator's grace period expires first, the pod is `SIGKILL`ed mid-flush and the buffered spans die with it. So:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
terminationGracePeriodSeconds > quarkus.shutdown.timeout + (flush worst case) + margin
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**`bsp.export.timeout` is per batch export, not per flush** — a full queue drains in `max.queue.size / max.export.batch.size` sequential batches, so with the values above the worst case is `2048/512 = 4` exports, i.e. up to `4 × 10S`, not `10S`. Budget `15S + 40S + margin` → **`terminationGracePeriodSeconds: 75`** in the deployment manifest, or shrink the queue if you would rather cap the tail. Sizing the grace period off a single export timeout is the common mistake here. Set both sides deliberately; the Kubernetes default of 30s silently truncates this budget, and the loss is invisible — you get a trace that simply ends, which reads like a hung request rather than a dropped export. This is sharper for native binaries, which start and stop fast enough that pods churn often (scale-downs, rollouts, spot reclaims), so shutdown is a routine path, not an edge case.
|
|
105
|
+
|
|
106
|
+
Metrics behave differently and need no budget: Prometheus **pulls**, so the last scrape interval before termination is simply never collected. Counters are cumulative and aggregation tolerates the gap — just never build an alert on the final seconds of a terminating pod's counter.
|
|
107
|
+
|
|
108
|
+
### On the limits
|
|
109
|
+
|
|
110
|
+
- Queue overflow and attribute truncation are both **silent**. The only signal is the BSP's own dropped-span counter — alert on it, or discover the gap mid-incident when the trace you need is the one that was dropped.
|
|
111
|
+
- **A limit truncates, it does not sanitize.** A 4096-character cap on an attribute value does nothing about privacy: a truncated email address is still an email address. Limits are a cost and blast-radius control; the no-PII rule below is the privacy control, and neither substitutes for the other.
|
|
112
|
+
- Sampling (`%prod` ratio above) is the primary volume lever; these caps are the per-span backstop for when a code change, not traffic, is what blew up the volume.
|
|
113
|
+
|
|
32
114
|
## Logs: trace_id and span_id in every line
|
|
33
115
|
|
|
34
116
|
Quarkus OTel injects `traceId`, `spanId`, `sampled` into the MDC automatically.
|
|
@@ -42,10 +124,14 @@ Console (dev, human-readable):
|
|
|
42
124
|
Prod: JSON logs (parseable, ship to the log pipeline):
|
|
43
125
|
|
|
44
126
|
```properties
|
|
45
|
-
%prod.quarkus.log.console.json=true
|
|
127
|
+
%prod.quarkus.log.console.json.enabled=true
|
|
46
128
|
%prod.quarkus.log.console.json.additional-field."service.name".value=${quarkus.application.name}
|
|
129
|
+
%prod.quarkus.log.console.json.additional-field."service.version".value=${quarkus.application.version}
|
|
130
|
+
%prod.quarkus.log.console.json.additional-field."service.instance.id".value=${HOSTNAME:unknown}
|
|
47
131
|
```
|
|
48
132
|
|
|
133
|
+
`service.version` and `service.instance.id` repeat the resource attributes from the baseline config on purpose: a log line has no OTel resource, so without these fields "which build logged this?" is only answerable by joining through the trace — and unsampled or pre-trace log lines cannot be joined at all.
|
|
134
|
+
|
|
49
135
|
`quarkus-logging-json` includes MDC (traceId/spanId) as fields — logs↔traces correlation in Grafana/Tempo/Loki works with zero code. Rule: NEVER log a business operation without going through JBoss Logging/`Log` — `System.out` breaks correlation.
|
|
50
136
|
|
|
51
137
|
Also return the trace id to API callers for support tickets: a tiny response filter adds header `X-Trace-Id: ${Span.current().getSpanContext().getTraceId()}` on every response (especially errors — pairs with the unified exception handler).
|
|
@@ -56,7 +142,7 @@ With the extension present, these produce/propagate spans with no code:
|
|
|
56
142
|
- HTTP server (Quarkus REST) and REST Client
|
|
57
143
|
- gRPC server & client (metadata `traceparent`)
|
|
58
144
|
- Kafka via SmallRye Reactive Messaging (header `traceparent`)
|
|
59
|
-
- Agroal/JDBC:
|
|
145
|
+
- Agroal/JDBC: set `quarkus.datasource.jdbc.telemetry=true` for SQL spans — that property is the whole mechanism, on `quarkus-opentelemetry`; there is no separate JDBC extension to add
|
|
60
146
|
|
|
61
147
|
## Mandatory: trace context extraction via the OTel SDK only
|
|
62
148
|
|
|
@@ -69,6 +155,23 @@ String spanId = Span.current().getSpanContext().getSpanId();
|
|
|
69
155
|
|
|
70
156
|
If `Span.current()` is invalid (no active trace — e.g., a batch job without instrumentation), fix the instrumentation of the entry point; do not fabricate ids.
|
|
71
157
|
|
|
158
|
+
## Propagation and baggage: what crosses the service boundary
|
|
159
|
+
|
|
160
|
+
Trace context headers are **data that leaves the trust boundary in both directions**, and OTel baggage is the part teams underestimate: it is a set of key-value pairs that the instrumented clients attach to *every* outbound call — including calls to third-party APIs. Anything placed in baggage is, in practice, published to every vendor the service talks to.
|
|
161
|
+
|
|
162
|
+
**Default: `quarkus.otel.propagators=tracecontext`** (as in the baseline config). Baggage is off unless the platform has a documented cross-service need for it, recorded in the app's `service.yaml`. Narrowing the propagator list is free and removes the whole class of problem; do not enable baggage "in case someone needs it later."
|
|
163
|
+
|
|
164
|
+
When baggage *is* enabled, these are hard rules:
|
|
165
|
+
|
|
166
|
+
- **Never PII, credentials, tokens, secrets, or free text.** Same prohibition as span attributes (below) and log lines, with a wider blast radius because baggage crosses into systems you do not operate. Pairs with the quarkus-security-standards skill §7.
|
|
167
|
+
- **Baggage is caller-controlled input — never an authorization input.** The inbound `baggage` header is unauthenticated and unvalidated; any caller can set any value. In particular, **`tenantId` never comes from baggage**: it comes from a validated JWT claim, per the tenancy rule in the quarkus-hexagonal-core skill and §4 of the security skill. Reading a tenant from baggage is the same defect as reading it from a free header, wearing a telemetry costume.
|
|
168
|
+
- Bounded, low-cardinality enumerations only, with short keys — baggage rides in an HTTP header on every hop and is a per-request bandwidth cost.
|
|
169
|
+
- Do not auto-promote baggage entries into span attributes. That launders caller-controlled values into your telemetry store, where the cardinality and PII rules below assume the values are yours.
|
|
170
|
+
|
|
171
|
+
### Inbound trace context at the public edge
|
|
172
|
+
|
|
173
|
+
North-bound REST is reachable by callers you do not control, and `traceparent` is as forgeable as any other header — an untrusted client can pin a trace id, join itself to an unrelated trace, or force sampling decisions. Internal gRPC calls come from inside the mesh and are trusted; the public REST ingress is not. Where the platform does not already strip client trace headers at the gateway, treat an inbound `traceparent` on a public endpoint as untrusted: start a new root span and **link** to the incoming context rather than continuing it. Trust the header on internal transports, never at the public boundary.
|
|
174
|
+
|
|
72
175
|
## Audit standard: `context` jsonb column in `audit_event`
|
|
73
176
|
|
|
74
177
|
Every service with a local `audit_event` table persists the active trace in a `context` column of type `jsonb`:
|
|
@@ -176,11 +279,80 @@ Canonical class-naming rules live in the quarkus-hexagonal-core skill; these are
|
|
|
176
279
|
|
|
177
280
|
Span, metric and log-field names are a contract with the dashboards — renaming one silently breaks alerts. Treat a rename like an API change.
|
|
178
281
|
|
|
179
|
-
## Metrics (Micrometer)
|
|
282
|
+
## Metrics (Micrometer — mandatory extension)
|
|
283
|
+
|
|
284
|
+
**`quarkus-micrometer` ships in every service. It is not opt-in and does not need a per-service justification** — a service with no metrics is invisible to the platform's dashboards, alerts and capacity planning, and support cannot triage it. Declare the registry, which brings the core extension transitively:
|
|
285
|
+
|
|
286
|
+
```xml
|
|
287
|
+
<dependency>
|
|
288
|
+
<groupId>io.quarkus</groupId>
|
|
289
|
+
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
|
|
290
|
+
</dependency>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Declare the **registry only** — `quarkus-micrometer` arrives as its transitive dependency, and listing both invites a version skew on a Quarkus upgrade. Prometheus is the platform default registry; `quarkus-micrometer-registry-otlp` (metrics through the same collector as traces) is the sanctioned alternative where the platform standardizes on OTLP, and swapping registries is an ADR-level decision, not a per-service preference. Never two registries in one service.
|
|
294
|
+
|
|
295
|
+
```properties
|
|
296
|
+
quarkus.micrometer.enabled=true
|
|
297
|
+
quarkus.micrometer.export.prometheus.enabled=true
|
|
298
|
+
quarkus.micrometer.export.prometheus.path=/q/metrics
|
|
299
|
+
|
|
300
|
+
# Binders: the free instrumentation. Turn them ON, do not reimplement them.
|
|
301
|
+
quarkus.micrometer.binder.jvm=true
|
|
302
|
+
quarkus.micrometer.binder.system=true
|
|
303
|
+
quarkus.micrometer.binder.http-server.enabled=true
|
|
304
|
+
quarkus.micrometer.binder.http-client.enabled=true
|
|
305
|
+
quarkus.micrometer.binder.grpc-server.enabled=true # where the app exposes gRPC
|
|
306
|
+
quarkus.micrometer.binder.kafka.enabled=true # where the app produces/consumes
|
|
307
|
+
|
|
308
|
+
# Pool metrics come from the datasource, not from a binder (see quarkus-sql-jdbc-agroal skill)
|
|
309
|
+
quarkus.datasource.metrics.enabled=true
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### What you get for free — and must not rewrite by hand
|
|
313
|
+
|
|
314
|
+
| Source | Sample metrics | Enabled by |
|
|
315
|
+
|---|---|---|
|
|
316
|
+
| HTTP server | `http_server_requests_seconds` (count/sum/max, tagged `method`, `uri`, `status`, `outcome`) | `binder.http-server` |
|
|
317
|
+
| JVM / runtime | heap, GC, thread and class-loader gauges | `binder.jvm` |
|
|
318
|
+
| Agroal pool | active/available/awaiting connections, acquisition time | `quarkus.datasource.metrics.enabled` |
|
|
319
|
+
| Kafka clients | producer/consumer lag, throughput, error rates | `binder.kafka` |
|
|
320
|
+
| gRPC server | per-method call count and latency | `binder.grpc-server` |
|
|
321
|
+
|
|
322
|
+
A hand-written counter that duplicates one of these rows is a defect — it costs cardinality and drifts from the binder's semantics. Write a custom metric only for something the binders cannot know: a **business** outcome.
|
|
323
|
+
|
|
324
|
+
**Native caveat:** under GraalVM the JVM binder reports only what the substrate VM exposes (GC and heap are present; some HotSpot-specific gauges are not). Do not build an alert on a JVM gauge without confirming it appears in `/q/metrics` of the **native** binary — the JVM-mode dev run is not evidence.
|
|
325
|
+
|
|
326
|
+
### Custom business metrics
|
|
327
|
+
|
|
328
|
+
Recorded in the `Handler`, the same boundary that owns the span and the transaction — never in `Sql`, never in `Resource`:
|
|
329
|
+
|
|
330
|
+
```java
|
|
331
|
+
@ApplicationScoped
|
|
332
|
+
public class RegisterUserHandler {
|
|
333
|
+
|
|
334
|
+
@Inject MeterRegistry registry;
|
|
335
|
+
|
|
336
|
+
@Transactional
|
|
337
|
+
@WithSpan("usecase.registerUser")
|
|
338
|
+
public RegisterUserResponseDto process(RegisterUserRequestDto request) {
|
|
339
|
+
validate(request);
|
|
340
|
+
UUID userId = execution(request);
|
|
341
|
+
registry.counter("iam_user_registrations_total", "channel", request.getChannel()).increment();
|
|
342
|
+
return getResult(userId);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- **Naming is the contract** (see the naming table above): `<module>_<entity>_<action>_total` for counters, `_seconds` for timers, `snake_case`, module prefix from the semantic module name — never `bcNN`.
|
|
348
|
+
- **Tags are bounded enumerations only.** `result=success|failure`, `channel=web|app|ivr`, `party_type=Individual|Organization`. **Never** `tenantId`, `userId`, `email`, `partyId`, `trace_id`, a raw URL, or an error *message* — each distinct value is a new time series, and an unbounded tag takes down the scrape target before anyone notices. The error **code** (`PTY-400-001`) is bounded and therefore a legal tag; the rendered message is not.
|
|
349
|
+
- No PII in a metric name or tag — same rule as span attributes, same reason.
|
|
350
|
+
- `@Timed` / `@Counted` on `process()` are acceptable for plain latency/invocation counts (CDI interceptors are build-time in Quarkus, so they are native-safe). Use the explicit `MeterRegistry` call whenever the tag depends on the outcome — an annotation cannot tag `result=failure` from a thrown `BusinessException`.
|
|
351
|
+
- Renaming a metric or a tag **breaks dashboards and alerts silently**. Treat it exactly like an API change: coordinate with whoever owns the dashboard, or add the new name alongside the old one for one release.
|
|
352
|
+
|
|
353
|
+
### Exposure
|
|
180
354
|
|
|
181
|
-
|
|
182
|
-
- Custom business metrics: counters/timers via `MeterRegistry`, names `iam_user_registrations_total` style, low-cardinality tags only.
|
|
183
|
-
- Expose `/q/metrics` for Prometheus scrape or bridge Micrometer→OTLP if the platform standardizes on the collector for metrics too.
|
|
355
|
+
`/q/metrics` is an internal endpoint: scraped by Prometheus inside the cluster, **never routed through the public ingress** (see quarkus-security-standards skill §7 — it must also never echo a config value or connection string). It is excluded from tracing by `suppress-non-application-uris=true` above. The scrape path and the service's custom metrics belong in the app's `service.yaml` observability contract and its `README.md` (see quarkus-hexagonal-core skill).
|
|
184
356
|
|
|
185
357
|
## Health
|
|
186
358
|
|
|
@@ -188,9 +360,15 @@ Span, metric and log-field names are a contract with the dashboards — renaming
|
|
|
188
360
|
|
|
189
361
|
## Checklist per service
|
|
190
362
|
|
|
191
|
-
1. `quarkus-opentelemetry` + JSON logging configured as above; OTLP → collector.
|
|
363
|
+
1. `quarkus-opentelemetry` + `quarkus-micrometer-registry-prometheus` + JSON logging configured as above; OTLP → collector.
|
|
192
364
|
2. Log format includes traceId/spanId (both profiles); `X-Trace-Id` response filter registered.
|
|
193
365
|
3. JDBC telemetry enabled; `audit_event.context` jsonb populated with `trace_id`/`span_id` (+ index) on the Handler's own `Connection`; outbox rows carry `traceparent` (header) AND `traceContext` in payload; the relay forwards the header.
|
|
194
366
|
3b. No manual trace-string construction anywhere — grep-check in review; only `Span.current()` via the OTel SDK.
|
|
195
367
|
4. Every slice `Handler.process()` annotated `@WithSpan("usecase.<sliceCamelCase>")`; attributes reviewed for PII/cardinality.
|
|
196
|
-
5.
|
|
368
|
+
5. Micrometer binders enabled (jvm, system, http-server/client, plus grpc/kafka where used) and `quarkus.datasource.metrics.enabled=true`; every custom metric follows the naming table, carries only bounded tags, and duplicates no binder metric.
|
|
369
|
+
6. `/q/metrics` returns data from the **native** binary and is reachable only from inside the cluster.
|
|
370
|
+
7. `service.version` + `service.instance.id` present in resource attributes AND in the JSON log fields; `<module>_build_info` gauge registered — and `version` is **not** a common tag on other metrics.
|
|
371
|
+
8. Shutdown budget satisfied: `terminationGracePeriodSeconds > quarkus.shutdown.timeout + quarkus.otel.bsp.export.timeout`, set in the deployment manifest, not left at the 30s default by accident.
|
|
372
|
+
9. `quarkus.otel.propagators` explicitly set (`tracecontext` unless baggage is documented in `service.yaml`); no PII, token or `tenantId` in baggage; public-edge `traceparent` treated as untrusted.
|
|
373
|
+
10. Span attribute/event/link limits configured; the BSP dropped-span counter is alerted on.
|
|
374
|
+
11. Verify end-to-end in dev: one request produces a single trace spanning REST → DB → Kafka → consumer (Dev UI or Jaeger/Tempo), and increments the metrics that back its dashboard.
|
|
@@ -31,7 +31,7 @@ The raw OpenAPI document at `/q/openapi` may stay enabled in prod behind the gat
|
|
|
31
31
|
|
|
32
32
|
### URLs & versioning
|
|
33
33
|
- Plural kebab-free camelCase resource names as in TMF specs: `/user`, `/partyAccount` — follow the TMF spec exactly when implementing one; plural nouns for custom resources.
|
|
34
|
-
- Base path starts with the module's semantic name (`iam`, `tenant` — see quarkus-hexagonal-core skill, never a `bcNN` code): `/{module}/{context}/{apiName}/v{major}` e.g. `/iam/tmf-api/digitalIdentityManagement/v4`. Major version in path only.
|
|
34
|
+
- Base path starts with the module's semantic name (`iam`, `tenant` — see quarkus-hexagonal-core skill, never a `bcNN` code): `/{module}/{context}/{apiName}/v{major}` e.g. `/iam/tmf-api/digitalIdentityManagement/v4`. Major version in path only. That leading `/{module}` segment is also the app's **API gateway base path**, and it is declared in the same PR in README §2 and `service.yaml` under `metadata.gateway` — infra configures the route from there (see quarkus-hexagonal-core skill, "Gateway routing").
|
|
35
35
|
|
|
36
36
|
### Standard operations
|
|
37
37
|
| Operation | Verb | Response |
|
|
@@ -89,6 +89,7 @@ public class DigitalIdentityResource {
|
|
|
89
89
|
|
|
90
90
|
@Inject CreateDigitalIdentityHandler createHandler; // the slice's business logic
|
|
91
91
|
@Inject FindDigitalIdentityHandler findHandler;
|
|
92
|
+
@Inject JsonWebToken jwt; // tenant/roles come from the validated token
|
|
92
93
|
|
|
93
94
|
@GET
|
|
94
95
|
@Blocking
|
|
@@ -96,12 +97,11 @@ public class DigitalIdentityResource {
|
|
|
96
97
|
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = DigitalIdentityDto[].class)))
|
|
97
98
|
@APIResponse(responseCode = "400", ref = "#/components/responses/BadRequest")
|
|
98
99
|
public RestResponse<List<DigitalIdentityDto>> list(
|
|
99
|
-
@HeaderParam("tenantId") String tenantId,
|
|
100
100
|
@QueryParam("fields") String fields,
|
|
101
101
|
@QueryParam("offset") @DefaultValue("0") int offset,
|
|
102
102
|
@QueryParam("limit") @DefaultValue("20") int limit) {
|
|
103
103
|
var query = FilterDigitalIdentityDto.builder()
|
|
104
|
-
.tenantId(
|
|
104
|
+
.tenantId(jwt.getClaim("tenant_id")).fields(fields).offset(offset).limit(limit).build();
|
|
105
105
|
return RestResponse.ok(findHandler.process(query).getItems());
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -109,10 +109,9 @@ public class DigitalIdentityResource {
|
|
|
109
109
|
@Blocking
|
|
110
110
|
@Operation(operationId = "createDigitalIdentity")
|
|
111
111
|
@APIResponse(responseCode = "201", headers = @Header(name = "Location"))
|
|
112
|
-
public RestResponse<DigitalIdentityDto> create(@
|
|
113
|
-
@Valid DigitalIdentityCreateDto dto,
|
|
112
|
+
public RestResponse<DigitalIdentityDto> create(@Valid DigitalIdentityCreateDto dto,
|
|
114
113
|
@Context UriInfo uri) {
|
|
115
|
-
dto.setTenantId(
|
|
114
|
+
dto.setTenantId(jwt.getClaim("tenant_id")); // validated JWT claim, never a header
|
|
116
115
|
var created = createHandler.process(dto); // BusinessException propagates to the global handler
|
|
117
116
|
return RestResponse.ResponseBuilder
|
|
118
117
|
.created(uri.getAbsolutePathBuilder().path(created.getId()).build())
|
|
@@ -123,8 +122,8 @@ public class DigitalIdentityResource {
|
|
|
123
122
|
|
|
124
123
|
Rules:
|
|
125
124
|
- Return `RestResponse<T>` synchronously — typed, so the generated schema is right. Never `Uni`/`Multi`: the `Handler` is blocking JDBC.
|
|
126
|
-
- `@Blocking` (`io.smallrye.common.annotation.Blocking`) on JDBC-backed methods. With a plain return type Quarkus REST already dispatches to a worker thread, so it is redundant today — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the high-concurrency alternative (see sql skill §
|
|
127
|
-
-
|
|
125
|
+
- `@Blocking` (`io.smallrye.common.annotation.Blocking`) on JDBC-backed methods. With a plain return type Quarkus REST already dispatches to a worker thread, so it is redundant today — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the high-concurrency alternative (see sql skill §8).
|
|
126
|
+
- Take `tenantId` — and anything else that drives authorization, such as `partyId` or `partyRolList` — from the validated token (`@Inject JsonWebToken jwt` → `jwt.getClaim(...)`, or `@Claim`; `SecurityIdentity` carries roles and mechanism attributes, **not** claims), **never** from a `@HeaderParam`: a header the caller sets is not authentication (see quarkus-security-standards skill §4 and the tenancy rule in quarkus-hexagonal-core). `language` and other non-security metadata may come from headers. Set them on the request DTO before calling `process()`.
|
|
128
127
|
- No `try/catch` around `process()`. Errors travel as `BusinessException` to `GlobalExceptionHandler`, which resolves the localized TMF Error body (see quarkus-error-handling-i18n skill).
|
|
129
128
|
- `operationId` on every operation (client generation depends on it); match TMF naming (`listX`, `retrieveX`, `createX`, `patchX`, `deleteX`).
|
|
130
129
|
- Reusable components: define common responses (400/401/404/409/500 with TMF Error schema) once via an `@OpenAPIDefinition`/filter class, `ref` them everywhere.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: quarkus-security-standards
|
|
3
|
+
description: Security baseline for Quarkus backend services — credential and environment-variable handling via `.env` (never committed) plus a committed `.env.example` template, secrets management, authentication/authorization (OIDC/JWT), transport security, input/output hardening, dependency and container hardening, and secret-safe logging. Use this skill whenever the user creates or reviews `application.properties`/`.env` files, injects a credential/URL/API key/token, adds `quarkus-oidc`/`quarkus-smallrye-jwt`, configures CORS/TLS/headers, writes a `Dockerfile`, adds a dependency, or asks about security review, secret leakage, `.gitignore`, or OWASP. Pairs with quarkus-hexagonal-core (project layout), quarkus-sql-jdbc-agroal (injection-safe SQL), and quarkus-observability-otel (PII-safe logging).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Security Standard (Quarkus)
|
|
7
|
+
|
|
8
|
+
Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, explicit instructions) override these defaults where they conflict.
|
|
9
|
+
|
|
10
|
+
## 1. Environment variables: `.env` for local secrets, never committed
|
|
11
|
+
|
|
12
|
+
**No credential, URL, API key, token, or other environment-specific secret is ever hardcoded in source or committed to the repository.** Locally, they live in a `.env` file at the project root; in every other environment they come from the platform's secret store (see §2). This is the first rule to apply on any new service, before the first `application.properties` line is written.
|
|
13
|
+
|
|
14
|
+
Rules, in order:
|
|
15
|
+
|
|
16
|
+
1. **Create `.env`** at the project root (or per-app root in a monorepo, `apps/<app>/.env`) holding every local credential, connection URL, host, port, and API key the service needs to run in `%dev`/`%test`. Real values only, never sample/placeholder values here.
|
|
17
|
+
2. **`.env` is never versioned.** Add it to `.gitignore` **immediately**, in the same commit that creates it — never after the fact:
|
|
18
|
+
|
|
19
|
+
```gitignore
|
|
20
|
+
# Local environment secrets — never commit
|
|
21
|
+
.env
|
|
22
|
+
.env.*
|
|
23
|
+
!.env.example
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
If `.env` was ever committed by mistake, it must be purged from history (`git filter-repo` or equivalent) and every credential it contained rotated — removing the file from the tip of the branch is not sufficient.
|
|
27
|
+
3. **Create `.env.example`** alongside it, committed to the repo, listing every variable `.env` must define — **keys only, placeholder or empty values, never real secrets**:
|
|
28
|
+
|
|
29
|
+
```dotenv
|
|
30
|
+
# .env.example — copy to .env and fill in real values. Never commit .env.
|
|
31
|
+
DB_HOST=localhost
|
|
32
|
+
DB_PORT=5432
|
|
33
|
+
DB_NAME=customer
|
|
34
|
+
DB_USER=
|
|
35
|
+
DB_PASSWORD=
|
|
36
|
+
KAFKA_BOOTSTRAP_SERVERS=localhost:9092
|
|
37
|
+
OIDC_AUTH_SERVER_URL=
|
|
38
|
+
OIDC_CLIENT_ID=
|
|
39
|
+
OIDC_CLIENT_SECRET=
|
|
40
|
+
EXTERNAL_API_BASE_URL=
|
|
41
|
+
EXTERNAL_API_KEY=
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`.env.example` is the authoritative, reviewable list of what the service needs — update it in the same PR that introduces a new variable. A variable missing from `.env.example` does not exist as far as onboarding and CI are concerned.
|
|
45
|
+
4. **Reference the variables from `application.properties` via Quarkus property expressions**, never by reading `.env` manually in code:
|
|
46
|
+
|
|
47
|
+
```properties
|
|
48
|
+
quarkus.datasource.username=${DB_USER}
|
|
49
|
+
quarkus.datasource.password=${DB_PASSWORD}
|
|
50
|
+
quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
|
|
51
|
+
quarkus.oidc.auth-server-url=${OIDC_AUTH_SERVER_URL}
|
|
52
|
+
quarkus.oidc.credentials.secret=${OIDC_CLIENT_SECRET}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Quarkus resolves `${VAR}` from process environment variables at runtime (uppercase-with-underscores env var names map automatically — no extra wiring). Quarkus/SmallRye Config reads a `.env` file in the working directory natively in dev and test — no extension is needed, and `quarkus-dotenv` should not be added. Nothing in `main` code parses `.env` directly.
|
|
56
|
+
5. **Never print, log, or return an environment variable's value.** A `Handler`/`Resource` that echoes a config value back for "debugging" is a leak waiting to happen — see §7.
|
|
57
|
+
|
|
58
|
+
This applies to **every** environment-specific value, not just passwords: hostnames, ports, bucket names, tenant-specific URLs, and feature-flag endpoints belong in `.env`/the platform secret store too — hardcoding a "just a hostname" value is how an internal URL ends up in a public repo.
|
|
59
|
+
|
|
60
|
+
## 2. Beyond local dev: secrets in real environments
|
|
61
|
+
|
|
62
|
+
`.env` is a **local-development convenience only** — it is never the secret source in a deployed environment.
|
|
63
|
+
|
|
64
|
+
- Deployed environments (dev cluster, staging, prod) inject secrets as environment variables or mounted files from a vault (HashiCorp Vault, cloud KMS/Secrets Manager, Kubernetes `Secret` backed by an external secret operator) — never from a `.env` file baked into an image.
|
|
65
|
+
- `application.properties` stays identical across environments: `${VAR}` placeholders only. What changes per environment is *where the platform gets the value from*, never the property file.
|
|
66
|
+
- Kubernetes: mount secrets as env vars via `secretKeyRef`, never as plain `ConfigMap` values, and never bake a secret into the container image at build time.
|
|
67
|
+
- The per-app `README.md` **Secrets** section (see quarkus-hexagonal-core skill, §"Per-app documentation") documents logical name, purpose, vault source, owner, and rotation period for every secret — **never the value itself**.
|
|
68
|
+
- Rotate credentials on a schedule and immediately on suspected exposure (accidental commit, departing team member with access, log leak).
|
|
69
|
+
|
|
70
|
+
## 3. No secrets in code, config, or version control
|
|
71
|
+
|
|
72
|
+
- No API key, password, connection string, private key, or token as a string literal anywhere in `src/`, `pom.xml`, test fixtures, or committed `application.properties`. Grep for this in review: `grep -rniE "(password|secret|api[_-]?key|token)[[:space:]]*=[[:space:]]*['\"][^$]" src/` (a `${VAR}` right-hand side is fine; a literal value is not).
|
|
73
|
+
- Test fixtures use `%test` profile values pointing at Dev Services (ephemeral Testcontainers credentials, not real ones) — never a copy of a real credential "just for tests."
|
|
74
|
+
- `application.properties` committed to the repo may reference `${VAR}` freely; it never assigns a literal secret value, including in a profile-specific override (`%prod.quarkus.datasource.password=...` is banned exactly like the unscoped form).
|
|
75
|
+
- Private keys/certificates (`.pem`, `.jks`, `.p12`) are never committed. Reference their filesystem path (mounted from a secret) via `${VAR}`, same as any other credential.
|
|
76
|
+
|
|
77
|
+
## 4. Authentication and authorization
|
|
78
|
+
|
|
79
|
+
- Inbound REST/gRPC authentication is `quarkus-oidc` (OIDC/JWT) against the org's identity provider — no home-grown token schemes, no hardcoded API keys as the sole auth mechanism for a production endpoint.
|
|
80
|
+
- `tenantId` is read from a **validated JWT claim**, never a free-form request header (see quarkus-hexagonal-core skill's tenancy rule) — a header the caller can set to any value is not authentication.
|
|
81
|
+
- Authorize at the `Resource`/`GrpcService` boundary with `@RolesAllowed`/`SecurityIdentity` checks before invoking `Handler.process()`; the `Handler` trusts the DTO it receives came from an already-authorized caller and does not re-implement authorization logic.
|
|
82
|
+
- Service-to-service calls (internal gRPC) use mTLS or a service-account token from the platform's identity system, injected via `${VAR}` per §1 — never a shared static secret checked into `common/client`.
|
|
83
|
+
- `quarkus.oidc.credentials.secret` and any client secret follow §1/§3 exactly: `.env` locally, vault in deployed environments, `${VAR}` in properties.
|
|
84
|
+
|
|
85
|
+
## 5. Transport and network security
|
|
86
|
+
|
|
87
|
+
- TLS terminates at the ingress/service mesh in most deployments; where the service terminates TLS itself, `quarkus.http.ssl.certificate.*` paths point at mounted secret files via `${VAR}`, never inline certs/keys.
|
|
88
|
+
- HTTP is never the accepted transport for a real environment. **Where TLS terminates at the ingress (the usual case) leave `quarkus.http.insecure-requests` alone** — the pod has no certificate, so forcing `redirect`/`disabled` there breaks its own probes and the mesh's plain-HTTP hop to it. Set it only on a service that terminates TLS itself; otherwise enforce HTTPS at the ingress, where the certificate lives.
|
|
89
|
+
- CORS is explicit and scoped, never `*` in a deployed environment:
|
|
90
|
+
|
|
91
|
+
```properties
|
|
92
|
+
quarkus.http.cors.enabled=true
|
|
93
|
+
quarkus.http.cors.origins=${ALLOWED_ORIGINS:http://localhost:3000}
|
|
94
|
+
quarkus.http.cors.methods=GET,POST,PUT,PATCH,DELETE
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- Internal service calls are gRPC, external/north-bound is REST (see quarkus-hexagonal-core skill) — this also means the internal surface is not exposed to the public network by default; ingress rules should reflect that boundary.
|
|
98
|
+
|
|
99
|
+
## 6. Input handling and injection
|
|
100
|
+
|
|
101
|
+
- SQL: `PreparedStatement` placeholders only, never string-concatenated user input — full standard in quarkus-sql-jdbc-agroal skill (§2 there). This skill's `.env` rule and that skill's placeholder rule are the two halves of "never trust a string that reaches persistence."
|
|
102
|
+
- Bean Validation (`@NotBlank`, `@Size`, `@Pattern`) on every DTO field at the transport boundary; `Handler.validate()` covers business-rule validation (see quarkus-hexagonal-core skill) — reject malformed input before it reaches `execution()`.
|
|
103
|
+
- Deserialize only into typed DTOs (`@RegisterForReflection` where native requires it) — never accept and reflect back arbitrary JSON structures (mass-assignment risk).
|
|
104
|
+
- Any value echoed into a response, log line, or downstream call that originated from user input is treated as untrusted: encode/escape for its destination context (JSON, log line, SQL, shell) rather than assuming it is already safe.
|
|
105
|
+
- Never build a shell command, file path, or SQL fragment by concatenating request data. If a slice genuinely needs to invoke an external process, pass arguments as an array (no shell interpretation), never a single interpolated string.
|
|
106
|
+
|
|
107
|
+
## 7. Secret-safe logging and error responses
|
|
108
|
+
|
|
109
|
+
- **Never log a credential, token, full connection string, or environment-variable value.** Log the variable's *name*, never its value, when debugging config issues.
|
|
110
|
+
- Pairs with quarkus-observability-otel skill: spans, attributes, and audit `context` are business-relevant and low-cardinality — the same rule bans PII, and it bans secrets identically. If a field would be a problem in a trace, it is a problem in a log line too.
|
|
111
|
+
- `GlobalExceptionHandler`/`GrpcExceptionInterceptor` (see quarkus-error-handling-i18n skill) return the catalog message only — never a stack trace, an internal exception message, or a raw `SQLException` string to the client. Full detail goes to the log (still secret-scrubbed), the client gets `<MOD>-<HTTP>-<seq>` + localized text + `X-Trace-Id`.
|
|
112
|
+
- Actuator/health/metrics endpoints (`/q/health`, `/q/metrics`) never surface configuration values, environment variables, or datasource URLs — verify no custom health check echoes a connection string in its response.
|
|
113
|
+
|
|
114
|
+
## 8. Dependencies and container hardening
|
|
115
|
+
|
|
116
|
+
- Run dependency vulnerability scanning (OWASP Dependency-Check, `mvn org.owasp:dependency-check-maven:check`, or the platform's equivalent) in CI; a new dependency with a known critical CVE and no fix available needs an explicit, documented exception, not a silent merge.
|
|
117
|
+
- Pin dependency versions through the Quarkus BOM; avoid version ranges that can silently pull in an unreviewed transitive update.
|
|
118
|
+
- The native-image Dockerfile (see quarkus-hexagonal-core skill) already enforces the two container-hardening rules that matter most: a minimal base image with **no JDK/JRE layer** in the final image, and a **non-root `USER`**. Never widen either for convenience.
|
|
119
|
+
- No secret is ever baked into a Docker image layer (`ENV MY_SECRET=...` in a `Dockerfile`, or a `COPY .env`) — secrets are injected at container start by the orchestrator, per §2.
|
|
120
|
+
|
|
121
|
+
## Checklist for a new service or slice
|
|
122
|
+
|
|
123
|
+
1. `.env` created with real local values; `.gitignore` entry added in the **same commit**.
|
|
124
|
+
2. `.env.example` committed with every variable name `.env` defines, placeholder/empty values only, kept current with every new variable.
|
|
125
|
+
3. `application.properties` references every credential/URL via `${VAR}` — zero literal secrets, in any profile.
|
|
126
|
+
4. `grep -rniE "(password|secret|api[_-]?key|token)[[:space:]]*=[[:space:]]*['\"][^$]" src/` returns nothing.
|
|
127
|
+
5. Deployed environments source the same variables from the platform vault/secret store, documented (never valued) in the app's `README.md` Secrets section.
|
|
128
|
+
6. Inbound auth is `quarkus-oidc`/JWT; `tenantId` comes from a validated claim, not a free header.
|
|
129
|
+
7. TLS enforced outside `%dev`; CORS origins explicit, never `*` in a deployed environment.
|
|
130
|
+
8. No log line, error response, health/metrics endpoint, or trace attribute ever carries a secret, token, or full connection string.
|
|
131
|
+
9. Native Dockerfile: non-root user, no JDK/JRE layer, no secret baked into a layer.
|
|
132
|
+
10. Dependency scan clean or exceptions documented.
|
|
@@ -9,7 +9,7 @@ Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, exp
|
|
|
9
9
|
|
|
10
10
|
Persistence is explicit SQL through the Agroal pool. No Panache, no Hibernate, no reflection-based row mappers. Data access lives in the slice's `<Slice>Sql` class — the driven adapter of the vertical slice (see quarkus-hexagonal-core skill). There are no `*Repository` ports and no `Jdbc*` adapters: one `Sql` class per slice, called only by that slice's `Handler`.
|
|
11
11
|
|
|
12
|
-
One clarification to keep teams from chasing ghosts: **Agroal is a JDBC (blocking) pool** — there is no "reactive Agroal". The reactive path in Quarkus is the Vert.x SQL client (`quarkus-reactive-pg-client`) with its own pool. Default choice here is **Agroal + JDBC** (simpler, dominant skill base, works perfectly in native); reactive client only for measured hot paths with extreme concurrency (see §
|
|
12
|
+
One clarification to keep teams from chasing ghosts: **Agroal is a JDBC (blocking) pool** — there is no "reactive Agroal". The reactive path in Quarkus is the Vert.x SQL client (`quarkus-reactive-pg-client`) with its own pool. Default choice here is **Agroal + JDBC** (simpler, dominant skill base, works perfectly in native); reactive client only for measured hot paths with extreme concurrency (see §8).
|
|
13
13
|
|
|
14
14
|
## 1. Datasource configuration (Agroal)
|
|
15
15
|
|
|
@@ -78,7 +78,7 @@ Absolute rules:
|
|
|
78
78
|
- SQL as `private static final String` text blocks (comma-first concatenation is equally acceptable), schema-qualified. Never concatenate user input — `PreparedStatement` placeholders ALWAYS (SQL injection + plan cache).
|
|
79
79
|
- Quote reserved-word schemas and tables: `"order".party`. Verify the real schema name before writing the query.
|
|
80
80
|
- Dynamic WHERE clauses: build from a whitelist of column/operator constants, values still as placeholders.
|
|
81
|
-
- Methods declare `throws SQLException` and never catch it — the `Handler` translates it (§
|
|
81
|
+
- Methods declare `throws SQLException` and never catch it — the `Handler` translates it (§9). No `commit`, `rollback` or `setAutoCommit` here, ever.
|
|
82
82
|
- One private `mapRow(ResultSet) -> Dto` per `Sql` class. Rows map to the slice's own DTOs; there is no separate domain entity and no `*RowMapper` class. No reflection mappers: they break native and hide cost.
|
|
83
83
|
|
|
84
84
|
Naming (canonical rules in the quarkus-hexagonal-core skill):
|
|
@@ -90,7 +90,7 @@ Naming (canonical rules in the quarkus-hexagonal-core skill):
|
|
|
90
90
|
| SQL constant | `UPPER_SNAKE_CASE` matching the method, `private static final String` | `SELECT_PARTY_BY_ID`, `INSERT_PARTY` |
|
|
91
91
|
| Table / column | `snake_case`, singular table name, schema-qualified | `customer.party`, `created_at` |
|
|
92
92
|
| Row target | the slice's `*Dto` | `PartyDto` |
|
|
93
|
-
| Technical exception | `PersistenceException` + specific subtypes (§
|
|
93
|
+
| Technical exception | `PersistenceException` + specific subtypes (§9) | `TransientPersistenceException` |
|
|
94
94
|
|
|
95
95
|
Never name this class `*Repository`, `*Dao`, `*Manager` or `*Service` — those suffixes are banned by the core skill's ArchUnit rules. An `Sql` class that grows an `if` encoding a business rule has swallowed logic that belongs in the `Handler`.
|
|
96
96
|
|
|
@@ -116,7 +116,7 @@ public class CreatePartyIndividualHandler {
|
|
|
116
116
|
UUID partyId = sql.insertParty(conn, request.getTenantId(), "Individual",
|
|
117
117
|
"Active", request.getCreatedBy());
|
|
118
118
|
sql.insertIndividual(conn, partyId, request.getGivenName(), request.getFamilyName());
|
|
119
|
-
|
|
119
|
+
outbox.record(conn, "party", partyId.toString(), EVENT_TYPE, TOPIC, eventMap(partyId, request));
|
|
120
120
|
return partyId;
|
|
121
121
|
} catch (SQLException e) {
|
|
122
122
|
throw SqlStateTranslator.translate("PTY-500-001", e); // unchecked -> container rolls back
|
|
@@ -129,7 +129,7 @@ public class CreatePartyIndividualHandler {
|
|
|
129
129
|
|
|
130
130
|
- `@Transactional` goes on `process()` only — never on a private method (CDI interceptors don't fire on self-invocation, so the transaction would silently never open), never on the `Sql` class.
|
|
131
131
|
- **Read-only slices skip `@Transactional` entirely.** A single `SELECT`, or several reads that tolerate a non-repeatable view, are cheaper without a JTA transaction — still one `Connection`, opened and closed in `execution()`. Add the annotation the moment there is a write, or when several reads must see one snapshot.
|
|
132
|
-
- Runtime exceptions roll back by default; `BusinessException` extends `RuntimeException`, so throwing it rolls back — correct by construction. A checked `SQLException` would **not** roll back, which is why §
|
|
132
|
+
- Runtime exceptions roll back by default; `BusinessException` extends `RuntimeException`, so throwing it rolls back — correct by construction. A checked `SQLException` would **not** roll back, which is why §9 translation is mandatory.
|
|
133
133
|
- Programmatic control when annotations don't fit (loops with per-item commit, batch jobs) — from a `common/` bean or a `*Job`, never from inside a slice `Handler`:
|
|
134
134
|
|
|
135
135
|
```java
|
|
@@ -166,7 +166,7 @@ public UUID insertParty(Connection conn, String tenantId, String partyType,
|
|
|
166
166
|
- **Generated ids**: when the PK has a database default (`id uuid DEFAULT customer.uuidv7() NOT NULL`), exclude `id` from the column list and never bind it. Read it back with `RETURNING id` + `executeQuery()` — preferred over `getGeneratedKeys()`.
|
|
167
167
|
- Check `executeUpdate()` counts — 0 rows on an expected UPDATE is a bug or a concurrency signal, not a success. The `Handler` decides what that means; `Sql` just returns the count.
|
|
168
168
|
- Optimistic locking: `version` column, `UPDATE ... WHERE id = ? AND version = ?`; 0 rows → the `Handler` throws `StaleVersionException` (code-carrying, in `common/exception`) with the slice's conflict code → 409 via its dedicated mapper (see quarkus-error-handling-i18n skill). It is not SQLState-derived — no `SQLException` occurs, so `SqlStateTranslator` never sees it; the `Handler` checks the update count. Do NOT name it `ConcurrentModificationException` — it shadows `java.util.ConcurrentModificationException` and an accidental import turns the 409 mapping into a 500.
|
|
169
|
-
- Upserts: `ON CONFLICT ... DO UPDATE` explicitly; never SELECT-then-INSERT races. A unique-violation (`23505`) surfacing as a `SQLException` is translated by the `Handler` (§
|
|
169
|
+
- Upserts: `ON CONFLICT ... DO UPDATE` explicitly; never SELECT-then-INSERT races. A unique-violation (`23505`) surfacing as a `SQLException` is translated by the `Handler` (§9), not swallowed here.
|
|
170
170
|
|
|
171
171
|
## 5. Batches, pagination, jsonb
|
|
172
172
|
|
|
@@ -205,9 +205,22 @@ ps.setObject(5, pgo);
|
|
|
205
205
|
|
|
206
206
|
Read side: `rs.getString("context")` then parse. Index jsonb lookups you actually query (`(context->>'trace_id')`).
|
|
207
207
|
|
|
208
|
-
## 6.
|
|
208
|
+
## 6. Cross-cutting tables get a cross-cutting `Sql`
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
One `Sql` class per slice is the rule for the slice's **own** tables. A table that every slice writes the same way — `outbox_event`, `audit_event`, `processed_event` — gets **one** `Sql` class in `common/`, and a `Connection`-first capability bean in front of it:
|
|
211
|
+
|
|
212
|
+
| Table | Owner in `common/` | Called by |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| outbox | `OutboxSql` + `OutboxEventRecorder` (`common/messaging`) | slice `Handler`s, `OutboxDispatcher`, `OutboxRelayJob` |
|
|
215
|
+
| audit_event | `AuditRecorder` (`common/audit`) | slice `Handler`s |
|
|
216
|
+
|
|
217
|
+
The transactional guarantee is unaffected: these methods take the `Connection` the `Handler` already opened in `execution()`, so the row commits with the state change (§3). **Never copy `insertOutboxEvent` (or any other cross-cutting insert) onto a `<Slice>Sql`.** The columns are fixed by the migration and the envelope is fixed by the messaging standard, so every copy is identical by construction — and identical-by-construction code that is nonetheless written N times drifts: one slice grows a null-guard for a `NOT NULL` column, another falls behind a column addition, and nothing fails until the relay cannot route a row. The `duplicate twice, extract on the third` rule (hexagonal-core skill) is about code that might *turn out* to differ per slice; this cannot.
|
|
218
|
+
|
|
219
|
+
This does not license a `common/` grab-bag: it applies to tables `common/` genuinely owns, not to a query two slices happen to share today.
|
|
220
|
+
|
|
221
|
+
## 7. A tiny helper is allowed; a framework is not
|
|
222
|
+
|
|
223
|
+
To kill boilerplate, ONE small internal helper class (~50 lines) per service or shared lib is the sanctioned maximum. (The cross-cutting owners of §6 are not helpers and do not count against this budget — they own a table, they do not generalize SQL.)
|
|
211
224
|
|
|
212
225
|
```java
|
|
213
226
|
public final class Jdbc { // common/util — the one sanctioned exception to the banned-suffix rule
|
|
@@ -225,7 +238,7 @@ Note the signatures take `Connection`, not `DataSource` — the helper must not
|
|
|
225
238
|
- The class is named `Jdbc` — a namespace, not `JdbcUtils`/`JdbcHelper`. The banned-suffix ArchUnit rule (see quarkus-hexagonal-core skill) exists precisely to stop this class from becoming a junk drawer.
|
|
226
239
|
- jOOQ (code-gen, type-safe SQL) MAY be evaluated as an alternative via formal ADR; MyBatis/Hibernate remain excluded.
|
|
227
240
|
|
|
228
|
-
##
|
|
241
|
+
## 8. Blocking model: worker threads or virtual threads (and when reactive)
|
|
229
242
|
|
|
230
243
|
JDBC blocks. Never run it on the event loop:
|
|
231
244
|
|
|
@@ -234,7 +247,7 @@ JDBC blocks. Never run it on the event loop:
|
|
|
234
247
|
- **Virtual threads**: `@RunOnVirtualThread` on JDBC-heavy endpoints is the modern default for high-concurrency blocking work (Java 25 baseline; synchronized-block pinning is fixed since JDK 24) — cheap threads, same simple code. Caveat: keep pool `max-size` as the real ceiling; virtual threads make it easy to pile up on `acquisition-timeout`.
|
|
235
248
|
- **Reactive SQL client** (`quarkus-reactive-pg-client`, Vert.x pool — NOT Agroal): only for measured hot paths (extreme fan-in, streaming thousands of rows). It's a different programming model and a second pool to size; adopting it in a service requires an ADR. Do not mix both models in the same repository class.
|
|
236
249
|
|
|
237
|
-
##
|
|
250
|
+
## 9. SQLException translation
|
|
238
251
|
|
|
239
252
|
`Sql` methods propagate `SQLException`; the `Handler` catches it once, in `execution()`, and translates it through `SqlStateTranslator` (`common/error`) so nothing checked ever escapes `process()` — a checked exception would not trigger the container rollback:
|
|
240
253
|
|
|
@@ -256,7 +269,7 @@ Every resulting code is registered in `ErrorCatalog` and present in all locale b
|
|
|
256
269
|
|
|
257
270
|
Set `statement_timeout` (session or per-datasource via `quarkus.datasource.jdbc.additional-jdbc-properties.options=-c statement_timeout=5000`) so runaway queries fail fast instead of holding pool connections.
|
|
258
271
|
|
|
259
|
-
##
|
|
272
|
+
## 10. Schema migrations (Flyway — opt-in, disabled by default)
|
|
260
273
|
|
|
261
274
|
The schema is versioned in git as SQL under `db/` in every environment. **Who applies it is a flag**, because a service is rarely allowed to alter its own schema in production — that is a DBA or pipeline responsibility, and a native binary starting up in a replica set is the worst possible place to run DDL.
|
|
262
275
|
|
|
@@ -271,6 +284,9 @@ quarkus.flyway.migrate-at-start=false
|
|
|
271
284
|
%test.quarkus.flyway.enabled=true
|
|
272
285
|
%test.quarkus.flyway.migrate-at-start=true
|
|
273
286
|
|
|
287
|
+
# Classpath location by default. The monorepo keeps migrations in db/ at the REPO root, which is
|
|
288
|
+
# not on the classpath — either copy them into src/main/resources/db/migration at build time, or
|
|
289
|
+
# point Flyway at the filesystem: quarkus.flyway.locations=filesystem:../../db/migration
|
|
274
290
|
quarkus.flyway.locations=db/migration
|
|
275
291
|
quarkus.flyway.baseline-on-migrate=true # adopting an existing database
|
|
276
292
|
```
|
|
@@ -289,11 +305,11 @@ Rules:
|
|
|
289
305
|
- The extension stays in `pom.xml` even when disabled — one binary for every environment, and a runtime flag can't resurrect a dependency that isn't there. Drop `quarkus-flyway` entirely only if no environment migrates at boot; then `db/` is applied exclusively by the pipeline.
|
|
290
306
|
- Cross-cutting tables (`outbox_event`, `processed_event`, `audit_event` — see kafka/observability skills) are ordinary migrations in the same `db/migration` folder, not runtime-created.
|
|
291
307
|
|
|
292
|
-
##
|
|
308
|
+
## 11. Testing
|
|
293
309
|
|
|
294
310
|
- `<Slice>Sql` tests: `@QuarkusTest` + Dev Services (Testcontainers Postgres starts automatically — no config). Real SQL against real Postgres; never H2 (dialect lies). Obtain a `Connection` in the test and pass it in, exactly as the `Handler` does.
|
|
295
311
|
- `<Slice>Handler` tests are pure Mockito with a mocked `Sql` — no database (see quarkus-hexagonal-core skill).
|
|
296
|
-
- Flyway migrations run at test start — `%test` is one of the two profiles where the flag is on (`quarkus.flyway.enabled=true` + `migrate-at-start=true`, see §
|
|
312
|
+
- Flyway migrations run at test start — `%test` is one of the two profiles where the flag is on (`quarkus.flyway.enabled=true` + `migrate-at-start=true`, see §10), so tests validate DDL and queries together against the same SQL production will receive.
|
|
297
313
|
- Native verification: `@QuarkusIntegrationTest` re-runs the same tests against the binary.
|
|
298
314
|
|
|
299
315
|
## Checklist for a new `Sql` method
|
|
@@ -306,3 +322,4 @@ Rules:
|
|
|
306
322
|
5. `executeUpdate()` count returned or checked; `SQLException` propagated for the `Handler` to translate, never swallowed.
|
|
307
323
|
6. Generated ids via `RETURNING`; jsonb via `PGobject`; batch + chunking for bulk; keyset pagination if deep.
|
|
308
324
|
7. Rows mapped by hand into the slice's `*Dto`; Dev Services test written.
|
|
325
|
+
8. The table belongs to this slice. If it is cross-cutting (`outbox_event`, `audit_event`, `processed_event`), the method belongs in `common/` (§6) — not here.
|