A Java monolith can become difficult to change as its codebase, team, and traffic grow. One business feature may require edits across ordering, customer management, billing, notifications, and reporting. Deploying a small correction means rebuilding and releasing the entire application, while scaling one busy capability means scaling everything with it.
Splitting the application into microservices may appear to solve these problems. The danger is performing only a physical split. If the new services still share database tables, depend on one large internal library, and expose every internal endpoint directly to web and mobile clients, the system remains tightly coupled. It now has the complexity of a distributed system without gaining meaningful independence.
A successful migration is therefore not measured by the number of deployable services. It is measured by whether each service can evolve, be tested, be deployed, and fail with limited impact on the rest of the platform.
The Problem
Consider a Java commerce application with these responsibilities:
- product search
- customer profiles
- order processing
- payment coordination
- email and push notifications
- reporting
The application currently runs as one deployment and uses one database.
Web and Mobile Clients
|
v
Java Monolith
|
v
Shared Database
The internal code may already use layers:
Presentation
|
Business Logic
|
Persistence
|
Database
A layered architecture is not automatically a problem. It separates presentation, business rules, persistence, and storage responsibilities. For a small or stable application, a layered monolith may remain simpler to develop, deploy, and operate than a distributed alternative.
The migration becomes relevant when the existing architecture prevents the organization from meeting practical goals such as:
- changing one capability without risking unrelated features
- deploying parts of the system independently
- scaling selected workloads independently
- allowing teams to own distinct business areas
- isolating failures
- replacing one implementation without restructuring the whole application
The target is not "microservices everywhere." The target is an architecture that improves development, deployment, operation, and maintenance.
First Decide Whether the Monolith Actually Needs to Be Split
A monolith is not inherently bad. Keeping it may be the correct decision when:
- the application has limited complexity
- traffic does not require selective scaling
- the application is stable and meets foreseeable business needs
- the team is small
- operational knowledge and infrastructure are limited
- the cost of distributed communication would exceed the expected benefit
Microservices add network communication, distributed data, independent deployments, monitoring needs, and coordination between teams. Those costs are justified only when they solve a real constraint.
Before starting the migration, write down the reasons for it.
A useful decision statement is concrete:
Current constraint:
Notification releases are tied to the complete commerce application.
Desired capability:
The notification component can be changed, tested, deployed, and scaled independently.
Evidence of success:
A notification deployment does not require rebuilding or redeploying order processing.
An unhelpful reason is simply:
We should use microservices because the monolith is old.
Age alone does not identify the architectural problem.
Why a Naive Split Fails
A team may create several applications and call the result microservices:
Clients
|----> Customer Service ---- |----> Order Service --------> Shared Database
|----> Notification Service -/
This looks distributed, but the services remain connected through three forms of coupling.
Shared persistence
When multiple services read and write the same tables, a schema change can require coordinated changes across them. A service cannot safely evolve its data model because another service may rely on the same columns, relationships, or interpretation.
Concurrency also becomes harder to reason about. One service may change data while another assumes an older state.
Shared business libraries
A shared library can reduce duplication, but it can also connect release cycles. If every service imports one company-wide domain model, changing that model may trigger retesting, rebuilding, and redeploying many services.
The code is physically reused, but the services are no longer independently changeable.
Direct client-to-service communication
When browsers, mobile applications, and external consumers call internal services directly, those clients become dependent on service addresses and contracts.
Moving, splitting, renaming, or replacing a service now requires client changes. Internal architecture becomes part of the public interface.
The system has several deployments, but the boundaries are still weak.
Define Service Boundaries Around Responsibilities
Start with business responsibilities, not packages, tables, or technical layers.
For the commerce platform, possible boundaries are:
- Catalog owns searchable product information.
- Customer Profile owns customer details and preferences.
- Ordering owns order creation and order state.
- Notification owns delivery of email and push messages.
A service boundary should answer:
- What business capability does this service provide?
- What data does it own?
- Which operations can other components request?
- Which events does it publish?
- Which failures can occur without bringing down unrelated capabilities?
- Which team is responsible for its development and operation?
Do not split a monolith by converting each existing layer into a service.
This design creates technical services:
Presentation Service
|
Business Logic Service
|
Persistence Service
|
Database
Every request must cross several network boundaries, yet no service owns a complete business capability. The original layer coupling remains, with network latency and operational complexity added.
A better boundary keeps related business rules and data together.
Migrate Incrementally
A large, immediate rewrite creates too much uncertainty. Migrate one bounded capability at a time.
A sensible first candidate is often a non-critical capability with a clear input and output. Notification is useful for illustration because order creation can remain in the monolith while message delivery moves out.
Initial state
Java Monolith
|
+-- Order Processing
+-- Notification Logic
+-- Customer Logic
|
v
Shared Database
Transitional state
Java Monolith
|
+-- Order Processing
+-- Customer Logic
|
+---- Order Confirmed Event ----> Notification Service
|
v
Notification Storage
Later state
Clients
|
v
API Gateway or BFF
|
+----> Ordering Service
+----> Customer Service
+----> Catalog Service
Ordering Service
|
+---- Order Confirmed Event ----> Notification Service
The migration remains reversible and observable. The team can learn how deployment, communication, testing, logging, and ownership work before extracting a more critical capability.
Give Each Service Ownership of Its Data
The strongest rule is that one service owns a piece of data and provides controlled access to it.
Other services should not bypass the owner and query its tables directly.
Suppose Ordering needs a customer's notification preference. The tempting solution is to read the customer table from the order service. That creates a hidden contract with the customer schema.
Prefer one of these approaches:
- request the information through the Customer Profile API
- receive customer preference events and maintain an appropriate local view
- include the required preference in a command or request when the workflow begins
The selected option depends on freshness, consistency, and availability requirements. The important point is that the Customer Profile service remains responsible for its data contract.
Does every service require a separate database server?
Not necessarily.
The architectural requirement is ownership, not a particular infrastructure layout. During a migration, services may temporarily use the same database platform while owning different tables or schemas. One service must not update another service's data directly.
A transitional rule can be written like this:
Customer Profile Service:
- owns customer and preference data
- is the only writer of that data
- exposes approved read operations or events
Ordering Service:
- owns orders and order state
- does not update customer tables
- stores only the customer information required by the order domain
Sharing storage temporarily may be unavoidable. Treat it as migration debt with a clear removal plan, not as the final architecture.
Define Communication Contracts Before Extracting Code
Inside a monolith, one Java method can call another directly. After extraction, that interaction crosses a network or messaging boundary.
The contract must become explicit.
For synchronous communication, define:
- the request
- the response
- validation rules
- error behaviour
- compatibility expectations
- ownership of the operation
A conceptual Java boundary may look like this:
public interface CustomerProfileClient {
CustomerNotificationPreference
findNotificationPreference(CustomerId customerId);
}
The interface is not the service itself. It represents what the Ordering component needs from the remote capability.
For asynchronous communication, define events as completed facts.
public final class OrderConfirmedEvent {
private final String orderId;
private final String customerId;
private final String contactAddress;
public OrderConfirmedEvent(
String orderId,
String customerId,
String contactAddress) {
this.orderId = orderId;
this.customerId = customerId;
this.contactAddress = contactAddress;
}
public String orderId() {
return orderId;
}
public String customerId() {
return customerId;
}
public String contactAddress() {
return contactAddress;
}
}
The publisher announces that an order was confirmed. It does not need to know which components will react.
A command expresses a different intention:
public final class SendOrderConfirmationCommand {
private final String orderId;
private final String destination;
public SendOrderConfirmationCommand(
String orderId,
String destination) {
this.orderId = orderId;
this.destination = destination;
}
}
An event states that something happened. A command asks a known capability to perform an action. Keeping this difference clear helps prevent confusing service responsibilities.
Choose Synchronous and Asynchronous Communication Deliberately
Synchronous communication is useful when the caller needs an immediate answer before continuing.
For example, order creation may need to know whether a requested product is available. The caller sends a request and waits for a response.
Asynchronous communication is useful when the producer does not need the consumer to finish before continuing.
For example, after an order is confirmed, email delivery can happen independently. The order workflow publishes an event, and the notification component processes it.
Ordering Service
|
| publishes Order Confirmed
v
Event Router
|
+----> Notification Service
+----> Reporting Service
+----> Loyalty Service
This reduces direct knowledge between producers and consumers. Services interact with the event router rather than maintaining point-to-point connections to every interested component.
An event-driven design can improve:
- independent scaling
- fault isolation
- workload buffering
- team autonomy
- extensibility when new consumers are added
It also introduces responsibilities:
- event contracts must be managed
- consumers must handle failures
- monitoring must cover the complete flow
- developers must understand that processing may not be immediate
- debugging requires correlation across components
Do not replace every method call with an event. Use events when loose coupling and independent processing support the business workflow.
Put a Stable Entry Point Between Clients and Services
Clients should not need to understand the internal service map.
Instead of this:
Mobile App ----> Customer Service
----> Order Service
----> Catalog Service
----> Notification Service
place an API gateway between clients and internal services:
Web App ----Mobile App ---> API Gateway ---> Internal Services
Partner API -/
An API gateway can provide a unified entry point and handle concerns such as:
- request routing
- response aggregation
- authentication and authorization
- load distribution
- caching
- request limits
- monitoring and analytics
The gateway protects clients from internal service addresses and structural changes.
Use a Backend for Frontend when clients have different needs
A Backend for Frontend, usually shortened to BFF, creates a specialized backend entry point for each client type.
Web App ----> Web BFF ---- Mobile App -> Mobile BFF ----> Internal Services
/
Partner UI -> Partner BFF /
A mobile application may require smaller responses and fewer round trips. A web application may need different aggregations. Separate BFFs allow each interface to evolve around its client.
A BFF should adapt and aggregate. It should not copy core business rules from the backend services. Duplicating business behaviour in several BFFs recreates inconsistency at another layer.
Be Selective with Shared Libraries
Shared libraries are useful for stable, cross-cutting capabilities such as:
- logging conventions
- security integration
- monitoring support
- exception handling
- asynchronous communication infrastructure
They are risky when they contain service-specific business models or rules.
Avoid a shared library like this:
commerce-domain-library
|
+-- Customer
+-- Order
+-- Product
+-- Payment
+-- Discount Rules
If Ordering, Catalog, and Customer Profile all depend on the same business model, they cannot evolve their concepts independently.
Prefer contracts that are intentionally designed for communication:
ordering-service
+-- internal Order model
+-- published order event contract
customer-service
+-- internal Customer model
+-- customer API contract
Two services may use similar classes without sharing the same implementation. Duplication can be less harmful than permanent domain coupling.
For service clients, an API specification can be a better source than a manually maintained shared client library. Clients can be generated from the contract, reducing handwritten duplication while keeping the service API explicit.
Before creating a shared library, ask:
- Is the code truly cross-cutting?
- Is it stable enough to be shared?
- Will changing it force many service releases?
- Does it contain business meaning that should remain inside one service?
- Is the versioning and release process clear?
- Can the same goal be achieved through an API or event contract?
Add Testing Before Increasing Distribution
A distributed architecture needs more than unit tests.
Use several testing levels:
Unit tests
Verify business behaviour inside each service without external dependencies.
Integration tests
Verify communication with storage, message infrastructure, and external adapters.
Contract tests
Verify that a service provider and its consumers agree on requests, responses, and events.
Contract tests are especially important when services deploy independently. They detect incompatible changes before one service surprises another in a shared environment.
End-to-end tests
Verify selected business workflows across multiple services, such as:
Create order
|
v
Confirm order
|
v
Publish event
|
v
Deliver notification
Do not make every scenario an end-to-end test. A smaller number of important cross-service workflows combined with focused tests at lower levels is easier to maintain.
Prepare Operations Before Depending on Independent Services
A monolith usually produces one primary set of logs and exposes one main runtime to monitor. Microservices multiply those operational surfaces.
Before migration progresses, plan for:
- service health monitoring
- performance metrics
- centralized logging
- trace or correlation identifiers
- failure investigation across service boundaries
- independent deployment and rollback
- automated build and delivery
- ownership of production incidents
Without these capabilities, extracting services can make failures harder to locate and recover from.
Reactive architecture provides a useful quality lens for distributed systems. A well-designed system should aim to be:
- responsive, returning results with predictable delays
- resilient, limiting the effect of component failures
- elastic, adjusting resources as workload changes
- message-driven, using asynchronous boundaries where they improve isolation
These qualities are not created by choosing a reactive library. They come from architectural decisions about communication, failure handling, scaling, and ownership.
Align Team Structure with Service Ownership
A microservices migration changes responsibilities as well as code.
Independent deployment works only when teams have enough ownership to:
- develop the service
- test it
- deploy it
- monitor it
- investigate failures
- evolve its contracts responsibly
This usually requires close collaboration between development and operations, automated continuous integration and continuous delivery, and shared responsibility for runtime behaviour.
Creating many services while keeping every operational decision in one separate team can leave delivery blocked by organizational dependencies even when the code is technically separated.
A Practical Migration Sequence
Use this order to reduce risk:
- Document why the monolith currently blocks a business or operational goal.
- Confirm that a distributed architecture is worth its additional complexity.
- Identify one bounded, non-critical capability with a clear contract.
- Define its data ownership before extracting code.
- Decide which interactions must be synchronous and which can use events.
- Add tests around the current behaviour and communication contract.
- Extract the capability incrementally.
- Route clients through a gateway or BFF rather than exposing internal services directly.
- Remove direct table access from other components.
- Review shared libraries and remove domain-specific coupling.
- Add centralized monitoring and logging for the new distributed flow.
- Give a team clear development and operational ownership.
- Measure whether the extracted service can actually change and deploy independently.
- Use what the team learned before selecting the next capability.
The migration should pause when the organization cannot operate the services safely. More services do not automatically mean better architecture.
Common Mistakes
Splitting technical layers into services
Presentation, business, and persistence services remain dependent on one another for nearly every request. The network becomes part of an interaction that was previously an internal call.
Starting with the most critical capability
A critical payment or order path exposes the team to high migration risk before it has learned how to test, deploy, monitor, and recover distributed services.
Treating one shared database as permanent
Temporary shared storage may support an incremental migration. Permanent cross-service table access prevents independent evolution.
Putting the complete domain into a shared library
This removes duplication but creates a release dependency between services. Cross-cutting infrastructure is a better library candidate than business entities and rules.
Exposing every service to every client
Clients become tied to internal addresses and contracts. An API gateway or BFF provides a more stable boundary.
Moving business logic into the gateway
A gateway should route, secure, aggregate, and adapt requests. Core domain behaviour belongs in the service that owns the capability.
Making every interaction synchronous
A long chain of blocking service calls increases runtime dependence. Use asynchronous events where the producer does not require an immediate consumer result.
Using events for operations that require immediate answers
Event-driven communication is not a universal replacement for request-response operations. Choose based on workflow requirements.
Ignoring operational readiness
Services that cannot be monitored, traced, deployed, and recovered independently are not operationally autonomous.
Migrating without cultural ownership
Technical boundaries provide little value when teams cannot make and operate changes independently.
Migration Checklist
Before extracting another service, verify:
- The migration solves a documented constraint.
- Keeping the monolith was evaluated honestly.
- The candidate service represents a business capability.
- Its data ownership is explicit.
- Other services do not write its data directly.
- API or event contracts are defined.
- Synchronous and asynchronous interactions were chosen intentionally.
- Contract, integration, and important end-to-end tests exist.
- Clients access a stable gateway or BFF.
- The BFF does not duplicate domain logic.
- Shared libraries contain only justified cross-cutting code.
- Logs and metrics can be correlated across the workflow.
- Deployment and rollback are automated enough for independent releases.
- A team owns both development and operation.
- The service can evolve without coordinated changes across the whole platform.
Conclusion
Migrating a Java monolith to microservices is not primarily an exercise in creating more applications. It is an exercise in creating meaningful ownership boundaries.
A service is not independent when another service can modify its tables, when every deployment depends on the same domain library, or when clients call its internal address directly. Those choices preserve the original coupling while adding network and operational complexity.
Proceed incrementally. Start with a clear business capability, give it ownership of its data, define stable APIs and events, introduce a gateway between clients and internal services, and keep shared libraries focused on genuinely cross-cutting concerns. Support the design with automated testing, centralized observability, continuous delivery, and team ownership.
The migration is successful when a service can change for its own business reason without forcing the rest of the system to move with it.