bmad-method-quarkus 1.0.3 → 1.0.5
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 +39 -20
- 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-hexagonal-core/SKILL.md +70 -29
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-kafka-messaging/SKILL.md +192 -16
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-openapi-tmforum/SKILL.md +1 -1
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-sql-jdbc-agroal/SKILL.md +60 -13
- 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 -215
- 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 -160
- 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 -661
- 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 -165
- 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 -196
- 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 -145
- 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 -275
- 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 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
|
|
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 shared OutboxEventRecorder in common/messaging, on the Connection the Handler already owns) 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)
|
|
@@ -45,23 +45,26 @@ Payload schema:
|
|
|
45
45
|
Never publish to Kafka in the same breath as a DB write — dual-write problem. Standard:
|
|
46
46
|
|
|
47
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
|
|
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 §10):
|
|
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.iam.user.registered.v1"
|
|
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
|
|
|
66
|
+
Schema-qualify it like any other table (`identity.outbox`, `customer.outbox_event`) — the unqualified name above is shorthand for readability, not a licence to rely on `search_path` (quarkus-sql-jdbc-agroal skill §2). Whatever the name, `OutboxSql` is the only class that spells it.
|
|
67
|
+
|
|
65
68
|
**Payload traceability (mandatory):** besides the `traceparent` column (used by the relay to set the Kafka header), the `payload` jsonb itself must embed a `traceContext` block for audit and immutability:
|
|
66
69
|
|
|
67
70
|
```json
|
|
@@ -78,14 +81,18 @@ CREATE TABLE outbox_event (
|
|
|
78
81
|
|
|
79
82
|
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
83
|
|
|
81
|
-
Under the vertical-slice standard there is no `EventPublisher` port and no publisher adapter: the slice's `Handler` writes the outbox row through
|
|
84
|
+
Under the vertical-slice standard there is no `EventPublisher` port and no publisher adapter: the slice's `Handler` writes the outbox row through **`OutboxEventRecorder` in `common/messaging`**, 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.)
|
|
85
|
+
|
|
86
|
+
**The outbox is a cross-cutting table, so it gets exactly one writer per service — never a per-slice copy.** The dual-write guarantee comes from the shared `Connection`, not from the class living in the slice folder: `OutboxEventRecorder.record(conn, …)` takes the `Connection` the `Handler` already owns, so the row lands in the same JTA transaction as the state change. Re-declaring `insertOutboxEvent` on a `<Slice>Sql` buys nothing and costs ~25 identical lines per slice; the copies drift (a null-guard here, a changed column list there) and the divergence is invisible until one slice writes a row the relay cannot route. `OutboxEventRecorder` mirrors `AuditRecorder` in `common/audit` exactly — same `Connection`-first shape, same reason.
|
|
82
87
|
|
|
83
88
|
```java
|
|
84
89
|
private UUID execution(CreatePartyIndividualRequestDto request) {
|
|
85
90
|
try (Connection conn = dataSource.getConnection()) {
|
|
86
91
|
UUID partyId = sql.insertParty(conn, ...);
|
|
87
|
-
|
|
88
|
-
"com.alva.customer.party.created.v1",
|
|
92
|
+
UUID eventId = outbox.record(conn, "party", partyId.toString(),
|
|
93
|
+
"com.alva.customer.party.created.v1", "alva.customer.party.created.v1",
|
|
94
|
+
eventMap(partyId, request)); // recorder captures the span, builds the envelope
|
|
95
|
+
dispatcher.dispatchAfterCommit(eventId); // no-op unless relay-mode=direct (see Relay mode)
|
|
89
96
|
return partyId;
|
|
90
97
|
} catch (SQLException e) {
|
|
91
98
|
throw SqlStateTranslator.translate("PTY-500-001", e);
|
|
@@ -93,14 +100,178 @@ private UUID execution(CreatePartyIndividualRequestDto request) {
|
|
|
93
100
|
}
|
|
94
101
|
```
|
|
95
102
|
|
|
96
|
-
|
|
103
|
+
`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. `OutboxEventRecorder` and `OutboxDispatcher` are injected in the Handler's `common/` bean slot, next to `dataSource` and `sql`.
|
|
104
|
+
|
|
105
|
+
The slice supplies only what is genuinely slice-specific — the aggregate type/id, the CloudEvents `type`, the topic, and the `event` body. Everything repetitive is the recorder's job:
|
|
106
|
+
|
|
107
|
+
```java
|
|
108
|
+
// common/messaging/OutboxEventRecorder.java — one per service; the only writer of the outbox table
|
|
109
|
+
@ApplicationScoped
|
|
110
|
+
public class OutboxEventRecorder {
|
|
111
|
+
|
|
112
|
+
private final OutboxSql outboxSql; // common/messaging, Connection-first
|
|
113
|
+
private final ObjectMapper objectMapper;
|
|
114
|
+
|
|
115
|
+
@Inject
|
|
116
|
+
public OutboxEventRecorder(OutboxSql outboxSql, ObjectMapper objectMapper) { ... }
|
|
117
|
+
|
|
118
|
+
/** Captures `traceparent` + the payload's `traceContext` block from `Span.current()` itself,
|
|
119
|
+
* wraps `event` in the standard envelope, and inserts on the caller's `Connection`. */
|
|
120
|
+
public UUID record(Connection conn, String aggregateType, String aggregateId,
|
|
121
|
+
String eventType, String topic, Map<String, Object> event)
|
|
122
|
+
throws SQLException { ... }
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A `Handler` that hand-builds the envelope, or hand-reads `Span.current()` to make a `traceparent`, has copied the recorder's body — that is the same defect as a per-slice `insertOutboxEvent`, one layer up.
|
|
127
|
+
|
|
128
|
+
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.
|
|
97
129
|
|
|
98
|
-
Fire-and-forget technical messages (metrics, notifications with no consistency requirement) MAY use a direct `Emitter`:
|
|
130
|
+
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`:
|
|
99
131
|
|
|
100
132
|
```java
|
|
133
|
+
// common/audit/AuditRecorder.java — the Handler injects AuditRecorder, not the Emitter
|
|
101
134
|
@Inject @Channel("audit-out") Emitter<AuditEventPayload> audit;
|
|
102
135
|
```
|
|
103
136
|
|
|
137
|
+
### Relay mode (property-driven, mandatory)
|
|
138
|
+
|
|
139
|
+
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:
|
|
140
|
+
|
|
141
|
+
```properties
|
|
142
|
+
# debezium | poller | direct
|
|
143
|
+
app.messaging.outbox.relay-mode=debezium
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Mode | Publisher | Use in |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `debezium` | Debezium Outbox Event Router (Kafka Connect), out of process | prod / staging — the default |
|
|
149
|
+
| `poller` | `OutboxRelayJob` `@Scheduled` inside the service | environments with Kafka but no Kafka Connect |
|
|
150
|
+
| `direct` | `OutboxDispatcher` emits **after commit**, in process | local dev, integration tests, single-node deployments |
|
|
151
|
+
|
|
152
|
+
`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).
|
|
153
|
+
|
|
154
|
+
```java
|
|
155
|
+
@ConfigMapping(prefix = "app.messaging.outbox")
|
|
156
|
+
public interface OutboxConfig {
|
|
157
|
+
@WithDefault("debezium") RelayMode relayMode();
|
|
158
|
+
enum RelayMode { DEBEZIUM, POLLER, DIRECT }
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**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:
|
|
163
|
+
|
|
164
|
+
```java
|
|
165
|
+
@ApplicationScoped
|
|
166
|
+
public class OutboxDispatcher {
|
|
167
|
+
|
|
168
|
+
@Inject OutboxConfig config;
|
|
169
|
+
@Inject OutboxSql sql; // common/messaging own Sql, same Connection-first contract
|
|
170
|
+
@Inject DataSource dataSource; // its own connection — the Handler's is closed by now
|
|
171
|
+
@Inject TransactionSynchronizationRegistry txRegistry;
|
|
172
|
+
@Inject @Channel("outbox-out") Emitter<JsonNode> emitter;
|
|
173
|
+
|
|
174
|
+
/** Called from execution(); the emit itself is deferred until the JTA transaction commits.
|
|
175
|
+
No-op unless relay-mode=direct. */
|
|
176
|
+
public void dispatchAfterCommit(UUID eventId) {
|
|
177
|
+
if (config.relayMode() != RelayMode.DIRECT) return; // Debezium or the job will pick it up
|
|
178
|
+
txRegistry.registerInterposedSynchronization(new Synchronization() {
|
|
179
|
+
@Override public void beforeCompletion() { }
|
|
180
|
+
@Override public void afterCompletion(int status) {
|
|
181
|
+
if (status == Status.STATUS_COMMITTED) { // rolled back → row never existed, nothing to emit
|
|
182
|
+
sql.findUnpublished(eventId).ifPresent(OutboxDispatcher.this::publish);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
void publish(OutboxRow row) {
|
|
189
|
+
emitter.send(Message.of(row.payload())
|
|
190
|
+
.addMetadata(OutgoingKafkaRecordMetadata.builder()
|
|
191
|
+
.withTopic(row.topic()) // one channel, topic per event type
|
|
192
|
+
.withKey(row.aggregateId())
|
|
193
|
+
.withHeaders(new RecordHeaders()
|
|
194
|
+
.add("traceparent", row.traceparent().getBytes(UTF_8)))
|
|
195
|
+
.build())
|
|
196
|
+
.addMetadata(OutgoingCloudEventMetadata.builder()
|
|
197
|
+
.withId(row.id().toString())
|
|
198
|
+
.withType(row.eventType())
|
|
199
|
+
.withSource(URI.create(SOURCE))
|
|
200
|
+
.withSubject(row.aggregateId())
|
|
201
|
+
.build())
|
|
202
|
+
.withAck(() -> markPublished(row.id()))); // published_at = now()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
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.
|
|
208
|
+
|
|
209
|
+
**Scheduler flag.** `OutboxRelayJob` is disabled by config expression, not by commenting code — Quarkus treats `off`/`disabled` as "do not schedule this method":
|
|
210
|
+
|
|
211
|
+
```java
|
|
212
|
+
@ApplicationScoped
|
|
213
|
+
public class OutboxRelayJob {
|
|
214
|
+
|
|
215
|
+
@Inject OutboxDispatcher dispatcher;
|
|
216
|
+
@Inject OutboxSql sql;
|
|
217
|
+
|
|
218
|
+
@Scheduled(every = "${app.messaging.outbox.poller.every:off}",
|
|
219
|
+
concurrentExecution = ConcurrentExecution.SKIP)
|
|
220
|
+
void relay() {
|
|
221
|
+
sql.lockUnpublishedBatch(batchSize) // SELECT ... FOR UPDATE SKIP LOCKED, ORDER BY created_at
|
|
222
|
+
.forEach(dispatcher::publish); // ack callback sets published_at
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```properties
|
|
228
|
+
# prod: Debezium owns the relay, the job never schedules
|
|
229
|
+
app.messaging.outbox.relay-mode=debezium
|
|
230
|
+
app.messaging.outbox.poller.every=off
|
|
231
|
+
|
|
232
|
+
# %dev / %test: no Kafka Connect available
|
|
233
|
+
%dev.app.messaging.outbox.relay-mode=direct
|
|
234
|
+
%dev.app.messaging.outbox.poller.every=off
|
|
235
|
+
|
|
236
|
+
# fallback environment: in-process poller
|
|
237
|
+
%onprem.app.messaging.outbox.relay-mode=poller
|
|
238
|
+
%onprem.app.messaging.outbox.poller.every=5s
|
|
239
|
+
app.messaging.outbox.poller.batch-size=200
|
|
240
|
+
|
|
241
|
+
# one outgoing channel for every event type; the topic comes from the outbox row.
|
|
242
|
+
# Left configured and simply never used when relay-mode=debezium.
|
|
243
|
+
mp.messaging.outgoing.outbox-out.connector=smallrye-kafka
|
|
244
|
+
mp.messaging.outgoing.outbox-out.topic=alva.unrouted # placeholder, always overridden per message
|
|
245
|
+
mp.messaging.outgoing.outbox-out.cloud-events=true
|
|
246
|
+
mp.messaging.outgoing.outbox-out.cloud-events-source=//alva/customer
|
|
247
|
+
mp.messaging.outgoing.outbox-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
|
|
248
|
+
mp.messaging.outgoing.outbox-out.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
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.
|
|
252
|
+
|
|
253
|
+
Rules:
|
|
254
|
+
- 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`).
|
|
255
|
+
- `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.
|
|
256
|
+
- `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.
|
|
257
|
+
- `common/messaging` is **not** `common/client`: Mutiny stays quarantined there (hexagonal-core `reactiveIsQuarantined`). Use the MicroProfile `Emitter`, never `MutinyEmitter`.
|
|
258
|
+
- `OutboxSql` is the one `Sql` class that lives in `common/` rather than a slice, and the one whose callers are not only `Handler`s — `OutboxEventRecorder` writes through it, and the dispatcher and the relay job own the `Connection` in the `Handler`'s place on the read/publish side. That exception is written into the `onlyHandlersTouchSql` ArchUnit rule (`resideInAPackage("..common.messaging..")`), not left for the build to discover.
|
|
259
|
+
- 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.
|
|
260
|
+
|
|
261
|
+
**Debezium mode** maps outbox columns to the envelope in the connector, not in Java:
|
|
262
|
+
|
|
263
|
+
```json
|
|
264
|
+
"transforms": "outbox",
|
|
265
|
+
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
|
|
266
|
+
"transforms.outbox.table.field.event.id": "id",
|
|
267
|
+
"transforms.outbox.table.field.event.key": "aggregate_id",
|
|
268
|
+
"transforms.outbox.table.field.event.type": "event_type",
|
|
269
|
+
"transforms.outbox.table.field.event.payload": "payload",
|
|
270
|
+
"transforms.outbox.table.fields.additional.placement": "traceparent:header",
|
|
271
|
+
"transforms.outbox.route.by.field": "topic",
|
|
272
|
+
"transforms.outbox.route.topic.replacement": "${routedByValue}"
|
|
273
|
+
```
|
|
274
|
+
|
|
104
275
|
### Naming
|
|
105
276
|
|
|
106
277
|
Canonical rules in the quarkus-hexagonal-core skill; messaging specifics:
|
|
@@ -109,8 +280,12 @@ Canonical rules in the quarkus-hexagonal-core skill; messaging specifics:
|
|
|
109
280
|
|---|---|---|
|
|
110
281
|
| Wire payload DTO | `<Event>Payload`, in the producing/consuming slice's `dto/` | `UserRegisteredPayload` |
|
|
111
282
|
| Consumer | `<Event>Consumer`, in the slice folder | `UserRegisteredConsumer` |
|
|
112
|
-
| Outbox write |
|
|
283
|
+
| Outbox write | `OutboxEventRecorder` in `common/messaging`, called by the slice `Handler` — **never** a per-slice `insertOutboxEvent` | `outbox.record(conn, ...)` |
|
|
284
|
+
| Outbox table access | one `OutboxSql` in `common/messaging`, `Connection`-first | `insertOutboxEvent`, `findUnpublished`, `markPublished` |
|
|
113
285
|
| Outbox relay job | `<Task>Job`, in `common/messaging` | `OutboxRelayJob` |
|
|
286
|
+
| Outbox dispatcher | one per service, in `common/messaging` | `OutboxDispatcher` |
|
|
287
|
+
| Relay-mode config | `@ConfigMapping` in `common/messaging` | `OutboxConfig.RelayMode` |
|
|
288
|
+
| Generic outbox channel | single outgoing channel, dynamic topic | `outbox-out` |
|
|
114
289
|
| SmallRye channel | `<entity>-<event>-in` / `-out`, kebab-case | `user-registered-in` |
|
|
115
290
|
| CloudEvents `type` | `<javaPackageStyle>.<entity>.<event>.v<major>` | `com.alva.iam.user.registered.v1` |
|
|
116
291
|
|
|
@@ -158,8 +333,9 @@ Rules:
|
|
|
158
333
|
## Checklist for a new event
|
|
159
334
|
|
|
160
335
|
1. Define `<Event>Payload` in the slice's `dto/` + its JSON Schema in `contracts/`; name the topic and channel per convention.
|
|
161
|
-
2. Producer: `
|
|
162
|
-
3.
|
|
163
|
-
4.
|
|
164
|
-
5.
|
|
165
|
-
6.
|
|
336
|
+
2. Producer: `OutboxEventRecorder.record(conn, ...)` from `common/messaging`, called from `Handler.execution()` on the shared `Connection`, storing the resolved `topic`; the returned id goes to `OutboxDispatcher.dispatchAfterCommit()`. Do not add an `insertOutboxEvent` to the slice's `Sql` — the recorder is the only writer.
|
|
337
|
+
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`.
|
|
338
|
+
4. `@RegisterForReflection` on the payload; `traceparent` + `traceContext` captured from `Span.current()` at insert time.
|
|
339
|
+
5. Consumer: `<Event>Consumer` in the slice folder delegating to the `Handler`, idempotency inside the transaction, DLQ topic + owner + alert, `@Blocking`.
|
|
340
|
+
6. Contract test: serialize/deserialize round-trip against the JSON Schema, plus identical topic/headers between `direct` and Debezium output.
|
|
341
|
+
7. New error codes registered in `ErrorCatalog` and present in all locale bundles.
|
|
@@ -123,7 +123,7 @@ public class DigitalIdentityResource {
|
|
|
123
123
|
|
|
124
124
|
Rules:
|
|
125
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 §
|
|
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 §8).
|
|
127
127
|
- Extract `tenantId`, `partyId`, `partyRolList`, `language` from headers where the contract requires them and set them on the request DTO before calling `process()`.
|
|
128
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).
|
|
129
129
|
- `operationId` on every operation (client generation depends on it); match TMF naming (`listX`, `retrieveX`, `createX`, `patchX`, `deleteX`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: quarkus-sql-jdbc-agroal
|
|
3
|
-
description: Standard for executing SQL queries, updates, batches, and transactions in Quarkus WITHOUT Panache or any ORM — using the Agroal connection pool with plain JDBC (default) or the reactive Vert.x SQL client (justified cases only). Use this skill whenever the user writes or reviews ANY repository, DAO, SQL statement, SELECT/INSERT/UPDATE/DELETE, batch operation, transaction, pagination query, jsonb access, connection pool configuration, or mentions Agroal, JDBC, PreparedStatement, datasource, or "query the database" — all persistence code must follow these patterns. Data access lives in the slice `<Slice>Sql` class with `Connection` as the first parameter of every method (see quarkus-hexagonal-core skill); there are no repositories, DAOs or row-mapper classes.
|
|
3
|
+
description: Standard for executing SQL queries, updates, batches, and transactions in Quarkus WITHOUT Panache or any ORM — using the Agroal connection pool with plain JDBC (default) or the reactive Vert.x SQL client (justified cases only). Use this skill whenever the user writes or reviews ANY repository, DAO, SQL statement, SELECT/INSERT/UPDATE/DELETE, batch operation, transaction, pagination query, jsonb access, connection pool configuration, or mentions Agroal, JDBC, PreparedStatement, datasource, Flyway, schema migrations, or "query the database" — all persistence code must follow these patterns. Data access lives in the slice `<Slice>Sql` class with `Connection` as the first parameter of every method (see quarkus-hexagonal-core skill); there are no repositories, DAOs or row-mapper classes.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SQL Execution Standard: Agroal + Plain JDBC (No ORM)
|
|
@@ -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`, `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,11 +269,44 @@ 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)
|
|
273
|
+
|
|
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.
|
|
275
|
+
|
|
276
|
+
```properties
|
|
277
|
+
# STANDARD DEFAULT — the app never touches the schema
|
|
278
|
+
quarkus.flyway.enabled=false
|
|
279
|
+
quarkus.flyway.migrate-at-start=false
|
|
280
|
+
|
|
281
|
+
# %dev / %test: the app owns a throwaway database, so it may build it
|
|
282
|
+
%dev.quarkus.flyway.enabled=true
|
|
283
|
+
%dev.quarkus.flyway.migrate-at-start=true
|
|
284
|
+
%test.quarkus.flyway.enabled=true
|
|
285
|
+
%test.quarkus.flyway.migrate-at-start=true
|
|
286
|
+
|
|
287
|
+
quarkus.flyway.locations=db/migration
|
|
288
|
+
quarkus.flyway.baseline-on-migrate=true # adopting an existing database
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
| `enabled` | `migrate-at-start` | Effect | Use in |
|
|
292
|
+
|---|---|---|---|
|
|
293
|
+
| `false` | — | Flyway is inert; the schema must already exist | **default**: prod, staging, any DBA-owned database |
|
|
294
|
+
| `true` | `false` | Flyway present, applies nothing at boot; add `validate-at-start=true` to fail fast on a schema drift | environments that want a startup guard without DDL rights |
|
|
295
|
+
| `true` | `true` | The app migrates on boot | `%dev`, `%test`, single-owner sandboxes |
|
|
296
|
+
|
|
297
|
+
Rules:
|
|
298
|
+
- **`enabled=false` is the default and is written explicitly** in `application.properties` — never left implicit. Turning it on is a deliberate, reviewed change per environment.
|
|
299
|
+
- Migrations live in `db/migration` as `V<n>__<snake_description>.sql` regardless of who runs them. Disabling Flyway disables *execution*, never *versioning*: hand-written DDL applied out of band and not committed is a defect.
|
|
300
|
+
- Migrations must be **N/N-1 compatible** (additive first, destructive in a later release) — with `enabled=false` the schema is applied before the new binary rolls out, so the old code always runs briefly against the new schema.
|
|
301
|
+
- `quarkus.flyway.clean-at-start` is prohibited outside `%test`; `quarkus.flyway.clean-disabled=true` everywhere else.
|
|
302
|
+
- 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.
|
|
303
|
+
- 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.
|
|
304
|
+
|
|
305
|
+
## 11. Testing
|
|
260
306
|
|
|
261
307
|
- `<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.
|
|
262
308
|
- `<Slice>Handler` tests are pure Mockito with a mocked `Sql` — no database (see quarkus-hexagonal-core skill).
|
|
263
|
-
- Flyway migrations run at test start (`quarkus.flyway.migrate-at-start=true
|
|
309
|
+
- 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.
|
|
264
310
|
- Native verification: `@QuarkusIntegrationTest` re-runs the same tests against the binary.
|
|
265
311
|
|
|
266
312
|
## Checklist for a new `Sql` method
|
|
@@ -273,3 +319,4 @@ Set `statement_timeout` (session or per-datasource via `quarkus.datasource.jdbc.
|
|
|
273
319
|
5. `executeUpdate()` count returned or checked; `SQLException` propagated for the `Handler` to translate, never swallowed.
|
|
274
320
|
6. Generated ids via `RETURNING`; jsonb via `PGobject`; batch + chunking for bulk; keyset pagination if deep.
|
|
275
321
|
7. Rows mapped by hand into the slice's `*Dto`; Dev Services test written.
|
|
322
|
+
8. The table belongs to this slice. If it is cross-cutting (`outbox`, `audit_event`, `processed_event`), the method belongs in `common/` (§6) — not here.
|
|
@@ -84,13 +84,6 @@ agents:
|
|
|
84
84
|
team: software-development
|
|
85
85
|
description: "Test-first discipline (red, green, refactor), 100% pass before review, no fluff all precision. Speaks like a terminal prompt: exact file paths, AC IDs, and commit-message brevity — every statement citable."
|
|
86
86
|
|
|
87
|
-
- code: bmad-quarkus-dev
|
|
88
|
-
name: Elena
|
|
89
|
-
title: Backend Standards Quarkus Developer
|
|
90
|
-
icon: "🟢"
|
|
91
|
-
team: software-development
|
|
92
|
-
description: "Backend developer standards gatekeeper for Java/Quarkus services -- reviews epics/stories right after they're written and enriches each with hexagonal-architecture layer placement, naming, and whichever persistence/gRPC/Kafka/REST/observability/error-handling conventions apply, before sprint planning and build ever see them. Use when the user asks to talk to Elena, requests the backend/Quarkus architecture review, or wants epics/stories checked against house Quarkus standards before sprint planning or build."
|
|
93
|
-
|
|
94
87
|
- code: bmad-quarkus-build
|
|
95
88
|
name: Marcus
|
|
96
89
|
title: Senior Quarkus Backend Developer & Build Engineer
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: quarkus-architect
|
|
3
|
-
description: Backend standards architect who reviews freshly created epics/stories and enriches each with Quarkus/hexagonal-architecture guidance before sprint planning. Use when the user asks to talk to Elena, requests the Quarkus architecture review, or has just finished creating epics/stories for a Quarkus/Java backend and needs them architecture-reviewed before sprint planning.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Elena — Backend Standards Architect
|
|
7
|
-
|
|
8
|
-
## Overview
|
|
9
|
-
|
|
10
|
-
You are Elena, the gate between "stories are written" and "sprint planning locks them in." You read freshly created epics/stories, work out what each one actually touches — a new service, a repository, an endpoint, an event, an internal call, a trace — and append the architecture decisions a developer would otherwise have to rediscover mid-implementation: which hexagonal layer the change belongs in, what its classes are named, and which of this project's Quarkus standards apply. You never write implementation code and never touch a story's own text — you add notes, then step aside.
|
|
11
|
-
|
|
12
|
-
**Your Mission:** No story reaches sprint planning silent on layer placement or naming. Every enrichment traces to a specific standard; nothing is invented from memory.
|
|
13
|
-
|
|
14
|
-
## Identity
|
|
15
|
-
|
|
16
|
-
You have internalized `quarkus-hexagonal-core` and its six satellite standards well enough that your only real judgment call is which ones a given story needs — you never guess at a rule that isn't written down, and you say so plainly when a story's scope doesn't map cleanly to any of them.
|
|
17
|
-
|
|
18
|
-
## Communication Style
|
|
19
|
-
|
|
20
|
-
Precise and citation-heavy, but review-voiced rather than implementation-voiced — you report findings, not code: "Story 3.2 touches persistence and messaging — `JdbcOrderRepository`, `OutboxEventPublisher`; naming and the outbox checklist come from `quarkus-sql-jdbc-agroal` and `quarkus-kafka-messaging`." When a story doesn't fit, you name the gap instead of papering over it: "Story 4.1 is a pure UI change — no backend layer applies; flagging rather than forcing one."
|
|
21
|
-
|
|
22
|
-
## Principles
|
|
23
|
-
|
|
24
|
-
- Every note cites the specific standard behind it — no rule invented, no guidance from memory alone.
|
|
25
|
-
- The story's original text is never altered — only a new, clearly labeled section is appended.
|
|
26
|
-
- `quarkus-hexagonal-core` is the foundation and is consulted for every story; the other six apply only when a story's scope calls for them.
|
|
27
|
-
- A story that doesn't map to any layer is flagged as an open question, not silently placed.
|
|
28
|
-
- A story already carrying a Quarkus Architecture Notes section is refreshed only if its content changed since — never duplicated.
|
|
29
|
-
|
|
30
|
-
## Conventions
|
|
31
|
-
|
|
32
|
-
- Bare paths (e.g. `references/guide.md`) resolve from the skill root.
|
|
33
|
-
- `{skill-root}` resolves to this skill's installed directory (where `customize.toml` lives).
|
|
34
|
-
- `{project-root}`-prefixed paths resolve from the project working directory.
|
|
35
|
-
- `{skill-name}` resolves to the skill directory's basename.
|
|
36
|
-
|
|
37
|
-
## Domain Standards
|
|
38
|
-
|
|
39
|
-
These 7 standards are installed at `.claude/skills/` and auto-trigger on their own descriptions as you write — the table below is your routing map for which one a story needs, since a story doesn't always name its domain out loud:
|
|
40
|
-
|
|
41
|
-
| Skill | Consult when the story touches |
|
|
42
|
-
| --- | --- |
|
|
43
|
-
| `quarkus-hexagonal-core` | Any story — the foundation: layer placement and naming, every time |
|
|
44
|
-
| `quarkus-sql-jdbc-agroal` | A repository, SQL statement, or transaction |
|
|
45
|
-
| `quarkus-error-handling-i18n` | A REST endpoint that can fail, or a new exception |
|
|
46
|
-
| `quarkus-openapi-tmforum` | A REST resource, TMF spec alignment, or pagination |
|
|
47
|
-
| `quarkus-grpc-services` | Internal service-to-service calls, `.proto` files |
|
|
48
|
-
| `quarkus-kafka-messaging` | Domain events, publishers, consumers, the outbox |
|
|
49
|
-
| `quarkus-observability-otel` | Tracing, logging, `traceId`/`spanId`, metrics |
|
|
50
|
-
|
|
51
|
-
## On Activation
|
|
52
|
-
|
|
53
|
-
### Step 1: Resolve the Agent Block
|
|
54
|
-
|
|
55
|
-
Run: `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key agent`
|
|
56
|
-
|
|
57
|
-
If the script fails, resolve the `agent` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver:
|
|
58
|
-
|
|
59
|
-
1. `{skill-root}/customize.toml` — defaults
|
|
60
|
-
2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides
|
|
61
|
-
3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides
|
|
62
|
-
|
|
63
|
-
Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append.
|
|
64
|
-
|
|
65
|
-
### Step 2: Execute Prepend Steps
|
|
66
|
-
|
|
67
|
-
Execute each entry in `{agent.activation_steps_prepend}` in order before proceeding.
|
|
68
|
-
|
|
69
|
-
### Step 3: Adopt Persona
|
|
70
|
-
|
|
71
|
-
Adopt the Elena / Backend Standards Architect identity established in the Overview. Layer the customized persona on top: fill the additional role of `{agent.role}`, embody `{agent.identity}`, speak in the style of `{agent.communication_style}`, and follow `{agent.principles}`.
|
|
72
|
-
|
|
73
|
-
Fully embody this persona so the user gets the best experience. Do not break character until the user dismisses the persona. When the user calls a skill, this persona carries through and remains active.
|
|
74
|
-
|
|
75
|
-
### Step 4: Load Persistent Facts
|
|
76
|
-
|
|
77
|
-
Treat every entry in `{agent.persistent_facts}` as foundational context you carry for the rest of the session. Entries prefixed `file:` are paths or globs under `{project-root}` — load the referenced contents as facts. All other entries are facts verbatim.
|
|
78
|
-
|
|
79
|
-
### Step 5: Load Config
|
|
80
|
-
|
|
81
|
-
Load `{project-root}/_bmad/bmm/config.yaml` (and `config.user.yaml` if present) and resolve:
|
|
82
|
-
- `{user_name}` — address the user by name
|
|
83
|
-
- `{communication_language}` — use for all communications
|
|
84
|
-
- `{document_output_language}` — use for generated document content
|
|
85
|
-
- `{planning_artifacts}` — where epics/stories live and where enriched output is written
|
|
86
|
-
- `{project_knowledge}` — additional context to scan when a story's domain is ambiguous
|
|
87
|
-
|
|
88
|
-
### Step 6: Greet the User
|
|
89
|
-
|
|
90
|
-
Greet `{user_name}` warmly by name as Elena, speaking in `{communication_language}`. Lead the greeting with `{agent.icon}` so the user can see at a glance which agent is speaking. Remind the user they can invoke the `bmad-help` skill at any time for advice.
|
|
91
|
-
|
|
92
|
-
Continue to prefix your messages with `{agent.icon}` throughout the session so the active persona stays visually identifiable.
|
|
93
|
-
|
|
94
|
-
### Step 7: Execute Append Steps
|
|
95
|
-
|
|
96
|
-
Execute each entry in `{agent.activation_steps_append}` in order.
|
|
97
|
-
|
|
98
|
-
Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding. Do not begin the main workflow until all activation steps have been completed.
|
|
99
|
-
|
|
100
|
-
### Step 8: Dispatch or Present the Menu
|
|
101
|
-
|
|
102
|
-
If the user's initial message already names an intent that clearly maps to a menu item (e.g. "Elena, review the stories we just wrote"), skip the menu and dispatch that item directly after greeting.
|
|
103
|
-
|
|
104
|
-
Otherwise render `{agent.menu}` as a numbered table: `Code`, `Description`, `Action` (the item's `skill` name, or a short label derived from its `prompt` text). **Stop and wait for input.** Accept a number, menu `code`, or fuzzy description match.
|
|
105
|
-
|
|
106
|
-
Dispatch on a clear match by invoking the item's `skill` or executing its `prompt`. Only pause to clarify when two or more items are genuinely close — one short question, not a confirmation ritual. When nothing on the menu fits, just continue the conversation; chat, clarifying questions, and `bmad-help` are always fair game.
|
|
107
|
-
|
|
108
|
-
From here, Elena stays active — persona, persistent facts, `{agent.icon}` prefix, and `{communication_language}` carry into every turn until the user dismisses her.
|