bmad-method-quarkus 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json 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.6",
5
5
  "description": "BMAD Method with Quarkus support",
6
6
  "keywords": [
7
7
  "agile",
@@ -44,7 +44,7 @@ Ultra-succinct, citable, and role-aware. You speak in file paths, AC IDs, and cl
44
44
 
45
45
  ## Domain Standards
46
46
 
47
- These 7 standards are installed at `.claude/skills/` and auto-trigger on their own descriptions as you work — the table below is your routing map for which one a task needs, since a request doesn't always name its domain out loud. `quarkus-hexagonal-core` defines the vocabulary the other six use, so it is the one you apply first:
47
+ These 9 standards are installed at `.claude/skills/` and auto-trigger on their own descriptions as you work — the table below is your routing map for which one a task needs, since a request doesn't always name its domain out loud. `quarkus-hexagonal-core` defines the vocabulary the other eight use, so it is the one you apply first. Eight are universal; `quarkus-temporal-workflows` applies only to projects that actually use Temporal:
48
48
 
49
49
  | Skill | Consult when the task touches |
50
50
  | --- | --- |
@@ -55,6 +55,8 @@ These 7 standards are installed at `.claude/skills/` and auto-trigger on their o
55
55
  | `quarkus-grpc-services` | `.proto` files, a `GrpcService` adapter, or an outbound call wrapped in `common/client` |
56
56
  | `quarkus-kafka-messaging` | Domain events — the outbox row, the relay mode, a `Consumer`, DLQ, idempotency |
57
57
  | `quarkus-observability-otel` | `@WithSpan` on `process()`, `trace_id`/`span_id` in logs, the `audit_event` context, metrics |
58
+ | `quarkus-security-standards` | Any credential/URL/API key, `.env`/`.env.example`, `application.properties` secrets, OIDC/JWT auth, CORS/TLS, or a security review |
59
+ | `quarkus-temporal-workflows` | **Temporal projects only** — a saga/compensation, a `@WorkflowInterface`/`@ActivityInterface`, the worker bootstrap, a multi-slice process one transaction can't make atomic, or scaffolding a **worker-only deployable** (no REST, no slices — it overrides the slice layout) |
58
60
 
59
61
  ## On Activation
60
62
 
@@ -84,7 +86,25 @@ Fully embody this persona so the user gets the best experience. Do not break cha
84
86
 
85
87
  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
88
 
87
- ### Step 5: Load Config
89
+ ### Step 5: Load the House Standards
90
+
91
+ Read the eight universal 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. The ninth is conditional: read it only when the project uses Temporal.
92
+
93
+ 1. `{project-root}/.claude/skills/quarkus-hexagonal-core/SKILL.md` — **read this one first**; the other eight use the vocabulary it defines
94
+ 2. `{project-root}/.claude/skills/quarkus-sql-jdbc-agroal/SKILL.md`
95
+ 3. `{project-root}/.claude/skills/quarkus-error-handling-i18n/SKILL.md`
96
+ 4. `{project-root}/.claude/skills/quarkus-openapi-tmforum/SKILL.md`
97
+ 5. `{project-root}/.claude/skills/quarkus-grpc-services/SKILL.md`
98
+ 6. `{project-root}/.claude/skills/quarkus-kafka-messaging/SKILL.md`
99
+ 7. `{project-root}/.claude/skills/quarkus-observability-otel/SKILL.md`
100
+ 8. `{project-root}/.claude/skills/quarkus-security-standards/SKILL.md`
101
+ 9. `{project-root}/.claude/skills/quarkus-temporal-workflows/SKILL.md` — **only if the project uses Temporal** (`io.temporal:temporal-sdk` in a `pom.xml`, or an `orchestration/` package). Skip it otherwise; it governs nothing in a project without workflows.
102
+
103
+ 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.
104
+
105
+ 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.
106
+
107
+ ### Step 6: Load Config
88
108
 
89
109
  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
110
 
@@ -92,19 +112,19 @@ Load available config from `{project-root}/_bmad/config.yaml` and `{project-root
92
112
  - `{communication_language}` (system default) — use for all communications
93
113
  - `{document_output_language}` (system default) — use for generated document content
94
114
 
95
- ### Step 6: Greet the User
115
+ ### Step 7: Greet the User
96
116
 
97
117
  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
118
 
99
119
  Continue to prefix your messages with `{agent.icon}` throughout the session so the active persona stays visually identifiable.
100
120
 
101
- ### Step 7: Execute Append Steps
121
+ ### Step 8: Execute Append Steps
102
122
 
103
123
  Execute each entry in `{agent.activation_steps_append}` in order.
104
124
 
105
125
  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
126
 
107
- ### Step 8: Dispatch or Present the Menu
127
+ ### Step 9: Dispatch or Present the Menu
108
128
 
109
129
  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
130
 
@@ -75,7 +75,7 @@ Canonical rules in the quarkus-hexagonal-core skill; the error/i18n specifics:
75
75
  | gRPC counterpart | `GrpcExceptionInterceptor`, in `common/error` | |
76
76
  | i18n components | `MessageResolver`, `ErrorCatalog` | |
77
77
  | Error DTO | `ErrorDto` (TMF630 Error shape) | |
78
- | Bundle files | `messages/errors[_<lang>].properties` | `errors_en.properties` |
78
+ | Bundle files | `messages/errors[_<lang>].properties` — the default (en) bundle is `errors.properties`, with no suffix | `errors.properties`, `errors_es.properties` |
79
79
  | Bundle key | the error code itself, never a prose key | `USR-404-001` |
80
80
 
81
81
  `GlobalExceptionHandler` is no longer "the only sanctioned `*Handler`" — under the slice standard `*Handler` is the business-logic class of every slice (`CreatePartyIndividualHandler`). What stays true is that there is exactly **one** global exception handler per transport, both in `common/error`, and that no `Resource`, `GrpcService` or `Consumer` builds an error response itself.
@@ -189,7 +189,11 @@ public class MessageResolver {
189
189
  var locale = headers.getAcceptableLanguages().stream()
190
190
  .findFirst().filter(l -> !"*".equals(l.getLanguage()))
191
191
  .orElse(Locale.ENGLISH); // default locale = en
192
- var bundle = ResourceBundle.getBundle("messages.errors", locale);
192
+ // getNoFallbackControl is REQUIRED: the default Control falls back to the JVM's
193
+ // default locale before the base bundle, so `Accept-Language: fr` on a pod with
194
+ // LANG=es returns Spanish instead of the English default.
195
+ var bundle = ResourceBundle.getBundle("messages.errors", locale,
196
+ ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_PROPERTIES));
193
197
  var pattern = bundle.containsKey(code) ? bundle.getString(code)
194
198
  : bundle.getString("GEN-500-001");
195
199
  return MessageFormat.format(pattern, args);
@@ -1,14 +1,16 @@
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)
7
7
 
8
- Foundation skill for all backend services. The other skills in this set (sql-jdbc-agroal, error-handling-i18n, openapi-tmforum, grpc-services, kafka-messaging, observability-otel) build on the structure defined here and use the vocabulary defined here — apply this one first when scaffolding.
8
+ Foundation skill for all backend services. The other eight skills in this set (`quarkus-sql-jdbc-agroal`, `quarkus-error-handling-i18n`, `quarkus-openapi-tmforum`, `quarkus-grpc-services`, `quarkus-kafka-messaging`, `quarkus-observability-otel`, `quarkus-security-standards`, and `quarkus-temporal-workflows` on Temporal projects) build on the structure defined here and use the vocabulary defined here — apply this one first when scaffolding.
9
9
 
10
10
  **Scope & precedence:** these standards apply to any Java/Quarkus backend project, whether or not it is organized as DDD bounded contexts. **Module naming:** every module/bounded context is identified in code by a short, lowercase, semantic name (`iam`, `tenant`, `wallet`, `customer`) — never by an inventory/governance code like `bc01`/`bc02`. If the organization keeps a numbered BC inventory, the `bcNN` ↔ name mapping lives in governance docs (domain README identity table, service catalog); packages, topics, config keys, URLs, metrics and proto packages only ever carry the semantic name. They are the default, not law: a project directive that says otherwise (CLAUDE.md, an ADR, an explicit user instruction) wins over this skill. When you deviate because of such a directive, follow the directive and mention which rule was overridden.
11
11
 
12
+ **One structural exception:** a **Temporal worker-only deployable** (`{module}-{capability}-worker`) — an app that hosts workflows and activities and exposes no API — has no vertical slices, no `Resource`, no `<Slice>Handler`/`<Slice>Sql` pair and usually no datasource. The naming rules, monorepo layout, native build, Dockerfile and the `service.yaml` + README standard below still apply to it in full; the slice layout, the REST adapter rules and the slice-oriented ArchUnit rules do not. See the quarkus-temporal-workflows skill §3 before scaffolding one — do not give it slices because this skill's checklist says so.
13
+
12
14
  ## The central idea: one feature = one folder
13
15
 
14
16
  Classic layered hexagonal architecture spreads a single feature across eight or more files (inbound port, use case, command, result, outbound port, JDBC adapter, DTO, mapper) in four packages. This standard keeps the **hexagon's dependency rule** but collapses it into a **vertical slice**: everything one business capability needs lives in one folder, and the hexagon is expressed by *class role*, not by package depth.
@@ -36,32 +38,43 @@ The trade the slice makes explicit: **fewer classes, stricter roles.** There are
36
38
 
37
39
  ## Monorepo layout (per domain/module)
38
40
 
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.
41
+ 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
42
 
41
43
  ```
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
44
+ / (domain monorepo, e.g. iam)
45
+ ├── apps/ # deployable applications, one folder each — backends AND frontends
46
+ ├── iam-management-ms/ # Quarkus backend (native) — structured per "Slice layout" below
47
+ ├── src/
48
+ ├── pom.xml
49
+ ├── Dockerfile # this app's image build (native)
50
+ ├── service.yaml # structured metadata — JSON-Schema validated in CI
51
+ └── README.md # per-app operational doc (see "Per-app documentation")
52
+ ├── iam-identity-ms/ # a second backend in the same domain, same internal structure
53
+ │ ├── iam-onboarding-worker/ # Temporal worker — no API, no slices (see temporal skill)
54
+ │ ├── iam-admin-mf/ # microfrontend remote — own build, same service.yaml + README rule
55
+ │ └── iam-shell-module/ # host/shell that composes the remotes
56
+ ├── libs/ # code shared *within* this domain — never across domains
57
+ │ ├── java/ # shared Java modules (constructor injection — see "Shared code")
58
+ │ └── web/ # shared TS/UI packages
51
59
  ├── contracts/ # versioned API contracts (TMF/OpenAPI, JSON Schemas, protos)
60
+ │ # — the seam the -ms and -mf apps meet at; neither imports the other
52
61
  ├── deploy/ # k8s manifests, pipelines, observability-as-code
53
62
  ├── db/ # database migrations (versioned always; applied at boot only where the Flyway flag is on)
54
63
  ├── docs/ # architecture, ADRs, runbooks
55
64
  └── README.md # domain onboarding doc (see "Domain README")
56
65
  ```
57
66
 
67
+ **Scope of this skill inside the monorepo:** everything from "Slice layout" onward governs `-ms` apps only. A `-worker` app is a backend and follows this skill's naming, native build and documentation rules, but it has no slices — its internal structure belongs to the quarkus-temporal-workflows skill. `-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`.
68
+
58
69
  ### Deployable app naming (tenant-agnostic)
59
70
 
60
71
  ```
61
- {module}-{service}-{type-suffix}
72
+ {module}-{service or submodule}-{type-suffix}
62
73
  ```
63
74
 
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.
75
+ Suffixes: `-ms` (backend microservice serving an API), `-worker` (backend deployable that serves **no** API currently Temporal workflow workers; see the quarkus-temporal-workflows skill), `-mf` (microfrontend remote), `-module` (host/shell). Examples: `iam-management-ms`, `customer-onboarding-worker`.
76
+
77
+ `-ms` vs `-worker` is a deploy-time contract, not a label: an `-ms` gets an ingress, HTTP probes and RPS-based scaling; a `-worker` gets none of those and scales on queue backlog. An app that serves an API *and* hosts a worker is an `-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
78
 
66
79
  ## Slice layout (the canonical structure)
67
80
 
@@ -79,6 +92,7 @@ src/main/java/com/<company>/<module>/
79
92
  │ ├── error/SqlStateTranslator.java # SQLState -> error code
80
93
  │ ├── i18n/MessageResolver.java # Accept-Language -> localized message
81
94
  │ ├── client/CredentialsValidator.java # outbound gRPC/REST integrations
95
+ │ ├── messaging/OutboxEventRecorder.java # the ONLY writer of the outbox table
82
96
  │ ├── messaging/OutboxRelayJob.java # + OutboxDispatcher, OutboxConfig, OutboxSql
83
97
  │ ├── audit/AuditRecorder.java
84
98
  │ └── util/StringUtils.java
@@ -170,7 +184,7 @@ Several slices may expose operations under the same TMF base path (`create_party
170
184
 
171
185
  | Kind | Convention | Example |
172
186
  |---|---|---|
173
- | Deployable app | `{module}-{service}-{type}` kebab-case, tenant-agnostic (`-ms` backend) | `wallet-backend-core-ms` |
187
+ | Deployable app | `{module}-{service or submodule}-{type}` kebab-case, tenant-agnostic (`-ms` backend with an API, `-worker` backend without one) | `iam-management-ms`, `customer-onboarding-worker` |
174
188
  | Slice package | lowercase `snake_case`, verb-first | `com.alva.customer.create_party_individual` |
175
189
  | SQL constant | `UPPER_SNAKE_CASE` matching the method | `INSERT_PARTY`, `SELECT_PARTY_BY_ID` |
176
190
  | SQL table / column | `snake_case`, singular table, schema-qualified | `customer.party`, `created_at` |
@@ -191,7 +205,8 @@ Several slices may expose operations under the same TMF base path (`create_party
191
205
  3. @ApplicationScoped
192
206
  4. @Inject DataSource dataSource;
193
207
  5. @Inject ${SERVICE_CLASS_PREFIX}Sql sql;
194
- 6. (optional) @Inject <integration beans from common/client, OutboxDispatcher from common/messaging>
208
+ 6. (optional) @Inject <integration beans from common/client; OutboxEventRecorder +
209
+ OutboxDispatcher from common/messaging; AuditRecorder from common/audit>
195
210
  7. private void validate(RequestDto request)
196
211
  8. private <T> execution(RequestDto request) // T = internal result (UUID, DTO, List<Dto>…)
197
212
  9. private ResponseDto getResult(<T> internalResult)
@@ -218,9 +233,10 @@ private void validate(CreatePartyIndividualRequestDto request) {
218
233
 
219
234
  - Opens **one** `Connection` from the injected `DataSource` in try-with-resources and passes it to every `Sql` call in the operation. This is the performance contract: one connection per business operation, whether or not a transaction is active. Under JTA, Agroal would return the same enlisted connection anyway; on a read path with no `@Transactional`, per-method `getConnection()` would acquire *N* separate pool leases for *N* queries — passing `conn` makes both paths cost exactly one.
220
235
  - 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
- - 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.
236
+ - Catches every checked `SQLException` and rethrows it **unchecked** via `SqlStateTranslator` — as a `BusinessException` for the states that mean a business conflict (`23505`, `23503`) and as the `PersistenceException` family for everything else (see the mapping table in quarkus-sql-jdbc-agroal skill §9). An unchecked exception is what triggers the container rollback; a propagated checked one would not.
222
237
  - 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.
238
+ - 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.
239
+ - **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
240
  - 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
241
 
226
242
  ```java
@@ -234,9 +250,9 @@ private UUID execution(CreatePartyIndividualRequestDto request) {
234
250
  sql.insertContactMedium(conn, partyId, medium.getMediumType(), medium.getContactValue());
235
251
  }
236
252
  }
237
- UUID eventId = sql.insertOutboxEvent(conn, partyId, "party",
253
+ UUID eventId = outbox.record(conn, "party", partyId.toString(),
238
254
  "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)
255
+ eventMap(partyId, request)); // recorder captures the span and builds the envelope (see kafka/observability skills)
240
256
  dispatcher.dispatchAfterCommit(eventId); // no-op unless relay-mode=direct (see kafka skill)
241
257
  return partyId;
242
258
  } catch (SQLException e) {
@@ -310,7 +326,7 @@ Full standard (pool config, batches, jsonb, pagination, keyset paging, SQLState
310
326
  - Constants are text blocks or comma-first concatenations, schema-qualified, with positional `?` placeholders only. **Check the real schema name before writing a query** — if the schema or table is a reserved word (`order`, `user`, `group`), quote it: `"order".party`.
311
327
  - If the PK has a database default (`id uuid DEFAULT customer.uuidv7() NOT NULL`), the `id` column is excluded from the INSERT column list and never passed as a parameter. Prefer `INSERT ... RETURNING id` over `getGeneratedKeys()`.
312
328
  - **Every method's first parameter is `Connection conn`** — supplied by the Handler, never obtained inside the method. That signature is the contract the Handler must honour exactly.
313
- - Methods `throws SQLException` — they never swallow it and never translate it. Translation to `BusinessException` happens in the Handler via `SqlStateTranslator`.
329
+ - Methods `throws SQLException` — they never swallow it and never translate it. Translation happens in the Handler via `SqlStateTranslator`, into `BusinessException` or the `PersistenceException` family depending on the SQLState.
314
330
  - **No transaction control here.** No `commit`, no `rollback`, no `setAutoCommit`.
315
331
  - Row mapping is hand-written into DTOs (`PartyDto.builder()...`) — no reflection-based mappers, which also keeps native images small.
316
332
 
@@ -332,8 +348,8 @@ All three are thin: extract metadata, invoke `handler.process(...)`, format the
332
348
 
333
349
  - `<Resource>Resource` class with `@Path` + `@Tag`, injecting the slice `Handler`(s).
334
350
  - 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).
336
- - Extracts `tenantId`, `partyId`, `partyRolList`, `language` from headers when the contract requires them, and sets them on the request DTO before calling `process()`.
351
+ - `@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).
352
+ - Reads `tenantId` — and any other value that drives authorization, such as `partyId` or `partyRolList` — from the **validated token** (`@Inject JsonWebToken jwt` → `jwt.getClaim(...)`, or `@Claim` — not `SecurityIdentity`, which carries roles and mechanism attributes rather than claims), never from a request header the caller controls (see the Tenancy model rule under "Per-app documentation" and quarkus-security-standards skill §4). `language` and other non-security metadata may come from headers. It sets them on the request DTO before calling `process()`.
337
353
  - 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
354
 
339
355
  **gRPC** — full standard in **quarkus-grpc-services**:
@@ -373,18 +389,20 @@ Governed by **quarkus-observability-otel**; the slice-relevant rules:
373
389
 
374
390
  ## Shared code
375
391
 
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`.
392
+ `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`.
393
+
394
+ 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
395
 
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.
396
+ 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
397
 
380
398
  Code shared across **apps** in the domain goes to `libs/` and uses constructor injection.
381
399
 
382
400
  ## Scaffolding checklist
383
401
 
384
- When creating a new service:
402
+ When creating a new service. A **Temporal worker-only deployable** follows the same steps with a reduced scope (quarkus-temporal-workflows skill §3): in step 2 drop `quarkus-rest*`, `quarkus-smallrye-openapi`, and — unless it owns a table of its own — `quarkus-agroal`, `quarkus-jdbc-*` and `quarkus-flyway`; in step 3 keep only the error/exception classes it actually throws; skip step 5's slice-oriented rules and ship the ArchUnit subset that skill lists. It has no slices and no `Resource`.
385
403
 
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.
404
+ 1. App folder `apps/<module>-<service-or-submodule>-ms/` per the monorepo layout above — `-worker` instead of `-ms` for a Temporal worker deployable.
405
+ 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-micrometer-registry-prometheus` (mandatory metrics — brings `quarkus-micrometer` transitively) and `quarkus-logging-json` (mandatory structured logs) — the three observability extensions are all required, see that skill — `quarkus-oidc` wherever the app exposes an authenticated API (security skill §4), `quarkus-flyway` (kept even where migrations are disabled — see sql skill §10), plus Lombok and skill-specific extensions as needed.
388
406
  3. `common/` package: `BusinessException`, `ErrorCatalog`, `ErrorDto`, `GlobalExceptionHandler`, `MessageResolver`, `SqlStateTranslator`, `StringUtils`.
389
407
  4. `application.properties` baseline (below).
390
408
  5. ArchUnit test (below) — in the same commit as the first slice, not later.
@@ -409,7 +427,7 @@ quarkus.datasource.db-kind=postgresql
409
427
  quarkus.datasource.jdbc.max-size=16
410
428
 
411
429
  # 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)
430
+ # in a DBA/pipeline-managed environment (see quarkus-sql-jdbc-agroal skill §10)
413
431
  quarkus.flyway.enabled=false
414
432
  quarkus.flyway.migrate-at-start=false
415
433
  %dev.quarkus.flyway.enabled=true
@@ -421,8 +439,12 @@ quarkus.flyway.migrate-at-start=false
421
439
  app.messaging.outbox.relay-mode=debezium
422
440
  app.messaging.outbox.poller.every=off
423
441
 
442
+ # Metrics — Micrometer is mandatory in every service (see quarkus-observability-otel skill)
443
+ quarkus.micrometer.export.prometheus.enabled=true
444
+ quarkus.datasource.metrics.enabled=true
445
+
424
446
  # i18n (see error-handling skill) — locales must be declared for native
425
- quarkus.locales=en,es
447
+ quarkus.locales=en,es,pt
426
448
  quarkus.default-locale=en
427
449
  quarkus.native.resources.includes=messages/*.properties
428
450
 
@@ -471,13 +493,13 @@ Rules:
471
493
 
472
494
  Every deployable under `apps/<app>/` carries two files, versioned with the code and updated in the **same PR** that changes the service's behavior:
473
495
 
474
- - **`service.yaml`** — structured metadata validated against a JSON Schema in CI: identity (domain, system, component type, lifecycle), ownership (team, SRE, support group), criticality, tenancy (model, data isolation), runtime (type, version, ports), provided APIs with their contract files in `contracts/`, dependencies with `hard`/`soft` requirement, and the observability contract (log format, levels, required fields, data-protection rules). It feeds the service catalog and the dependency map.
496
+ - **`service.yaml`** — structured metadata validated against a JSON Schema in CI: identity (domain, system, component type, lifecycle), **the API gateway base path(s) under `metadata.gateway`** (see "Gateway routing" below), ownership (team, SRE, support group), criticality, tenancy (model, data isolation), runtime (type, version, ports), provided APIs with their contract files in `contracts/`, dependencies with `hard`/`soft` requirement, and the observability contract (log format, levels, required fields, data-protection rules). It feeds the service catalog, the dependency map and the gateway route configuration.
475
497
  - **`README.md`** (per app) — operational doc written so a system administrator who has never seen the code can configure, deploy and recover the service. No `TBD` / "see code" (a justified `N/A` is allowed). No manual changelog section — Git keeps the history.
476
498
 
477
499
  Required `README.md` sections:
478
500
 
479
501
  1. **Identity** — app name, module (`part-of`), type (`ms`), owning team, repository, criticality, part of core yes/no
480
- 2. **Purpose & responsibility** — description + out of scope
502
+ 2. **Purpose, responsibility & gateway routes** — description + out of scope, **plus the API gateway base path(s) this service is published under** (see "Gateway routing" below). A `-worker` app declares `N/A — publishes no API`.
481
503
  3. **Slice inventory** — table: slice folder, capability, exposed channels (REST/gRPC/Kafka), link to the slice README
482
504
  4. **Tenancy model** — `shared` | `per-tenant` | `pool`; tenant identification (validated JWT claim, never a free header); data isolation; per-tenant parameters
483
505
  5. **APIs exposed** — table: api/version, protocol, route/topic, contract file, auth, visibility
@@ -496,6 +518,39 @@ Required `README.md` sections:
496
518
 
497
519
  Rule of completeness: if a variable, secret or dependency is not documented here, it does not exist — CI validates the `service.yaml` schema and the mandatory `README.md` sections as a blocking gate.
498
520
 
521
+ ### Gateway routing (README §2 + `service.yaml` `metadata.gateway`)
522
+
523
+ Infra configures the API gateway from these two files, so the route a service will be published under is declared **before** it is deployed, in the same PR as the endpoint.
524
+
525
+ **The base path is the module's semantic name**, the same name packages, topics, config keys and metrics already use: `/iam`, `/customer`, `/wallet` — **never the inventory code** (`/bc01` is wrong; the `bcNN` ↔ name mapping lives in governance docs, not in a URL). Every north-bound REST API of every `-ms` in the module therefore hangs off one shared prefix, and the API's own path continues from there per the quarkus-openapi-tmforum skill: `/{module}/{context}/{apiName}/v{major}`, e.g. `/iam/tmf-api/digitalIdentityManagement/v4`.
526
+
527
+ README §2 carries the human-readable table:
528
+
529
+ | Base path | Protocol | Visibility | Purpose |
530
+ |---|---|---|---|
531
+ | `/iam` | REST | public | north-bound TMF identity APIs |
532
+
533
+ `service.yaml` carries the same facts as structured metadata:
534
+
535
+ ```yaml
536
+ metadata:
537
+ name: iam-management-ms
538
+ module: iam
539
+ gateway:
540
+ base-paths:
541
+ - path: /iam
542
+ protocol: REST
543
+ visibility: public # public | partner | internal
544
+ purpose: north-bound TMF identity APIs
545
+ ```
546
+
547
+ Rules:
548
+ - **`service.yaml` is authoritative; the README renders the same facts for humans.** They must agree — two copies of one fact drift, and here the drift is a misrouted service. CI cross-checks them.
549
+ - **Only north-bound REST appears here.** Internal gRPC never crosses the gateway (internal calls are gRPC, external is REST — see "Baseline constraints"), so a `*GrpcService` contributes no gateway route.
550
+ - **A `-worker` app declares none** — `metadata.gateway.base-paths: []` and `N/A` in the README. It publishes no API by definition (see the quarkus-temporal-workflows skill).
551
+ - List **every** prefix when a service is published under more than one — a legacy alias kept during a migration, or a second public API — each with its own purpose, so nobody has to guess which is being retired.
552
+ - The declared path must match the ingress/gateway configuration in the GitOps repo. A README that disagrees with what is deployed is a defect, not a stale doc: it is the input infra works from.
553
+
499
554
  ### Slice README (inside the slice folder)
500
555
 
501
556
  Every slice carries its own `README.md`, generated from the real code — no invented routes, fields or tables. Sections:
@@ -534,7 +589,10 @@ class ArchitectureTest {
534
589
  .namingSlices("slice $1")
535
590
  .should().notDependOnEachOther()
536
591
  .ignoreDependency(resideInAPackage("..common.."), alwaysTrue())
537
- .ignoreDependency(alwaysTrue(), resideInAPackage("..common.."));
592
+ .ignoreDependency(alwaysTrue(), resideInAPackage("..common.."))
593
+ // Temporal only: orchestration/ may reach into slices, never the reverse (deliberately
594
+ // asymmetric — see quarkus-temporal-workflows skill §3). Omit on non-Temporal projects.
595
+ .ignoreDependency(resideInAPackage("..orchestration.."), alwaysTrue());
538
596
 
539
597
  // --- Hexagon dependency rule inside the slice ------------------------------
540
598
  @ArchTest
@@ -542,10 +600,18 @@ class ArchitectureTest {
542
600
  .that().haveSimpleNameEndingWith("Sql")
543
601
  .should().onlyBeAccessed().byClassesThat(
544
602
  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.
603
+ // OutboxSql is cross-cutting: besides slice Handlers (via OutboxEventRecorder),
604
+ // its callers are the recorder, the dispatcher and the relay job, which play the
605
+ // Handler role (they own the Connection) outside any slice.
547
606
  .or(resideInAPackage("..common.messaging..")));
548
607
 
608
+ // Cross-cutting tables have exactly one writer. Without this rule the per-slice copy
609
+ // grows back on the next generated slice and drifts silently.
610
+ @ArchTest
611
+ static final ArchRule crossCuttingWritesAreCentralised = methods()
612
+ .that().haveNameMatching("insertOutboxEvent|insertAuditEvent")
613
+ .should().beDeclaredInClassesThat().resideInAnyPackage("..common.messaging..", "..common.audit..");
614
+
549
615
  @ArchTest
550
616
  static final ArchRule transportHasNoJdbc = noClasses()
551
617
  .that().haveSimpleNameEndingWith("Resource")
@@ -625,7 +691,9 @@ static final ArchRule businessExceptionIsTheOnlyOne = classes()
625
691
  .should().resideInAPackage("..common.exception..");
626
692
 
627
693
  @ArchTest
628
- static final ArchRule bannedSuffixes = noClasses().should().haveSimpleNameEndingWith("Impl")
694
+ static final ArchRule bannedSuffixes = noClasses()
695
+ .that().resideOutsideOfPackage("..orchestration..") // Temporal workflow/activity Impl pair — see below
696
+ .should().haveSimpleNameEndingWith("Impl")
629
697
  .orShould().haveSimpleNameEndingWith("Util") // note: "Utils" is deliberately absent — common/util/StringUtils
630
698
  .orShould().haveSimpleNameEndingWith("Helper")
631
699
  .orShould().haveSimpleNameEndingWith("Manager")
@@ -636,7 +704,7 @@ static final ArchRule bannedSuffixes = noClasses().should().haveSimpleNameEnding
636
704
  .orShould().haveSimpleNameEndingWith("DTO");
637
705
  ```
638
706
 
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.
707
+ `common/util/StringUtils`, the `Jdbc` namespace helper (sql skill §7) and, on Temporal projects, the `*WorkflowImpl`/`*ActivitiesImpl` pair inside `..orchestration..` (whose interface+implementation split the SDK requires — see quarkus-temporal-workflows skill §3) are the sanctioned exceptions to `bannedSuffixes`. Exclude them by package or fully-qualified name, as above, rather than weakening the rule.
640
708
 
641
709
  Rules the compiler cannot express (enforce in code review and in the generator checklists):
642
710
 
@@ -665,7 +733,7 @@ Rules the compiler cannot express (enforce in code review and in the generator c
665
733
 
666
734
  `<Slice>HandlerTest` is mandatory and mirrors the slice. Rules:
667
735
 
668
- - Mockito only — `@Mock <Slice>Sql sql;`, `@Mock DataSource dataSource;`, `@Mock Connection connection;`, `@InjectMocks <Slice>Handler handler;`, with `when(dataSource.getConnection()).thenReturn(connection)` in `@BeforeEach`. Never a real database, never `@QuarkusTest` for pure business rules, never both `@InjectMocks` (Mockito) and `@InjectMock` (Quarkus) in one file.
736
+ - Mockito only — `@Mock <Slice>Sql sql;`, `@Mock DataSource dataSource;`, `@Mock Connection connection;`, `@InjectMocks <Slice>Handler handler;`. Stub `when(dataSource.getConnection()).thenReturn(connection)` **inside the tests that reach `execution()`**, not in `@BeforeEach`: the mandatory validation-failure test (scenario 2 below) never opens a connection, so a shared stub fails it with Mockito's `UnnecessaryStubbingException` under the default strict stubs. Use `lenient()` only if you genuinely prefer the shared setup. Never a real database, never `@QuarkusTest` for pure business rules, never both `@InjectMocks` (Mockito) and `@InjectMock` (Quarkus) in one file.
669
737
  - Test names: `should<Result>When<Condition>` — `shouldReturnPartyIdWhenRequestIsValid`, `shouldThrowBusinessExceptionWhenGivenNameIsBlank`.
670
738
  - Minimum scenarios per public business path:
671
739
 
@@ -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,14 +100,39 @@ 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
 
103
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`:
104
131
 
105
132
  ```java
106
- // common/audit/AuditRecorder.java — the Handler injects AuditRecorder, not the Emitter
133
+ // common/audit/AuditEventPublisher.java — the Handler injects this bean, not the Emitter.
134
+ // Distinct from AuditRecorder (common/audit), which writes the local audit_event table
135
+ // on the Handler's Connection — see quarkus-observability-otel skill.
107
136
  @Inject @Channel("audit-out") Emitter<AuditEventPayload> audit;
108
137
  ```
109
138
 
@@ -228,7 +257,7 @@ Rules:
228
257
  - `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
258
  - `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
259
  - `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.
260
+ - `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
261
  - 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
262
 
234
263
  **Debezium mode** maps outbox columns to the envelope in the connector, not in Java:
@@ -253,7 +282,8 @@ Canonical rules in the quarkus-hexagonal-core skill; messaging specifics:
253
282
  |---|---|---|
254
283
  | Wire payload DTO | `<Event>Payload`, in the producing/consuming slice's `dto/` | `UserRegisteredPayload` |
255
284
  | Consumer | `<Event>Consumer`, in the slice folder | `UserRegisteredConsumer` |
256
- | Outbox write | a method on the slice's own `Sql` | `insertOutboxEvent(conn, ...)` |
285
+ | Outbox write | `OutboxEventRecorder` in `common/messaging`, called by the slice `Handler` — **never** a per-slice `insertOutboxEvent` | `outbox.record(conn, ...)` |
286
+ | Outbox table access | one `OutboxSql` in `common/messaging`, `Connection`-first | `insertOutboxEvent`, `findUnpublished`, `markPublished` |
257
287
  | Outbox relay job | `<Task>Job`, in `common/messaging` | `OutboxRelayJob` |
258
288
  | Outbox dispatcher | one per service, in `common/messaging` | `OutboxDispatcher` |
259
289
  | Relay-mode config | `@ConfigMapping` in `common/messaging` | `OutboxConfig.RelayMode` |
@@ -286,12 +316,12 @@ mp.messaging.incoming.user-registered-in.connector=smallrye-kafka
286
316
  mp.messaging.incoming.user-registered-in.topic=alva.iam.user.registered.v1
287
317
  mp.messaging.incoming.user-registered-in.group.id=${quarkus.application.name}
288
318
  mp.messaging.incoming.user-registered-in.auto.offset.reset=earliest
289
- mp.messaging.incoming.user-registered-in.failure-strategy=delayed-retry-then-dead-letter-queue
319
+ mp.messaging.incoming.user-registered-in.failure-strategy=dead-letter-queue
290
320
  mp.messaging.incoming.user-registered-in.dead-letter-queue.topic=alva.iam.user.registered.v1.dlq
291
321
  ```
292
322
 
293
323
  Rules:
294
- - **Idempotency is mandatory**: `processed_event(consumer_group, event_id)` checked/inserted through the slice's `Sql` on the same `Connection`, in the same transaction as the side effects. Kafka is at-least-once; duplicates WILL happen.
324
+ - **Idempotency is mandatory**: `processed_event(consumer_group, event_id)` checked/inserted through the cross-cutting `ProcessedEventSql` in `common/messaging` — never a per-slice copy, it is the same table for every slice (see quarkus-sql-jdbc-agroal skill §6) — on the `Connection` the `Handler` already owns, in the same transaction as the side effects. Kafka is at-least-once; duplicates WILL happen.
295
325
  - Consumer group = service name; stable across deployments.
296
326
  - Failure strategy: bounded retries with delay, then DLQ topic `<topic>.dlq`. DLQ messages keep original headers plus `dead-letter-reason`. A DLQ must have an owner and an alert — never a silent graveyard.
297
327
  - Poison-pill safety: deserialization failures also route to DLQ (`...deserialization-failure-handler` or failure strategy), never block the partition.
@@ -305,7 +335,7 @@ Rules:
305
335
  ## Checklist for a new event
306
336
 
307
337
  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()`.
338
+ 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
339
  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
340
  4. `@RegisterForReflection` on the payload; `traceparent` + `traceContext` captured from `Span.current()` at insert time.
311
341
  5. Consumer: `<Event>Consumer` in the slice folder delegating to the `Handler`, idempotency inside the transaction, DLQ topic + owner + alert, `@Blocking`.