A modular rapid microservice development framework based on Quarkus, with built-in everyday development fundamentals such as unified responses, exception handling, data access, security authentication, rate limiting, Redis, and observability.
| Module | Responsibility | Core Contents |
|---|---|---|
quarkforge-bom |
Version alignment | Single import that locks the Quarkus platform and all framework module versions |
quarkforge-core |
Infrastructure (pure JDK, no framework dependencies) | Result unified response body, ErrorCode error codes, BizException, PageResult, Checks validation, SnowflakeIdGenerator |
quarkforge-web |
Web layer | Global exception mapping, automatic response wrapping, @RawResponse exemption, access log with traceId, PageQuery pagination parameters, file upload/download, API versioning |
quarkforge-validation |
Parameter validation | Business-oriented Bean Validation annotations: @Phone, @IdCard, @BankCard, @PasswordStrength, @EnumValid |
quarkforge-data |
Data access | BaseEntity (snowflake ID + audit timestamps), BaseRepository (pagination straight to PageResult), TenantEntity multi-tenant auto-filtering, SoftDeleteEntity/SoftDeleteRepository soft delete with recycle bin, AuditSupport audit SPI |
quarkforge-audit |
Audit logging | Automatically records create/update/delete operations with field-level diffs on updates (e.g. price 100 → 120), AuditLog persistence, operator resolution |
quarkforge-crypto |
Encryption & masking | AES-256-GCM CryptoService, EncryptedStringConverter field encryption, @Sensitive response masking (138****0000) |
quarkforge-cache |
Local cache | CacheService SPI, Caffeine default implementation, @Cacheable/@CacheEvict annotations, built-in Bloom-filter penetration guard and single-flight breakdown guard |
quarkforge-dict |
Dictionary & metadata | Multi-tenant dict types/items, cached lookups, value→label translation, hierarchical dictionaries via parentValue |
quarkforge-excel |
Excel / CSV import & export | Streaming xlsx/CSV reading, row-level validation with error rows, SXSSF streaming export, REST upload/download helpers |
quarkforge-migration |
Database migration | Flyway starter that automatically runs versioned scripts under db/migration at application startup |
quarkforge-security |
Security & authentication | JwtTokenService token issuance, AuthContext current-user resolution, LoginUser |
quarkforge-ratelimit |
Rate limiting | @RateLimit annotation, in-memory fixed-window default implementation, RateLimiter SPI |
quarkforge-idempotency |
Idempotency | @Idempotent annotation, expression-based idempotency keys, cached result replay, IdempotentStore SPI (in-memory default / Redis auto-takeover) |
quarkforge-messaging |
Message publishing | MessageEnvelope message envelope, MessagePublisher SPI, JsonMessagePublisher direct object publishing (local default implementation) |
quarkforge-messaging-rabbitmq |
RabbitMQ | Exchange declaration, routing-key delivery, persistent messages, optional publisher confirms |
quarkforge-outbox |
Transactional outbox | Local message table, delayed delivery, exponential-backoff retry on failure, scheduled redelivery |
quarkforge-rpc |
RPC communication | gRPC infrastructure: BizException ↔ gRPC status mapping with error-code trailers, traceId propagation interceptors, built-in Echo diagnostics service |
quarkforge-scheduler |
Job scheduling | JobRunr integration with in-memory storage (standalone) and SQL storage (distributed), built-in dashboard |
quarkforge-notification |
Notification center | Multi-tenant notification persistence, NotificationSender SPI for business modules to decouple from delivery concerns, REST API, and WebSocket real-time push |
quarkforge-redis |
Redis integration | RedisCacheService cache-aside, RedisTwoLevelCacheService local + Redis two-level cache, RedisLockService distributed locks, Redis backends for rate limiting and idempotency (auto-takeover) |
quarkforge-observability |
Observability | OpenAPI/Swagger UI (built-in bearerAuth scheme), /q/health health checks, /q/metrics Prometheus metrics |
Modules are pulled in on demand with no forced coupling; core has no Quarkus extension dependencies and can be used standalone in non-web scenarios.
Import the BOM and the modules you need in a new service:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.foundry4j</groupId>
<artifactId>quarkforge-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.foundry4j</groupId>
<artifactId>quarkforge-web</artifactId>
</dependency>
</dependencies>Resource classes can then return business objects directly:
@Path("/users")
public class UserResource {
@GET
public Map<String, Object> hello() {
// Automatically wrapped as {"code":200,"message":"success","data":{"msg":"hi"}}
return Map.of("msg", "hi");
}
@GET
@Path("/{id}")
public User get(@RestPath Long id) {
// Throw an exception when not found; globally mapped to 404 + unified response body
return userRepository.requireById(id);
}
}- Returned POJOs are automatically wrapped in the
Resultstructure; annotate with@RawResponseto skip wrapping (file downloads, external callbacks, etc.). - Throwing
BizException(CommonErrorCode.NOT_FOUND)in business code yields a404 {"code":40400,...}response. - Parameter validation failures (
@NotBlank,@Valid) are automatically aggregated into a 400 response. - Every request gets a
traceIdwritten to the logging MDC and theX-Trace-Idresponse header.
Adds business-oriented validation annotations on top of standard Bean Validation (@NotBlank / @Size, etc.). Validation failures are mapped by the web layer to 400 {"code":40000,"message":"field error-message"}.
public class RegisterRequest {
@NotBlank
public String username;
@Phone // 11 digits starting with 1[3-9]
public String phone;
@IdCard // 18 digits + birth date + weighted check digit
public String idCard;
@BankCard // 12-19 digits + Luhn checksum
public String bankCard;
@PasswordStrength(min = 8, requireSpecial = true)
public String password;
@EnumValid(UserStatus.class) // Must be an enum constant name; ignoreCase supported
public String status;
}All annotations follow the convention "null is valid; leave required-ness to @NotNull". @Phone/@IdCard/@BankCard/@PasswordStrength apply to string fields; @EnumValid applies to string fields with a target enum specified.
FileStorage is the storage SPI; the default implementation writes to the local disk (partitioned by yyyy/MM/dd, filenames rewritten as UUIDs, path traversal prevented).
@Inject FileStorage storage;
// Multipart upload
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
public StoredFile upload(@RestForm FileUpload file) {
return FileUploads.store(storage, file);
}
// Streaming download; non-ASCII filenames are RFC 5987 encoded so browsers display them correctly
@GET
@Path("/{id}/content")
public Response download(@RestPath Long id) {
FileRecord record = repository.requireById(id);
return FileDownloads.download(storage, record.key(), record.filename(), record.contentType());
}Built-in protection: exceeding the size limit throws 413 {"code":41300}, a MIME type outside the whitelist throws 415 {"code":41500}, and an illegal storage key throws 400; partially written files are cleaned up automatically on write failure.
quarkforge.web.file.base-dir=${java.io.tmpdir}/quarkforge-files
quarkforge.web.file.max-size=10M
quarkforge.web.file.allowed-content-types=image/png,application/pdf # empty means unrestrictedTo integrate object storage, implement FileStorage and register it as a regular bean to override the default.
Declare supported versions with @ApiVersion; callers specify the version via the X-API-Version header (or the first URL segment /v1/...):
@Path("/orders")
@ApiVersion("v1")
public class OrderResource {
@GET
public PageResult<OrderVO> list() { ... }
@GET
@Path("/legacy")
@ApiVersion(value = "v1", deprecated = true, sunset = "2027-01-01", successor = "/v2/orders")
public List<OrderVO> legacyList() { ... }
}- Requested version not in the supported list →
400 {"code":40001,...} - Responses always write back
X-API-Version - Deprecated endpoints additionally return
Deprecation: true,Sunset: 2027-01-01,Link: </v2/orders>; rel="successor-version" - Endpoints without
@ApiVersionare not validated, keeping backward compatibility; version numbers1andv1are equivalent
After the filter resolves the version, it can be read by injecting ApiVersionContext:
@Inject ApiVersionContext versionContext;
String version = versionContext.requested();@Entity
public class User extends BaseEntity { // Automatically gets snowflake id / createdAt / updatedAt
public String name;
}
@ApplicationScoped
public class UserRepository extends BaseRepository<User> {
public PageResult<User> search(String keyword, PageQuery query) {
return page(find("name like ?1", "%" + keyword + "%"), query.page(), query.size());
}
}In multi-instance deployments, distinguish snowflake nodes via quarkforge.data.worker-id.
// Extend TenantEntity: writes auto-fill tenant_id; queries and load-by-id automatically carry the tenant condition
@Entity
public class Order extends TenantEntity { }
// Scheduled jobs / admin tasks can run across tenants
TenantContext.runAs("tenant-a", () -> orderRepository.listAll());
// Extend SoftDeleteEntity + SoftDeleteRepository
@Entity
public class Article extends SoftDeleteEntity { }
@ApplicationScoped
public class ArticleRepository extends SoftDeleteRepository<Article> { }
article.delete(); // Only sets the flag; hidden from default listings
articleRepository.findAllIncludingDeleted(); // Recycle bin
articleRepository.restoreById(id); // Restore
articleRepository.purgeById(id); // Physical deleteWhen no tenant is set explicitly, the default tenant default is used (quarkforge.data.tenant.default). The request entry point can call TenantContext.set(...) to propagate the tenant according to the project's authentication scheme.
Adding quarkforge-audit takes effect automatically with no business code changes:
@Inject AuditLogRepository auditLogRepository;
List<AuditLog> logs = auditLogRepository.findByEntity(Order.class.getName(), orderId);
// action=UPDATE, actor=bob, changes={"changes":[{"field":"price","oldValue":"100","newValue":"120"}]}- The operator is resolved from
ActorContextfirst, then the currentSecurityIdentity, and finallysystem - Audit records are persisted in the same transaction as business data; both roll back together
@NotAuditedskips specified entities; JPQL / native bulk updates do not trigger lifecycle callbacks and are therefore not audited
# Base64 of a 32-byte key; generate with openssl rand -base64 32
quarkforge.crypto.key=...@Convert(converter = EncryptedStringConverter.class)
@Sensitive(SensitiveType.PHONE)
public String phone; // Ciphertext in the database, "138****0000" in API responsesBuilt-in rules: PHONE / ID_CARD / EMAIL / BANK_CARD / NAME / PASSWORD / DEFAULT; you can also call Masking.mask(value, type) directly.
After adding quarkforge-migration, versioned scripts run automatically at startup:
src/main/resources/db/migration/
├── V1__create_order.sql
└── V2__add_order_note.sql
Defaults are migrate-at-start=true and baseline-on-migrate=true; override them in the application's application.properties.
Built on JobRunr 9 (native support for Quarkus 3.40 LTS). It runs locally with zero configuration, and adding a database automatically turns it into a distributed scheduler.
@Inject SchedulerService scheduler;
// Fire-and-forget (a static method reference matches JobLambda and is serializable by JobRunr)
scheduler.enqueue(TicketService::processDailyReports);
// Run once at a specific instant
scheduler.scheduleAt(Instant.now().plus(Duration.ofMinutes(30)),
OrderService::sendClosingReminder);
// Standard 6-field cron schedule
scheduler.scheduleCron("0 0 2 * * *", BillingService::runNightlySettlement);
scheduler.scheduleCron("0 0/5 * * * *", "refresh-metrics", MetricsService::refreshCaches);
// Fixed-interval recurring job (JobRunr does not distinguish fixed-delay / fixed-rate)
scheduler.scheduleRecurrently(Duration.ofSeconds(30), Heartbeat::send);Automatic switching between local and distributed modes:
-
Standalone mode (default): without a DataSource, the
@DefaultBean InMemoryStorageProviderprovided by StorageProviderProducer takes effect, and the BackgroundJobServer polls and executes jobs within the same JVM. Suitable for development and single-node deployments. -
Distributed mode: once any DataSource (H2 / Postgres / MySQL) is present, the JobRunr Quarkus extension automatically detects it and registers a
SqlStorageProvider. All nodes share the samejobrunr_*tables and use optimistic locking for mutual exclusion — in a multi-node deployment each job is executed by exactly one node. This is JobRunr's core advantage: lighter than a hand-rolled Redis lock or a Quartz cluster table.
Common configuration (quarkus.jobrunr.* prefix; override in the application's application.properties):
# The background job server is enabled by default; tune the poll interval as needed
quarkus.jobrunr.background-job-server.poll-interval=5 # seconds (15-30 recommended in production)
# Retry and job retention
quarkus.jobrunr.jobs.default-number-of-retries=3
quarkus.jobrunr.jobs.delete-succeeded-jobs-after=36h
quarkus.jobrunr.jobs.permanently-delete-deleted-jobs-after=72h
# The dashboard is disabled by default; enable it explicitly and secure it in production
quarkus.jobrunr.dashboard.enabled=false
quarkus.jobrunr.dashboard.port=8000When passing method references or lambdas, make sure the target can be serialized by JobRunr:
- Static method references (e.g.
MyService::myStaticMethod) ✅ safest — the background runner invokes them directly via reflection- Instance method references (e.g.
myBean::doWork) require the CDI bean instance to be resolvable through ArC, so make sure the bean is registered- Lambda expressions capture the enclosing class reference, so deserialization requires that class to be on the classpath
CacheService SPI backed by Caffeine by default. Every local cache gets three anti-anomaly protections out of the box when used through the declarative annotations or through LocalCacheService.computeProtected:
- Bloom filter — short-circuits known-miss keys before the cache query
- Single-flight gate — concurrent requests on the same key share one loader call
- Null-value caching — loader returning
nullis written as a short-lived sentinel to stop penetration
@Inject CacheService cache;
// SPI usage — no protection
cache.getOrLoad("user:" + id, User.class, Duration.ofMinutes(10), () -> load(id));
// Declarative — protection enabled automatically on LocalCacheService
@Cacheable(key = "#p0", namespace = "user", ttl = "PT10M")
public User getUser(long id) { ... }
@CacheEvict(key = "#p0", namespace = "user") // after method success
public void updateUser(long id, UserPatch patch) { ... }Supported key expressions inside the annotation (no SpEL dependency):
| Form | Meaning |
|---|---|
'literal' |
String literal |
#p0, #p1, … |
Positional method argument |
#p0.field |
Bean property of the argument |
#p0.method() |
Zero-arg method on the argument |
#root.methodName |
Name of the annotated method |
'prefix/' + #p0 |
Concatenation (split on + and rejoin) |
| (empty) | Default = ClassName#methodName(args) |
Annotation attributes:
| Attribute | Default | Purpose |
|---|---|---|
key |
auto | Cache key expression |
namespace |
"" |
Namespace prefix (qf:cache:{ns}:{key}) |
ttl |
PT5M |
ISO-8601 duration; overrides quarkforge.cache.local.default-ttl |
cacheNull |
true |
Cache loader null with short TTL (quarkforge.cache.local.null-value-ttl) |
preventPenetration |
true |
Use Bloom filter (only LocalCacheService) |
preventBreakdown |
true |
Use single-flight gate (only LocalCacheService) |
Configuration:
quarkforge.cache.local.max-size=10000
quarkforge.cache.local.default-ttl=PT5M
quarkforge.cache.local.null-value-ttl=PT1M # short TTL for negative cache
quarkforge.cache.local.bloom.enabled=true
quarkforge.cache.local.bloom.expected-insertions=100000
quarkforge.cache.local.bloom.false-positive-rate=0.01After adding quarkforge-redis, the default bean switches to RedisTwoLevelCacheService; two-level cache inherits the SPI but the Bloom / single-flight protections stay at the local layer, which is usually sufficient for hot-key shielding while Redis handles distribution.
The interceptor degrades gracefully when the active CacheService is not a LocalCacheService — it still caches through getOrLoad, but bloom + single-flight are skipped.
Stores enum-like reference data (gender, order status, units, …) so business tables keep only a short value while the UI renders a localized label. Every dictionary is tenant-scoped, cached, and read through the CacheService SPI.
@Inject DictService dict;
List<DictItemView> statuses = dict.items("order_status"); // enabled items, ordered by sort
String label = dict.label("gender", "M"); // "男", also works for disabled items
Map<String, String> mapping = dict.translate("order_status"); // value → labelManage dictionaries over REST:
| Method | Path | Description |
|---|---|---|
| GET | /dict/types?keyword=&enabled=&page=&size= |
Page through dictionary types |
| POST | /dict/types |
Create a dictionary type |
| PUT | /dict/types/{id} |
Rename/disable a type (the code is immutable) |
| DELETE | /dict/types/{id} |
Delete a type together with its items |
| GET | /dict/{typeCode}/items?enabled=false |
List items, optionally including disabled ones |
| POST | /dict/{typeCode}/items |
Add an item |
| PUT | /dict/items/{id} |
Update an item |
| DELETE | /dict/items/{id} |
Delete an item |
| GET | /dict/{typeCode}/translate |
Full value → label mapping |
Notes:
(tenant_id, code)and(tenant_id, type_code, item_value)are unique, so duplicate codes or values return409 {"code":40900}.- Reads are cached per tenant; any write through
DictServiceevicts both the enabled-only and the full view, so callers never see stale labels. parentValuesupports hierarchical dictionaries (province → city, category → subcategory).- Cache TTL:
quarkforge.dict.cache-ttl=PT10M(default 10 minutes).
Both directions are streaming — memory usage does not grow with the number of rows. Reading uses the POI SAX event model for .xlsx and a character-level parser for CSV (quoted commas, embedded newlines, "" escapes, UTF-8 BOM). Export uses SXSSFWorkbook with a sliding row window, flushing to temporary files as it goes.
List<User> users = new ArrayList<>();
ImportResult result = ExcelImporter.importData(in, ExcelFormat.XLSX,
ExcelImportSpec.<User>builder()
.mapper(row -> new User(
row.cell("id"),
row.cell("name"),
Integer.parseInt(row.cell("age"))))
.validator((user, ctx) -> {
if (user.name().isBlank()) {
ctx.reject("name", user.name(), "name is required");
}
if (user.age() < 1 || user.age() > 120) {
ctx.reject("age", String.valueOf(user.age()), "age out of range");
}
})
.rowSink(users::add) // successful rows are consumed one by one
.maxErrors(500)
.build());Mapping or validation failures never abort the whole file. ImportResult carries totalRows, successRows, failedRows, errors, and errorsTruncated; each RowError contains rowNumber, field, value, message, and the original rowValues, so the client can highlight or re-export the offending rows.
ExcelExporter.write(out, ExcelFormat.XLSX,
ExcelExportSpec.<Order>builder("Orders")
.column("Order No", Order::orderNo)
.column("Amount", Order::amount, 16)
.windowSize(100)
.build(),
orderStream);REST helpers stream straight to the response and avoid buffering the whole file:
@GET
@Path("/export")
public Response export() {
return ExcelHttp.download("orders.xlsx", spec, orderService.streamAll());
}
@POST
@Path("/import")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public ImportResult importUsers(@RestForm FileUpload file) {
return ExcelHttp.importUpload(file, spec);
}Notes:
- Supported inputs:
.xlsx/.xlsmand.csv; legacy.xlsmust be converted first. - CSV defaults to UTF-8; configure another charset with
ExcelImportSpec.Builder#charset. maxErrors(0)means unlimited error collection — use it only for small files.- The export window (
windowSize) trades temporary disk usage for memory; 100 rows is a good default.
@Inject JwtTokenService jwt;
@Inject AuthContext auth;
// Issue a token at the login endpoint
String token = jwt.issue(user.id.toString(), user.name, Set.of("admin"));
// Read the current user in business endpoints; 401 is thrown automatically when not logged in
LoginUser current = auth.currentUser();Signing/verification keys use the standard SmallRye JWT configuration (RSA key files recommended; never commit private keys in production):
smallrye.jwt.sign.key.location=jwt/privateKey.pem
mp.jwt.verify.publickey.location=jwt/publicKey.pem
mp.jwt.verify.issuer=quarkforge@POST
@RateLimit(permits = 5, windowSeconds = 60, key = KeyStrategy.USER)
public Result<Void> sendSmsCode() { ... }Supports three dimensions: USER / IP / GLOBAL; excess requests get HTTP 429. The default in-memory implementation suits single-node deployments; adding quarkforge-redis automatically switches to Redis-based distributed rate limiting.
@POST
@Idempotent(key = "order:#req.orderNo", ttlSeconds = 600)
public Result<OrderVO> createOrder(@RequestBody CreateOrderRequest req) {
// Duplicate requests with the same orderNo:
// - The first request executes the business method and caches the return value for 600 seconds
// - Within the TTL, the cached result is replayed without re-executing
// - Concurrent requests with the same key get HTTP 409 (CONFLICT)
}The key supports #paramName and #p0 index references with chained .field access; when left empty, the key is generated from the serialized arguments. The default in-memory store suits single-node deployments; adding quarkforge-redis automatically switches to the distributed implementation. If the business method throws, the idempotency key is released immediately so later retries are allowed.
No middleware required by default: messages are only written to memory and logged (LocalMessagePublisher), so local development works out of the box; adding quarkforge-messaging-rabbitmq automatically switches to real delivery.
quarkforge.messaging.rabbitmq.uri=amqp://guest:guest@localhost:5672/
quarkforge.messaging.rabbitmq.exchange=quarkforge.topic
quarkforge.messaging.rabbitmq.exchange-type=topic
quarkforge.messaging.rabbitmq.publisher-confirms=true@Inject JsonMessagePublisher publisher;
publisher.publish("order.created", Map.of("orderNo", orderNo));destination is used as the routing key; if exchange is left empty, the message goes to the default exchange (in which case destination is the queue name). Consumers can use Quarkus-native @Incoming or a RabbitMQ client directly.
Keep business writes and message sending in the same transaction, avoiding "order persisted but message never sent":
@Transactional
public void createOrder(CreateOrderRequest request) {
orderRepository.persist(order);
outbox.enqueue("order.created", new OrderCreatedEvent(order.id, order.no));
}Delayed delivery and failure retry:
// Trigger an order-closing check 30 minutes later
outbox.enqueue("order.close-check", Map.of("orderNo", orderNo), Duration.ofMinutes(30));A background job scans the local message table every 5 seconds for redelivery; failures back off exponentially as 30s → 60s → 120s … and are marked FAILED after 5 attempts. In multi-instance deployments, messages are claimed via conditional updates, so each message is delivered by exactly one instance. For plain scheduled tasks, use Quarkus @Scheduled directly.
gRPC-based communication foundation for inter-service calls, built on quarkus-grpc. Service definitions live in src/main/proto; the Quarkus build generates blocking, async and Mutiny stubs.
Server side: implement the generated *ImplBase (or the Mutiny service interface) as a CDI bean annotated @GrpcService + @Singleton; it is exposed on the gRPC port (default 9000):
@GrpcService
@Singleton
public class OrderGrpcService extends OrderGrpc.OrderImplBase {
@Override
public void get(GetRequest request, StreamObserver<GetResponse> responseObserver) {
// Throw BizException directly — the global interceptor maps it to a gRPC status
Order order = orderRepository.findByIdOptional(request.getId())
.orElseThrow(() -> new BizException(CommonErrorCode.NOT_FOUND));
responseObserver.onNext(toResponse(order));
responseObserver.onCompleted();
}
}Client side: inject stubs with @GrpcClient and configure the channel:
@GrpcClient("order")
OrderGrpc.OrderBlockingStub orderStub;quarkus.grpc.clients.order.host=order-service
quarkus.grpc.clients.order.port=9000Unified error mapping: a server-side BizException is converted to the matching gRPC status by its HTTP-aligned code segment (40400 → NOT_FOUND, 40100 → UNAUTHENTICATED, 42900 → RESOURCE_EXHAUSTED, …), with the original numeric code carried in the x-error-code trailer. Clients recover it with RpcErrors:
try {
orderStub.get(request);
} catch (StatusRuntimeException e) {
throw RpcErrors.toBizException(e); // back to BizException with the original error code
}Unhandled server exceptions map to INTERNAL without leaking internal details in the status description. Note the mapping applies to exceptions thrown directly by the service method (ImplBase style, or Mutiny methods that throw synchronously) rather than failed Unis.
Trace propagation: with tracing enabled (default), the client interceptor forwards the current MDC traceId as the x-trace-id metadata header, and the server interceptor restores it into MDC — RPC logs line up with HTTP logs under the same trace id.
Diagnostics: the built-in Echo service returns the message it receives plus the trace id observed on the server, handy for verifying connectivity and interceptor wiring after deployment.
Drop-in notification infrastructure for microservices: multi-tenant persistence, a decoupled sender SPI, REST API for the current user, and WebSocket real-time push with offline compensation.
Business module integration: inject the SPI and fire notifications from anywhere — the module takes care of persistence and WebSocket delivery:
@Inject NotificationSender notifier;
// When a task is assigned
notifier.send(assigneeId, NotificationType.TASK,
"You have a new task", "Please handle it", currentUserId, "/tasks/" + taskId);
// System-wide announcement (actorId omitted)
notifier.sendSystem(userId, "Maintenance window", "Scheduled tonight 02:00-03:00");The SPI lives in quarkforge.notification.service.NotificationSender; the default implementation is NotificationService, which persists the record and attempts a WebSocket push in the same transaction. Offline users get the notification on reconnection — the WebSocket endpoint auto-delivers all rows with deliveredAt IS NULL to newly connected clients.
REST API (all scoped to the authenticated user via AuthContext):
| Method | Path | Description |
|---|---|---|
| GET | /notifications |
Paginated list; ?unread-only=true to filter |
| GET | /notifications/unread-count |
Unread count |
| PATCH | /notifications/{id}/read |
Mark a single notification read |
| PATCH | /notifications/read-all |
Mark everything read (returns how many rows changed) |
| DELETE | /notifications/{id} |
Delete a notification |
Responses auto-wrap with Result<T> by the web module. Forbidding someone else's notification is guarded by both the tenant filter and the explicit recipientId check.
WebSocket real-time push: the server exposes /ws/notifications. Clients should authenticate via the standard Quarkus Security flow — or pass ?userId=<id> for trusted internal service-to-service scenarios. The server pushes a plain JSON envelope for each new notification:
{
"id": 1234567890,
"type": "TASK",
"title": "You have a new task",
"content": "Please handle it",
"link": "/tasks/42",
"actorId": "u001",
"createdAt": "2026-11-04T10:00:00Z"
}Clients keep the connection alive by replying pong to the server's ping (or send their own; the WebSocket handler accepts both directions). On reconnect the server replays all notifications with deliveredAt IS NULL, so no notification is lost across brief disconnections.
Multi-tenancy: Notification extends TenantEntity — tenant_id is auto-filled from TenantContext, and all reads go through the Hibernate tenant filter, so tenant A cannot peek at tenant B's notifications.
// Cache-aside
User user = cache.getOrLoad("user:" + id, User.class, Duration.ofMinutes(10),
() -> userRepository.requireById(id));
// Distributed lock
lock.withLock("order:pay:" + orderId, Duration.ofSeconds(30), () -> doPay());Works out of the box: Swagger UI at /q/swagger-ui (with a built-in JWT bearerAuth debugging scheme), health checks at /q/health, Prometheus metrics at /q/metrics.
Two runnable sample services demonstrate inter-service communication over gRPC:
examples/user-service— a user microservice (web / data / security / ratelimit / observability) that also exposes aUserServicegRPC endpoint on port9001.examples/order-service— an order microservice that validates the ordering user by calling user-service over gRPC before persisting an order.
examples/user-service wires together the web / data / security / ratelimit / observability modules:
| Capability | Where it lands in the example |
|---|---|
| User CRUD | UserResource + UserService + UserRepository |
| Unified responses | Resource methods return DTOs directly, automatically wrapped in the Result envelope |
| Exceptions & validation | Duplicate username throws BizException; request-body validation failures automatically return 400 |
| Login & auth | JwtTokenService issues JWTs; /users/me is protected by @RolesAllowed |
| Rate limiting | Login endpoint: @RateLimit(permits = 20, windowSeconds = 60) |
| gRPC user lookup | UserGrpcService exposes GetUser on port 9001, delegating to UserService |
| Docs & monitoring | Swagger UI, /q/health, /q/metrics |
Endpoint overview:
| Method | Path | Description |
|---|---|---|
| POST | /users |
Register a user |
| GET | /users?keyword=&page=&size= |
Keyword search with pagination |
| GET | /users/{id} |
User details; 404 when not found |
| PUT | /users/{id} |
Update profile or status |
| DELETE | /users/{id} |
Delete a user (204) |
| POST | /auth/login |
Log in to obtain a JWT |
| GET | /users/me |
Current logged-in user; requires Authorization: Bearer <token> |
examples/order-service shows how one microservice calls another over gRPC using the quarkforge-rpc infrastructure. Before persisting an order it calls user-service's GetUser to verify the user exists and is active.
| Capability | Where it lands in the example |
|---|---|
| Order CRUD | OrderResource + OrderService |
| gRPC client | @GrpcClient("user") UserServiceGrpc.UserServiceBlockingStub configured via quarkus.grpc.clients.user.* |
| Error propagation | StatusRuntimeException from user-service is restored to BizException via RpcErrors.toBizException, so a 404 from user-service surfaces as 40400 to the HTTP client |
| Disabled-user guard | Returns 403 when the looked-up user is DISABLED |
Run both locally (user-service first so its gRPC server is up):
mvn -pl examples/user-service -am install -DskipTests
mvn -pl examples/order-service -am install -DskipTests
mvn -pl examples/user-service quarkus:dev # HTTP 8081, gRPC 9001
mvn -pl examples/order-service quarkus:dev # HTTP 8082Both examples use in-memory H2 and (user-service) demo JWT keys from the repository, so they run out of the box; replace the data source and keys in production.
| Property | Default | Description |
|---|---|---|
quarkforge.web.response.auto-wrap |
true |
Automatic response-body wrapping switch |
quarkforge.web.access-log.enabled |
true |
Access log switch |
quarkforge.web.file.base-dir |
${java.io.tmpdir}/quarkforge-files |
File storage root directory |
quarkforge.web.file.max-size |
10M |
Per-file size limit; excess returns 413 |
quarkforge.web.file.allowed-content-types |
empty (unrestricted) | Allowed upload MIME types, comma-separated |
quarkforge.web.version.enabled |
true |
API version handling switch |
quarkforge.web.version.default |
v1 |
Default version when none is specified |
quarkforge.rpc.trace.enabled |
true |
gRPC traceId propagation switch (client + server interceptors) |
quarkforge.data.worker-id |
0 |
Snowflake ID node number ([0, 1023]) |
quarkforge.data.tenant.default |
default |
Tenant ID used when none is set explicitly |
quarkforge.audit.enabled |
true |
Audit log persistence switch |
quarkforge.crypto.key |
none (required) | Field encryption key, Base64 of 32 bytes |
quarkforge.cache.local.max-size |
10000 |
Maximum number of local cache entries |
quarkforge.cache.local.default-ttl |
PT5M |
Default local cache TTL |
quarkforge.jwt.issuer |
quarkforge |
JWT issuer |
quarkforge.jwt.ttl-minutes |
120 |
JWT validity (minutes) |
quarkforge.outbox.enabled |
true |
Outbox background dispatch switch |
quarkforge.outbox.dispatch-interval |
5s |
Outbox scan interval |
quarkforge.outbox.max-attempts |
5 |
Maximum delivery attempts per message |
quarkforge.outbox.backoff-seconds |
30 |
Initial failure backoff in seconds (doubles exponentially afterwards) |