Building Cloud-Native Java Applications Instead of Containerized Monoliths
“Running a Spring Boot application inside a container doesn’t automatically make it cloud-native. Kubernetes can orchestrate your application, but it cannot fix an application that wasn’t designed for a distributed environment.”
A Successful Deployment… Until It Wasn’t
Monday morning.
The digital banking platform at Global Trust Bank has just completed its migration from virtual machines to Kubernetes.
The migration team is confident.
Every microservice has been containerized.
The CI/CD pipeline is working flawlessly.
Deployments complete in minutes instead of hours.
Operations celebrates another successful cloud migration.
Everything looks perfect.
Until the first production incident.
At 10:15 AM, the Horizontal Pod Autoscaler notices increasing CPU utilization on the Payment Service.
Instead of three replicas, Kubernetes scales the application to six.
Within a few minutes, customer complaints begin appearing.
Some users suddenly lose their login sessions.
Others receive inconsistent payment statuses.
A few large file uploads fail midway.
Operations immediately checks Kubernetes.
Every Pod is healthy.
READY STATUS RESTARTS
1/1 Running 0
1/1 Running 0
1/1 Running 0
1/1 Running 0
1/1 Running 0
1/1 Running 0
No Pod failures.
No CrashLoopBackOff.
No memory issues.
No CPU throttling.
Everything inside Kubernetes appears healthy.
So why are customers experiencing problems?
Because Kubernetes successfully deployed the application.
It did not verify whether the application was designed for Kubernetes.
This is one of the biggest misconceptions when organizations begin their cloud-native journey.
Containerizing an application and building a cloud-native application are two very different things.
Kubernetes Doesn’t Change Your Application
Many organizations believe their migration strategy is straightforward.
Existing Spring Boot Application
│
Docker Build
│
Deploy to Kubernetes
│
Cloud Native
Unfortunately, reality looks very different.
Existing Spring Boot Application
│
Docker Build
│
Deploy to Kubernetes
│
Existing Problems
+
Distributed Systems Problems
Kubernetes provides incredible capabilities:
- Self-healing
- Automatic scaling
- Rolling deployments
- Service discovery
- Health monitoring
- Infrastructure automation
However, Kubernetes assumes that your application is designed to operate in a distributed environment.
If the application still carries assumptions from the virtual machine era, those assumptions quickly become production issues.
The rest of this article focuses on the design principles that transform a traditional Spring Boot application into a cloud-native application.
Principle 1 – Design Every Service to Be Stateless
If there is one principle that defines cloud-native applications, it is this:
Pods are temporary. Data should not be.
This idea is uncomfortable for developers who have spent years deploying applications onto long-lived servers.
Traditionally, a server might remain online for months.
Developers gradually become attached to that server.
They store files on disk.
They cache data in memory.
They assume the process will continue running indefinitely.
Kubernetes challenges every one of those assumptions.
A Pod can disappear at any moment.
Reasons include:
- Rolling deployments
- Node maintenance
- Autoscaling
- Resource pressure
- Hardware failures
- Cluster upgrades
- Manual deployments
From Kubernetes’ perspective, replacing a Pod is a normal operational event.
Your application must therefore behave as though any instance can disappear without warning.
A Dangerous Assumption
Consider this code.
private final Map<String, PaymentStatus> paymentCache = new ConcurrentHashMap<>();
During development, everything works perfectly.
Every request handled by the application can retrieve the payment status from memory.
Now deploy three replicas.
Payment Service
┌──────────┬──────────┬──────────┐
│ │ │
▼ ▼ ▼
Pod A Pod B Pod C
Each Pod has its own JVM.
Each JVM has its own memory.
Each JVM therefore contains its own cache.
Pod A
Payment 101 = SUCCESS
---------------------
Pod B
Payment 101 = NOT FOUND
---------------------
Pod C
Payment 101 = NOT FOUND
A customer submits a payment through Pod A.
The next request is routed to Pod C.
Suddenly the application behaves inconsistently.
Nothing is wrong with Kubernetes.
The application assumed memory was shared.
It isn’t.
What Belongs in Memory?
Memory is still extremely valuable.
Use it for:
- Temporary request processing
- Object creation
- Serialization
- Local computation
- Thread-local information
Do not use it as the system of record.
For shared application state, use dedicated infrastructure.
Application State
↓
Redis
↓
Oracle
↓
Kafka
↓
Solace
↓
Object Storage
Think of every Pod as a hotel room.
Guests come and go.
The room should never contain the hotel’s permanent records.
Those belong in the central system.
Pods should behave the same way.
Sessions Don’t Belong Inside Pods
One of the first production issues teams encounter is HTTP session management.
Imagine Sarah logs into the banking portal.
Her request reaches Pod A.
Customer
↓
Pod A
↓
HTTP Session Created
The next request arrives a few seconds later.
The Service routes it to Pod C.
Customer
↓
Pod C
↓
Session Not Found
The customer is unexpectedly logged out.
Historically, organizations solved this using sticky sessions.
While this works, it also defeats one of Kubernetes’ greatest strengths—load balancing across replicas.
A better approach is to externalize session state.
Examples include:
- Spring Session with Redis
- OAuth2 / OpenID Connect
- JWT-based authentication
- Distributed session stores
When every Pod can retrieve the same session information, requests can safely reach any replica.
Local Files Are Temporary
Let’s look at another common example.
Suppose the Payment Service processes supporting documents.
A developer writes uploaded files to:
/tmp/uploads
Everything works during testing.
Now the application scales.
Upload Request
↓
Pod B
↓
File Stored
↓
/tmp/uploads/document.pdf
A few minutes later, another request attempts to retrieve the document.
This request reaches Pod A.
Pod A
↓
File Missing
The file exists.
Just not on this Pod.
Even worse, Kubernetes may terminate Pod B during a rolling deployment.
The file disappears forever.
In Kubernetes, the local filesystem should be treated as temporary working space—not permanent storage.
Enterprise alternatives include:
- Amazon S3
- Azure Blob Storage
- Google Cloud Storage
- Network File Systems
- Persistent Volumes (when appropriate)
For our banking platform, supporting documents are stored in object storage, while metadata resides in Oracle.
The Payment Service simply stores references.
This design allows any replica to retrieve the document regardless of which Pod processes the request.
Principle 2 – Externalize Configuration
Imagine maintaining separate copies of your application for each environment.
payment-dev.jar
payment-sit.jar
payment-uat.jar
payment-prod.jar
Fortunately, modern Spring Boot applications don’t need this approach.
Instead, the application should remain identical across environments.
Only its configuration changes.
This is the foundation of the Twelve-Factor App methodology and one of the most important principles for cloud-native systems.
A production deployment should promote the same artifact through every environment:
Build Once
↓
Deploy Everywhere
↓
Change Configuration Only
In Kubernetes, configuration belongs outside the application.
Typical examples include:
- Database URLs
- Redis hosts
- Kafka bootstrap servers
- Solace endpoints
- Feature flags
- Logging levels
- External API endpoints
Spring Boot integrates naturally with environment variables, ConfigMaps, and Secrets, making it unnecessary to rebuild the application for each environment.
We’ll dedicate an entire article to ConfigMaps and Secrets later in this series, but it’s important to establish the principle now:
Configuration is deployment-specific. The application binary is not.
Principle 3 – Log to Standard Output, Not Local Files
Many enterprise applications still write logs to files such as:
/logs/application.log
This approach worked well when applications ran on long-lived servers.
In Kubernetes, Pods are disposable.
If the Pod is deleted, those log files disappear with it.
Instead, cloud-native applications should write logs to standard output (stdout) and standard error (stderr).
This allows Kubernetes and OpenShift to collect logs automatically, making them available to centralized logging platforms such as Elasticsearch, OpenSearch, Splunk, or Grafana Loki.
Your application should focus on producing meaningful logs.
The platform should handle storage, indexing, retention, and visualization.
Designing for Failure
Perhaps the biggest mindset shift when building for Kubernetes is accepting that failure is normal.
Nodes fail.
Pods restart.
Networks experience transient issues.
External services become unavailable.
Rather than attempting to prevent every failure, cloud-native applications assume failures will occur and recover gracefully.
This means:
- Retry transient failures intelligently.
- Use circuit breakers where appropriate.
- Make operations idempotent.
- Persist important state externally.
- Avoid assuming requests will always reach the same instance.
The most resilient applications aren’t those that never fail.
They’re the ones that recover predictably when failures occur.
A Cloud-Native Readiness Checklist
Before deploying any Spring Boot application to Kubernetes, ask the following questions:
- Can any Pod be terminated without losing business data?
- Is application state stored externally?
- Are HTTP sessions shared across replicas or eliminated entirely?
- Is configuration externalized?
- Are secrets managed securely?
- Does the application write logs to
stdoutandstderr? - Can multiple replicas process the same workload consistently?
- Is startup deterministic and repeatable?
- Can the application tolerate infrastructure failures?
If you answer “no” to any of these questions, Kubernetes may still run your application—but it won’t be able to provide the resilience and scalability you’re expecting.
Looking Ahead
We’ve redesigned our Spring Boot application to behave like a true cloud-native service.
It is now stateless.
Its configuration is externalized.
It no longer depends on local files or in-memory session state.
It is ready to scale horizontally and recover from infrastructure failures.
But one major challenge still remains.
What happens when Kubernetes decides it’s time to terminate a Pod?
Suppose the Payment Service is processing a high-value transaction.
What if a customer is uploading a 500 MB document?
What if an asynchronous payment settlement is halfway through execution?
Can Kubernetes simply kill the Pod?
Or is there a better way to shut down an enterprise application without disrupting customers?
In the next article, we’ll follow the complete shutdown lifecycle—from the moment Kubernetes sends a SIGTERM signal to the JVM until the last in-flight request completes—while learning how to achieve truly zero-downtime deployments in production.
It’s not just “how Kubernetes works”; it answers why enterprise Spring Boot applications often fail after migrating to Kubernetes and how to design them correctly. The next article on Graceful Shutdown & Zero-Downtime Deployments will naturally build on this by showing how a well-designed application cooperates with Kubernetes during rolling updates, scaling events, and node maintenance.