You have a Java application that started as a single deployable unit. It has a user interface layer, business logic, persistence logic, and a database. For a while, that was fine. The team moved quickly, deployment was simple, and a single code base made it easy to search for logic.
Then the system grew. More teams joined. Releases became harder to coordinate. One change in order handling forced regression testing in customer management, billing, and notification code. Scaling one busy workflow meant scaling the entire application. The obvious proposal is: split the monolith into microservices.
That proposal can help, but it can also make the system worse. A badly split monolith often becomes a distributed monolith: many deployable services, but still tied together by one database, shared domain libraries, direct client calls, and release coordination. You get network latency, deployment complexity, and operational overhead without gaining true independence.
A successful migration is not measured by the number of services. It is measured by whether each service can be understood, changed, deployed, monitored, and scaled with less risk than before. The goal is not to break code into pieces. The goal is to reduce the cost of development, deployment, operation, and maintenance.
The Problem
Software architecture is the high-level structure that decides how parts of a system are organized and how they communicate. In a Java system, those parts might be modules, packages, services, databases, queues, API gateways, or background workers.
When architecture is good, it supports four practical needs:
- Development: teams can work on separate areas without constantly blocking each other.
- Deployment: changes can be released predictably, and rollback is possible when something fails.
- Operation: the system can be monitored, secured, backed up, patched, and kept healthy.
- Maintenance: developers can find where a change belongs without spending days exploring unrelated code.
A monolith is not automatically bad. A small, stable application with limited traffic and a small team may be easier to maintain as a monolith. Microservices add distributed-system complexity: network calls, service discovery, API contracts, monitoring, logging, independent deployment, and data ownership. Moving too early can create more problems than it solves.
The practical question is not "Are microservices better?" The better question is: "Which parts of this system need independence badly enough to justify the extra complexity?"
Start With an Architecture Decision, Not a Package Split
A layered Java application usually has clear internal responsibilities:
Presentation layer
|
v
Business layer
|
v
Persistence layer
|
v
Database layer
This structure is useful inside a single application because each layer has a focused job. The presentation layer should not know how database queries work. The business layer should not know how screens are rendered. The persistence layer should hide storage details.
The mistake is to treat these layers as service boundaries by default. A microservice should usually represent a business capability, not merely a technical layer. For example, splitting into ui-service, business-service, and database-service often keeps the same coupling but adds network calls.
A better starting point is to identify business areas that can own their data and behavior:
Customer Service
- customer profile rules
- customer contact data
- customer-facing APIs
Order Service
- order creation rules
- order status transitions
- order persistence
Notification Service
- email and push delivery
- message templates
- delivery status
This is where service granularity matters. If a service is too large, it may not deliver the benefits of independent deployment, focused ownership, or targeted scaling. If services are too small, one user request may require orchestration across many services, making the architecture feel like a complex service-oriented system rather than a simpler microservices model.
Use this rule of thumb: a service boundary is suspicious if every change to one service usually requires a coordinated change in several others.
Decide Whether the Monolith Should Actually Move
Before extracting services, check whether the monolith is still the better design. The migration cost is not only code movement. It includes testing, deployment, team coordination, monitoring, logging, data migration, API design, and operational support.
| Situation | A monolith may still be better | Microservices may help |
|---|---|---|
| Team size | One small team owns everything | Multiple teams need separate ownership |
| Change rate | Requirements are stable | Different areas change independently |
| Scaling | The whole app scales acceptably | One workflow needs different scaling |
| Deployment | Coordinated releases are fine | Releases block each other |
| Operations | Limited infrastructure support | Team can run distributed systems |
| Data model | Data is highly interconnected | Data can be owned by clear domains |
The cleanest migration is incremental. Choose a non-critical capability first, extract it carefully, and use that experience to refine the migration approach. Avoid a large rewrite that attempts to split everything at once.
Avoid Smell 1: Shared Persistence
Shared persistence happens when two or more services read and write the same durable data store as if it belongs to all of them. The store might be a database, cache, Redis instance, or any other persistent layer.
At first, shared persistence looks convenient. Services can keep using the same tables the monolith already used. No one has to build new APIs. Reporting still works. The migration appears faster.
The hidden cost appears when the schema changes. Suppose Order Service and Billing Service both read and write the same order tables. A column rename, table split, or validation change can require both services to change and deploy together. That breaks one of the main reasons to use microservices.
Problematic split:
Order Service -----> Shared Database <----- Billing Service
Customer Service ----/ \---- Reporting Service
The better model is service data ownership. A service owns the data needed for its business capability, and other services access that data through the owning service or through events that the owning service publishes.
Preferred ownership:
Order Service -------> Order storage
Billing Service -----> Billing storage
Customer Service ----> Customer storage
Other services do not write directly to another service's storage.
This does not always require one physical database per service. During a migration, two services might use different tables in the same database. The important rule is ownership: one service should be responsible for writing a given data area, and other services should not bypass it.
For example, a customer service could own CUSTOMER, while an address service owns ADDRESS. The tables might still live in the same database for a while, and the database may enforce relational integrity, but each service has a clear write boundary. That is a transitional step, not a final excuse to keep all services coupled to one schema.
Avoid Smell 2: Shared Libraries That Contain Business Decisions
The principle "Do not repeat yourself" is valuable, but it can become dangerous in a microservices architecture. Shared libraries reduce duplication, but they also create coupling. If many services depend on a shared library, a change in that library can trigger a chain of retesting, rebuilding, and redeploying.
Shared libraries work best for infrastructure concerns that are genuinely common across services:
- logging conventions
- security helpers
- monitoring instrumentation
- asynchronous communication utilities
- exception handling helpers
- generated clients for stable service APIs
They become risky when they contain domain models or business rules that different services may need to evolve independently. A shared Customer model might look harmless at first, but one service may later need fields, validation, or naming that another service should not inherit.
A good shared library hides tool details behind stable names. For example, avoid naming a company-wide abstraction after a specific vendor or storage product. The public contract should describe the capability, not the implementation.
public interface FileUploader {
UploadResult upload(FilePayload payload);
}
final class CloudStorageFileUploader implements FileUploader {
@Override
public UploadResult upload(FilePayload payload) {
// Storage-specific implementation stays hidden here.
return UploadResult.accepted();
}
}
The calling service should depend on FileUploader, not on the specific storage provider. If the storage implementation changes later, the service code should not need to learn a new concept.
When a shared library acts as a client for another service, consider generating that client from an API specification instead of hand-writing and hand-maintaining a custom wrapper. The important idea is to keep the contract explicit and avoid spreading service communication details through many code bases.
Avoid Smell 3: Direct Communication From Clients to Services
Direct client-to-service communication happens when web apps, mobile apps, admin tools, or other consumers call many internal services directly.
Web app -------> Customer Service
Mobile app ----> Order Service
Admin app -----> Billing Service
Tablet app ----> Notification Service
This creates a rigid architecture. Service addresses become part of the client contract. If you split a service, move an endpoint, change a protocol, or reshape an API, every client may need an update. Publicly exposed services must also preserve backward compatibility for each client type, even when the internal design should change.
An API gateway can reduce that coupling by giving clients one entry point. The gateway routes requests to the right service, can aggregate responses, and can centralize cross-cutting concerns such as authentication, authorization, rate limiting, caching, monitoring, and request routing.
Web app
Mobile app ---> API Gateway ---> Customer Service
Admin app ---> Order Service
Tablet app ---> Billing Service
An API gateway is especially helpful when many clients consume the same backend capabilities in similar ways.
A Backend for Frontend, often shortened to BFF, is a related pattern. Instead of one gateway for every client, each frontend type gets a backend tailored to its needs.
Web app -----> Web BFF -----> Backend services
Mobile app --> Mobile BFF --> Backend services
Admin app ---> Admin BFF ---> Backend services
Use a BFF when frontend clients have meaningfully different needs. For example, a mobile app may need smaller payloads and fewer round trips, while an admin UI may need richer operational data. The BFF should adapt and filter backend calls for a specific frontend. It should not duplicate core backend business logic.
Use Events When Services Should React Without Knowing Each Other
Some workflows do not need direct service-to-service calls. When one service completes something and other services need to react, an event-driven design can reduce coupling.
An event is an observable change, such as OrderCompleted, CustomerRegistered, or InventoryUpdated. The service that notices the change publishes an event. Consumers subscribe to events they care about. An event router or broker sits between producers and consumers.
Order Service
publishes OrderCompleted
|
v
Event Broker
| |
v v
Email Service Analytics Service
This design lets Order Service avoid knowing which services react afterward. The email workflow can change without changing order creation. Analytics can be added later without editing the order workflow.
There are two common styles:
- Publish/subscribe: subscribers receive events they have expressed interest in.
- Event streaming: events are stored in a log, and consumers can read from different positions in that stream.
Event-driven design can improve independent scaling and fault isolation, but it is not free. You need clear event names, stable payload contracts, monitoring, retry behavior, and a team that understands asynchronous workflows. Use events when they reduce knowledge between services, not simply because a broker is available.
Keep Deployment, Testing, and Operations in the Migration Plan
A service is not independent just because it has its own repository or process. It is independent when it can be deployed, tested, monitored, and fixed without unnecessary coordination.
A practical migration plan should include these engineering controls:
- Automated tests for each service. Keep unit tests close to local behavior and integration tests around service boundaries.
- Contract tests between consumers and providers. When one service depends on another service's API, protect the contract before deployment.
- End-to-end tests for critical flows. Distributed systems can fail at the connections, not only inside services.
- Centralized logging. Developers need to trace a user action across multiple services.
- Service health monitoring. Each service should expose enough operational signals to detect failures and performance problems.
- Clear rollback strategy. Deployment should be predictable, and recovery should not require guesswork.
The operational side matters because microservices move complexity from code structure into runtime behavior. You may write smaller services, but you now need better visibility into how they interact.
A Practical Migration Workflow
Use this workflow when planning the first service extraction from a Java monolith.
1. Pick a capability with a clear boundary
Choose an area that has recognizable business ownership. Avoid starting with a capability that touches everything. A notification workflow, catalog lookup, or read-focused reporting slice may be easier than the core order transaction.
Ask:
- Who owns the business rules?
- Which data does this capability write?
- Which other areas need to know when something changes?
- Does this capability need to scale or deploy independently?
2. Define the public contract
Before moving code, define how the rest of the system will use the new service. That contract might be an API endpoint, a message, an event, or a generated client interface.
Write down the contract in a simple form:
Capability: Customer notifications
Service owns: notification templates, delivery attempts, delivery status
Receives: NotificationRequested event
Publishes: NotificationSent, NotificationFailed
Does not own: customer profile data, order state, payment state
This prevents the new service from accidentally becoming a second home for unrelated business logic.
3. Move behavior before moving all data
In a real migration, data movement is often harder than code movement. Start by moving behavior behind a clear boundary. Then gradually reduce direct database access until the new service owns its data access path.
Temporary shared storage can happen during migration, but it should be visible and tracked as technical debt. Do not let the temporary design become the permanent architecture.
4. Protect clients from topology changes
Route clients through an API gateway or BFF instead of letting them call internal services directly. This gives you room to move, split, merge, or rename services without forcing every client to change immediately.
5. Add observability before traffic grows
Once one user request crosses multiple services, local logs are not enough. Add monitoring and logging before the system becomes difficult to diagnose. A smaller service is easier to understand only if you can see how it behaves inside the full workflow.
Common Mistakes
Splitting by technical layer
Creating controller-service, business-service, and repository-service usually preserves the old monolith structure and adds network calls. Split by business capability instead.
Creating tiny services too early
Overly fine-grained services can force orchestration for simple requests. If every operation needs several services to complete in a strict sequence, the boundary may be wrong.
Treating a shared database as harmless
A shared schema makes services change together. Even if separate storage is not possible immediately, assign ownership and remove cross-service writes as soon as possible.
Putting domain models in shared libraries
Shared infrastructure is useful. Shared business logic can freeze services together. Keep domain-specific behavior close to the service that owns it.
Exposing every service to every client
Direct client calls make internal topology public. Use an API gateway or BFF to keep the backend flexible.
Ignoring the team model
Architecture and organization affect each other. If five teams must coordinate to ship one small change, the service boundaries are not helping.
Migration Checklist
Use this checklist before extracting a service:
- The service has a clear business responsibility.
- The service owns a clear data area or has a tracked plan to reach ownership.
- Other services do not write directly to its data store.
- The public contract is documented and testable.
- Client applications do not depend on internal service addresses.
- Shared libraries are limited to infrastructure or stable generated clients.
- Critical interactions are covered by automated tests.
- Logs and monitoring can follow a request across services.
- Deployment and rollback are understood before production traffic arrives.
- The team agrees why this service should exist independently.
Conclusion
Migrating from a Java monolith to microservices is not a cleanup exercise. It is an architectural change that affects development, deployment, operations, and maintenance.
The safest migration is gradual. Keep the monolith when it still serves the business well. Extract services only where independent ownership, deployment, scaling, or fault isolation gives clear value. When you do extract, avoid the three coupling traps: shared persistence, business-heavy shared libraries, and direct client-to-service communication.
A microservice should reduce coordination, not hide it behind more repositories and network calls. If each service owns a business capability, protects its data, communicates through clear contracts, and remains observable in production, the system becomes easier to evolve instead of merely more distributed.