bmad-method-quarkus 1.0.2 → 1.0.3

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.
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: quarkus-sql-jdbc-agroal
3
- description: Standard for executing SQL queries, updates, batches, and transactions in Quarkus WITHOUT Panache or any ORM — using the Agroal connection pool with plain JDBC (default) or the reactive Vert.x SQL client (justified cases only). Use this skill whenever the user writes or reviews ANY repository, DAO, SQL statement, SELECT/INSERT/UPDATE/DELETE, batch operation, transaction, pagination query, jsonb access, connection pool configuration, or mentions Agroal, JDBC, PreparedStatement, datasource, or "query the database" — all persistence code must follow these patterns, including repository/adapter/row-mapper naming.
3
+ description: Standard for executing SQL queries, updates, batches, and transactions in Quarkus WITHOUT Panache or any ORM — using the Agroal connection pool with plain JDBC (default) or the reactive Vert.x SQL client (justified cases only). Use this skill whenever the user writes or reviews ANY repository, DAO, SQL statement, SELECT/INSERT/UPDATE/DELETE, batch operation, transaction, pagination query, jsonb access, connection pool configuration, or mentions Agroal, JDBC, PreparedStatement, datasource, or "query the database" — all persistence code must follow these patterns. Data access lives in the slice `<Slice>Sql` class with `Connection` as the first parameter of every method (see quarkus-hexagonal-core skill); there are no repositories, DAOs or row-mapper classes.
4
4
  ---
5
5
 
6
6
  # SQL Execution Standard: Agroal + Plain JDBC (No ORM)
7
7
 
8
8
  Applies to any Quarkus backend project; project directives (CLAUDE.md, ADRs, explicit instructions) override these defaults where they conflict.
9
9
 
10
- Persistence is explicit SQL through the Agroal pool. No Panache, no Hibernate, no reflection-based row mappers. Repositories are adapters in `infrastructure/persistence` implementing outbound ports (see quarkus-hexagonal-core skill).
10
+ Persistence is explicit SQL through the Agroal pool. No Panache, no Hibernate, no reflection-based row mappers. Data access lives in the slice's `<Slice>Sql` class the driven adapter of the vertical slice (see quarkus-hexagonal-core skill). There are no `*Repository` ports and no `Jdbc*` adapters: one `Sql` class per slice, called only by that slice's `Handler`.
11
11
 
12
12
  One clarification to keep teams from chasing ghosts: **Agroal is a JDBC (blocking) pool** — there is no "reactive Agroal". The reactive path in Quarkus is the Vert.x SQL client (`quarkus-reactive-pg-client`) with its own pool. Default choice here is **Agroal + JDBC** (simpler, dominant skill base, works perfectly in native); reactive client only for measured hot paths with extreme concurrency (see §7).
13
13
 
@@ -42,131 +42,149 @@ Rules:
42
42
 
43
43
  ## 2. Injection and the golden resource pattern
44
44
 
45
- Inject the pool, never raw drivers, never `DriverManager`:
45
+ Inject the pool, never raw drivers, never `DriverManager`. The `Sql` class holds the `DataSource` but **does not open connections** — the `Handler` opens exactly one per business operation and passes it in (see §3):
46
46
 
47
47
  ```java
48
48
  @ApplicationScoped
49
- public class JdbcUserRepository implements UserRepository {
49
+ public class CreatePartyIndividualSql {
50
50
 
51
- private final AgroalDataSource ds;
52
- public JdbcUserRepository(AgroalDataSource ds) { this.ds = ds; }
51
+ @Inject
52
+ DataSource dataSource; // injected for symmetry/health checks; connections come from the Handler
53
53
  }
54
54
  ```
55
55
 
56
- Every statement uses **try-with-resources on Connection, PreparedStatement AND ResultSet** this is what returns connections to the pool. A missed close under load exhausts the pool and takes the native pod down:
56
+ **Every method's first parameter is `Connection conn`.** That signature is the contract the `Handler` must honour exactly — same name, order, arity and types. Statements use **try-with-resources on PreparedStatement AND ResultSet** (the `Connection` is owned and closed by the caller). A missed close under load exhausts the pool and takes the native pod down:
57
57
 
