Design, build, test and benchmarks
Design#
Architecture#
Every service is split into a portable API that applications use, and an SPI that each cloud implements. The API does validation, capability checks, interceptors and error translation once, for every provider:
application ──► BlobStore (API) halyx-core
│ DefaultBlobStore: validation → capability check →
│ interceptor chain → driver call → error translation
▼
BlobStoreDriver (SPI)
┌───────────┼──────────────────┬──────────────────┐
S3BlobStoreDriver AzureBlobStoreDriver GcsBlobStoreDriver InMemoryBlobStoreDriver
halyx-aws halyx-azure halyx-gcp halyx-core
The same split repeats for every service: DocumentStore and DocumentStoreDriver, Publisher and
PublisherDriver, StreamProducer and ProducerDriver, Cache and CacheDriver, ParameterStore and
ParameterDriver. Providers are
discovered with ServiceLoader, so halyx-core has no cloud SDK dependency, and the build enforces
that.
Patterns#
| Pattern | Where |
|---|---|
| Bridge | each API (BlobStore, DefaultBlobStore; FunctionInvoker, DefaultFunctionInvoker) and its SPI (BlobStoreDriver, FunctionInvokerDriver) |
| Plugin, Separated Interface | CloudProvider in core, discovered from provider jars through ServiceLoader |
| Abstract Factory | ProviderConnection creates the family of drivers for one account and region |
| Registry, Facade | Halyx holds the named CloudContexts |
| Gateway, Adapter | each driver wraps a native SDK client |
| Template Method | an Abstract*Driver base per SPI, which every driver extends: blob stores, document stores, publishers and subscriptions, event publishers, stream producers and consumers, function invokers, caches and parameter stores (translation fallback, unwrap) |
| Chain of Responsibility | Interceptor and InterceptorChain (RetryInterceptor included) |
| Value Object | BlobKey, ETag, Key, Version, ProviderConfig: immutable |
| Service Stub | the memory provider, for tests, with InMemoryFunctions serving registered handlers |
| Exception Translation | the sealed CloudException hierarchy with isRetryable(); HTTP statuses through AzureErrors.fromStatus and GcpErrors.fromStatus, shared by the SDK translations and the HTTP function drivers; what a failed function reported kept whole as a FunctionError cause |
Principles#
- Portable semantics, not just portable signatures. Listing order, idempotent delete, conditional writes, versions and expiry mean the same on every provider, and the conformance kits check it.
- Declare differences instead of hiding them. Optional features are capabilities. Asking for a
missing one fails rather than being ignored, and
unwrapreaches the native client. - No hidden waits. Halyx doesn't block your call to paper over eventual consistency or asynchronous deletes. It fails fast with a retryable error, and you choose how to retry.
- Infrastructure is yours. Halyx never creates buckets, tables, topics or databases.
- Fit the platform. Java 21 virtual threads for parallel transfers and listeners, pooled buffers for part uploads, shared HTTP clients per provider connection, and SDK-native retry modes.
Modules#
| Module | Purpose |
|---|---|
halyx-bom |
Import to align Halyx versions |
halyx-core |
portable API, provider SPI, error model, interceptors, in-memory provider. No cloud SDKs (enforced) |
halyx-tck |
the conformance test kits every provider must pass |
halyx-redis |
the shared Redis and Valkey cache driver (Lettuce), used by every provider |
halyx-sql |
the shared HikariCP pool with per-connection credentials (passwords, cloud IAM tokens) |
halyx-r2dbc |
pooled R2DBC connections with the same password and cloud IAM logins, for reactive code and Spring Data R2DBC |
halyx-reactor |
Project Reactor views of every service on the application's scheduler: Mono and Flux, streams in and out, subscriptions and stream consumers as a Flux of deliveries |
halyx-micrometer |
Micrometer observations of every operation, and trace context through messages, events, stream records and function invocations, for any application; the starter applies them |
halyx-kafka |
the shared Kafka stream driver, with password and cloud IAM login (SASL OAUTHBEARER), used by every provider |
halyx-aws |
AWS SDK v2: S3, DynamoDB, SNS, SQS, EventBridge, Lambda, SSM Parameter Store, Secrets Manager, ElastiCache and RDS IAM login |
halyx-azure |
Azure SDK: Blob Storage, Cosmos DB, Service Bus, Event Grid namespaces, Azure Functions, Key Vault, App Configuration, Entra ID login for caches and databases |
halyx-gcp |
Google Cloud: Cloud Storage, Firestore, Pub/Sub, Eventarc Advanced, Cloud Run functions, Secret Manager, Parameter Manager, Memorystore and Cloud SQL IAM login |
halyx-vault |
HashiCorp Vault KV v2 parameters and secrets |
halyx-spring-data-document |
Spring Data repositories over document stores: records as entities, derived queries on keys and indexes, optimistic locking, keyset scrolling |
halyx-spring-boot-autoconfigure |
Spring Boot auto-configuration, properties, health and observations |
halyx-spring-boot-starter |
the dependency Spring Boot applications add |
halyx-reactor-tests |
the conformance kits through halyx-reactor's adapters, on the memory provider and on Kafka and Valkey (not published) |
halyx-spring-boot4-tests |
the shared Spring and Spring Data tests run on Boot 4 (not published) |
halyx-benchmarks |
JMH benchmarks and a load-test harness (not published) |
halyx-samples |
portable-orders, a Spring Boot 4 service on every Halyx API, and portable-orders-boot3, the same on Boot 3.5, with their end-to-end tests (not published); see Portable microservice |
AI skills#
The skills for AI coding assistants live in .claude/skills/, outside the Maven reactor, and are named
halyx-ai-<verb> so they can't be mistaken for modules. See AI skills.
Build#
mvn verify # unit tests and every conformance kit against emulators (needs Docker)
mvn test # unit tests and the kits on the in-memory provider only
mvn -pl halyx-aws -am verify -Dit.test='S3BlobStoreConformanceIT' # one suite
Requirements: Java 21 or later, Maven 3.9 or later, and Docker for the integration tests. They're skipped
automatically when Docker is unavailable. docker compose -f cloud-testing/docker-compose.yml up -d starts the
emulators for manual experiments.
Every source file carries the Apache-2.0 license header, and the build checks it. Run
mvn -N com.mycila:license-maven-plugin:format to add any missing ones.
mvn verify also compares each published module with the last release, japicmp.baseline (1.0.0), from Maven
Central, using japicmp. A change that would break code compiled against that
release, or its source, fails the build: a removed or changed public method, constructor or field, a method added to an
interface without a default, a narrower visibility. Its report is in each module's target/japicmp/. Test modules,
samples and benchmarks aren't published and set japicmp.skip. The check covers the provider SPI too, since other
providers implement it, which is why its additions come with defaults. Keep the old signature next to a new one: an
overload, a constructor that delegates, a default method. The baseline moves to each new release.
Testing#
Conformance kits#
halyx-tck holds one conformance kit per service. Every provider runs the same kit, so a behaviour is
either portable or explicitly declared as a capability:
| Kit | Tests | What it covers |
|---|---|---|
| Blob store | 80 | round trips, the asynchronous view, streams, parallel transfers, listing and pagination, conditional writes and deletes, batch delete, copy, metadata, checksums, tiers, presigned URLs and multipart |
| Document repositories | 10 | Spring Data repositories on the document store kit's tables: CRUD with records, UUID keys, optimistic locking and taken ids, batched saves, partition queries both ways with sort-key conditions and limits, windows, streams, index queries with and without a sort attribute |
| Reactive document repositories | 10 | the document repository cases through ReactiveDocumentRepository: Mono and Flux results, saves from a Publisher, Mono<Window> scrolling, and Flux queries that stop early |
| Document store | 26 | the asynchronous view, every value type, versions and conditions, partial updates with concurrent increments, batches beyond provider limits, sort-key queries both ways with paging, secondary indexes, transactions with rollback, keys with / ? # % \, expiry |
| Event bus | 9 | asynchronous publishes, every CloudEvent attribute through the router to its queue, text and binary data, events without data, batches across requests, republished ids, oversized events refused, listeners |
| Functions | 12 | one test function per environment: the payload round trip, configured and per-request headers reaching the function, a function that throws, 400 and 404, a missing function, payload and header limits, timeouts, a failed or timed-out non-idempotent invocation running once, the asynchronous view, and trigger where declared |
| Messaging | 21 | round trips, asynchronous publishes and receives, binary bodies, ack and nack, lease expiry and extension, batches, listener retries and concurrency, claim checks, ordering, deduplication, delays |
| Streams | 28 | asynchronous sends and polls, keys, headers, partitions and offsets, order per key, committed positions, uncommitted records read again, groups sharing partitions, start positions, listener retries holding only their partition, dead letters, commits on close, claim checks, idempotence and transactions |
| Cache | 52 | ping, the asynchronous view, basic and bulk operations, cache-aside, atomic operations, counters, locks, namespaces, expiry (fail-open has its own test against an unreachable server) |
| Parameter store | 26 | CRUD, the asynchronous view, ping past the cache, versions, prefixes, caching, JSON fields, native names, watching |
Emulators#
Integration tests (*IT) run the kits through Testcontainers against
Floci (floci/floci for AWS, floci/floci-az for Azure, floci/floci-gcp for
Google Cloud), Google's Pub/Sub and Firestore emulators, Microsoft's Service Bus emulator, Valkey (plain,
TLS with a private CA, and a 3-master cluster), HashiCorp Vault, PostgreSQL, MySQL and Apache Kafka. Image
versions are pinned in the parent POM. halyx-kafka runs its broker tests twice, on Kafka 3.9 and on Kafka 4.
The event bus kit runs on Floci's EventBridge; Event Grid namespaces and Eventarc Advanced have no emulator, so
their kits run on the real clouds only. The function kit's test function, in Python, runs on Floci's Lambda (in a
Lambda runtime container, with the Docker socket), on the Azure Functions host container
(mcr.microsoft.com/azure-functions/python), and on Google's Functions Framework built from python:3.12-slim.
Behaviour an emulator doesn't implement is declared per provider in knownEnvironmentGaps(), each case
confirmed with raw HTTP requests against the emulator. Those tests are reported as skipped with the
reason, so the kits stay strict for real clouds:
| Emulator | Gap | Test skipped |
|---|---|---|
| Floci (AWS) | drops Content-Language |
putStoresStandardHttpHeaders |
| Floci-az | ignores If-None-Match on Put Block List |
writeInPartsWithIfNotExistsRejectsExistingBlob |
| Floci-az | no Set Blob Tier, Set Blob Properties or Blob Batch; no x-ms-access-tier |
tier and metadata-update tests |
| Floci-gcp | drops cacheControl on copy; metadata PATCH replaces the map |
copyPreservesHttpHeaders, updateMetadataReplacesHeadersAndMetadataButNotContent |
| Floci-gcp | no redelivery on Pub/Sub; increments not atomic in Firestore | the redelivery and increment tests (covered by Google's emulators) |
Real clouds#
The same kits run against real AWS, Google Cloud and Azure, on throwaway infrastructure that's always torn down. They found bugs the emulators hid, from unprocessed DynamoDB batches to Cosmos DB id escaping and Pub/Sub ordering keys. See Cloud testing for how the runs work and every result.
Spring#
The Spring tests cover beans from properties (with providers from customizers and cache codecs), @HalyxListener
with retries and startup checks, claim checks, configuration import with live reload, health (including fail-open
caches), metrics and tracing through messages, events and records. They run on Spring Boot 3.5, and the same shared
tests run again on Boot 4.1. On the real clouds, Spring Boot 3 and 4 also run repositories, events, reactive beans,
@HalyxListener with claim checks and configuration import in tier 1, R2DBC in tier 2, caches and @Cacheable
in tier 3, and streams in tier 4.
Benchmarks#
mvn -pl halyx-benchmarks -am package -DskipTests
java -jar halyx-benchmarks/target/benchmarks.jar # all JMH benchmarks
java -jar halyx-benchmarks/target/benchmarks.jar TransferBenchmark -p latencyMillis=50
The JMH benchmarks measure Halyx itself, so they need no cloud account. TransferBenchmark and
BulkBenchmark run against a simulated network: every request pays a round-trip latency, and each
connection is capped at a bandwidth. That shows what the parallel engines gain, independently of your
network. One short run on a laptop, at 20 ms latency and 100 MB/s per connection:
| Benchmark | Sequential | Halyx | Speed-up |
|---|---|---|---|
| 64 MiB download, 8 MiB ranges | 1,004 ms | 310 ms (parallelism 4), 219 ms (8) | 3.2x, 4.6x |
| 64 MiB upload, 8 MiB parts | 1,125 ms | 445 ms (concurrency 4), 228 ms (8) | 2.5x, 4.9x |
| delete 500 keys, no batch API | 10,934 ms | 705 ms (16 at a time) | 15x |
| list 5,000 keys in 5 pages, 20 µs of work per key | 291 ms | 194 ms (next page prefetched) | 1.5x |
| checksum one 8 MiB part | MD5: 660 MiB/s | CRC32C: about 16 GiB/s | about 25x |
Calling the portable API costs about a microsecond more per call than calling the driver directly
(CoreBenchmark), which is noise next to any network round trip. These runs were short, with wide error
bars; rerun them on your own hardware before relying on the figures.
Load testing a real bucket#
Emulators don't behave like cloud storage under load, so tune against a real bucket with LoadTest. It
uses each cloud's default credentials and deletes its objects afterwards:
java -cp halyx-benchmarks/target/benchmarks.jar \
-Dprovider=aws -Dbucket=my-bench-bucket -Dregion=eu-west-1 \
-Dconcurrency=64 -Dduration=60s -Dsize=1MiB -Dmix=put:20,get:70,head:10 \
-Dhalyx.http.max-connections=256 \
dev.halyx.benchmarks.LoadTest
It prints count, operations per second, MB/s, p50, p90, p99 and maximum latency, and errors, for each
operation. Any halyx.* property goes to the provider, for example -Dhalyx.aws.retry-mode=standard.
Use -Dparallelism=8 for parallel ranged gets, and -Dendpoint=... for S3-compatible stores.