ss3-quarkus is a Quarkus extension that every SS3 microservice depends on. It provides shared infrastructure — request context propagation, audit publishing, idempotency enforcement, soft delete, feature flag resolution, and typed error handling — as auto-configured CDI beans and interceptors.
Adding the dependency is the only configuration required. All beans are registered at build time by the extension’s deployment processor. Services do not declare them manually.
// build.gradle.kts
dependencies {
implementation("io.shopstar:ss3-quarkus:${ss3QuarkusVersion}")
}Module Structure#
The extension follows the standard Quarkus two-module layout:
ss3-quarkus/
settings.gradle.kts
build.gradle.kts
deployment/ build-time processor — bean registration, reflection hints,
Flyway migration wiring
runtime/ runtime beans, annotations, and utilitiesRequestContext#
RequestContext is a CDI @RequestScoped bean that holds the principal, store, and roles for the current request. It is populated automatically — services never read raw headers.
| Field / Method | Source | Notes |
|---|---|---|
getStoreId() | X-Store-Id header | Injected by gateway-service |
getActorId() | X-User-Id header | UUID of the authenticated principal |
getActorType() | X-Principal-Type header | STAFF, CUSTOMER, or SYSTEM |
hasRole(role) | X-Roles header | Returns true if the actor holds the role on the current store |
getRolesForStore() | X-Roles header | All roles the actor holds on the current store |
X-Roles format: STORE_MANAGER@store-uuid1,CATALOG_EDITOR@store-uuid2. The context parses this at request start; services call hasRole() directly without string manipulation.
On HTTP requests — a ContainerRequestFilter populates RequestContext from the injected gateway headers before the request reaches service code.
On gRPC requests — a ServerInterceptor extracts the same fields from gRPC metadata headers.
On outgoing gRPC calls — a ClientInterceptor (registered globally on all @GrpcClient stubs) forwards the current RequestContext as metadata automatically. Service-to-service calls propagate identity without any explicit plumbing.
Audit#
AuditEventPublisher#
Inject AuditEventPublisher and call publish() after any state-mutating operation. Actor identity, service name, and OTel trace ID are extracted from RequestContext and the current span automatically.
@Inject AuditEventPublisher auditPublisher;
auditPublisher.publish("PRODUCT_UPDATED", "PRODUCT", product.id, before, after);The outgoing Kafka channel (audit-event-out → topic audit.event) is auto-configured by the extension.
@AuditExclude and SnapshotBuilder#
Fields annotated with @AuditExclude are stripped from before/after snapshots before the event is published. No PII reaches audit-service.
public class Customer {
public String id;
@AuditExclude public String email;
@AuditExclude public String phoneNumber;
public String status;
}SnapshotBuilder.toStruct(entity) is called internally by AuditEventPublisher. Services pass the entity objects directly; stripping is automatic.
Idempotency#
@Idempotent (gRPC methods)#
Annotate any state-mutating gRPC service method with @Idempotent. The CDI interceptor handles the full check/execute/record lifecycle:
@Idempotent
public Uni<ChargeResponse> charge(ChargeRequest request) {
// executed at most once per unique idempotency_key
}The interceptor extracts idempotency_key from the request object via reflection (convention: all mutating request protos expose getIdempotencyKey()), checks idempotency_log, and short-circuits with the cached response if already processed. Responses are cached for 24 hours. An hourly job purges expired entries.
KafkaIdempotencyGuard (Kafka consumers)#
Wrap Kafka consumer logic to prevent duplicate processing under consumer restart or redelivery:
@Inject KafkaIdempotencyGuard kafkaGuard;
@Incoming("order-placed")
public void onOrderPlaced(OrderPlaced event) {
kafkaGuard.guard(event.getIdempotencyKey(), "order.placed", () -> {
// processed at most once per key
});
}Database tables#
Both idempotency_log and processed_messages tables are created automatically in each service’s database by Flyway migrations shipped with the extension. No service migration file is needed.
Soft Delete#
Extend SoftDeletableEntity for any entity that requires soft delete:
@Entity
public class Product extends SoftDeletableEntity {
// deleted_at column and Hibernate @Filter inherited
}The excludeDeleted Hibernate filter (condition: deleted_at IS NULL) is activated automatically on every request. Call entity.softDelete() to set deleted_at = now(). Admin queries that must include deleted records disable the filter explicitly on the session.
The extension also activates the byStore multi-tenancy filter, populating store_id from RequestContext automatically.
Notifications#
NotificationPublisher abstracts Kafka topic routing for async notification delivery. Domain services inject it instead of managing topic names and proto construction themselves.
Mode 1 (sync gRPC SendTransactional) is not wrapped — services use @GrpcClient("communication-service") directly, consistent with how other service-to-service gRPC calls work.
The extension covers the three Kafka-based modes:
| Method | Mode | Description |
|---|---|---|
send(type, recipientId, priority, payload, idempotencyKey) | Immediate async | Routes to the correct priority topic |
sendDeferred(..., sendAt, expiresAt, correlationId) | Scheduled | Same as send() with scheduling fields |
sendDeferred(..., step, ...) | Multi-step drip | Includes a step number for drip campaigns |
cancel(correlationId, reason) | Cancellation | Publishes to notification.cancelled |
storeId is always sourced from RequestContext — callers never pass it. Callers are responsible for the idempotency key (domain-meaningful keys like "order-abc123:ORDER_CONFIRMED" prevent double-sends on retry) and for specifying priority explicitly (CRITICAL, TRANSACTIONAL, or MARKETING).
@Inject NotificationPublisher notifications;
// Immediate — order confirmed
notifications.send(
"ORDER_CONFIRMED", order.customerId, TRANSACTIONAL,
Map.of("orderId", order.id, "total", order.total),
"order-" + order.id + ":ORDER_CONFIRMED"
);
// Deferred — cart recovery drip step 1 (fires in 1 hour)
notifications.sendDeferred(
"CART_RECOVERY", 1, cart.customerId, MARKETING,
Map.of("cartId", cart.id),
"cart-" + cart.id + ":CART_RECOVERY:1",
Instant.now().plus(1, HOURS),
Instant.now().plus(24, HOURS),
cart.id // correlationId
);
// Cancel all pending recovery steps when cart converts
notifications.cancel(cart.id, "CART_RECOVERED");The four outgoing Kafka channels (notification-critical-out, notification-transactional-out, notification-marketing-out, notification-cancelled-out) are auto-configured by the extension — services do not declare them in application.properties.
Declaring message types#
Services that publish notifications must declare their message types so communication-service can validate incoming events, pre-populate the admin routing UI, and enforce template variable correctness.
Implement MessageTypeProvider in any CDI bean:
@ApplicationScoped
public class OrderNotificationTypes implements MessageTypeProvider {
@Override
public List<MessageTypeDeclaration> declarations() {
return List.of(
MessageTypeDeclaration.of("ORDER_CONFIRMED")
.name("Order Confirmed")
.description("Sent when an order is placed and payment is captured.")
.defaultPriority(TRANSACTIONAL)
.category(TRANSACTIONAL)
.payloadKeys("orderId", "total", "currency", "itemCount"),
MessageTypeDeclaration.of("ORDER_SHIPPED")
.name("Order Shipped")
.description("Sent when the order is dispatched.")
.defaultPriority(TRANSACTIONAL)
.category(TRANSACTIONAL)
.payloadKeys("orderId", "trackingNumber", "carrier", "estimatedDelivery")
);
}
}The extension’s NotificationMessageTypeRegistrar discovers all MessageTypeProvider beans at startup and calls communication-service gRPC RegisterMessageTypes in a single batch. If communication-service is not yet ready, it retries with exponential backoff (1s, 2s, 4s). If all retries fail, the service starts normally — notifications continue to work, but any newly declared types will land in the dead-letter topic until registration succeeds on next startup.
Platform Exceptions#
Typed exception classes that produce RFC 9457 Problem Details responses automatically. Throw them anywhere in service code; the extension’s exception mapper handles serialisation.
| Exception | HTTP status | Use case |
|---|---|---|
EntityNotFoundException(type, id) | 404 | Entity does not exist in the store |
StoreNotFoundException(storeId) | 404 | Store not accessible |
ForbiddenException(requiredRole) | 403 | Caller lacks the required role |
ConflictException(detail) | 409 | State conflict (duplicate, version mismatch) |
Extension Capability Summary#
| Capability | How to use |
|---|---|
| Request principal + store | Inject RequestContext |
| gRPC context propagation | Automatic on all @GrpcClient stubs |
| Audit event publishing | Inject AuditEventPublisher, call publish() |
| PII stripping | Annotate fields with @AuditExclude |
| gRPC idempotency | Annotate method with @Idempotent |
| Kafka dedup | Inject KafkaIdempotencyGuard, wrap handler with guard() |
| Soft delete | Extend SoftDeletableEntity |
| Multi-tenancy filter | Automatic from RequestContext |
| Async notifications | Inject NotificationPublisher, call send() / sendDeferred() / cancel() |
| Message type registration | Implement MessageTypeProvider; registrar runs at startup automatically |
| Typed errors | Throw EntityNotFoundException, ForbiddenException, etc. |
| DB migrations | Automatic — idempotency_log and processed_messages tables |