58
58
  ```java
59
- private static final String FIND_BY_ID = """
60
- SELECT id, email, status, created_at
61
- FROM app_user
62
- WHERE id = ?
59
+ private static final String SELECT_PARTY_BY_ID = """
60
+ SELECT id, tenant_id, party_type, status, created_at
61
+ FROM customer.party
62
+ WHERE id = ?
63
+ AND tenant_id = ?
63
64
  """;
64
65
 
65
- @Override
66
- public Optional<User> findById(UserId id) {
67
- try (var con = ds.getConnection();
68
- var ps = con.prepareStatement(FIND_BY_ID)) {
69
- ps.setObject(1, id.value());
70
- try (var rs = ps.executeQuery()) {
71
- return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
66
+ public PartyDto findPartyById(Connection conn, UUID id, String tenantId) throws SQLException {
67
+ try (PreparedStatement ps = conn.prepareStatement(SELECT_PARTY_BY_ID)) {
68
+ ps.setObject(1, id);
69
+ ps.setString(2, tenantId);
70
+ try (ResultSet rs = ps.executeQuery()) {
71
+ return rs.next() ? mapRow(rs) : null;
72
72
  }
73
- } catch (SQLException e) {
74
- throw translate("user.find_by_id", e);
75
73
  }
76
74
  }
77
75
  ```
78
76
 
79
77
  Absolute rules:
80
- - SQL as `private static final String` text blocks. Never concatenate user input — `PreparedStatement` placeholders ALWAYS (SQL injection + plan cache).
78
+ - SQL as `private static final String` text blocks (comma-first concatenation is equally acceptable), schema-qualified. Never concatenate user input — `PreparedStatement` placeholders ALWAYS (SQL injection + plan cache).
79
+ - Quote reserved-word schemas and tables: `"order".party`. Verify the real schema name before writing the query.
81
80
  - Dynamic WHERE clauses: build from a whitelist of column/operator constants, values still as placeholders.
82
- - One private `mapRow(ResultSet) -> Entity` per repository (or a `RowMapper<T>` functional interface shared via a tiny helper — see §6). No reflection mappers: they break native and hide cost.
81
+ - Methods declare `throws SQLException` and never catch it the `Handler` translates it (§8). No `commit`, `rollback` or `setAutoCommit` here, ever.
82
+ - One private `mapRow(ResultSet) -> Dto` per `Sql` class. Rows map to the slice's own DTOs; there is no separate domain entity and no `*RowMapper` class. No reflection mappers: they break native and hide cost.
83
83
 
84
84
  Naming (canonical rules in the quarkus-hexagonal-core skill):
85
85
 
86
86
  | Artifact | Convention | Example |
87
87
  |---|---|---|
88
- | Outbound port | `<Entity>Repository`, in `application/port/out` | `UserRepository` |
89
- | JDBC adapter | `Jdbc<Port>`, in `infrastructure/persistence` | `JdbcUserRepository` |
90
- | Shared row mapper | `<Entity>RowMapper` (private `mapRow` when not shared) | `UserRowMapper` |
91
- | SQL constant | `UPPER_SNAKE_CASE` verb-first `private static final String` | `FIND_BY_ID`, `INSERT_USER` |
92
- | Table / column | `snake_case`, singular table name | `app_user`, `created_at` |
88
+ | Data access class | `${SERVICE_CLASS_PREFIX}Sql`, in the slice folder | `CreatePartyIndividualSql` |
89
+ | Method | imperative verb, matching the query | `insertParty`, `findPartyById`, `listPartiesByTenant` |
90
+ | SQL constant | `UPPER_SNAKE_CASE` matching the method, `private static final String` | `SELECT_PARTY_BY_ID`, `INSERT_PARTY` |
91
+ | Table / column | `snake_case`, singular table name, schema-qualified | `customer.party`, `created_at` |
92
+ | Row target | the slice's `*Dto` | `PartyDto` |
93
93
  | Technical exception | `PersistenceException` + specific subtypes (§8) | `TransientPersistenceException` |
94
94
 
95
- Never name a repository `*Dao`, `*Manager` or `*Service` — `*Service` is reserved for use-case implementations, and a repository that grows business logic is a use case in disguise.
95
+ Never name this class `*Repository`, `*Dao`, `*Manager` or `*Service` — those suffixes are banned by the core skill's ArchUnit rules. An `Sql` class that grows an `if` encoding a business rule has swallowed logic that belongs in the `Handler`.
96
96
 
97
- ## 3. Transactions: on the use case, not the repository
97
+ ## 3. Transactions and connections: one of each, on `Handler.process()`
98
98
 
99
- The unit of work is the business operation. Annotate the **application-layer use case**:
99
+ The unit of work is the business operation, which in a slice is `process()`. The `Handler` opens **one** `Connection` in `execution()` and passes it to every `Sql` call:
100
100
 
