ss3-quarkus Extension

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 utilities

RequestContext#

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 / MethodSourceNotes
getStoreId()X-Store-Id headerInjected by gateway-service
getActorId()X-User-Id headerUUID of the authenticated principal
getActorType()X-Principal-Type headerSTAFF, CUSTOMER, or SYSTEM
hasRole(role)X-Roles headerReturns true if the actor holds the role on the current store
getRolesForStore()X-Roles headerAll 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:

MethodModeDescription
send(type, recipientId, priority, payload, idempotencyKey)Immediate asyncRoutes to the correct priority topic
sendDeferred(..., sendAt, expiresAt, correlationId)ScheduledSame as send() with scheduling fields
sendDeferred(..., step, ...)Multi-step dripIncludes a step number for drip campaigns
cancel(correlationId, reason)CancellationPublishes 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.

ExceptionHTTP statusUse case
EntityNotFoundException(type, id)404Entity does not exist in the store
StoreNotFoundException(storeId)404Store not accessible
ForbiddenException(requiredRole)403Caller lacks the required role
ConflictException(detail)409State conflict (duplicate, version mismatch)

Extension Capability Summary#

CapabilityHow to use
Request principal + storeInject RequestContext
gRPC context propagationAutomatic on all @GrpcClient stubs
Audit event publishingInject AuditEventPublisher, call publish()
PII strippingAnnotate fields with @AuditExclude
gRPC idempotencyAnnotate method with @Idempotent
Kafka dedupInject KafkaIdempotencyGuard, wrap handler with guard()
Soft deleteExtend SoftDeletableEntity
Multi-tenancy filterAutomatic from RequestContext
Async notificationsInject NotificationPublisher, call send() / sendDeferred() / cancel()
Message type registrationImplement MessageTypeProvider; registrar runs at startup automatically
Typed errorsThrow EntityNotFoundException, ForbiddenException, etc.
DB migrationsAutomatic — idempotency_log and processed_messages tables