A portable Spring Boot microservice

halyx-samples/portable-orders is an orders service that uses every Halyx API. It's built once, as one jar and one container image, and runs unchanged on AWS, Azure and Google Cloud. Spring profiles and properties pick the cloud; the code has no cloud-specific branches. The same end-to-end tests run against every deployment.

What it does#

POST /customers/{id}/orders ──► orders (DocumentStore, via a Spring Data repository)
                            ──► order-placed (Publisher) ──► fulfilment (@HalyxListener)
                            │                                  ├─► receipts (BlobStore), signed with a key from secrets (ParameterStore)
                            │                                  ├─► ledger (DataSource, JdbcTemplate)           sql
                            │                                  └─► status CONFIRMED (DocumentStore.update)
                            ├─► order-events (EventPublisher) ──► notifications (@HalyxListener, CloudEvent)   events
                            ├─► order-audit (StreamProducer)  ──► auditor (@HalyxListener, stream consumer)    streams
                            └─► idempotency (Cache): an Idempotency-Key header makes retries safe            cache
spring.config.import: halyx:settings  ──► orders.max-lines, orders.currency (ParameterStore, reloaded every minute)
settings features/* ──► flipjack (polled) ──► bulk-discount: BulkDiscountPricing, or StandardPricing when off
Endpoint Returns
POST /customers/{customerId}/orders 201 with the new order; 200 with the earlier order for a repeated Idempotency-Key; 400 for an invalid order
GET /customers/{customerId}/orders the customer's orders, newest first
GET /customers/{customerId}/orders/{orderId} the order: its status, and whether it has been receipted, notified and audited
GET /customers/{customerId}/orders/{orderId}/receipt the signed receipt, once fulfilment has written it
GET /customers/{customerId}/ledger the customer's ledger entries (sql only)
GET /deployment the cloud, the features on, the provider behind each Halyx bean, and the settings
GET /actuator/health Boot's health, with Halyx's indicators

Choosing the cloud and the features#

One profile names the cloud, and up to four more turn on optional features:

Profile Adds
local every service in memory: no cloud, no containers
aws, azure or gcp the cloud's provider, named after it, and the settings import
events an event bus and the subscription its router delivers to
streams a Kafka stream cluster, a producer and a consumer group
cache a Redis or Valkey cache for idempotency keys
sql a PostgreSQL ledger, with IAM login and schema.sql
Shell
SPRING_PROFILES_ACTIVE=aws,events,cache,sql java -jar portable-orders.jar
docker run -e SPRING_PROFILES_ACTIVE=gcp,events,streams,cache,sql -e GOOGLE_CLOUD_PROJECT=acme halyx/portable-orders:1.1.0

Each feature is a document in application.yml, gated with spring.config.activate.on-profile, and its components are @Profile beans. A cloud and a feature together, such as azure & events, add what only that pair needs, such as the Event Grid endpoint. Features stay optional because not every deployment can offer every service: MSK isn't available on the AWS test account, and Event Grid and Eventarc have no emulators.

Resource names come from ORDERS_* environment variables, with defaults for a deployment that uses the sample's names:

Variable Used for Default
ORDERS_RECEIPTS_BUCKET the S3 bucket, Blob Storage container or Cloud Storage bucket portable-orders-receipts
ORDERS_TABLE the DynamoDB table, Cosmos DB container or Firestore collection (keys pk and sk) portable-orders
ORDERS_TOPIC, ORDERS_FULFILMENT_SUBSCRIPTION the SNS topic and SQS queue, Service Bus topic and subscription, or Pub/Sub topic and subscription portable-orders-placed, portable-orders-fulfilment (portable-orders-placed/subscriptions/fulfilment on Azure)
ORDERS_EVENT_BUS, ORDERS_NOTIFICATIONS_SUBSCRIPTION the event bus, and the queue or subscription its rule delivers to portable-orders, portable-orders-notifications (notifications on Azure)
ORDERS_KAFKA_BOOTSTRAP_SERVERS, ORDERS_KAFKA_AUTH, ORDERS_AUDIT_TOPIC the stream cluster, its login (iam by default) and topic portable-orders-audit
ORDERS_CACHE_ENDPOINT, ORDERS_CACHE_AUTH the cache, and its login (iam by default)
ORDERS_DB_HOST, ORDERS_DB_PORT, ORDERS_DB_NAME, ORDERS_DB_USER, ORDERS_DB_AUTH the ledger database, with IAM login by default port 5432, database orders, user portable_orders
AWS_REGION the AWS region eu-west-2
ORDERS_STORAGE_ACCOUNT, ORDERS_COSMOS_ENDPOINT, ORDERS_SERVICEBUS_NAMESPACE, ORDERS_KEYVAULT_URL, ORDERS_APPCONFIG_ENDPOINT, ORDERS_EVENTGRID_ENDPOINT the Azure accounts and endpoints
GOOGLE_CLOUD_PROJECT, ORDERS_PARAMETER_LOCATION the Google Cloud project, and the Parameter Manager location location global

The settings (orders/max-lines and orders/currency), the feature flags (features/bulk-discount) and the receipt-signing-key secret live under the portable-orders prefix: in SSM Parameter Store and Secrets Manager, App Configuration and Key Vault, or Parameter Manager and Secret Manager.

Feature flags#

Profiles decide which services a deployment has; feature flags switch behaviour at runtime. The sample's one flag, bulk-discount, takes 10% off each order line of ten or more. flipjack evaluates it from the settings store (FeatureFlags): OrderService calls OrderPricing, and flipjack routes the call to BulkDiscountPricing while the flag is true, and to StandardPricing while it's false or missing. Flipjack polls the store every orders.feature-flags.poll-interval (30 seconds), so a change takes effect without a restart. An override on the featureToggles bean wins over the store, as a kill switch that doesn't wait for a poll. See Feature flags.

What portability takes#

The service's code uses Halyx' interfaces and Spring's, and nothing from a cloud SDK. What differs between clouds is all configuration:

Difference Where it's handled
Resource naming, such as a Service Bus subscription's <topic>/subscriptions/<name> the cloud's document in application.yml
Account endpoints on Azure; the project on Google Cloud the provider's properties
Login: IAM roles, Entra ID or Google service accounts the default credential chain, or credentials.*; nothing in the code
Cache login details: the ElastiCache cache name, the Entra ID object id aws & cache and azure & cache documents
Numbers: DynamoDB, Cosmos DB and Firestore don't keep a decimal's scale, so 3.50 reads back as 3.5 the receipt formats amounts itself
Bean names: a halyx.* key is a bean name, so a class called Fulfilment can't sit next to halyx.subscriptions.fulfilment Halyx fails at startup, naming both; the sample's classes are OrderFulfilment, ReceiptService and so on

On Kubernetes: EKS, GKE and AKS#

The Helm chart in halyx-samples/portable-orders/deploy/helm/portable-orders deploys the same image on any cluster. Its values carry what differs between clouds:

Value Holds
profiles the cloud and the features, such as [gcp, events, streams, cache, sql]
env the ORDERS_* resource names, AWS_REGION or GOOGLE_CLOUD_PROJECT
config more Spring configuration as YAML, mounted as an extra application.yml: settings an environment variable can't express, such as provider properties
files files mounted under /etc/portable-orders/files, such as a cache's private CA
serviceAccount.annotations, podLabels the binding to the cloud's workload identity
javaToolOptions JVM options; by default -XX:MaxDirectMemorySize=64M, since the buildpack's memory calculator otherwise caps direct memory at 10 MB, too little for the cloud SDKs' Netty clients

The pods log in with the platform's workload identity. There are no keys in the cluster, and the code and configuration don't name the mechanism: each cloud's default credential chain finds it.

Cluster Workload identity What the chart sets
EKS EKS Pod Identity: an association of the service account with an IAM role nothing; the association names the namespace and service account
GKE Workload Identity Federation for GKE: the Kubernetes service account acts as a Google service account iam.gke.io/gcp-service-account: <email>
AKS Microsoft Entra Workload ID: a managed identity federated to the service account azure.workload.identity/client-id: <client id>, and the pod label azure.workload.identity/use: "true"

cloud-testing/scripts/build-portable-orders-images.sh builds the Spring Boot 4 and 3.5 images once. cloud-testing/scripts/eks-real-cloud.sh, gke-real-cloud.sh and aks-real-cloud.sh each create a throwaway cluster, a registry and every service the sample uses, all named after the run. Each script pushes those same two images and deploys them in turn, and checks that its pods run the digests it pushed. It then runs DeployedServiceIT against each through a port-forward, and always tears everything down. The latest runs are in Cloud testing. The services are reached the way production would reach them:

Service EKS GKE AKS
Cache ElastiCache, inside the VPC, IAM user Memorystore, inside the VPC through Private Service Connect, IAM and TLS with its private CA Azure Managed Redis, Entra ID
SQL RDS, inside the VPC, IAM login Cloud SQL through the Cloud SQL connector, IAM login Azure Database for PostgreSQL, the identity as its Entra admin
Streams off: MSK isn't available on the test account Managed Service for Apache Kafka, inside the VPC Event Hubs' Kafka endpoint, Entra ID
Settings, secrets SSM Parameter Store, Secrets Manager Parameter Manager, Secret Manager App Configuration, Key Vault

On Google Cloud, the Cloud SQL connector needs com.google.cloud.sql:postgres-socket-factory on the classpath; the sample declares it at runtime scope, as an application would.

Testing#

PortableOrdersSuite holds the end-to-end tests. They use only the HTTP API, so they run unchanged against every deployment. Each deployment states what it expects, such as its cloud, its features, the settings it seeded and its signing key, and the suite checks the service's /deployment report against that. Tests of features that are off are skipped, so the report shows what each deployment ran.

Deployment Test Services
local profile, in process LocalDeploymentTest the memory provider; every feature but SQL
local profile, in process FeatureFlagsTest the memory provider: bulk-discount turned on, off and deleted while the service runs
AWS emulators, in process AwsEmulatorIT Floci (S3, DynamoDB, SNS, SQS, EventBridge, SSM, Secrets Manager), PostgreSQL, Valkey, Kafka; every feature
Azure emulators, in process AzureEmulatorIT Floci-az (Blob Storage, Cosmos DB, Key Vault, App Configuration), the Service Bus emulator, PostgreSQL, Valkey, Kafka; all but events
Google Cloud emulators, in process GcpEmulatorIT Floci-gcp (Cloud Storage, Firestore, Pub/Sub, Secret Manager), PostgreSQL, Valkey, Kafka; all but events
the image, on the AWS emulators ImageIT, with -Pimage as AwsEmulatorIT, with the image configured only through its environment
a running deployment DeployedServiceIT, with -Dorders.e2e.url=... whatever it runs on: the image on EKS, GKE and AKS, through the scripts above

The suite's bulk-discount test follows each deployment's flags: on for the AWS and Azure emulators, the image, EKS and GKE; missing in memory and on the Google Cloud emulators; false on AKS.

portable-orders-boot3 builds the same sources and runs the same tests on Spring Boot 3.5. The sample itself targets Boot 4.

Shell
mvn -pl halyx-samples/portable-orders,halyx-samples/portable-orders-boot3 -am verify   # local and emulators, Boot 4 and 3.5
mvn -pl halyx-samples/portable-orders -Pimage verify                                   # also builds the image and tests it

The emulator runs differ from the real clouds in two places. Event Grid namespaces and Eventarc Advanced have no emulators, so events is off on the Azure and Google Cloud emulators. Parameter Manager has none either, so the Google Cloud emulator run reads its settings from Secret Manager (halyx.parameter-stores.settings.kind=secrets). The latest results are in Cloud testing.