101
101
  ```java
102
102
  @ApplicationScoped
103
- public class RegisterUserService implements RegisterUserUseCase { // impl = *Service, port = *UseCase
104
-
105
- @Transactional
106
- public UserId register(RegisterUserCommand cmd) {
107
- var user = User.register(cmd); // domain
108
- userRepository.save(user); // same tx
109
- eventPublisher.publish(new UserRegistered(user.id())); // same tx (outbox insert)
110
- return user.id();
103
+ public class CreatePartyIndividualHandler {
104
+
105
+ @Inject DataSource dataSource;
106
+ @Inject CreatePartyIndividualSql sql;
107
+
108
+ @Transactional // the ONLY @Transactional in the slice
109
+ public CreatePartyIndividualResponseDto process(CreatePartyIndividualRequestDto request) {
110
+ validate(request);
111
+ return getResult(execution(request));
112
+ }
113
+
114
+ private UUID execution(CreatePartyIndividualRequestDto request) {
115
+ try (Connection conn = dataSource.getConnection()) { // exactly one, for the whole operation
116
+ UUID partyId = sql.insertParty(conn, request.getTenantId(), "Individual",
117
+ "Active", request.getCreatedBy());
118
+ sql.insertIndividual(conn, partyId, request.getGivenName(), request.getFamilyName());
119
+ sql.insertOutboxEvent(conn, partyId, "party", EVENT_TYPE, payloadJson(partyId, request), traceparent());
120
+ return partyId;
121
+ } catch (SQLException e) {
122
+ throw SqlStateTranslator.translate("PTY-500-001", e); // unchecked -> container rolls back
123
+ }
111
124
  }
112
125
  }
113
126
  ```
114
127
 
115
- With `@Transactional` active, Agroal enlists the connection in the JTA transaction automaticallyrepositories keep using `ds.getConnection()` and all writes inside the use case share one transaction, committed/rolled back together. This is exactly what the outbox pattern requires (see quarkus-kafka-messaging skill).
128
+ **Why the explicit `Connection` parameter (performance):** inside an active JTA transaction Agroal returns the *same* enlisted connection for every `getConnection()` call, so both styles cost one physical connection. The difference shows on **read paths that carry no `@Transactional`** there, a per-method `ds.getConnection()` takes *N* pool leases for *N* queries, each with its own acquisition and return. Passing `conn` makes every path, transactional or not, cost exactly one lease. It also keeps session state (`statement_timeout`, prepared-statement cache, temp tables) coherent across the operation.
116
129
 
