bmad-method-quarkus 1.0.2 → 1.0.4
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/removals.txt +10 -0
- package/src/bmm-skills/agents/bmad-quarkus-build/SKILL.md +19 -16
- package/src/bmm-skills/agents/bmad-quarkus-build/customize.toml +7 -5
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/bqa-setup/assets/module-help.csv +0 -1
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/bqa-setup/assets/module.yaml +2 -9
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-error-handling-i18n/SKILL.md +61 -27
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-grpc-services/SKILL.md +60 -41
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-hexagonal-core/SKILL.md +504 -215
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-kafka-messaging/SKILL.md +199 -34
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-observability-otel/SKILL.md +47 -31
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-openapi-tmforum/SKILL.md +42 -25
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-sql-jdbc-agroal/SKILL.md +150 -87
- package/src/bmm-skills/module.yaml +0 -7
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-architect/SKILL.md +0 -108
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-architect/customize.toml +0 -62
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-architect/references/architecture-review.md +0 -17
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-architect/references/prompt-quality-canon.md +0 -79
- package/src/bmm-skills/agents/bmad-quarkus-dev/.memlog.md +0 -12
- package/src/bmm-skills/agents/bmad-quarkus-dev/.memlog.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/SKILL.md +0 -86
- package/src/bmm-skills/agents/bmad-quarkus-dev/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/customize.toml +0 -37
- package/src/bmm-skills/agents/bmad-quarkus-dev/customize.toml:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/references/enrich-stories.md +0 -19
- package/src/bmm-skills/agents/bmad-quarkus-dev/references/enrich-stories.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/references/prompt-quality-canon.md +0 -79
- package/src/bmm-skills/agents/bmad-quarkus-dev/references/prompt-quality-canon.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/SKILL.md +0 -80
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/assets/module-help.csv +0 -9
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/assets/module-help.csv:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/assets/module.yaml +0 -16
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/assets/module.yaml:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/scripts/cleanup-legacy.py +0 -287
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/scripts/cleanup-legacy.py:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/scripts/merge-config.py +0 -441
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/scripts/merge-config.py:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/scripts/merge-help-csv.py +0 -246
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/bqa-setup/scripts/merge-help-csv.py:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-error-handling-i18n/SKILL.md +0 -181
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-error-handling-i18n/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-grpc-services/SKILL.md +0 -141
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-grpc-services/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-hexagonal-core/SKILL.md +0 -392
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-hexagonal-core/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-kafka-messaging/SKILL.md +0 -148
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-kafka-messaging/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-observability-otel/SKILL.md +0 -180
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-observability-otel/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-openapi-tmforum/SKILL.md +0 -128
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-openapi-tmforum/SKILL.md:Zone.Identifier +0 -0
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-sql-jdbc-agroal/SKILL.md +0 -245
- package/src/bmm-skills/agents/bmad-quarkus-dev/skills/quarkus-sql-jdbc-agroal/SKILL.md:Zone.Identifier +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: quarkus-kafka-messaging
|
|
3
|
-
description: Standard for asynchronous event-driven messaging with Apache Kafka in Quarkus native services — CloudEvents envelope, topic naming, transactional outbox, idempotent consumers, DLQ, and W3C trace context propagation. Use this skill whenever the user mentions Kafka, events, messaging, publish/subscribe, producers, consumers, outbox, domain events between services, SmallRye Reactive Messaging, or asynchronous integration between bounded contexts — even if they just say "emit an event" or "listen to a topic". Includes
|
|
3
|
+
description: Standard for asynchronous event-driven messaging with Apache Kafka in Quarkus native services — CloudEvents envelope, topic naming, transactional outbox, idempotent consumers, DLQ, and W3C trace context propagation. Use this skill whenever the user mentions Kafka, events, messaging, publish/subscribe, producers, consumers, outbox, domain events between services, SmallRye Reactive Messaging, or asynchronous integration between bounded contexts — even if they just say "emit an event" or "listen to a topic". Includes payload, consumer, relay-job and channel naming conventions for the vertical-slice layout (the Consumer lives in the slice folder and delegates to the slice Handler; the outbox row is written by the slice's own Sql inside the Handler transaction) and the property-driven relay mode (`app.messaging.outbox.relay-mode` = debezium | poller | direct) that decides whether Debezium, a scheduled poller or a direct Emitter publishes the row.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Kafka Messaging Standard (Quarkus + SmallRye Reactive Messaging)
|
|
@@ -12,9 +12,9 @@ Extension: `quarkus-messaging-kafka` (SmallRye Reactive Messaging). Native-compa
|
|
|
12
12
|
## Topic naming
|
|
13
13
|
|
|
14
14
|
```
|
|
15
|
-
<org>.<module>.<entity>.<event-type>.v<major> # <module> =
|
|
16
|
-
alva.
|
|
17
|
-
alva.
|
|
15
|
+
<org>.<module>.<entity>.<event-type>.v<major> # <module> = semantic module name (see quarkus-hexagonal-core skill)
|
|
16
|
+
alva.iam.user.registered.v1
|
|
17
|
+
alva.tenant.subscription.status-changed.v2
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
- Kebab-case segments, past-tense event types.
|
|
@@ -27,10 +27,10 @@ Producers emit CloudEvents in **binary mode** (attributes as Kafka headers `ce_*
|
|
|
27
27
|
|
|
28
28
|
```properties
|
|
29
29
|
mp.messaging.outgoing.user-registered.connector=smallrye-kafka
|
|
30
|
-
mp.messaging.outgoing.user-registered.topic=alva.
|
|
30
|
+
mp.messaging.outgoing.user-registered.topic=alva.iam.user.registered.v1
|
|
31
31
|
mp.messaging.outgoing.user-registered.cloud-events=true
|
|
32
|
-
mp.messaging.outgoing.user-registered.cloud-events-source=//alva/
|
|
33
|
-
mp.messaging.outgoing.user-registered.cloud-events-type=com.alva.
|
|
32
|
+
mp.messaging.outgoing.user-registered.cloud-events-source=//alva/iam
|
|
33
|
+
mp.messaging.outgoing.user-registered.cloud-events-type=com.alva.iam.user.registered.v1
|
|
34
34
|
mp.messaging.outgoing.user-registered.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -42,23 +42,24 @@ Payload schema:
|
|
|
42
42
|
|
|
43
43
|
## Producing: transactional outbox (mandatory for domain events)
|
|
44
44
|
|
|
45
|
-
Never publish
|
|
45
|
+
Never publish to Kafka in the same breath as a DB write — dual-write problem. Standard:
|
|
46
46
|
|
|
47
|
-
1.
|
|
48
|
-
2. A relay publishes to Kafka
|
|
47
|
+
1. The slice `Handler` (in the SAME JDBC transaction as the state change) inserts into the outbox table.
|
|
48
|
+
2. A relay publishes to Kafka. **Which relay is a runtime property, not a code change** — Debezium outbox connector, in-process scheduled poller, or direct post-commit `Emitter`. See *Relay mode* below.
|
|
49
49
|
|
|
50
|
-
Standard outbox table (one per service):
|
|
50
|
+
Standard outbox table (one per service), created by a versioned migration in `db/migration` — never at runtime, and independently of whether the Flyway flag lets the app apply it (see quarkus-sql-jdbc-agroal skill §9):
|
|
51
51
|
|
|
52
52
|
```sql
|
|
53
53
|
CREATE TABLE outbox_event (
|
|
54
54
|
id UUID PRIMARY KEY,
|
|
55
55
|
aggregate_type VARCHAR(64) NOT NULL, -- "user"
|
|
56
56
|
aggregate_id VARCHAR(64) NOT NULL, -- partition key
|
|
57
|
-
event_type VARCHAR(128) NOT NULL, -- "com.alva.
|
|
57
|
+
event_type VARCHAR(128) NOT NULL, -- "com.alva.iam.user.registered.v1" (CloudEvents type)
|
|
58
|
+
topic VARCHAR(255) NOT NULL, -- "alva.iam.user.registered.v1" — resolved once, at insert
|
|
58
59
|
payload JSONB NOT NULL,
|
|
59
60
|
traceparent VARCHAR(64) NOT NULL, -- captured at insert time (W3C Trace Context)
|
|
60
61
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
61
|
-
published_at TIMESTAMPTZ NULL -- poller
|
|
62
|
+
published_at TIMESTAMPTZ NULL -- poller/direct modes only (see Relay mode)
|
|
62
63
|
);
|
|
63
64
|
```
|
|
64
65
|
|
|
@@ -78,31 +79,189 @@ CREATE TABLE outbox_event (
|
|
|
78
79
|
|
|
79
80
|
Both `traceparent` and `traceContext` are captured at insert time from `Span.current().getSpanContext()` — manual construction of trace strings is prohibited (see quarkus-observability-otel skill).
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
Under the vertical-slice standard there is no `EventPublisher` port and no publisher adapter: the slice's `Handler` writes the outbox row through **its own `Sql` class**, inside `execution()`, on the same `Connection` and therefore in the same transaction as the state change. (`OutboxDispatcher` below is not that port — it publishes nothing in the default mode and never appears in the event's write path.)
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
```java
|
|
85
|
+
private UUID execution(CreatePartyIndividualRequestDto request) {
|
|
86
|
+
try (Connection conn = dataSource.getConnection()) {
|
|
87
|
+
UUID partyId = sql.insertParty(conn, ...);
|
|
88
|
+
UUID eventId = sql.insertOutboxEvent(conn, partyId, "party",
|
|
89
|
+
"com.alva.customer.party.created.v1", "alva.customer.party.created.v1",
|
|
90
|
+
payloadJson(partyId, request), traceparent());
|
|
91
|
+
dispatcher.dispatchAfterCommit(eventId); // no-op unless relay-mode=direct (see Relay mode)
|
|
92
|
+
return partyId;
|
|
93
|
+
} catch (SQLException e) {
|
|
94
|
+
throw SqlStateTranslator.translate("PTY-500-001", e);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`execution()` still returns the slice's internal result (`partyId`) for `getResult()`, `@Transactional` still sits on `process()` alone, and the method order of the hexagonal-core skill is unchanged. `OutboxDispatcher` is injected in the Handler's `common/` bean slot, next to `dataSource` and `sql`.
|
|
100
|
+
|
|
101
|
+
That is the whole dual-write guarantee — one connection, one transaction, no port indirection. Nothing in the slice knows how the row reaches Kafka: `common/messaging` owns the single publish path (`OutboxDispatcher` + `OutboxRelayJob`) and it is the only code that talks to Kafka on the produce side.
|
|
102
|
+
|
|
103
|
+
Fire-and-forget technical messages (metrics, notifications with no consistency requirement) MAY use a direct `Emitter` — but it lives in a **`common/` capability bean**, never in a slice. `@Channel`/`Emitter` are `org.eclipse.microprofile.reactive.messaging` types, and the `handlerKnowsNoTransport` ArchUnit rule forbids them in a `Handler`:
|
|
84
104
|
|
|
85
105
|
```java
|
|
106
|
+
// common/audit/AuditRecorder.java — the Handler injects AuditRecorder, not the Emitter
|
|
86
107
|
@Inject @Channel("audit-out") Emitter<AuditEventPayload> audit;
|
|
87
108
|
```
|
|
88
109
|
|
|
110
|
+
### Relay mode (property-driven, mandatory)
|
|
111
|
+
|
|
112
|
+
The outbox row is **always** written in the transaction — that never depends on a flag. What a flag selects is *who publishes it*, so the same `Handler` code runs unchanged in every environment:
|
|
113
|
+
|
|
114
|
+
```properties
|
|
115
|
+
# debezium | poller | direct
|
|
116
|
+
app.messaging.outbox.relay-mode=debezium
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Mode | Publisher | Use in |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `debezium` | Debezium Outbox Event Router (Kafka Connect), out of process | prod / staging — the default |
|
|
122
|
+
| `poller` | `OutboxRelayJob` `@Scheduled` inside the service | environments with Kafka but no Kafka Connect |
|
|
123
|
+
| `direct` | `OutboxDispatcher` emits **after commit**, in process | local dev, integration tests, single-node deployments |
|
|
124
|
+
|
|
125
|
+
`direct` is NOT a licence to dual-write: the row is committed first, the emit is deferred to a post-commit synchronization, and a lost emit is recovered by re-running in `poller` mode (`published_at IS NULL` is still the source of truth). An emit that never reaches Kafka must never fail `process()` — the caller already has a committed result, so the dispatcher logs the failure and leaves `published_at` null for the next relay to pick up; it does not rethrow and it does not build an error response (see quarkus-error-handling-i18n skill). All three modes wrap the emit in the `outbox.publish` span (see quarkus-observability-otel skill).
|
|
126
|
+
|
|
127
|
+
```java
|
|
128
|
+
@ConfigMapping(prefix = "app.messaging.outbox")
|
|
129
|
+
public interface OutboxConfig {
|
|
130
|
+
@WithDefault("debezium") RelayMode relayMode();
|
|
131
|
+
enum RelayMode { DEBEZIUM, POLLER, DIRECT }
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Single dispatch point.** `common/messaging` owns one `OutboxDispatcher`; it is the only code on the produce side that touches the `Emitter`, and it is a no-op unless the mode says otherwise:
|
|
136
|
+
|
|
137
|
+
```java
|
|
138
|
+
@ApplicationScoped
|
|
139
|
+
public class OutboxDispatcher {
|
|
140
|
+
|
|
141
|
+
@Inject OutboxConfig config;
|
|
142
|
+
@Inject OutboxSql sql; // common/messaging own Sql, same Connection-first contract
|
|
143
|
+
@Inject DataSource dataSource; // its own connection — the Handler's is closed by now
|
|
144
|
+
@Inject TransactionSynchronizationRegistry txRegistry;
|
|
145
|
+
@Inject @Channel("outbox-out") Emitter<JsonNode> emitter;
|
|
146
|
+
|
|
147
|
+
/** Called from execution(); the emit itself is deferred until the JTA transaction commits.
|
|
148
|
+
No-op unless relay-mode=direct. */
|
|
149
|
+
public void dispatchAfterCommit(UUID eventId) {
|
|
150
|
+
if (config.relayMode() != RelayMode.DIRECT) return; // Debezium or the job will pick it up
|
|
151
|
+
txRegistry.registerInterposedSynchronization(new Synchronization() {
|
|
152
|
+
@Override public void beforeCompletion() { }
|
|
153
|
+
@Override public void afterCompletion(int status) {
|
|
154
|
+
if (status == Status.STATUS_COMMITTED) { // rolled back → row never existed, nothing to emit
|
|
155
|
+
sql.findUnpublished(eventId).ifPresent(OutboxDispatcher.this::publish);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
void publish(OutboxRow row) {
|
|
162
|
+
emitter.send(Message.of(row.payload())
|
|
163
|
+
.addMetadata(OutgoingKafkaRecordMetadata.builder()
|
|
164
|
+
.withTopic(row.topic()) // one channel, topic per event type
|
|
165
|
+
.withKey(row.aggregateId())
|
|
166
|
+
.withHeaders(new RecordHeaders()
|
|
167
|
+
.add("traceparent", row.traceparent().getBytes(UTF_8)))
|
|
168
|
+
.build())
|
|
169
|
+
.addMetadata(OutgoingCloudEventMetadata.builder()
|
|
170
|
+
.withId(row.id().toString())
|
|
171
|
+
.withType(row.eventType())
|
|
172
|
+
.withSource(URI.create(SOURCE))
|
|
173
|
+
.withSubject(row.aggregateId())
|
|
174
|
+
.build())
|
|
175
|
+
.withAck(() -> markPublished(row.id()))); // published_at = now()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The Handler never touches an `Emitter`, a topic or a Kafka header — it hands over an id and the dispatcher decides whether anything happens at all. `dispatchAfterCommit()` is safe to call from inside `execution()` precisely because it only *registers* a synchronization: nothing reaches Kafka until the container commits `process()`. Never call `publish()` from a slice.
|
|
181
|
+
|
|
182
|
+
**Scheduler flag.** `OutboxRelayJob` is disabled by config expression, not by commenting code — Quarkus treats `off`/`disabled` as "do not schedule this method":
|
|
183
|
+
|
|
184
|
+
```java
|
|
185
|
+
@ApplicationScoped
|
|
186
|
+
public class OutboxRelayJob {
|
|
187
|
+
|
|
188
|
+
@Inject OutboxDispatcher dispatcher;
|
|
189
|
+
@Inject OutboxSql sql;
|
|
190
|
+
|
|
191
|
+
@Scheduled(every = "${app.messaging.outbox.poller.every:off}",
|
|
192
|
+
concurrentExecution = ConcurrentExecution.SKIP)
|
|
193
|
+
void relay() {
|
|
194
|
+
sql.lockUnpublishedBatch(batchSize) // SELECT ... FOR UPDATE SKIP LOCKED, ORDER BY created_at
|
|
195
|
+
.forEach(dispatcher::publish); // ack callback sets published_at
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```properties
|
|
201
|
+
# prod: Debezium owns the relay, the job never schedules
|
|
202
|
+
app.messaging.outbox.relay-mode=debezium
|
|
203
|
+
app.messaging.outbox.poller.every=off
|
|
204
|
+
|
|
205
|
+
# %dev / %test: no Kafka Connect available
|
|
206
|
+
%dev.app.messaging.outbox.relay-mode=direct
|
|
207
|
+
%dev.app.messaging.outbox.poller.every=off
|
|
208
|
+
|
|
209
|
+
# fallback environment: in-process poller
|
|
210
|
+
%onprem.app.messaging.outbox.relay-mode=poller
|
|
211
|
+
%onprem.app.messaging.outbox.poller.every=5s
|
|
212
|
+
app.messaging.outbox.poller.batch-size=200
|
|
213
|
+
|
|
214
|
+
# one outgoing channel for every event type; the topic comes from the outbox row.
|
|
215
|
+
# Left configured and simply never used when relay-mode=debezium.
|
|
216
|
+
mp.messaging.outgoing.outbox-out.connector=smallrye-kafka
|
|
217
|
+
mp.messaging.outgoing.outbox-out.topic=alva.unrouted # placeholder, always overridden per message
|
|
218
|
+
mp.messaging.outgoing.outbox-out.cloud-events=true
|
|
219
|
+
mp.messaging.outgoing.outbox-out.cloud-events-source=//alva/customer
|
|
220
|
+
mp.messaging.outgoing.outbox-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
|
|
221
|
+
mp.messaging.outgoing.outbox-out.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Per-event outgoing channels (`user-registered-out`, one channel per topic, as in the CloudEvents section above) remain valid for fire-and-forget technical messages. Domain events go through the single `outbox-out` channel — one channel, topic per message — so adding an event never means adding messaging config.
|
|
225
|
+
|
|
226
|
+
Rules:
|
|
227
|
+
- The three modes are **mutually exclusive by configuration** — `relay-mode=debezium` with a scheduled poller means two publishers and duplicate events. Validate at startup (`@Startup` observer: fail fast if `relayMode != POLLER` and `poller.every` is not `off`).
|
|
228
|
+
- `ORDER BY created_at` + `SKIP LOCKED` in `poller`; multiple replicas are safe, per-entity ordering is preserved because the partition key is the aggregate id.
|
|
229
|
+
- `published_at` is only maintained in `poller`/`direct`; in `debezium` mode the router deletes (or ignores) the row and the column stays null — do not build alerts on it without checking the mode.
|
|
230
|
+
- `common/messaging` is **not** `common/client`: Mutiny stays quarantined there (hexagonal-core `reactiveIsQuarantined`). Use the MicroProfile `Emitter`, never `MutinyEmitter`.
|
|
231
|
+
- `OutboxSql` is the one `Sql` class whose callers are not a `Handler` — the dispatcher and the job own the `Connection` in its place. That exception is written into the `onlyHandlersTouchSql` ArchUnit rule, not left for the build to discover.
|
|
232
|
+
- Consumers are unaffected: the same CloudEvent lands on the same topic in all three modes. That is the contract the flag must not break — a contract test asserts headers/topic are identical between `direct` and Debezium output.
|
|
233
|
+
|
|
234
|
+
**Debezium mode** maps outbox columns to the envelope in the connector, not in Java:
|
|
235
|
+
|
|
236
|
+
```json
|
|
237
|
+
"transforms": "outbox",
|
|
238
|
+
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
|
|
239
|
+
"transforms.outbox.table.field.event.id": "id",
|
|
240
|
+
"transforms.outbox.table.field.event.key": "aggregate_id",
|
|
241
|
+
"transforms.outbox.table.field.event.type": "event_type",
|
|
242
|
+
"transforms.outbox.table.field.event.payload": "payload",
|
|
243
|
+
"transforms.outbox.table.fields.additional.placement": "traceparent:header",
|
|
244
|
+
"transforms.outbox.route.by.field": "topic",
|
|
245
|
+
"transforms.outbox.route.topic.replacement": "${routedByValue}"
|
|
246
|
+
```
|
|
247
|
+
|
|
89
248
|
### Naming
|
|
90
249
|
|
|
91
250
|
Canonical rules in the quarkus-hexagonal-core skill; messaging specifics:
|
|
92
251
|
|
|
93
252
|
| Artifact | Convention | Example |
|
|
94
253
|
|---|---|---|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
254
|
+
| Wire payload DTO | `<Event>Payload`, in the producing/consuming slice's `dto/` | `UserRegisteredPayload` |
|
|
255
|
+
| Consumer | `<Event>Consumer`, in the slice folder | `UserRegisteredConsumer` |
|
|
256
|
+
| Outbox write | a method on the slice's own `Sql` | `insertOutboxEvent(conn, ...)` |
|
|
257
|
+
| Outbox relay job | `<Task>Job`, in `common/messaging` | `OutboxRelayJob` |
|
|
258
|
+
| Outbox dispatcher | one per service, in `common/messaging` | `OutboxDispatcher` |
|
|
259
|
+
| Relay-mode config | `@ConfigMapping` in `common/messaging` | `OutboxConfig.RelayMode` |
|
|
260
|
+
| Generic outbox channel | single outgoing channel, dynamic topic | `outbox-out` |
|
|
102
261
|
| SmallRye channel | `<entity>-<event>-in` / `-out`, kebab-case | `user-registered-in` |
|
|
103
|
-
| CloudEvents `type` | `<javaPackageStyle>.<entity>.<event>.v<major>` | `com.alva.
|
|
262
|
+
| CloudEvents `type` | `<javaPackageStyle>.<entity>.<event>.v<major>` | `com.alva.iam.user.registered.v1` |
|
|
104
263
|
|
|
105
|
-
|
|
264
|
+
`<Event>Payload` is the **only** event class: there is no separate in-memory domain event, because the slice has no domain layer to protect it from. The payload is a published contract versioned by topic — treat a field rename as a breaking change and bump the topic version, never "because the model moved". It carries `@RegisterForReflection` for native. There is no `*EventMapper`: the payload is built in the `Handler`, the same place `getResult()` builds the response.
|
|
106
265
|
|
|
107
266
|
## Consuming
|
|
108
267
|
|
|
@@ -110,25 +269,29 @@ Keep the domain event (`UserRegistered`) and the wire payload (`UserRegisteredPa
|
|
|
110
269
|
@ApplicationScoped
|
|
111
270
|
public class UserRegisteredConsumer {
|
|
112
271
|
|
|
272
|
+
@Inject RegisterUserFromEventHandler handler; // the slice's business logic
|
|
273
|
+
|
|
113
274
|
@Incoming("user-registered-in")
|
|
114
275
|
@Blocking // JDBC inside → run on worker thread (or virtual thread)
|
|
115
|
-
public void on(UserRegisteredPayload payload) {
|
|
116
|
-
//
|
|
276
|
+
public void on(UserRegisteredPayload payload) {
|
|
277
|
+
handler.process(toRequest(payload)); // idempotency + side effects, one transaction
|
|
117
278
|
}
|
|
118
279
|
}
|
|
119
280
|
```
|
|
120
281
|
|
|
282
|
+
The consumer is as thin as a `Resource`: deserialize, convert to the slice's request DTO, call `process()`. **The idempotency check belongs inside `Handler.execution()`**, not here — it must share the transaction with the side effects, and only the Handler owns the `Connection`. For explicit ack/nack, take `Message<UserRegisteredPayload>` and return `message.ack()` / `message.nack(e)`; do not catch `BusinessException` to build your own error response.
|
|
283
|
+
|
|
121
284
|
```properties
|
|
122
285
|
mp.messaging.incoming.user-registered-in.connector=smallrye-kafka
|
|
123
|
-
mp.messaging.incoming.user-registered-in.topic=alva.
|
|
286
|
+
mp.messaging.incoming.user-registered-in.topic=alva.iam.user.registered.v1
|
|
124
287
|
mp.messaging.incoming.user-registered-in.group.id=${quarkus.application.name}
|
|
125
288
|
mp.messaging.incoming.user-registered-in.auto.offset.reset=earliest
|
|
126
289
|
mp.messaging.incoming.user-registered-in.failure-strategy=delayed-retry-then-dead-letter-queue
|
|
127
|
-
mp.messaging.incoming.user-registered-in.dead-letter-queue.topic=alva.
|
|
290
|
+
mp.messaging.incoming.user-registered-in.dead-letter-queue.topic=alva.iam.user.registered.v1.dlq
|
|
128
291
|
```
|
|
129
292
|
|
|
130
293
|
Rules:
|
|
131
|
-
- **Idempotency is mandatory**: `processed_event(consumer_group, event_id)`
|
|
294
|
+
- **Idempotency is mandatory**: `processed_event(consumer_group, event_id)` checked/inserted through the slice's `Sql` on the same `Connection`, in the same transaction as the side effects. Kafka is at-least-once; duplicates WILL happen.
|
|
132
295
|
- Consumer group = service name; stable across deployments.
|
|
133
296
|
- Failure strategy: bounded retries with delay, then DLQ topic `<topic>.dlq`. DLQ messages keep original headers plus `dead-letter-reason`. A DLQ must have an owner and an alert — never a silent graveyard.
|
|
134
297
|
- Poison-pill safety: deserialization failures also route to DLQ (`...deserialization-failure-handler` or failure strategy), never block the partition.
|
|
@@ -141,8 +304,10 @@ Rules:
|
|
|
141
304
|
|
|
142
305
|
## Checklist for a new event
|
|
143
306
|
|
|
144
|
-
1. Define `<Event>Payload`
|
|
145
|
-
2. Producer:
|
|
146
|
-
3.
|
|
147
|
-
4.
|
|
148
|
-
5.
|
|
307
|
+
1. Define `<Event>Payload` in the slice's `dto/` + its JSON Schema in `contracts/`; name the topic and channel per convention.
|
|
308
|
+
2. Producer: `insertOutboxEvent` method on the slice's `Sql`, called from `Handler.execution()` on the shared `Connection`, storing the resolved `topic`; `process()` passes the returned id to `OutboxDispatcher.dispatchAfterCommit()`.
|
|
309
|
+
3. Relay: set `app.messaging.outbox.relay-mode` per environment and keep `poller.every=off` in every mode but `poller`; register the Debezium routing config where the mode is `debezium`.
|
|
310
|
+
4. `@RegisterForReflection` on the payload; `traceparent` + `traceContext` captured from `Span.current()` at insert time.
|
|
311
|
+
5. Consumer: `<Event>Consumer` in the slice folder delegating to the `Handler`, idempotency inside the transaction, DLQ topic + owner + alert, `@Blocking`.
|
|
312
|
+
6. Contract test: serialize/deserialize round-trip against the JSON Schema, plus identical topic/headers between `direct` and Debezium output.
|
|
313
|
+
7. New error codes registered in `ErrorCatalog` and present in all locale bundles.
|
|
@@ -1,18 +1,18 @@
|
|
|
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 with Micrometer, custom spans, and trace propagation across REST, gRPC, and Kafka. Use this skill whenever the user mentions OpenTelemetry, OTel, tracing, trace_id, span_id, traceparent, distributed tracing, logging standard, log correlation, metrics, Grafana/Tempo/Jaeger/Prometheus, or debugging requests across services. Includes span, metric, logger and audit-
|
|
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 with Micrometer, custom spans, and trace propagation across REST, gRPC, and Kafka. Use this skill whenever the user mentions OpenTelemetry, OTel, tracing, trace_id, span_id, traceparent, distributed tracing, logging standard, log correlation, metrics, Grafana/Tempo/Jaeger/Prometheus, 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
|
-
Every request must be traceable end-to-end: REST →
|
|
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
10
|
Extensions: `quarkus-opentelemetry` (+ `quarkus-micrometer-registry-prometheus` or Micrometer→OTLP for metrics, `quarkus-logging-json` for structured logs). All native-compatible.
|
|
11
11
|
|
|
12
12
|
## Baseline configuration
|
|
13
13
|
|
|
14
14
|
```properties
|
|
15
|
-
quarkus.application.name=
|
|
15
|
+
quarkus.application.name=iam-users # becomes service.name
|
|
16
16
|
quarkus.otel.exporter.otlp.endpoint=http://otel-collector:4317
|
|
17
17
|
quarkus.otel.resource.attributes=deployment.environment=${ENV:dev},service.namespace=alva
|
|
18
18
|
|
|
@@ -85,35 +85,43 @@ Stored value (snake_case keys, exactly these):
|
|
|
85
85
|
{ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7" }
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
Reference implementation.
|
|
88
|
+
Reference implementation. Auditing is cross-cutting, so it is a single capability-named bean in `common/audit` — no port, no adapter pair. It captures the active span itself and takes the caller's `Connection` so the audit row joins the caller's transaction:
|
|
89
89
|
|
|
90
90
|
```java
|
|
91
|
-
//
|
|
92
|
-
public interface AuditRecorder {
|
|
93
|
-
void record(String action, String entity, String entityId);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// infrastructure/persistence — Jdbc<Port>
|
|
91
|
+
// common/audit — capability noun, no technology in the name, no interface
|
|
97
92
|
@ApplicationScoped
|
|
98
|
-
public class
|
|
93
|
+
public class AuditRecorder {
|
|
99
94
|
|
|
100
|
-
private final
|
|
95
|
+
private static final String INSERT_AUDIT = """
|
|
96
|
+
INSERT INTO audit_event (action, entity, entity_id, context)
|
|
97
|
+
VALUES (?, ?, ?, ?::jsonb)
|
|
98
|
+
""";
|
|
101
99
|
|
|
102
|
-
public
|
|
103
|
-
|
|
104
|
-
@Override
|
|
105
|
-
public void record(String action, String entity, String entityId) {
|
|
100
|
+
public void record(Connection conn, String action, String entity, String entityId) throws SQLException {
|
|
106
101
|
String traceId = Span.current().getSpanContext().getTraceId();
|
|
107
102
|
String spanId = Span.current().getSpanContext().getSpanId();
|
|
108
|
-
|
|
103
|
+
String context = new JsonObject()
|
|
109
104
|
.put("trace_id", traceId)
|
|
110
|
-
.put("span_id", spanId)
|
|
111
|
-
|
|
105
|
+
.put("span_id", spanId)
|
|
106
|
+
.encode();
|
|
107
|
+
try (PreparedStatement ps = conn.prepareStatement(INSERT_AUDIT)) {
|
|
108
|
+
ps.setString(1, action);
|
|
109
|
+
ps.setString(2, entity);
|
|
110
|
+
ps.setString(3, entityId);
|
|
111
|
+
ps.setString(4, context);
|
|
112
|
+
ps.executeUpdate();
|
|
113
|
+
}
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
```
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
The slice `Handler` calls it from `execution()` on the same `Connection` it already holds, so an audit row can never survive a rolled-back operation:
|
|
119
|
+
|
|
120
|
+
```java
|
|
121
|
+
audit.record(conn, "CREATE", "party", partyId.toString());
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Identifiers are English (`record`, `action`, `entity`), even when the business vocabulary and the UI are Spanish — Spanish belongs in the message bundles, not in class and method names. `*Service` is not used here: under the slice standard that suffix is reserved for `*GrpcService`.
|
|
117
125
|
|
|
118
126
|
Any code path that writes `audit_event` without populating `context` is non-compliant — flag it in review.
|
|
119
127
|
|
|
@@ -132,15 +140,23 @@ Both values come from `Span.current()` at insert time — never from manual stri
|
|
|
132
140
|
|
|
133
141
|
## Custom spans and attributes
|
|
134
142
|
|
|
135
|
-
Annotate
|
|
143
|
+
Annotate the entry point of each slice — `Handler.process()` — not every method:
|
|
136
144
|
|
|
137
145
|
```java
|
|
138
|
-
//
|
|
146
|
+
// the slice's Handler — one span per business operation
|
|
147
|
+
@Transactional
|
|
139
148
|
@WithSpan("usecase.registerUser")
|
|
140
|
-
public
|
|
149
|
+
public RegisterUserResponseDto process(RegisterUserRequestDto request) {
|
|
150
|
+
Span.current().setAttribute("user.email.domain", domainOf(request.getEmail()));
|
|
151
|
+
validate(request);
|
|
152
|
+
return getResult(execution(request));
|
|
153
|
+
}
|
|
141
154
|
```
|
|
142
155
|
|
|
143
|
-
- Span names: `usecase.<
|
|
156
|
+
- Span names: `usecase.<sliceCamelCase>` (lowerCamelCase). Because a slice is named verb+entity (`register_user`), this is identical to the older `usecase.<verb><Entity>` convention — existing dashboards keep working.
|
|
157
|
+
- **`process()` is the span boundary.** One span per business operation; do not annotate `validate()`/`execution()`/`getResult()` (they are private, so the interceptor would not fire anyway). JDBC spans come free from `quarkus.datasource.jdbc.telemetry=true`.
|
|
158
|
+
- Other span names: `outbox.publish`, `job.<name>`.
|
|
159
|
+
- Attributes are set inside `process()` via `Span.current().setAttribute(...)` — never by adding `@SpanAttribute` parameters, which would change the `process(RequestDto)` single-argument signature that every transport adapter and the Handler contract depend on (see quarkus-hexagonal-core skill).
|
|
144
160
|
- Attributes: business-relevant, LOW-cardinality, NEVER PII (no emails, MSISDNs, tokens — use derived/hashed values).
|
|
145
161
|
- Manual spans (`Tracer.spanBuilder`) only for background work not covered by annotations; always `try/finally` end the span and restore scope.
|
|
146
162
|
|
|
@@ -150,12 +166,12 @@ Canonical class-naming rules live in the quarkus-hexagonal-core skill; these are
|
|
|
150
166
|
|
|
151
167
|
| Artifact | Convention | Example |
|
|
152
168
|
|---|---|---|
|
|
153
|
-
| Audit
|
|
169
|
+
| Audit bean | `AuditRecorder` — one class in `common/audit`, no port | `AuditRecorder` |
|
|
154
170
|
| Trace-id response filter | `<Purpose>Filter` | `TraceIdResponseFilter` |
|
|
155
|
-
| Span name | `usecase.<
|
|
171
|
+
| Span name | `usecase.<sliceCamelCase>` on `Handler.process()`, `outbox.publish`, `job.<name>` | `usecase.registerUser` |
|
|
156
172
|
| Span attribute | dotted lowercase, OTel semconv where one exists | `user.email.domain` |
|
|
157
|
-
| Metric | `<module>_<entity>_<action>_total` / `_seconds`, snake_case | `
|
|
158
|
-
| Logger | one per class via `Log`/`Logger.getLogger(Xxx.class)` — never a shared `LogUtil
|
|
173
|
+
| Metric | `<module>_<entity>_<action>_total` / `_seconds`, snake_case | `iam_user_registrations_total` |
|
|
174
|
+
| Logger | one per class via `Log`/`Logger.getLogger(Xxx.class)` — never a shared `LogUtil`; the Handler's class name already carries the slice | |
|
|
159
175
|
| MDC / jsonb trace keys | `snake_case`, exactly `trace_id` / `span_id` | |
|
|
160
176
|
|
|
161
177
|
Span, metric and log-field names are a contract with the dashboards — renaming one silently breaks alerts. Treat a rename like an API change.
|
|
@@ -163,7 +179,7 @@ Span, metric and log-field names are a contract with the dashboards — renaming
|
|
|
163
179
|
## Metrics (Micrometer)
|
|
164
180
|
|
|
165
181
|
- Rely on built-in HTTP/JVM-substrate/Kafka metrics first.
|
|
166
|
-
- Custom business metrics: counters/timers via `MeterRegistry`, names `
|
|
182
|
+
- Custom business metrics: counters/timers via `MeterRegistry`, names `iam_user_registrations_total` style, low-cardinality tags only.
|
|
167
183
|
- Expose `/q/metrics` for Prometheus scrape or bridge Micrometer→OTLP if the platform standardizes on the collector for metrics too.
|
|
168
184
|
|
|
169
185
|
## Health
|
|
@@ -174,7 +190,7 @@ Span, metric and log-field names are a contract with the dashboards — renaming
|
|
|
174
190
|
|
|
175
191
|
1. `quarkus-opentelemetry` + JSON logging configured as above; OTLP → collector.
|
|
176
192
|
2. Log format includes traceId/spanId (both profiles); `X-Trace-Id` response filter registered.
|
|
177
|
-
3. JDBC telemetry enabled; `audit_event.context` jsonb populated with `trace_id`/`span_id` (+ index)
|
|
193
|
+
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.
|
|
178
194
|
3b. No manual trace-string construction anywhere — grep-check in review; only `Span.current()` via the OTel SDK.
|
|
179
|
-
4.
|
|
195
|
+
4. Every slice `Handler.process()` annotated `@WithSpan("usecase.<sliceCamelCase>")`; attributes reviewed for PII/cardinality.
|
|
180
196
|
5. Verify end-to-end in dev: one request produces a single trace spanning REST → DB → Kafka → consumer (Dev UI or Jaeger/Tempo).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: quarkus-openapi-tmforum
|
|
3
|
-
description: API design and documentation standard for Quarkus REST services — OpenAPI 3.1 via SmallRye, Swagger UI restricted to dev/test, and full alignment with TM Forum Open API specifications (TMF630 design guidelines, TMF resource patterns, Error schema, pagination, filtering, JSON Merge Patch). Use this skill whenever the user mentions Swagger, OpenAPI, API documentation, REST API design, TMF/TM Forum APIs, endpoints/resources naming, pagination, PATCH semantics, or creates/reviews ANY REST resource class. Includes resource, DTO
|
|
3
|
+
description: API design and documentation standard for Quarkus REST services — OpenAPI 3.1 via SmallRye, Swagger UI restricted to dev/test, and full alignment with TM Forum Open API specifications (TMF630 design guidelines, TMF resource patterns, Error schema, pagination, filtering, JSON Merge Patch). Use this skill whenever the user mentions Swagger, OpenAPI, API documentation, REST API design, TMF/TM Forum APIs, endpoints/resources naming, pagination, PATCH semantics, or creates/reviews ANY REST resource class. Includes resource, DTO and operationId naming conventions for the vertical-slice layout (the Resource class lives in the slice folder and delegates to the slice Handler; there are no REST mappers).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# OpenAPI + TM Forum API Standard (Quarkus)
|
|
@@ -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: `/{context}/{apiName}/v{major}` e.g. `/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.
|
|
35
35
|
|
|
36
36
|
### Standard operations
|
|
37
37
|
| Operation | Verb | Response |
|
|
@@ -57,7 +57,9 @@ No PUT unless the TMF spec defines it. No RPC-ish URLs (`/user/activate`) — st
|
|
|
57
57
|
|
|
58
58
|
## Annotating resources (SmallRye OpenAPI)
|
|
59
59
|
|
|
60
|
-
DTOs live in `
|
|
60
|
+
DTOs live in the slice's own `dto/` package (`<slice_folder>/dto/*Dto.java`) — see quarkus-hexagonal-core skill. There is no separate domain class to protect: the DTO **is** the wire contract, so annotate it freely, and keep business logic out of it.
|
|
61
|
+
|
|
62
|
+
The `Resource` class lives in the slice folder alongside the `Handler` it fronts, and does exactly three things: extract headers/params, call `handler.process(...)`, shape the response. It never catches `BusinessException` (the global handler owns that) and never touches JDBC.
|
|
61
63
|
|
|
62
64
|
### Naming
|
|
63
65
|
|
|
@@ -65,55 +67,69 @@ Canonical rules in the quarkus-hexagonal-core skill; REST/TMF specifics:
|
|
|
65
67
|
|
|
66
68
|
| Artifact | Convention | Example |
|
|
67
69
|
|---|---|---|
|
|
68
|
-
| Resource class | `<Resource>Resource` — matches the TMF resource name | `DigitalIdentityResource` |
|
|
69
|
-
| Response DTO | `<Resource>Dto` | `DigitalIdentityDto` |
|
|
70
|
+
| Resource class | `<Resource>Resource` — matches the TMF resource name, lives in the slice folder | `DigitalIdentityResource` |
|
|
71
|
+
| Response DTO | `<Resource>Dto`, or `${SERVICE_CLASS_PREFIX}ResponseDto` for a slice-specific shape | `DigitalIdentityDto` |
|
|
70
72
|
| Create / update DTO | `<Resource>CreateDto` / `<Resource>UpdateDto` (TMF `X_Create` / `X_Update`) | `DigitalIdentityCreateDto` |
|
|
73
|
+
| Request DTO (non-TMF) | `${SERVICE_CLASS_PREFIX}RequestDto` | `CreatePartyIndividualRequestDto` |
|
|
71
74
|
| Error DTO | `ErrorDto` (see quarkus-error-handling-i18n skill) | |
|
|
72
|
-
| DTO ↔ domain mapper | `<Resource>RestMapper` | `DigitalIdentityRestMapper` |
|
|
73
75
|
| `operationId` | `list/retrieve/create/patch/delete` + `<Resource>` | `listDigitalIdentity` |
|
|
74
76
|
| Path segment | camelCase, exactly as the TMF spec spells it | `/partyAccount` |
|
|
75
77
|
| JSON field | camelCase; TMF meta-fields keep their `@` | `@type`, `@baseType` |
|
|
76
78
|
|
|
79
|
+
There is **no `*RestMapper` class** — the DTO↔internal conversion is the `Handler`'s `getResult()` (see quarkus-hexagonal-core skill), and `*Mapper` is a banned suffix. When a TMF spec prescribes `X_Create`/`X_Update`, those names win over the generic `RequestDto`; use the generic form only for non-TMF operations.
|
|
80
|
+
|
|
81
|
+
**One Resource per TMF resource, or one per slice?** When several slices serve the same TMF path, the default is one `Resource` class per TMF resource that injects each slice's `Handler` — JAX-RS path resolution stays unambiguous and the OpenAPI tag stays clean. One class per slice sharing a base `@Path` also works in Quarkus REST provided no two classes declare the same method+sub-path; the `/q/openapi` snapshot test below is what catches a collision.
|
|
82
|
+
|
|
77
83
|
When implementing a TMF spec the wire names are **not negotiable** — DTO field names must match the spec even where our own convention would differ. The Java class name adds the `Dto` suffix; the serialized schema name stays the TMF one (`@Schema(name = "DigitalIdentity")`).
|
|
78
84
|
|
|
79
85
|
```java
|
|
80
|
-
@Path("/tmf-api/digitalIdentityManagement/v4/digitalIdentity")
|
|
86
|
+
@Path("/iam/tmf-api/digitalIdentityManagement/v4/digitalIdentity")
|
|
81
87
|
@Tag(name = "DigitalIdentity")
|
|
82
88
|
public class DigitalIdentityResource {
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
public DigitalIdentityResource(CreateDigitalIdentityUseCase createIdentity,
|
|
88
|
-
DigitalIdentityRestMapper mapper) {
|
|
89
|
-
this.createIdentity = createIdentity;
|
|
90
|
-
this.mapper = mapper;
|
|
91
|
-
}
|
|
90
|
+
@Inject CreateDigitalIdentityHandler createHandler; // the slice's business logic
|
|
91
|
+
@Inject FindDigitalIdentityHandler findHandler;
|
|
92
92
|
|
|
93
93
|
@GET
|
|
94
|
+
@Blocking
|
|
94
95
|
@Operation(operationId = "listDigitalIdentity", summary = "List or find DigitalIdentity objects")
|
|
95
96
|
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = DigitalIdentityDto[].class)))
|
|
96
97
|
@APIResponse(responseCode = "400", ref = "#/components/responses/BadRequest")
|
|
97
98
|
public RestResponse<List<DigitalIdentityDto>> list(
|
|
99
|
+
@HeaderParam("tenantId") String tenantId,
|
|
98
100
|
@QueryParam("fields") String fields,
|
|
99
101
|
@QueryParam("offset") @DefaultValue("0") int offset,
|
|
100
|
-
@QueryParam("limit") @DefaultValue("20") int limit) {
|
|
102
|
+
@QueryParam("limit") @DefaultValue("20") int limit) {
|
|
103
|
+
var query = FilterDigitalIdentityDto.builder()
|
|
104
|
+
.tenantId(tenantId).fields(fields).offset(offset).limit(limit).build();
|
|
105
|
+
return RestResponse.ok(findHandler.process(query).getItems());
|
|
106
|
+
}
|
|
101
107
|
|
|
102
108
|
@POST
|
|
109
|
+
@Blocking
|
|
103
110
|
@Operation(operationId = "createDigitalIdentity")
|
|
104
111
|
@APIResponse(responseCode = "201", headers = @Header(name = "Location"))
|
|
105
|
-
public RestResponse<DigitalIdentityDto> create(@
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
public RestResponse<DigitalIdentityDto> create(@HeaderParam("tenantId") String tenantId,
|
|
113
|
+
@Valid DigitalIdentityCreateDto dto,
|
|
114
|
+
@Context UriInfo uri) {
|
|
115
|
+
dto.setTenantId(tenantId);
|
|
116
|
+
var created = createHandler.process(dto); // BusinessException propagates to the global handler
|
|
117
|
+
return RestResponse.ResponseBuilder
|
|
118
|
+
.created(uri.getAbsolutePathBuilder().path(created.getId()).build())
|
|
119
|
+
.entity(created).build();
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
122
|
```
|
|
111
123
|
|
|
112
124
|
Rules:
|
|
125
|
+
- 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 §7).
|
|
127
|
+
- Extract `tenantId`, `partyId`, `partyRolList`, `language` from headers where the contract requires them and set them on the request DTO before calling `process()`.
|
|
128
|
+
- No `try/catch` around `process()`. Errors travel as `BusinessException` to `GlobalExceptionHandler`, which resolves the localized TMF Error body (see quarkus-error-handling-i18n skill).
|
|
113
129
|
- `operationId` on every operation (client generation depends on it); match TMF naming (`listX`, `retrieveX`, `createX`, `patchX`, `deleteX`).
|
|
114
130
|
- Reusable components: define common responses (400/401/404/409/500 with TMF Error schema) once via an `@OpenAPIDefinition`/filter class, `ref` them everywhere.
|
|
115
131
|
- `*CreateDto` (no id/href) vs full DTO — TMF pattern (`X_Create`, `X_Update`).
|
|
116
|
-
- Bean Validation on DTOs (`@NotNull`, `@Size`) so constraints appear in the schema.
|
|
132
|
+
- Bean Validation on DTOs (`@NotNull`, `@Size`) so constraints appear in the schema — *form* only; business rules live in `Handler.validate()`.
|
|
117
133
|
|
|
118
134
|
## PATCH implementation (JSON Merge Patch)
|
|
119
135
|
|
|
@@ -121,8 +137,9 @@ Consume `application/merge-patch+json`. Apply merge onto the current DTO represe
|
|
|
121
137
|
|
|
122
138
|
## Checklist for a new endpoint
|
|
123
139
|
|
|
124
|
-
1. TMF spec exists? Copy & trim official OpenAPI
|
|
125
|
-
2. `<Resource>Resource` class with full annotations + operationIds
|
|
126
|
-
3. Error responses referenced to the shared TMF Error components.
|
|
127
|
-
4. fields/offset/limit on list operations; Location header on create.
|
|
128
|
-
5. Snapshot test: `/q/openapi` diff against committed contract.
|
|
140
|
+
1. TMF spec exists? Copy & trim official OpenAPI into `contracts/`, implement to match.
|
|
141
|
+
2. `<Resource>Resource` class in the slice folder with full annotations + operationIds, `@Blocking`, injecting the slice `Handler`; DTOs in `<slice>/dto` with validation + `@RegisterForReflection`.
|
|
142
|
+
3. Error responses referenced to the shared TMF Error components — and no `try/catch` in the resource.
|
|
143
|
+
4. fields/offset/limit on list operations; `Location` header on create; 201 on create, 204 on delete.
|
|
144
|
+
5. Snapshot test: `/q/openapi` diff against the committed contract (also catches a `@Path` collision between slices).
|
|
145
|
+
6. `@QuarkusTest` + REST Assured covering one success and one localized error body (`Accept-Language: es`).
|