Spring Boot
The Halyx starter turns halyx.* properties into ready-to-inject beans, with health checks, metrics,
tracing, @HalyxListener message handlers and spring.config.import for parameters and secrets. It
supports Spring Boot 3.5 and 4 (Java 21) with the same starter.
Setup#
Add the starter plus the provider modules you use:
<dependency>
<groupId>dev.halyx</groupId>
<artifactId>halyx-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>dev.halyx</groupId>
<artifactId>halyx-aws</artifactId>
</dependency>
Gradle (Kotlin DSL):
dependencies {
implementation(platform("dev.halyx:halyx-bom:1.1.0"))
implementation("dev.halyx:halyx-spring-boot-starter")
implementation("dev.halyx:halyx-aws")
}
Gradle (Groovy):
dependencies {
implementation platform('dev.halyx:halyx-bom:1.1.0')
implementation 'dev.halyx:halyx-spring-boot-starter'
implementation 'dev.halyx:halyx-aws'
}
(versions come from the halyx-bom; see Getting started)
Then describe your providers and resources:
halyx:
providers:
aws: { type: aws, region: eu-west-2 }
blob-stores:
invoices: { bucket: acme-invoices }
topics:
order-events: { name: orders }
subscriptions:
billing: { name: orders-billing }
@Service
class InvoiceService {
private final BlobStore invoices;
private final Publisher events;
InvoiceService(@Qualifier("invoices") BlobStore invoices, @Qualifier("order-events") Publisher events) {
this.invoices = invoices;
this.events = events;
}
void store(Invoice invoice) {
invoices.put(BlobKey.of("invoices/" + invoice.id() + ".pdf"), BlobContent.ofBytes(invoice.pdf()));
events.publish(OutgoingMessage.builder(invoice.json()).attribute("type", "invoiced").build());
}
}
@Component
class Billing {
@HalyxListener("billing")
void onOrder(String json) { // return = ack, throw = retry with backoff
bill(json);
}
}
Moving to Azure or Google Cloud means changing the provider type and the resource names in
configuration. The code stays the same.
Providers#
halyx:
providers:
orders:
type: aws # aws | azure | gcp | vault | memory
region: eu-west-2
# endpoint: http://localhost:4566
# credentials: { type: static, id: ..., secret: ... } # default | static | impersonate | service-principal | anonymous
properties: # provider settings: see Provider configuration
http.max-connections: 256
Assumed roles, impersonated service accounts and Entra ID service principals are set under credentials:
halyx:
providers:
partner-account:
type: aws
credentials:
type: impersonate
target: arn:aws:iam::123456789012:role/orders # a service account email on gcp
external-id: acme # optional, AWS only
session-name: billing # optional, AWS only
lifetime: 30m # optional
# id: ... and secret: ... # an access key to start from, instead of the default chain (AWS)
azure-from-eks:
type: azure
credentials:
type: service-principal
tenant-id: 00000000-0000-0000-0000-000000000000
client-id: 11111111-1111-1111-1111-111111111111
token-file: /var/run/secrets/eks.amazonaws.com/serviceaccount/token # or secret: ..., or certificate: app.pem
Each entry is a named provider, like Halyx.builder().register(name, config). When only one provider is
registered, resources use it without saying so; with several, each resource names its provider. Resources can
also name providers that a HalyxBuilderCustomizer registers, or your own Halyx bean has: names are resolved
against the Halyx bean when each resource bean is created. (spring.config.import runs before any bean exists, so
the stores it imports need their provider under halyx.providers.) See Provider configuration for
every property.
Object storage#
halyx:
blob-stores:
invoices: # BlobStore bean name
bucket: acme-invoices # defaults to the bean name
# provider: orders # optional when only one provider is configured
NoSQL documents#
halyx:
document-stores:
orders: # DocumentStore bean name
table: orders-v2 # DynamoDB table, Cosmos container or Firestore collection; defaults to the key
sort-key: sk # omit for tables keyed by partition only
indexes:
by-status: { partition-attribute: status, sort-attribute: placedAt }
Spring Data repositories#
With halyx-spring-data-document on the classpath, repository interfaces in the application's packages become beans over
these document stores. Entities are records: the key components are annotated, and the rest of the record is the
document.
<dependency>
<groupId>dev.halyx</groupId>
<artifactId>halyx-spring-data-document</artifactId>
</dependency>
@DocumentTable("orders") // a halyx.document-stores bean; optional if there is only one
record Order(@PartitionKey String customerId,
@SortKey String orderId,
String status,
String placedAt,
BigDecimal total,
@Version dev.halyx.nosql.Version version) { } // Spring Data's @Version: optimistic writes
interface OrderRepository extends DocumentRepository<Order, Key> { // Key for tables with a sort key, else String
List<Order> findByCustomerId(String customerId);
List<Order> findByCustomerIdAndOrderIdBetweenOrderByOrderIdDesc(String customerId, String from, String to);
Optional<Order> findFirstByCustomerIdOrderByOrderIdDesc(String customerId);
Window<Order> findByCustomerId(String customerId, ScrollPosition position, Limit limit);
Stream<Order> findByStatus(String status); // the by-status index
List<Order> findByStatusAndPlacedAtGreaterThanEqual(String status, String from);
boolean existsByStatus(String status);
}
| CRUD | save, saveAll, findById, existsById, findAllById, deleteById, delete, deleteAllById, deleteAll. Unversioned saveAll writes in batches |
| Keys | @PartitionKey, and @SortKey for tables with a sort key: String, UUID, LocalDate or an enum, stored in the table's key attributes. Sort keys compare as strings, so use fixed-width values |
| Optimistic locking | with a @Version component of type dev.halyx.nosql.Version, save inserts an entity without a version only if its id is free (DuplicateKeyException), and writes or deletes one with a version only if the stored item still has it (OptimisticLockingFailureException). save returns the entity with its new version. On Firestore, a save that changes nothing keeps the version |
| Values | as in records. Cosmos DB and Firestore keep decimals as doubles, so a BigDecimal can read back with another scale (10 as 10.0), and records holding one may not be equals to what was saved |
| Derived queries | by the partition key, with at most one condition on the sort key (Is, Between, StartingWith, GreaterThan(Equal), LessThan(Equal), After, Before); or by the partition attribute of a declared index, with at most one condition on its sort attribute. Ordered by that sort attribute, ascending or Desc. First/Top and a Limit parameter cap the results; exists queries too |
| Results | List, Stream (read page by page as it's consumed), a single entity or Optional, and Window with a ScrollPosition parameter: start from ScrollPosition.keyset() and continue from window.positionAt(window.size() - 1) |
| Fails at startup | anything that would scan the table or skip by offset: conditions on other attributes, Or, IgnoreCase, nested properties, count and derived deletes, Pageable, Slice, Page and Sort parameters, projections, and findAll, since DocumentRepository has none |
Index queries are eventually consistent on DynamoDB, as with queryIndex. There's no general @Transactional:
use DocumentStore.transact for atomic writes across items. Without Spring Boot, @EnableHalyxRepositories
creates the repositories over the application's DocumentStore beans; turn the auto-configuration off with
halyx.document-repositories.enabled=false. Halyx repositories work alongside other Spring Data modules, such as
JPA, JDBC, Spring Data Cosmos or Spring Cloud GCP's Firestore: each module claims only the repositories that
extend its own repository types, so a service can move its entities to Halyx one at a time. Both work with the
Spring Data each Boot version manages (2025.0 on Boot 3.5,
2026.0 on Boot 4).
Messaging#
halyx:
topics:
order-events: # Publisher bean name
name: orders # SNS topic, Service Bus topic or Pub/Sub topic; defaults to the key
claim-check: { blob-store: invoices, threshold: 204800 }
subscriptions:
billing: # Subscription bean name, used by @HalyxListener("billing")
name: orders-billing # SQS queue, <topic>/subscriptions/<name> or Pub/Sub subscription
listener: { concurrency: 32, max-backoff: 2m }
@HalyxListener("<subscription bean>") methods take a String body, a byte[] body, a CloudEvent or the whole
ReceivedMessage. Returning acks the message; throwing nacks it for redelivery with backoff. Override
the subscription's concurrency per listener with @HalyxListener(value = "billing", concurrency = 8).
Every listener is checked before any starts, so a mistake, such as a listener naming no subscription, fails startup
with nothing consuming; and if one fails to start, those already started are stopped.
Listeners start once the application is ready, and stop first on shutdown, letting running handlers
finish. Each consumer reports metrics and appears under the messaging health entry. Wrap every
listener, for example for MDC or tenancy, with a MessageListenerDecorator bean.
Events#
halyx:
event-buses:
order-events: # EventPublisher bean name
name: orders # EventBridge bus, Event Grid namespace topic or Eventarc message bus; defaults to the key
properties: { azure.eventgrid.endpoint: "https://acme.australiaeast-1.eventgrid.azure.net" }
subscriptions:
billing:
name: orders-inbox # the queue the router delivers to
@HalyxListener methods on a subscription can take a CloudEvent; each message is decoded with
CloudEvents.decode, and one that holds no CloudEvent fails like a throwing handler, so it is retried and in time
dead-lettered. See Events.
@HalyxListener("billing")
void onOrderEvent(CloudEvent event) { ... }
Functions#
halyx:
functions:
resize: # FunctionInvoker bean name
name: resize-image # Lambda function (name or ARN), Azure function or Cloud Run function; defaults to the key
timeout: 2m # 60s by default
idempotent: true # false by default: only idempotent functions are retried after a request was sent
headers:
X-App: orders # sent on every invocation
properties:
aws.lambda.qualifier: live
Each entry is a FunctionInvoker bean named after its key, and with halyx-reactor a ReactiveFunctionInvoker named
<key>Reactive. With Micrometer Tracing, invocations carry the trace context. There's no health indicator: nothing
can be checked without invoking the function. On Azure, a function key can come from a parameter store, read on
every call, so a rotated key takes effect without a restart:
halyx:
parameter-stores:
secrets:
kind: secrets
location: https://orders-vault.vault.azure.net
cache-ttl: 5m # how soon a rotated key takes effect
functions:
resize:
properties:
azure.functions.endpoint: https://orders.azurewebsites.net
secrets:
- property: azure.functions.key
store: secrets # one of halyx.parameter-stores
name: resize-function-key
# field: key # for a JSON secret
Each secret is read once at startup, so a missing one fails the start with its property named. A setting in both
properties and secrets fails too. A key in properties, from ${...} or spring.config.import, is read once
and keeps its startup value.
See Functions.
Streams#
halyx:
stream-clusters:
events:
provider: orders # optional when only one provider is configured
bootstrap-servers: boot-abc123.c2.kafka-serverless.eu-west-2.amazonaws.com:9098
auth: { type: iam } # none, password (mechanism, username, password or secret) or iam
# auth: { type: password, mechanism: scram-sha-512, username: orders-app, secret: { store: app-secrets, name: msk/orders-app, field: password } }
properties: { kafka.linger.ms: 5 }
stream-producers:
order-events: # StreamProducer bean name
topic: orders # defaults to the key
idempotent: true
order-events-dlt:
topic: orders-dlt
stream-consumers:
billing: # StreamConsumer bean name, used by @HalyxListener("billing")
topic: orders
group: billing # defaults to the key
start-position: earliest
listener: { max-attempts: 5, dead-letter: order-events-dlt, max-backoff: 1m }
cluster may be omitted on producers and consumers when only one stream cluster is configured.
@HalyxListener("<stream consumer bean>") methods take a String value, a byte[] value or the whole
ReceivedRecord. Returning commits the record; throwing retries it with backoff, holding back the rest of its
partition, until max-attempts sends it to the dead-letter producer. A stream consumer is one consumer group
member, so it runs one listener, without concurrency: its parallelism is its partitions. Startup fails if two
methods name the same consumer, or one sets concurrency; give each further listener its own
halyx.stream-consumers entry and group. Stream consumers leave their group when
the application stops. Each listener reports metrics and appears under the streams health entry, and each
producer has its own health indicator. Wrap every stream listener with a RecordListenerDecorator bean.
Spring for Apache Kafka#
Applications that use Spring for Apache Kafka (KafkaTemplate, @KafkaListener) can log in to a stream
cluster with Halyx instead of configuring SASL themselves:
halyx:
spring-kafka:
cluster: events # one of halyx.stream-clusters
spring:
kafka:
consumer: { auto-offset-reset: earliest }
@Autowired KafkaTemplate<String, String> template;
@KafkaListener(topics = "orders", groupId = "shipping")
void onOrder(String order) { ... }
Every DefaultKafkaProducerFactory and DefaultKafkaConsumerFactory bean, Boot's or your own, gets the
cluster's bootstrap servers and login: MSK IAM, Entra ID or Google IAM tokens, refreshed before they expire.
Halyx' settings go on top of spring.kafka.*, so leave out spring.kafka.bootstrap-servers and the
security and sasl settings; serializers, groups and listener settings stay Spring's. Boot's KafkaAdmin
keeps its own settings, so create topics outside the application, as with Halyx' own streams. Without
halyx.spring-kafka.cluster, Spring for Apache Kafka is left alone. This needs spring-kafka on the classpath
(spring-boot-starter-kafka on Boot 4), and works with the version each Boot manages.
SQL databases#
halyx:
databases:
orders: # DataSource bean name; a single database is the primary DataSource
engine: postgresql # postgresql | mysql | sqlserver
host: orders.cluster-abc.eu-west-1.rds.amazonaws.com
database: orders
auth: { type: iam, username: orders_app } # RDS IAM, Entra ID or Cloud SQL IAM tokens
# auth: { type: password, username: app, secret: { store: app-secrets, name: orders/db, field: password } }
pool: { max-size: 20, min-idle: 5 }
Every entry becomes a DataSource bean, covered by Spring Boot's JDBC health check and Hikari metrics.
Halyx configures itself before Boot's DataSourceAutoConfiguration, which then backs off. Spring Data
JPA and JDBC repositories, JdbcTemplate, Flyway and Liquibase all use the primary Halyx DataSource
unchanged. With several databases, mark one primary: true, and wire extra EntityManagerFactory beans
yourself, as in plain Spring Boot. Pool settings live under halyx.databases.<name>.pool, not
spring.datasource.*.
A password secret reference reads the password from a parameter store for every new connection, so
rotated secrets take effect without a restart.
R2DBC and Spring Data R2DBC#
With halyx-r2dbc and an R2DBC driver on the classpath, halyx.r2dbc-databases takes the same settings as
halyx.databases and registers a pooled R2DBC ConnectionFactory bean for each entry:
halyx:
r2dbc-databases:
orders: # ConnectionFactory bean name
engine: postgresql # postgresql or mysql with iam; sqlserver with a password only
host: orders.cluster-abc.eu-west-1.rds.amazonaws.com
database: orders
auth: { type: iam, username: orders_app }
pool: { max-size: 20 }
Boot's own R2DBC auto-configuration backs off, and Spring Data R2DBC repositories, R2dbcEntityTemplate and
DatabaseClient use the Halyx pool, on Boot 3.5 and 4. With several, mark one primary: true. Each new
connection logs in as with JDBC: a password looked up every time, or the cloud's IAM token, fetched off the
event loop. The pool is disposed of with the context.
Caches#
halyx:
caches:
sessions: # Cache<String> bean name, also the default key namespace
endpoint: rediss://master.sessions.abc123.euw2.cache.amazonaws.com:6379
default-ttl: 30m
auth: { type: iam, username: sessions-app } # none | password | iam
# auth: { type: password, username: sessions-app, secret: { store: app-secrets, name: cache/auth-token } }
properties: { aws.elasticache.cache-name: sessions }
quotes:
endpoint: rediss://master.sessions.abc123.euw2.cache.amazonaws.com:6379
codec: json # a Cache<Object>; see below
trusted-packages: com.example.quotes
codec decides how values are stored, and so the bean's type: string (the default, a Cache<String>), bytes
(a Cache<byte[]>), jdk or json (a Cache<Object> that reads back only java.* types and those in
trusted-packages, as for @Cacheable), or the name of a Codec<V> bean of yours, which
makes a Cache<V> that injects by its type. The health check pings the server even when the cache is
fail-open, so an outage shows as DOWN.
Spring's @Cacheable#
halyx.cache-manager.* backs Spring's caching annotations, @Cacheable, @CachePut and @CacheEvict
(with @EnableCaching). Boot's own cache auto-configuration then backs off, so spring.cache.* does not apply;
a CacheManager bean of yours takes precedence over both:
halyx:
cache-manager:
endpoint: rediss://master.app.abc123.euw2.cache.amazonaws.com:6379
auth: { type: password, password: ${CACHE_AUTH_TOKEN} }
default-ttl: 10m
ttl: { users: 1h } # per-cache override
namespace-prefix: "orders-svc:"
fail-open: true # cached methods run uncached while the cache is down
codec: json # jdk (default) | json (Jackson 3 on Boot 4, Jackson 2 on Boot 3)
trusted-packages: com.example.orders
The halyx health contributor has a cache-manager entry, which pings the server with the manager's login, even
with fail-open, so an outage shows as DOWN rather than as cached methods quietly running uncached.
It supports allEntries evictions, sync = true, per-cache TTLs and cached nulls. The JSON codec stores
{"@class", "v"} and only reads back types from trusted-packages, with no unsafe polymorphic typing.
The JDK codec applies the same allow-list through an ObjectInputFilter.
Methods returning CompletableFuture, Mono or Flux are cached too (Spring 6.1's Cache.retrieve), with
sync = true and @CacheEvict. The cache's calls to the server run on an executor rather than the caller's
thread: virtual threads by default, or an Executor bean named halyxCacheExecutor. Spring writes and
evicts a reactive method's result from inside its pipeline, so on a Reactor non-blocking thread, such as a
Netty event loop, the write is handed to that executor and lands just after the method's value is emitted;
everywhere else writes complete before returning, as before.
Parameters and secrets#
halyx:
parameter-stores:
app-secrets: # ParameterStore bean name, and the name used by halyx: imports
kind: secrets # secrets | parameters
prefix: orders-svc
cache-ttl: 5m
reload-interval: 1m # for halyx: imports; omit to read once at startup
# native-names: true # use existing backend names as they are
# expand-json: true # (default) JSON values also become db.creds.password and so on
# startup-attempts: 3 # (default) retries while the backend is briefly unavailable
Secrets in settings#
Providers, event buses, subscriptions and functions take secrets: settings whose value is a secret in one of the
halyx.parameter-stores, read as they're used, so a rotated key takes effect without a restart. The auth.secret
of a database, cache, the cache manager or a stream cluster does the same for its password, read each time a
connection logs in.
halyx:
providers:
azure:
type: azure
properties:
azure.cosmos.endpoint: https://acme.documents.azure.com:443/
secrets:
- property: azure.cosmos.key
store: app-secrets # one of halyx.parameter-stores, on any provider
name: orders-cosmos-key
# field: key # for a JSON secret
event-buses:
order-events:
name: orders
properties: { azure.eventgrid.endpoint: "https://acme.australiaeast-1.eventgrid.azure.net" }
secrets:
- { property: azure.eventgrid.access-key, store: app-secrets, name: orders-events-key }
How soon a rotation lands depends on the setting (Provider configuration) plus the store's
cache-ttl. A resource's secrets are read when its bean is created, so a missing one fails the start, naming the
entry; a provider's are read when a client that uses them is built. A setting in both properties and secrets
fails too. A value in properties, from ${...} or spring.config.import, is read once and keeps its startup
value. The store can be on another cloud: see A store on another cloud.
Configuration import#
Load a store into the Spring Environment at startup:
spring:
config:
import: halyx:app-secrets # or halyx:app-secrets/db, or optional:halyx:app-secrets
@Value("${db.password}") String password; // the secret orders-svc/db/password
- Names become property keys, with
/replaced by., sodb/passwordbecomesdb.password. JSON values add their fields too, such asdb.creds.password. - The providers and stores must be defined in the same configuration file or an earlier one.
- A briefly unavailable backend is retried with backoff (
startup-attempts). - With
reload-interval, imported properties are updated in place when values change or rotate. Halyx publishes aHalyxParametersChangedEventwith the store name and changed keys, and Spring Cloud'sEnvironmentChangeEventwhen Spring Cloud Context is present.@ConfigurationPropertiesand@RefreshScopebeans then follow. Without Spring Cloud,Environmentreads and event listeners see changes, and@Valuefields keep their startup values. Halyx' own beans keep the values they were built with: a password or key that rotates belongs in a database'sauth.secretor insecrets, which are read from the store as they're used. halyx.config-import.enabled=falsemakes everyhalyx:import load nothing, optional or not, without connecting. Imports run while Spring prepares the environment, with the machine's own credentials, so a test that resolves a cloud profile as written, or a tool that starts the application with no cloud, sets it on the command line or in the environment (HALYX_CONFIGIMPORT_ENABLED=false).
Reactive applications#
With halyx-reactor on the classpath, every service bean from halyx.* properties also has a reactive view
(Reactive and async code): a bean named <name>Reactive, qualified <name>, so it injects
as the blocking bean does.
@RestController
class InvoiceController {
private final ReactiveBlobStore invoices;
InvoiceController(@Qualifier("invoices") ReactiveBlobStore invoices) {
this.invoices = invoices;
}
@GetMapping("/invoices/{name}")
Mono<byte[]> invoice(@PathVariable String name) {
return invoices.getBytes(BlobKey.of(name));
}
}
A ReactiveHalyx bean makes them, and wraps anything else (reactiveHalyx.wrap(...), or call and stream
for other blocking work):
halyx:
reactor:
scheduler: virtual-threads # default; or bounded-elastic, or the name of a Scheduler bean of yours
max-concurrency: 64 # most calls in flight across the reactive beans; 0 (default) for no limit
enabled: true # false: no reactive beans
The virtual-thread scheduler is disposed of with the context; a Scheduler bean of yours stays yours.
Listeners. @HalyxListener methods may return a Mono, any Reactive Streams Publisher or a
CompletableFuture. The listener waits for it on its virtual thread, so completing acks the message (or commits
the record) and failing retries it, exactly as returning and throwing do, within the subscription's
concurrency.
@HalyxListener("billing")
Mono<Void> onOrder(CloudEvent placed) {
return invoices.put(BlobKey.of(placed.subject().orElseThrow()), render(placed)).then();
}
Repositories. Interfaces extending ReactiveDocumentRepository in the application's packages become
beans over halyx.document-stores, next to blocking DocumentRepository ones, with the same derived queries
returning Flux and Mono; see Spring Data repositories.
halyx.document-repositories.enabled=false turns both off. Without Spring Boot, use
@EnableHalyxReactiveRepositories and declare a ReactiveHalyx bean.
interface Orders extends ReactiveDocumentRepository<Order, Key> {
Flux<Order> findByCustomerIdOrderByOrderIdDesc(String customerId); // read page by page as asked
Mono<Order> findFirstByCustomerIdOrderByOrderIdDesc(String customerId);
Mono<Boolean> existsByStatus(Status status);
}
Kotlin coroutines. With kotlinx-coroutines-reactor on the classpath, as well as the kotlin-reflect every Spring
Kotlin application has, a @HalyxListener may be a suspend fun. Returning acks the message (or commits the
record) and throwing retries it, as with any listener, wherever the coroutine resumes.
@HalyxListener("billing")
suspend fun onOrder(placed: CloudEvent) {
invoices.put(BlobKey.of(placed.subject().orElseThrow()), render(placed)).awaitSingle()
}
A coroutine repository is a reactive one. Extend Spring Data's Repository and declare the CRUD methods you use as
suspend functions, with derived queries returning Flow for many results, or as suspend functions for one
result, an existsBy... answer or a Window. A missing entity is null. Entities are records, so a Kotlin entity
is a @JvmRecord data class. Spring Data's CoroutineCrudRepository doesn't work, because it declares findAll,
count and deleteAll(), which would scan the table. Mixing styles works too: an interface extending
ReactiveDocumentRepository may add Flow and suspend queries.
@JvmRecord
@DocumentTable("orders")
data class Order(@PartitionKey val customerId: String, @SortKey val orderId: String, val status: String)
interface Orders : Repository<Order, Key> {
suspend fun save(order: Order): Order
suspend fun findById(id: Key): Order?
suspend fun deleteById(id: Key)
fun findByCustomerIdOrderByOrderIdDesc(customerId: String): Flow<Order> // read page by page as collected
suspend fun findFirstByCustomerIdOrderByOrderIdDesc(customerId: String): Order?
suspend fun findByCustomerId(customerId: String, position: ScrollPosition, limit: Limit): Window<Order>
}
Both are tested on Boot 3.5 (Kotlin 1.9) and Boot 4 (Kotlin 2.3), in memory and on Kafka, SQS and DynamoDB emulators. For the reactive beans themselves, see Kotlin coroutines.
Tracing. With Micrometer's context-propagation library on the classpath (Micrometer Tracing brings
it), Halyx's observations join the request's: WebFlux keeps the request's observation in the Reactor
context, and each reactive call restores it. Caching: @Cacheable methods returning Mono or Flux work
with Halyx caches (see Spring's @Cacheable). Health: Halyx's health contributor
reports in reactive applications as in servlet ones.
Observability#
| Feature | Details |
|---|---|
| Observations | With Micrometer, every operation records a halyx.operation observation (a timer and a span) tagged with halyx.service, halyx.operation, halyx.provider and error. Turn off with halyx.observations.enabled=false. |
| Health | A halyx health contributor with one indicator per blob store (lists one item), cache (runs an authenticated command, even when fail-open), parameter store (an authenticated read, past its cache), document store and stream producer (reads the topic's partitions), plus the messaging consumers, the streams listeners and the cache-manager (an authenticated command, even when fail-open). Turn off with management.health.halyx.enabled=false. |
| Cache metrics | Micrometer's standard cache.gets{result=hit|miss}, cache.puts and cache.evictions, plus cache.loads{result} and cache.fail.open, tagged cache=<name>, for Cache beans and CacheManager caches. |
| Messaging metrics | halyx.messaging.messages{subscription, listener, result}, halyx.messaging.in.flight and halyx.messaging.receive.errors. |
| Stream metrics | halyx.stream.records{consumer, listener, result=received|handled|failed|dead-lettered}, halyx.stream.poll.errors, halyx.stream.in.flight and halyx.stream.assigned.partitions. |
| Parameter metrics | halyx.parameters.reads{store, source=cache|backend}, halyx.parameters.changes{store} and halyx.parameters.watch.failures{store}. |
| Tracing | With Micrometer Tracing, publishers, event publishers, stream producers and function invokers add the current trace context to message attributes, CloudEvent extensions (traceparent, tracestate), record headers and invocation headers, and each listener invocation runs in a consumer span that continues the trace. Without Spring, halyx-micrometer does the same: see Observations and tracing. |
Customising#
| Extension | Use it to |
|---|---|
your own Halyx bean |
replace the auto-configured one entirely |
HalyxBuilderCustomizer beans |
add plugins, interceptors or registrations to the builder; halyx.* resources can use the providers they register |
Interceptor beans |
wrap every operation; applied in @Order order. Order, outermost first: observations, your interceptors, retries |
MessageListenerDecorator beans |
wrap every @HalyxListener |
halyx.retry.* |
uniform retries (enabled, max-attempts, initial-backoff, max-backoff, multiplier); off by default, since the SDKs already retry |
Bean names are shared across blob stores, caches, topics, subscriptions, event buses, stream producers and consumers, document stores, parameter stores, databases and your own beans, so keep them distinct. A key taken twice fails at startup, naming both properties. Keys only default the cloud resource's name, so a renamed key can keep it:
halyx:
topics:
orders-publisher:
name: orders # SNS topic, Service Bus topic or Pub/Sub topic "orders"
subscriptions:
orders-subscription:
name: orders
Fail-fast validation#
Startup fails with a message naming the property for:
- an unknown provider type, with the module to add
- an unknown or ambiguous
providerreference - incomplete static credentials, impersonation without a target, a service principal without exactly one of
secret,certificateandtoken-file, or a password login without its password - a
jsoncache withouttrusted-packages halyx.r2dbc-databaseswithouthalyx-r2dbc, orhalyx.spring-kafka.clusterwithout Spring for Apache Kafkahalyx.databasesorhalyx.spring-kafka.clusteron the memory provider, which has no SQL and whose streams Spring for Apache Kafka can't use: a profile without a cloud uses Boot'sspring.datasourceandspring.kafka.bootstrap-serversinstead- a
@HalyxListenernaming no subscription or stream consumer, a second listener on one stream consumer, orconcurrencyon a stream consumer's listener - a bean named
messaging,streamsorcache-manager, which would hide a health entry of the same name - a
halyx.*key that is already a bean name: another entry's, in any group, or one of your beans - a
halyx.*value whose${...}placeholder didn't resolve, which Spring would leave as written: a missing secret would be sent as the key - a
secretsentry naming no setting, no secret or an unknown store, a setting also in itsproperties, or a secret that isn't there; likewise anauth.secretnaming an unknown store or a secret that isn't there
Spring Boot settings that a Halyx bean makes ineffective are logged as warnings at startup: spring.datasource.*
next to halyx.databases, spring.r2dbc.* next to halyx.r2dbc-databases, and spring.cache.* while
halyx.cache-manager provides the CacheManager. When your own CacheManager bean makes Halyx' back off,
halyx.cache-manager.endpoint is reported as ignored.
Configuration metadata is generated, so IDEs autocomplete halyx.* keys.
Spring Boot 3.5 and 4#
The same starter works on both: your application's Boot parent or BOM picks the Spring versions.
- Compiled against: the auto-configuration is compiled against Boot 3.5.
- Health: wired with Actuator's
org.springframework.boot.actuate.healthon Boot 3, and with theorg.springframework.boot.healthmodule on Boot 4. Exactly one of the two activates. - Tested on both: the test suite runs on 3.5, and the same shared tests run again on 4.1.
- Dependency versions: your Boot BOM also picks Spring Framework, Reactor, Micrometer, Spring for Apache Kafka, Netty and Jackson, so their security fixes arrive with your Boot upgrades. Some fixes exist only in the lines Boot 4 manages (Spring Framework 7, Reactor 3.8, Micrometer 1.16, Spring for Apache Kafka 4), not in the Boot 3.5 lines. Without a Boot BOM, the provider modules bring patched versions of the libraries the cloud SDKs and clients pull in, such as Netty, Jackson and Kafka's compression libraries.