117
- - Runtime exceptions roll back by default; domain exceptions extend `RuntimeException`, so throwing them rolls back correct by construction.
118
- - Programmatic control when annotations don't fit (loops with per-item commit, batch jobs):
130
+ - `@Transactional` goes on `process()` only never on a private method (CDI interceptors don't fire on self-invocation, so the transaction would silently never open), never on the `Sql` class.
131
+ - **Read-only slices skip `@Transactional` entirely.** A single `SELECT`, or several reads that tolerate a non-repeatable view, are cheaper without a JTA transaction — still one `Connection`, opened and closed in `execution()`. Add the annotation the moment there is a write, or when several reads must see one snapshot.
132
+ - Runtime exceptions roll back by default; `BusinessException` extends `RuntimeException`, so throwing it rolls back — correct by construction. A checked `SQLException` would **not** roll back, which is why §8 translation is mandatory.
133
+ - Programmatic control when annotations don't fit (loops with per-item commit, batch jobs) — from a `common/` bean or a `*Job`, never from inside a slice `Handler`:
119
134
 
120
135
  ```java
121
- QuarkusTransaction.requiringNew().timeout(30).run(() -> { ...repos... });
136
+ QuarkusTransaction.requiringNew().timeout(30).run(() -> { ...sql calls... });
122
137
  ```
123
138
 
124
- - Never call `con.commit()`/`setAutoCommit()` manually inside JTA-managed code paths.
125
- - Read-only single queries need no `@Transactional` (auto-commit read is fine); multi-read consistency or any write → transaction.
139
+ - Never call `con.commit()`/`rollback()`/`setAutoCommit()` anywhere. The core skill ships a CI `grep` gate for exactly these three tokens.
126
140
 
127
141
  ## 4. Updates, inserts, upserts, generated keys
128
142
 
129
143
  ```java
130
- private static final String INSERT = """
131
- INSERT INTO app_user (id, email, status, created_at)
132
- VALUES (?, ?, ?, ?)
133
- ON CONFLICT (email) DO NOTHING
144
+ private static final String INSERT_PARTY = """
145
+ INSERT INTO customer.party (tenant_id, party_type, status, created_by, updated_by)
146
+ VALUES (?, ?, ?, ?, ?)
147
+ RETURNING id
134
148
  """;
135
149
 
136
- @Override
137
- public void save(User u) {
138
- try (var con = ds.getConnection(); var ps = con.prepareStatement(INSERT)) {
139
- ps.setObject(1, u.id().value());
140
- ps.setString(2, u.email().value());
141
- ps.setString(3, u.status().name());
142
- ps.setObject(4, u.createdAt());
143
- if (ps.executeUpdate() == 0) throw new DuplicateEmailException(u.email());
144
- } catch (SQLException e) {
145
- throw translate("user.save", e);
150
+ public UUID insertParty(Connection conn, String tenantId, String partyType,
151
+ String status, String createdBy) throws SQLException {
152
+ try (PreparedStatement ps = conn.prepareStatement(INSERT_PARTY)) {
153
+ ps.setString(1, tenantId);
154
+ ps.setString(2, partyType);
155
+ ps.setString(3, status);
156
+ ps.setString(4, createdBy);
157
+ ps.setString(5, createdBy);
158
+ try (ResultSet rs = ps.executeQuery()) {
159
+ rs.next();
160
+ return rs.getObject("id", UUID.class);
161
+ }
146
162
  }
147
163
  }
148
164
  ```
149
165
 
150
- - Check `executeUpdate()` counts 0 rows on an expected UPDATE is a bug or a concurrency signal, not a success.
151
- - Prefer DB-generated values via `RETURNING` (Postgres) over `getGeneratedKeys()` when you need them: `INSERT ... RETURNING id` + `executeQuery()`.
152
- - Optimistic locking: `version` column, `UPDATE ... WHERE id = ? AND version = ?`; 0 rows → `StaleVersionException` (domain) → 409 via the unified handler. Do NOT name it `ConcurrentModificationException` — it shadows `java.util.ConcurrentModificationException` and an accidental import turns the 409 mapping into a 500.
153
- - Upserts: `ON CONFLICT ... DO UPDATE` explicitly; never SELECT-then-INSERT races.
166
+ - **Generated ids**: when the PK has a database default (`id uuid DEFAULT customer.uuidv7() NOT NULL`), exclude `id` from the column list and never bind it. Read it back with `RETURNING id` + `executeQuery()` — preferred over `getGeneratedKeys()`.
167
+ - Check `executeUpdate()` counts 0 rows on an expected UPDATE is a bug or a concurrency signal, not a success. The `Handler` decides what that means; `Sql` just returns the count.
168
+ - Optimistic locking: `version` column, `UPDATE ... WHERE id = ? AND version = ?`; 0 rows → the `Handler` throws `StaleVersionException` (code-carrying, in `common/exception`) with the slice's conflict code → 409 via its dedicated mapper (see quarkus-error-handling-i18n skill). It is not SQLState-derived — no `SQLException` occurs, so `SqlStateTranslator` never sees it; the `Handler` checks the update count. Do NOT name it `ConcurrentModificationException` — it shadows `java.util.ConcurrentModificationException` and an accidental import turns the 409 mapping into a 500.
169
+ - Upserts: `ON CONFLICT ... DO UPDATE` explicitly; never SELECT-then-INSERT races. A unique-violation (`23505`) surfacing as a `SQLException` is translated by the `Handler` (§8), not swallowed here.
154
170
 
155
171
  ## 5. Batches, pagination, jsonb
156
172
 
157
173
  **Batch** (bulk inserts/updates — outbox relays, imports):
158
174
 
159
175
  ```java
160
- try (var con = ds.getConnection(); var ps = con.prepareStatement(INSERT)) {
161
- for (var item : items) {
162
- bind(ps, item);
163
- ps.addBatch();
176
+ public int[] insertPartiesBatch(Connection conn, List<PartyDto> items) throws SQLException {
177
+ try (PreparedStatement ps = conn.prepareStatement(INSERT_PARTY)) {
178
+ for (PartyDto item : items) {
179
+ bind(ps, item);
180
+ ps.addBatch();
181
+ }
182
+ return ps.executeBatch(); // add ?reWriteBatchedInserts=true to the JDBC URL (Postgres)
164
183
  }
165
- ps.executeBatch(); // add ?reWriteBatchedInserts=true to the JDBC URL (Postgres)
166
184
  }
167
185
  ```
168
186
 
169
- Chunk batches (500–1000) inside long jobs; combine with `QuarkusTransaction` per chunk.
187
+ Chunk batches (500–1000) inside long jobs; combine with `QuarkusTransaction` per chunk, driven from a `*Job` in `common/`, not from a slice `Handler`.
170
188
 
171
189
  **Pagination**: keyset over OFFSET for anything user-facing/deep:
172
190
 
@@ -192,15 +210,17 @@ Read side: `rs.getString("context")` then parse. Index jsonb lookups you actuall
192
210
  To kill boilerplate, ONE small internal helper class (~50 lines) per service or shared lib is the sanctioned maximum:
193
211
 
194
212
  ```java
195
- public final class Jdbc {
213
+ public final class Jdbc { // common/util — the one sanctioned exception to the banned-suffix rule
196
214
  @FunctionalInterface public interface RowMapper<T> { T map(ResultSet rs) throws SQLException; }
197
215
 
198
- public static <T> Optional<T> queryOne(DataSource ds, String sql, RowMapper<T> m, Object... params) { ... }
199
- public static <T> List<T> queryList(DataSource ds, String sql, RowMapper<T> m, Object... params) { ... }
200
- public static int update(DataSource ds, String sql, Object... params) { ... }
216
+ public static <T> Optional<T> queryOne(Connection conn, String sql, RowMapper<T> m, Object... params) throws SQLException { ... }
217
+ public static <T> List<T> queryList(Connection conn, String sql, RowMapper<T> m, Object... params) throws SQLException { ... }
218
+ public static int update(Connection conn, String sql, Object... params) throws SQLException { ... }
201
219
  }
202
220
  ```
203
221
 
222
+ Note the signatures take `Connection`, not `DataSource` — the helper must not open connections either, or it defeats the one-lease-per-operation rule of §3.
223
+
204
224
  - Pure delegation to the try-with-resources pattern above; no reflection, no annotations, no SQL generation. If someone proposes adding criteria builders or entity mapping to it, that's an ORM growing back — reject.
205
225
  - The class is named `Jdbc` — a namespace, not `JdbcUtils`/`JdbcHelper`. The banned-suffix ArchUnit rule (see quarkus-hexagonal-core skill) exists precisely to stop this class from becoming a junk drawer.
206
226
  - jOOQ (code-gen, type-safe SQL) MAY be evaluated as an alternative via formal ADR; MyBatis/Hibernate remain excluded.
@@ -216,30 +236,40 @@ JDBC blocks. Never run it on the event loop:
216
236
 
217
237
  ## 8. SQLException translation
218
238
 
219
- One translator in `infrastructure/persistence`, mapping SQLState to domain/persistence exceptions so callers never see `SQLException`:
239
+ `Sql` methods propagate `SQLException`; the `Handler` catches it once, in `execution()`, and translates it through `SqlStateTranslator` (`common/error`) so nothing checked ever escapes `process()` — a checked exception would not trigger the container rollback:
240
+
241
+ ```java
242
+ } catch (SQLException e) {
243
+ throw SqlStateTranslator.translate("PTY-500-001", e); // default code if the state is not mapped
244
+ }
245
+ ```
220
246
 
221
- | SQLState | Meaning | Throw |
247
+ | SQLState | Meaning | Resulting exception (all unchecked) |
222
248
  |---|---|---|
223
- | `23505` | unique violation | domain conflict exception (→ 409) |
224
- | `23503` | FK violation | domain integrity exception |
249
+ | `23505` | unique violation | `BusinessException` with the slice's conflict code (→ 409) |
250
+ | `23503` | FK violation | `BusinessException` with the slice's integrity code (→ 409/422) |
225
251
  | `40001` / `40P01` | serialization failure / deadlock | retryable `TransientPersistenceException` |
226
252
  | `57014` | statement timeout/cancel | `PersistenceTimeoutException` |
227
253
  | other | infrastructure failure | `PersistenceException(code, e)` (→ 500) |
228
254
 
255
+ Every resulting code is registered in `ErrorCatalog` and present in all locale bundles (see quarkus-error-handling-i18n skill). The client sees a localized message; the `SQLException` stays in the logs.
256
+
229
257
  Set `statement_timeout` (session or per-datasource via `quarkus.datasource.jdbc.additional-jdbc-properties.options=-c statement_timeout=5000`) so runaway queries fail fast instead of holding pool connections.
230
258
 
231
259
  ## 9. Testing
232
260
 
233
- - Repository tests: `@QuarkusTest` + Dev Services (Testcontainers Postgres starts automatically — no config). Real SQL against real Postgres; never H2 (dialect lies).
261
+ - `<Slice>Sql` tests: `@QuarkusTest` + Dev Services (Testcontainers Postgres starts automatically — no config). Real SQL against real Postgres; never H2 (dialect lies). Obtain a `Connection` in the test and pass it in, exactly as the `Handler` does.
262
+ - `<Slice>Handler` tests are pure Mockito with a mocked `Sql` — no database (see quarkus-hexagonal-core skill).
234
263
  - Flyway migrations run at test start (`quarkus.flyway.migrate-at-start=true`) — tests validate DDL and queries together.
235
264
  - Native verification: `@QuarkusIntegrationTest` re-runs the same tests against the binary.
236
265
 
237
- ## Checklist for a new repository method
266
+ ## Checklist for a new `Sql` method
238
267
 
239
- 0. Names follow the canonical table above (`Jdbc<Port>` adapter, `<Entity>Repository` port, `UPPER_SNAKE` SQL constants).
240
- 1. SQL text block constant, placeholders only; whitelist for any dynamic fragment.
241
- 2. try-with-resources on connection/statement/resultset (or the `Jdbc` helper).
242
- 3. Transaction boundary on the use case, not here.
243
- 4. `executeUpdate()` count checked; SQLState translated, never leaked.
244
- 5. jsonb via `PGobject`; batch + chunking for bulk; keyset pagination if deep.
245
- 6. `@Blocking`/virtual thread if called from a reactive context; Dev Services test written.
268
+ 0. Names follow the canonical table above (`<Slice>Sql` class, imperative method name, `UPPER_SNAKE` SQL constant).
269
+ 1. First parameter is `Connection conn`; the method declares `throws SQLException` and opens no connection of its own.
270
+ 2. SQL text block constant, schema-qualified, reserved words quoted, placeholders only; whitelist for any dynamic fragment.
271
+ 3. try-with-resources on statement/resultset (the caller owns the connection).
272
+ 4. No `commit`/`rollback`/`setAutoCommit`; the transaction boundary is `Handler.process()`.
273
+ 5. `executeUpdate()` count returned or checked; `SQLException` propagated for the `Handler` to translate, never swallowed.
274
+ 6. Generated ids via `RETURNING`; jsonb via `PGobject`; batch + chunking for bulk; keyset pagination if deep.
275
+ 7. Rows mapped by hand into the slice's `*Dto`; Dev Services test written.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: quarkus-error-handling-i18n
3
- description: Unified (single) global exception handler and internationalized (i18n) API responses for Quarkus backend services, aligned to TM Forum error schema (TMF630). Use this skill whenever the user mentions exception handling, error responses, ExceptionMapper, error codes, Problem Details, localization, i18n, translations, Accept-Language, message bundles, or when creating/reviewing ANY REST endpoint that can fail — errors and locale handling must always go through this standard, never ad-hoc try/catch in resources. Also defines exception class naming and the English-identifiers/localized-messages rule.
3
+ description: Unified (single) global exception handler and internationalized (i18n) API responses for Quarkus backend services, aligned to TM Forum error schema (TMF630). Use this skill whenever the user mentions exception handling, error responses, ExceptionMapper, error codes, Problem Details, localization, i18n, translations, Accept-Language, message bundles, or when creating/reviewing ANY REST endpoint that can fail — errors and locale handling must always go through this standard, never ad-hoc try/catch in resources. Also defines the single BusinessException type (code + args, never a pre-rendered message), the ErrorCatalog/MessageResolver machinery in common/, and the English-identifiers/localized-messages rule.
4
4
  ---
5
5
 
6
6
  # Unified Exception Handler + i18n Responses (Quarkus, TMF-aligned)
@@ -24,33 +24,42 @@ All error responses use `application/json` with the TMF Error structure (interop
24
24
  }
25
25
  ```
26
26
 
27
- - `code`: stable, machine-readable, defined in the **domain exception**, format `<MOD>-<HTTP>-<seq>` where `<MOD>` is a short module/domain code (the BC code in DDD projects). Never reworded, never localized.
27
+ - `code`: stable, machine-readable, carried by the thrown `BusinessException`, format `<MOD>-<HTTP>-<seq>` where `<MOD>` is a short uppercase code derived from the semantic module name or its main entity (`IAM`, `USR`, `PTY` — never a `bcNN` inventory code; see quarkus-hexagonal-core skill). It is also the **bundle key**. Never reworded, never localized.
28
28
  - `message`: human-readable, **localized** via bundles.
29
29
  - `referenceError`: link to error catalog docs.
30
30
  - Add `traceId` propagation via response header `X-Trace-Id` (see observability skill), not in the body.
31
31
 
32
- ## Exception hierarchy (domain layer, framework-free)
32
+ ## One business exception (`common/exception`)
33
+
34
+ The vertical-slice standard uses a **single** business exception carrying a stable code plus interpolation arguments — not a subclass per error. The subclass hierarchy bought nothing that the code does not already express, and cost one file per error:
33
35
 
34
36
  ```java
35
- // domain/exception
36
- public abstract class DomainException extends RuntimeException {
37
+ // common/exception
38
+ public class BusinessException extends RuntimeException {
37
39
  private final String code; // "USR-404-001"
38
- private final Object[] messageArgs; // interpolated into localized message
39
- protected DomainException(String code, Object... args) {
40
- super(code);
40
+ private final Object[] messageArgs; // interpolated into the localized message
41
+ public BusinessException(String code, Object... args) {
42
+ super(code); // super message = the code; the human text is resolved at the edge
41
43
  this.code = code;
42
44
  this.messageArgs = args;
43
45
  }
46
+ public BusinessException(String code, Throwable cause, Object... args) { ... }
44
47
  public String code() { return code; }
45
48
  public Object[] messageArgs() { return messageArgs; }
46
49
  }
50
+ ```
47
51
 
48
- public final class UserNotFoundException extends DomainException {
49
- public UserNotFoundException(UserId id) { super("USR-404-001", id.value()); }
50
- }
52
+ Thrown from the slice `Handler` and nowhere else:
53
+
54
+ ```java
55
+ throw new BusinessException("USR-404-001", userId);
51
56
  ```
52
57
 
53
- HTTP status is NOT known by the domain. The mapping code→status lives in the handler via a registry (an enum or properties-driven map in infrastructure).
58
+ **The thrower never formats or translates the message.** A `Handler` that injects a `ResourceBundle` and passes a rendered string silently pins every response to one language, defeating `Accept-Language`. Resolution happens once, at the edge, in `GlobalExceptionHandler` / `GrpcExceptionInterceptor`.
59
+
60
+ HTTP status is not known by the `Handler` either. The mapping code→status lives in `ErrorCatalog` (`common/error`).
61
+
62
+ One sibling, not a subclass: `StaleVersionException` (optimistic-lock conflict) is the only other code-carrying exception. It is NOT a `BusinessException` subtype and NOT part of the `PersistenceException` family — a version conflict is a client-actionable 409, not an infrastructure failure hidden behind a 500. It is thrown by the `Handler` when a versioned `UPDATE` returns 0 rows (never by `SqlStateTranslator` — there is no `SQLException` involved) and mapped by its own dedicated mapper below.
54
63
 
55
64
  ### Naming
56
65
 
@@ -58,21 +67,24 @@ Canonical rules in the quarkus-hexagonal-core skill; the error/i18n specifics:
58
67
 
59
68
  | Artifact | Convention | Example |
60
69
  |---|---|---|
61
- | Domain exception | `<Thing><Problem>Exception`, in `domain/exception` | `UserNotFoundException`, `DuplicateEmailException` |
62
- | Base class | `DomainException` | |
63
- | Technical exceptions | `PersistenceException`, `TransientPersistenceException`, `StaleVersionException` | |
64
- | Single REST handler | `GlobalExceptionHandler` (the only sanctioned `*Handler`) | |
65
- | gRPC counterpart | `GrpcExceptionInterceptor` | |
70
+ | Business exception | `BusinessException` — one class, in `common/exception` | thrown with `("USR-404-001", id)` |
71
+ | Technical exceptions | `PersistenceException`, `TransientPersistenceException`, `PersistenceTimeoutException`, in `common/exception` | |
72
+ | Concurrency-conflict exception | `StaleVersionException` — standalone, code-carrying (`code + args`, like `BusinessException`), in `common/exception` | thrown with `("PTY-409-001", id)` |
73
+ | SQLState translator | `SqlStateTranslator`, in `common/error` | |
74
+ | Single REST handler | `GlobalExceptionHandler`, in `common/error` | |
75
+ | gRPC counterpart | `GrpcExceptionInterceptor`, in `common/error` | |
66
76
  | i18n components | `MessageResolver`, `ErrorCatalog` | |
67
- | Error DTO | `ErrorDto` | |
77
+ | Error DTO | `ErrorDto` (TMF630 Error shape) | |
68
78
  | Bundle files | `messages/errors[_<lang>].properties` | `errors_en.properties` |
69
79
  | Bundle key | the error code itself, never a prose key | `USR-404-001` |
70
80
 
71
- Never name an exception after the HTTP status (`NotFoundException`, `BadRequestException`) the domain does not know about HTTP, and the status is resolved by `ErrorCatalog`. Never shadow a JDK type (`ConcurrentModificationException` `StaleVersionException`).
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.
72
82
 
73
- **Language rule:** class, method and field names are English; the *messages* are localized per bundle, with **English as the default locale** and other languages (es, pt, ...) added per project need. `UserNotFoundException` with a Spanish translation `El usuario {0} no existe.` in `errors_es.properties` is correct — `UsuarioNoEncontradoException` is not.
83
+ Never name an exception after the HTTP status (`NotFoundException`, `BadRequestException`) the status is resolved by `ErrorCatalog`. Never shadow a JDK type (`ConcurrentModificationException` `StaleVersionException`).
74
84
 
75
- ## The single global handler (infrastructure/rest)
85
+ **Language rule:** class, method and field names are English; the *messages* are localized per bundle, with **English as the default locale** and other languages (es, pt, ...) added per project need. A slice named `create_party_individual` throwing `PTY-400-001`, translated as `El campo ''{0}'' es obligatorio.` in `errors_es.properties`, is correct — a class or key named `crear_persona.campo_obligatorio` is not.
86
+
87
+ ## The single global handler (`common/error`)
76
88
 
77
89
  Exactly ONE class. Use `@ServerExceptionMapper` (Quarkus REST) — build-time wired, native-friendly:
78
90
 
@@ -89,13 +101,27 @@ public class GlobalExceptionHandler {
89
101
  }
90
102
 
91
103
  @ServerExceptionMapper
92
- public RestResponse<ErrorDto> map(DomainException e, HttpHeaders headers) {
104
+ public RestResponse<ErrorDto> map(BusinessException e, HttpHeaders headers) {
93
105
  var status = catalog.statusOf(e.code()); // e.g. 404
94
106
  var msg = messages.resolve(e.code(), headers, e.messageArgs());
95
107
  return RestResponse.status(status,
96
108
  ErrorDto.of(e.code(), status, msg));
97
109
  }
98
110
 
111
+ @ServerExceptionMapper
112
+ public RestResponse<ErrorDto> map(StaleVersionException e, HttpHeaders headers) {
113
+ // optimistic-lock conflict: 409 per catalog, client-visible and localized like a business error
114
+ var status = catalog.statusOf(e.code()); // 409
115
+ var msg = messages.resolve(e.code(), headers, e.messageArgs());
116
+ return RestResponse.status(status, ErrorDto.of(e.code(), status, msg));
117
+ }
118
+
119
+ @ServerExceptionMapper
120
+ public RestResponse<ErrorDto> map(PersistenceException e, HttpHeaders headers) {
121
+ // technical failures: 500/503 per catalog, stack trace to logs only
122
+ ...
123
+ }
124
+
99
125
  @ServerExceptionMapper
100
126
  public RestResponse<ErrorDto> map(ConstraintViolationException e, HttpHeaders headers) {
101
127
  // 400, code "GEN-400-001", join violations into localized detail
@@ -114,7 +140,7 @@ public class GlobalExceptionHandler {
114
140
  Rules:
115
141
  - Catch-all `Exception` mapper is mandatory: internal details/stack traces NEVER reach the client. Log with full context (trace_id lands in the log automatically via MDC).
116
142
  - Validation errors (Bean Validation) map to 400 with one generic code + per-field details array.
117
- - Resources and use cases must NOT catch-and-format errors themselves — throw domain exceptions and let the handler translate.
143
+ - `Resource`, `GrpcService`, `Consumer` and `Handler` classes must NOT catch-and-format errors themselves — throw `BusinessException` with a code and let the handler translate. A `try { ... } catch (BusinessException e) { return Response.status(400)... }` inside a `Resource` is the anti-pattern this skill exists to prevent: it bypasses the catalog, the locale and the trace header.
118
144
  - `ErrorDto` is `@RegisterForReflection` (native).
119
145
 
120
146
  ## i18n resolution from Accept-Language
@@ -135,17 +161,23 @@ quarkus.default-locale=en
135
161
  ```properties
136
162
  # errors.properties (en, default)
137
163
  USR-404-001=User {0} does not exist.
164
+ PTY-400-001=The ''{0}'' field is required.
138
165
  GEN-400-001=The request contains invalid data.
139
166
  GEN-500-001=Internal error. Contact support with the trace identifier.
140
167
  ```
141
168
 
169
+ Bundles are **per application, keyed by error code** — not per slice, not keyed by prose. A slice contributes rows to the same files, which is what keeps "every code exists in every locale" a single testable invariant. (`''` escapes a literal apostrophe for `MessageFormat`.)
170
+
142
171
  ```properties
143
172
  # errors_es.properties
144
173
  USR-404-001=El usuario {0} no existe.
174
+ PTY-400-001=El campo ''{0}'' es obligatorio.
145
175
  GEN-400-001=La solicitud contiene datos inválidos.
146
176
  GEN-500-001=Error interno. Contacte a soporte con el identificador de traza.
147
177
  ```
148
178
 
179
+ Quality bar per language — Spanish: formal or infinitive, precise, no vague `Error de validación`. English: direct declarative, no vague `something went wrong`. Same key order in every file so diffs stay readable.
180
+
149
181
  For native: `quarkus.native.resources.includes=messages/*.properties`.
150
182
 
151
183
  ### Resolver
@@ -175,7 +207,9 @@ Success-path localizable strings (notification texts, report labels) use the sam
175
207
 
176
208
  ## Checklist when adding a new error
177
209
 
178
- 1. Create domain exception extending `DomainException`, named `<Thing><Problem>Exception`, with new code `<MOD>-<HTTP>-<seq>`.
179
- 2. Register code status/reason in `ErrorCatalog`.
180
- 3. Add the message key to ALL locale bundles (build fails a test if a code is missing in any bundle — write that test once: iterate catalog codes × locales).
181
- 4. Document in the error catalog page referenced by `referenceError`.
210
+ 1. Pick the next code `<MOD>-<HTTP>-<seq>` — no new exception class.
211
+ 2. Throw `new BusinessException(code, args...)` from the slice `Handler` (`validate()` or `execution()`), with the interpolation args and no rendered text.
212
+ 3. Register code status/reason in `ErrorCatalog`.
213
+ 4. Add the message key to ALL locale bundles (a test fails the build if a code is missing in any bundle — write it once: iterate catalog codes × locales).
214
+ 5. Document in the error catalog page referenced by `referenceError`, and add the row to the slice `README.md` "Business errors" table.
215
+ 6. Assert the code (not the message) in the `HandlerTest`; assert the localized body in the `Resource` `@QuarkusTest`.