bmad-method-quarkus 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bmm-skills/agents/bmad-quarkus-build/SKILL.md +25 -5
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-error-handling-i18n/SKILL.md +6 -2
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-hexagonal-core/SKILL.md +106 -38
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-kafka-messaging/SKILL.md +42 -12
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-observability-otel/SKILL.md +191 -13
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-openapi-tmforum/SKILL.md +7 -8
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-security-standards/SKILL.md +132 -0
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-sql-jdbc-agroal/SKILL.md +30 -13
- package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-temporal-workflows/SKILL.md +616 -0
- package/src/commands/quarkus-all.md +6 -5
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/bmm-skills/agents/bmad-quarkus-build/skills/quarkus-error-handling-i18n/SKILL.md
CHANGED
|
@@ -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` | `
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
43
|
-
├── apps/ # deployable applications, one folder each
|
|
44
|
-
│
|
|
45
|
-
│
|
|
46
|
-
│
|
|
47
|
-
│
|
|
48
|
-
│
|
|
49
|
-
│
|
|
50
|
-
├──
|
|
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 —
|
|
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) | `
|
|
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
|
|
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`
|
|
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
|
|
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 =
|
|
253
|
+
UUID eventId = outbox.record(conn, "party", partyId.toString(),
|
|
238
254
|
"com.alva.customer.party.created.v1", "alva.customer.party.created.v1",
|
|
239
|
-
|
|
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
|
|
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 §
|
|
336
|
-
-
|
|
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`,
|
|
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 §
|
|
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 §
|
|
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
|
|
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 &
|
|
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:
|
|
546
|
-
//
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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 §
|
|
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
|
|
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 =
|
|
92
|
+
UUID eventId = outbox.record(conn, "party", partyId.toString(),
|
|
89
93
|
"com.alva.customer.party.created.v1", "alva.customer.party.created.v1",
|
|
90
|
-
|
|
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`
|
|
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/
|
|
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
|
|
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 |
|
|
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=
|
|
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
|
|
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: `
|
|
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`.
|