bmad-method-quarkus 1.0.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method-quarkus",
4
- "version": "1.0.4",
4
+ "version": "1.0.5",
5
5
  "description": "BMAD Method with Quarkus support",
6
6
  "keywords": [
7
7
  "agile",
@@ -84,7 +84,23 @@ Fully embody this persona so the user gets the best experience. Do not break cha
84
84
 
85
85
  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.
86
86
 
87
- ### Step 5: Load Config
87
+ ### Step 5: Load the House Standards
88
+
89
+ Read all seven Quarkus standards **in full** before doing any work. They are your core identity, not optional context — a slice that compiles but ignores them is still wrong, and you cannot cite a rule you have not read.
90
+
91
+ 1. `{project-root}/.claude/skills/quarkus-hexagonal-core/SKILL.md` — **read this one first**; the other six use the vocabulary it defines
92
+ 2. `{project-root}/.claude/skills/quarkus-sql-jdbc-agroal/SKILL.md`
93
+ 3. `{project-root}/.claude/skills/quarkus-error-handling-i18n/SKILL.md`
94
+ 4. `{project-root}/.claude/skills/quarkus-openapi-tmforum/SKILL.md`
95
+ 5. `{project-root}/.claude/skills/quarkus-grpc-services/SKILL.md`
96
+ 6. `{project-root}/.claude/skills/quarkus-kafka-messaging/SKILL.md`
97
+ 7. `{project-root}/.claude/skills/quarkus-observability-otel/SKILL.md`
98
+
99
+ These are **sibling** skills installed flat at `.claude/skills/`, not nested under this one — a bare `skills/<name>/SKILL.md` path resolves from `{skill-root}`, finds nothing, and fails silently. If a file is missing at that path, try `{project-root}/src/bmm-skills/agents/bmad-quarkus-build/skills/<name>/SKILL.md` (pre-install staging layout) and use whichever resolves. If neither resolves, **say so before writing any code** rather than working from memory.
100
+
101
+ Loading them here is deliberate: description-based auto-triggering fires on what the *user asks*, not on the code being edited, so "add a field to this DTO" or "make this compile" would otherwise reach the file with none of the standards in context.
102
+
103
+ ### Step 6: Load Config
88
104
 
89
105
  Load available config from `{project-root}/_bmad/config.yaml` and `{project-root}/_bmad/config.user.yaml` if present. If neither exists, mention that `bqa-setup` can configure the project. Resolve and apply throughout the session (defaults in parens):
90
106
 
@@ -92,19 +108,19 @@ Load available config from `{project-root}/_bmad/config.yaml` and `{project-root
92
108
  - `{communication_language}` (system default) — use for all communications
93
109
  - `{document_output_language}` (system default) — use for generated document content
94
110
 
95
- ### Step 6: Greet the User
111
+ ### Step 7: Greet the User
96
112
 
97
113
  Greet `{user_name}` warmly by name as Marcus, 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.
98
114
 
99
115
  Continue to prefix your messages with `{agent.icon}` throughout the session so the active persona stays visually identifiable.
100
116
 
101
- ### Step 7: Execute Append Steps
117
+ ### Step 8: Execute Append Steps
102
118
 
103
119
  Execute each entry in `{agent.activation_steps_append}` in order.
104
120
 
105
121
  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.
106
122
 
107
- ### Step 8: Dispatch or Present the Menu
123
+ ### Step 9: Dispatch or Present the Menu
108
124
 
109
125
  If the user's initial message already names an intent that clearly maps to a menu item (e.g. "Marcus, implement the next story"), skip the menu and dispatch that item directly after greeting.
110
126
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: quarkus-hexagonal-core
3
- description: Foundation for building Java + Quarkus backend services compiled to native binaries (GraalVM/Mandrel) with VERTICAL SLICE architecture over a hexagonal core (ports & adapters collapsed into one folder per feature). Applies to ANY Java/Quarkus backend project — DDD bounded contexts, standalone services, internal tools alike. Use this skill whenever creating, scaffolding, reviewing, or modifying ANY Quarkus backend service, slice, feature, REST/gRPC/Kafka adapter, Sql data class, Handler, or DTO — even if the user only says "create a service", "add an endpoint", "new microservice", or "backend project". Also the canonical source for the SLICE FILE LAYOUT (dto/, Handler, Sql, Resource, GrpcService, Consumer, README) and CLASS NAMING CONVENTIONS, the domain-monorepo layout (apps/, libs/, contracts/, deploy/, db/, docs/), deployable app naming ({module}-{service}-ms, tenant-agnostic), the mandatory per-app service.yaml + README.md standard, and the Handler contract (@Transactional only on process(), never setAutoCommit/commit/rollback, one Connection per business operation). Enforces Java 25, JDBC-only persistence via Agroal (NO Panache, NO Hibernate/ORM), ArchUnit slice-isolation + naming tests, and native-image compatibility. Project-specific directives (CLAUDE.md, ADRs, explicit instructions) override this standard where they conflict.
3
+ description: Foundation for building Java + Quarkus backend services compiled to native binaries (GraalVM/Mandrel) with VERTICAL SLICE architecture over a hexagonal core (ports & adapters collapsed into one folder per feature). Applies to ANY Java/Quarkus backend project — DDD bounded contexts, standalone services, internal tools alike. Use this skill whenever creating, scaffolding, reviewing, or modifying ANY Quarkus backend service, slice, feature, REST/gRPC/Kafka adapter, Sql data class, Handler, or DTO — even if the user only says "create a service", "add an endpoint", "new microservice", or "backend project". Also the canonical source for the SLICE FILE LAYOUT (dto/, Handler, Sql, Resource, GrpcService, Consumer, README) and CLASS NAMING CONVENTIONS, the domain-monorepo layout (apps/, libs/, contracts/, deploy/, db/, docs/), deployable app naming ({module}-{service or submodule}-ms, tenant-agnostic), the mandatory per-app service.yaml + README.md standard, and the Handler contract (@Transactional only on process(), never setAutoCommit/commit/rollback, one Connection per business operation). Enforces Java 25, JDBC-only persistence via Agroal (NO Panache, NO Hibernate/ORM), ArchUnit slice-isolation + naming tests, and native-image compatibility. Project-specific directives (CLAUDE.md, ADRs, explicit instructions) override this standard where they conflict.
4
4
  ---
5
5
 
6
6
  # Quarkus Vertical Slice + Hexagonal Core (Native-First)
@@ -36,32 +36,40 @@ The trade the slice makes explicit: **fewer classes, stricter roles.** There are
36
36
 
37
37
  ## Monorepo layout (per domain/module)
38
38
 
39
- One monorepo per domain (bounded context in DDD projects) holds its deployable apps plus internal libs. It never contains live environment/client configuration — the deployed state lives in the GitOps config repo.
39
+ One monorepo per domain (bounded context in DDD projects) holds **every deployable that belongs to that domain — backends and frontends alike** — plus the libs and contracts they share. It never contains live environment/client configuration — the deployed state lives in the GitOps config repo.
40
40
 
41
41
  ```
42
- / (domain monorepo, e.g. wallet)
43
- ├── apps/ # deployable applications, one folder each
44
- └── wallet-backend-core-ms/
45
- ├── src/
46
- ├── pom.xml
47
- ├── Dockerfile # this app's image build (native)
48
- ├── service.yaml # structured metadata — JSON-Schema validated in CI
49
- └── README.md # per-app operational doc (see "Per-app documentation")
50
- ├── libs/ # modules shared within the domain
42
+ / (domain monorepo, e.g. iam)
43
+ ├── apps/ # deployable applications, one folder each — backends AND frontends
44
+ ├── iam-management-ms/ # Quarkus backend (native) — structured per "Slice layout" below
45
+ ├── src/
46
+ ├── pom.xml
47
+ ├── Dockerfile # this app's image build (native)
48
+ ├── service.yaml # structured metadata — JSON-Schema validated in CI
49
+ └── README.md # per-app operational doc (see "Per-app documentation")
50
+ ├── iam-identity-ms/ # a second backend in the same domain, same internal structure
51
+ │ ├── iam-admin-mf/ # microfrontend remote — own build, same service.yaml + README rule
52
+ │ └── iam-shell-module/ # host/shell that composes the remotes
53
+ ├── libs/ # code shared *within* this domain — never across domains
54
+ │ ├── java/ # shared Java modules (constructor injection — see "Shared code")
55
+ │ └── web/ # shared TS/UI packages
51
56
  ├── contracts/ # versioned API contracts (TMF/OpenAPI, JSON Schemas, protos)
57
+ │ # — the seam the -ms and -mf apps meet at; neither imports the other
52
58
  ├── deploy/ # k8s manifests, pipelines, observability-as-code
53
59
  ├── db/ # database migrations (versioned always; applied at boot only where the Flyway flag is on)
54
60
  ├── docs/ # architecture, ADRs, runbooks
55
61
  └── README.md # domain onboarding doc (see "Domain README")
56
62
  ```
57
63
 
64
+ **Scope of this skill inside the monorepo:** everything from "Slice layout" onward governs `-ms` apps only. `-mf` and `-module` apps are first-class residents of the same repo — they share `contracts/`, `libs/web/`, `deploy/` and `docs/`, and they carry the same mandatory `service.yaml` + `README.md` — but their internal structure is not this standard's business. A frontend never reaches a backend except through a published contract in `contracts/`; there is no shared runtime code between an `-ms` and an `-mf`.
65
+
58
66
  ### Deployable app naming (tenant-agnostic)
59
67
 
60
68
  ```
61
- {module}-{service}-{type-suffix}
69
+ {module}-{service or submodule}-{type-suffix}
62
70
  ```
63
71
 
64
- Suffixes: `-ms` (backend microservice — every Quarkus service here), `-mf` (microfrontend remote), `-module` (host/shell). Example: `wallet-backend-core-ms`. **Client and tenant never appear in app or code names** — they are runtime configuration (namespaces, labels, Helm values) applied at deploy time; the code is identical for every client and tenant.
72
+ Suffixes: `-ms` (backend microservice — every Quarkus service here), `-mf` (microfrontend remote), `-module` (host/shell). Example: `iam-management-ms`. **Client and tenant never appear in app or code names** — they are runtime configuration (namespaces, labels, Helm values) applied at deploy time; the code is identical for every client and tenant.
65
73
 
66
74
  ## Slice layout (the canonical structure)
67
75
 
@@ -79,6 +87,7 @@ src/main/java/com/<company>/<module>/
79
87
  │ ├── error/SqlStateTranslator.java # SQLState -> error code
80
88
  │ ├── i18n/MessageResolver.java # Accept-Language -> localized message
81
89
  │ ├── client/CredentialsValidator.java # outbound gRPC/REST integrations
90
+ │ ├── messaging/OutboxEventRecorder.java # the ONLY writer of the outbox table
82
91
  │ ├── messaging/OutboxRelayJob.java # + OutboxDispatcher, OutboxConfig, OutboxSql
83
92
  │ ├── audit/AuditRecorder.java
84
93
  │ └── util/StringUtils.java
@@ -170,7 +179,7 @@ Several slices may expose operations under the same TMF base path (`create_party
170
179
 
171
180
  | Kind | Convention | Example |
172
181
  |---|---|---|
173
- | Deployable app | `{module}-{service}-{type}` kebab-case, tenant-agnostic (`-ms` backend) | `wallet-backend-core-ms` |
182
+ | Deployable app | `{module}-{service or submodule}-{type}` kebab-case, tenant-agnostic (`-ms` backend) | `iam-management-ms` |
174
183
  | Slice package | lowercase `snake_case`, verb-first | `com.alva.customer.create_party_individual` |
175
184
  | SQL constant | `UPPER_SNAKE_CASE` matching the method | `INSERT_PARTY`, `SELECT_PARTY_BY_ID` |
176
185
  | SQL table / column | `snake_case`, singular table, schema-qualified | `customer.party`, `created_at` |
@@ -191,7 +200,8 @@ Several slices may expose operations under the same TMF base path (`create_party
191
200
  3. @ApplicationScoped
192
201
  4. @Inject DataSource dataSource;
193
202
  5. @Inject ${SERVICE_CLASS_PREFIX}Sql sql;
194
- 6. (optional) @Inject <integration beans from common/client, OutboxDispatcher from common/messaging>
203
+ 6. (optional) @Inject <integration beans from common/client; OutboxEventRecorder +
204
+ OutboxDispatcher from common/messaging; AuditRecorder from common/audit>
195
205
  7. private void validate(RequestDto request)
196
206
  8. private <T> execution(RequestDto request) // T = internal result (UUID, DTO, List<Dto>…)
197
207
  9. private ResponseDto getResult(<T> internalResult)
@@ -220,7 +230,8 @@ private void validate(CreatePartyIndividualRequestDto request) {
220
230
  - Calls `${SERVICE_CLASS_PREFIX}Sql` methods in business order (insert `party` first to get `partyId`, reuse it for dependent inserts). Before writing a call, re-read the real `Sql` method signature and pass exactly those parameters, in that order and type.
221
231
  - Catches every checked `SQLException` and rethrows it as `BusinessException` via `SqlStateTranslator` — an unchecked exception is what triggers the container rollback, and a propagated checked exception would not.
222
232
  - Date fields arrive from the DTO as `String`. To use them as `LocalDate`/`ZonedDateTime`, declare a **new local variable** and convert explicitly (`LocalDate.parse(...)`) — never reassign the DTO field.
223
- - Domain events are published by inserting an outbox row through this slice's own `Sql`, in this same transaction — never by emitting to Kafka directly (see quarkus-kafka-messaging skill). Who relays that row (Debezium, `OutboxRelayJob`, or a post-commit emit) is a runtime property, `app.messaging.outbox.relay-mode`, and changes nothing here.
233
+ - Domain events are published by inserting an outbox row through `OutboxEventRecorder` from `common/messaging`, on this method's `Connection` and therefore in this same transaction — never by emitting to Kafka directly (see quarkus-kafka-messaging skill). Who relays that row (Debezium, `OutboxRelayJob`, or a post-commit emit) is a runtime property, `app.messaging.outbox.relay-mode`, and changes nothing here.
234
+ - **The outbox is cross-cutting, not slice-local: never declare `insertOutboxEvent` on a `<Slice>Sql`.** It is the same table, the same columns and the same envelope in every slice, so the "duplicate twice, extract on the third" rule below does not apply — there is nothing slice-specific to discover. The shared `Connection` is what keeps the write transactional; the class's location is irrelevant to that guarantee. Same for `AuditRecorder` and any other table `common/` owns.
224
235
  - External services are called through capability-named beans injected from `common/client` — never a raw `@GrpcClient` stub, which would drag Mutiny into the Handler.
225
236
 
226
237
  ```java
@@ -234,9 +245,9 @@ private UUID execution(CreatePartyIndividualRequestDto request) {
234
245
  sql.insertContactMedium(conn, partyId, medium.getMediumType(), medium.getContactValue());
235
246
  }
236
247
  }
237
- UUID eventId = sql.insertOutboxEvent(conn, partyId, "party",
248
+ UUID eventId = outbox.record(conn, "party", partyId.toString(),
238
249
  "com.alva.customer.party.created.v1", "alva.customer.party.created.v1",
239
- payloadJson(partyId, request), traceparent()); // traceparent captured from Span.current() (see kafka/observability skills)
250
+ eventMap(partyId, request)); // recorder captures the span and builds the envelope (see kafka/observability skills)
240
251
  dispatcher.dispatchAfterCommit(eventId); // no-op unless relay-mode=direct (see kafka skill)
241
252
  return partyId;
242
253
  } catch (SQLException e) {
@@ -332,7 +343,7 @@ All three are thin: extract metadata, invoke `handler.process(...)`, format the
332
343
 
333
344
  - `<Resource>Resource` class with `@Path` + `@Tag`, injecting the slice `Handler`(s).
334
345
  - Returns `RestResponse<T>` synchronously. Never `Uni`/`Multi`.
335
- - `@Blocking` on JDBC-backed methods. With a plain (non-reactive) return type Quarkus REST already dispatches to a worker thread, so the annotation is redundant *today* — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the alternative for high-concurrency blocking endpoints (see sql skill §7).
346
+ - `@Blocking` on JDBC-backed methods. With a plain (non-reactive) return type Quarkus REST already dispatches to a worker thread, so the annotation is redundant *today* — keep it as an explicit threading contract that survives a later signature change. `@RunOnVirtualThread` is the alternative for high-concurrency blocking endpoints (see sql skill §8).
336
347
  - Extracts `tenantId`, `partyId`, `partyRolList`, `language` from headers when the contract requires them, and sets them on the request DTO before calling `process()`.
337
348
  - Full OpenAPI annotations per method: `@Operation` with `operationId`, one `@APIResponse` per relevant status, `@Parameter` for headers and path/query params, `Location` header on create.
338
349
 
@@ -373,9 +384,11 @@ Governed by **quarkus-observability-otel**; the slice-relevant rules:
373
384
 
374
385
  ## Shared code
375
386
 
376
- `common/` holds only what **two or more slices** genuinely share, or what is structurally cross-cutting: the exception/error/i18n machinery, integration beans, `OutboxRelayJob`/`OutboxDispatcher`, `AuditRecorder`, `@ConfigMapping` interfaces, `StringUtils`.
387
+ `common/` holds only what **two or more slices** genuinely share, or what is structurally cross-cutting: the exception/error/i18n machinery, integration beans, `OutboxEventRecorder`/`OutboxSql`/`OutboxRelayJob`/`OutboxDispatcher`, `AuditRecorder`, `@ConfigMapping` interfaces, `StringUtils`.
388
+
389
+ Cross-cutting **tables** are exempt from the promotion rule and shared from day one — the outbox and audit tables have one writer each (`OutboxEventRecorder`, `AuditRecorder`), never a copy per slice. The test is whether the code is *identical by construction*: a slice cannot have its own opinion about the outbox schema, so waiting for a third occurrence just buys 3× the drift. Everything else follows the rule below.
377
390
 
378
- Promotion rule: **duplicate twice, extract on the third.** Two slices with similar-looking code is the expected cost of slice independence — premature extraction rebuilds the shared-layer coupling this structure exists to avoid. What must never be shared: a `Handler`, an `Sql`, or a slice's `dto` package. If slice B needs slice A's data, it queries it through its own `Sql` method or consumes A's event — it does not import A.
391
+ Promotion rule: **duplicate twice, extract on the third.** Two slices with similar-looking code is the expected cost of slice independence — premature extraction rebuilds the shared-layer coupling this structure exists to avoid. What must never be shared: a `Handler`, a **slice's** `Sql`, or a slice's `dto` package (the cross-cutting `Sql` classes `common/` owns are the exception above, not a violation). If slice B needs slice A's data, it queries it through its own `Sql` method or consumes A's event — it does not import A.
379
392
 
380
393
  Code shared across **apps** in the domain goes to `libs/` and uses constructor injection.
381
394
 
@@ -383,8 +396,8 @@ Code shared across **apps** in the domain goes to `libs/` and uses constructor i
383
396
 
384
397
  When creating a new service:
385
398
 
386
- 1. App folder `apps/<module>-<service>-ms/` per the monorepo layout above.
387
- 2. `pom.xml` with BOM `io.quarkus.platform:quarkus-bom` (latest 3.x LTS), extensions: `quarkus-rest`, `quarkus-rest-jackson`, `quarkus-agroal`, `quarkus-jdbc-postgresql`, `quarkus-arc`, `quarkus-narayana-jta`, `quarkus-hibernate-validator`, `quarkus-smallrye-health`, `quarkus-smallrye-openapi`, `quarkus-opentelemetry`, `quarkus-flyway` (kept even where migrations are disabled — see sql skill §9), plus Lombok and skill-specific extensions as needed.
399
+ 1. App folder `apps/<module>-<service-or-submodule>-ms/` per the monorepo layout above.
400
+ 2. `pom.xml` with BOM `io.quarkus.platform:quarkus-bom` (latest 3.x LTS), extensions: `quarkus-rest`, `quarkus-rest-jackson`, `quarkus-agroal`, `quarkus-jdbc-postgresql`, `quarkus-arc`, `quarkus-narayana-jta`, `quarkus-hibernate-validator`, `quarkus-smallrye-health`, `quarkus-smallrye-openapi`, `quarkus-opentelemetry`, `quarkus-flyway` (kept even where migrations are disabled — see sql skill §10), plus Lombok and skill-specific extensions as needed.
388
401
  3. `common/` package: `BusinessException`, `ErrorCatalog`, `ErrorDto`, `GlobalExceptionHandler`, `MessageResolver`, `SqlStateTranslator`, `StringUtils`.
389
402
  4. `application.properties` baseline (below).
390
403
  5. ArchUnit test (below) — in the same commit as the first slice, not later.
@@ -409,7 +422,7 @@ quarkus.datasource.db-kind=postgresql
409
422
  quarkus.datasource.jdbc.max-size=16
410
423
 
411
424
  # Schema migrations are OPT-IN and off by default — the app does not own the schema
412
- # in a DBA/pipeline-managed environment (see quarkus-sql-jdbc-agroal skill §9)
425
+ # in a DBA/pipeline-managed environment (see quarkus-sql-jdbc-agroal skill §10)
413
426
  quarkus.flyway.enabled=false
414
427
  quarkus.flyway.migrate-at-start=false
415
428
  %dev.quarkus.flyway.enabled=true
@@ -542,10 +555,18 @@ class ArchitectureTest {
542
555
  .that().haveSimpleNameEndingWith("Sql")
543
556
  .should().onlyBeAccessed().byClassesThat(
544
557
  have(simpleNameEndingWith("Handler"))
545
- // OutboxSql is cross-cutting: its callers are the dispatcher and the relay job,
546
- // which play the Handler role (they own the Connection) outside any slice.
558
+ // OutboxSql is cross-cutting: besides slice Handlers (via OutboxEventRecorder),
559
+ // its callers are the recorder, the dispatcher and the relay job, which play the
560
+ // Handler role (they own the Connection) outside any slice.
547
561
  .or(resideInAPackage("..common.messaging..")));
548
562
 
563
+ // Cross-cutting tables have exactly one writer. Without this rule the per-slice copy
564
+ // grows back on the next generated slice and drifts silently.
565
+ @ArchTest
566
+ static final ArchRule crossCuttingWritesAreCentralised = methods()
567
+ .that().haveNameMatching("insertOutboxEvent|insertAuditEvent")
568
+ .should().beDeclaredInClassesThat().resideInAnyPackage("..common.messaging..", "..common.audit..");
569
+
549
570
  @ArchTest
550
571
  static final ArchRule transportHasNoJdbc = noClasses()
551
572
  .that().haveSimpleNameEndingWith("Resource")
@@ -636,7 +657,7 @@ static final ArchRule bannedSuffixes = noClasses().should().haveSimpleNameEnding
636
657
  .orShould().haveSimpleNameEndingWith("DTO");
637
658
  ```
638
659
 
639
- `common/util/StringUtils` and the `Jdbc` namespace helper (sql skill §6) are the sanctioned exceptions to `bannedSuffixes` — exclude them by fully-qualified name rather than weakening the rule.
660
+ `common/util/StringUtils` and the `Jdbc` namespace helper (sql skill §7) are the sanctioned exceptions to `bannedSuffixes` — exclude them by fully-qualified name rather than weakening the rule.
640
661
 
641
662
  Rules the compiler cannot express (enforce in code review and in the generator checklists):
642
663
 
@@ -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 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.
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)
@@ -47,7 +47,7 @@ Never publish to Kafka in the same breath as a DB write — dual-write problem.
47
47
  1. The slice `Handler` (in the SAME JDBC transaction as the state change) inserts into the outbox table.
48
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), 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):
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 (
@@ -63,6 +63,8 @@ CREATE TABLE outbox_event (
63
63
  );
64
64
  ```
65
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
+
66
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:
67
69
 
68
70
  ```json
@@ -79,15 +81,17 @@ CREATE TABLE outbox_event (
79
81
 
80
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).
81
83
 
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.)
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.
83
87
 
84
88
  ```java
85
89
  private UUID execution(CreatePartyIndividualRequestDto request) {
86
90
  try (Connection conn = dataSource.getConnection()) {
87
91
  UUID partyId = sql.insertParty(conn, ...);
88
- UUID eventId = sql.insertOutboxEvent(conn, partyId, "party",
92
+ UUID eventId = outbox.record(conn, "party", partyId.toString(),
89
93
  "com.alva.customer.party.created.v1", "alva.customer.party.created.v1",
90
- payloadJson(partyId, request), traceparent());
94
+ eventMap(partyId, request)); // recorder captures the span, builds the envelope
91
95
  dispatcher.dispatchAfterCommit(eventId); // no-op unless relay-mode=direct (see Relay mode)
92
96
  return partyId;
93
97
  } catch (SQLException e) {
@@ -96,7 +100,30 @@ private UUID execution(CreatePartyIndividualRequestDto request) {
96
100
  }
97
101
  ```
98
102
 
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`.
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.
100
127
 
101
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.
102
129
 
@@ -228,7 +255,7 @@ Rules:
228
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.
229
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.
230
257
  - `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.
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.
232
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.
233
260
 
234
261
  **Debezium mode** maps outbox columns to the envelope in the connector, not in Java:
@@ -253,7 +280,8 @@ Canonical rules in the quarkus-hexagonal-core skill; messaging specifics:
253
280
  |---|---|---|
254
281
  | Wire payload DTO | `<Event>Payload`, in the producing/consuming slice's `dto/` | `UserRegisteredPayload` |
255
282
  | Consumer | `<Event>Consumer`, in the slice folder | `UserRegisteredConsumer` |
256
- | Outbox write | a method on the slice's own `Sql` | `insertOutboxEvent(conn, ...)` |
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` |
257
285
  | Outbox relay job | `<Task>Job`, in `common/messaging` | `OutboxRelayJob` |
258
286
  | Outbox dispatcher | one per service, in `common/messaging` | `OutboxDispatcher` |
259
287
  | Relay-mode config | `@ConfigMapping` in `common/messaging` | `OutboxConfig.RelayMode` |
@@ -305,7 +333,7 @@ Rules:
305
333
  ## Checklist for a new event
306
334
 
307
335
  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()`.
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.
309
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`.
310
338
  4. `@RegisterForReflection` on the payload; `traceparent` + `traceContext` captured from `Span.current()` at insert time.
311
339
  5. Consumer: `<Event>Consumer` in the slice folder delegating to the `Handler`, idempotency inside the transaction, DLQ topic + owner + alert, `@Blocking`.
@@ -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 §7).
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`).
@@ -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 §7).
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 (§8). No `commit`, `rollback` or `setAutoCommit` here, ever.
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 (§8) | `TransientPersistenceException` |
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
- sql.insertOutboxEvent(conn, partyId, "party", EVENT_TYPE, TOPIC, payloadJson(partyId, request), traceparent());
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 §8 translation is mandatory.
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` (§8), not swallowed here.
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. A tiny helper is allowed; a framework is not
208
+ ## 6. Cross-cutting tables get a cross-cutting `Sql`
209
209
 
210
- To kill boilerplate, ONE small internal helper class (~50 lines) per service or shared lib is the sanctioned maximum:
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
- ## 7. Blocking model: worker threads or virtual threads (and when reactive)
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
- ## 8. SQLException translation
250
+ ## 9. SQLException translation
238
251
 
239
252
  `Sql` methods propagate `SQLException`; the `Handler` catches it once, in `execution()`, and translates it through `SqlStateTranslator` (`common/error`) so nothing checked ever escapes `process()` — a checked exception would not trigger the container rollback:
240
253
 
@@ -256,7 +269,7 @@ Every resulting code is registered in `ErrorCatalog` and present in all locale b
256
269
 
257
270
  Set `statement_timeout` (session or per-datasource via `quarkus.datasource.jdbc.additional-jdbc-properties.options=-c statement_timeout=5000`) so runaway queries fail fast instead of holding pool connections.
258
271
 
259
- ## 9. Schema migrations (Flyway — opt-in, disabled by default)
272
+ ## 10. Schema migrations (Flyway — opt-in, disabled by default)
260
273
 
261
274
  The schema is versioned in git as SQL under `db/` in every environment. **Who applies it is a flag**, because a service is rarely allowed to alter its own schema in production — that is a DBA or pipeline responsibility, and a native binary starting up in a replica set is the worst possible place to run DDL.
262
275
 
@@ -289,11 +302,11 @@ Rules:
289
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.
290
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.
291
304
 
292
- ## 10. Testing
305
+ ## 11. Testing
293
306
 
294
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.
295
308
  - `<Slice>Handler` tests are pure Mockito with a mocked `Sql` — no database (see quarkus-hexagonal-core skill).
296
- - Flyway migrations run at test start — `%test` is one of the two profiles where the flag is on (`quarkus.flyway.enabled=true` + `migrate-at-start=true`, see §9), so tests validate DDL and queries together against the same SQL production will receive.
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.
297
310
  - Native verification: `@QuarkusIntegrationTest` re-runs the same tests against the binary.
298
311
 
299
312
  ## Checklist for a new `Sql` method
@@ -306,3 +319,4 @@ Rules:
306
319
  5. `executeUpdate()` count returned or checked; `SQLException` propagated for the `Handler` to translate, never swallowed.
307
320
  6. Generated ids via `RETURNING`; jsonb via `PGobject`; batch + chunking for bulk; keyset pagination if deep.
308
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.