Spring Boot
Monolithic vs Microservices
A practical comparison of monolithic, modular monolith and microservices architectures for Java and Spring Boot teams. It shows what actually changes in code (transactions, remote calls, failure handling), the costs that tutorials skip, a checklist for choosing, and how to migrate gradually with the strangler fig pattern.
What a Monolith Gives You
In a monolith, user management, orders and payments are compiled into one artifact, run in one process and usually share one database. The biggest practical benefit is not simplicity in the abstract but two concrete properties. A call between modules is an ordinary method call, so it is fast and cannot fail because of the network. And a single database transaction can span modules, so "save the order and charge the payment, or do neither" needs one annotation. In the example below, if charge throws an unchecked exception, the order insert is rolled back automatically. Remember this when you reach the microservices sections, because this guarantee is exactly what you give up.
Java
// Project layout: one deployable JAR, one database
//
// ecommerce-app/
// src/main/java/com/example/ecommerce/
// user/ UserController, UserService, UserRepository
// order/ OrderController, OrderService, OrderRepository
// payment/ PaymentController, PaymentService, PaymentRepository
// EcommerceApplication.java
@Service
public class OrderService {
private final UserService userService;
private final PaymentService paymentService;
private final OrderRepository orderRepository;
public OrderService(UserService userService,
PaymentService paymentService,
OrderRepository orderRepository) {
this.userService = userService;
this.paymentService = paymentService;
this.orderRepository = orderRepository;
}
@Transactional
public Order placeOrder(CreateOrderRequest req) {
User user = userService.findById(req.userId()); // plain method call
Order order = orderRepository.save(Order.from(req, user));
paymentService.charge(order); // throws PaymentException (unchecked)
return order; // order row is rolled back if charge fails
}
}Where Monoliths Start to Hurt
Monoliths rarely fail because they are monoliths; they fail because the boundaries between modules erode. When the order module reads the user module's repository directly, it now depends on the layout of the user tables, and any change to them can break orders. Multiply that by a few years and a few teams, and every change needs coordination, builds get slow, and a release means redeploying everything. Those are the real pain points that microservices address. But notice that the root cause here is coupling, which you can fix inside the monolith by giving each module a small public interface and keeping everything else private.
Java
// Problem: the order module reaches into the user module's persistence layer
@Service
public class OrderService {
private final UserRepository userRepository; // belongs to the user module
Order build(CreateOrderRequest req) {
User user = userRepository.findById(req.userId()).orElseThrow();
return Order.of(req, user.getAddress()); // now coupled to the User table layout
}
}
// Better: the user module exposes a deliberately small API
package com.example.ecommerce.user;
public interface UserLookup {
UserSummary findSummary(UUID userId);
}
public record UserSummary(UUID id, String displayName, String shippingCountry) { }The Middle Path: A Modular Monolith
A modular monolith is still one deployable unit, but module boundaries are enforced instead of merely suggested. In Spring Boot, the Spring Modulith project does this: each direct sub-package of your application package is a module, only the types in a module's top-level package are visible to other modules, and sub-packages are internal. A single test verifies this and fails the build if someone introduces a forbidden dependency or a cycle. To use it, add the Spring Modulith BOM and the spring-modulith-starter-core and spring-modulith-starter-test dependencies. For many teams this delivers most of the organizational benefit of microservices (clear ownership, independent reasoning) with none of the network, deployment or consistency costs. It also makes a later split far cheaper, because a well-isolated module can be lifted out into its own service.
Java
// Layout: only the top-level package of each module is public API
//
// com.example.ecommerce
// ├── user
// │ ├── UserLookup.java (public: other modules may use it)
// │ └── internal
// │ ├── UserService.java (hidden from other modules)
// │ └── UserRepository.java
// └── order
// └── ...
@SpringBootTest
class ModularityTests {
@Test
void verifyModuleStructure() {
// Fails when a module uses another module's internal types or creates a cycle
ApplicationModules.of(EcommerceApplication.class).verify();
}
}What Changes with Microservices
Splitting into services replaces the method call with a network call, and a network call can be slow, can time out and can fail even when the other service is healthy. Every remote call therefore needs a timeout (without one, a hung dependency ties up your threads until your own service falls over), and usually a circuit breaker so you stop hammering a dependency that is already down. Each service also owns its data: order-service can no longer join against the users table, so it either calls the user service or keeps its own copy of the few user fields it needs. The sketch below uses Spring's RestClient with explicit timeouts and a Resilience4j circuit breaker (add resilience4j-spring-boot3 and the AOP starter). Decide up front what the fallback is: sometimes cached data, sometimes a clear error, but never an indefinite wait.
Java
@Configuration
class UserClientConfig {
@Bean
RestClient userRestClient(RestClient.Builder builder,
@Value("${services.user.url}") String baseUrl) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(1));
factory.setReadTimeout(Duration.ofSeconds(2));
return builder.baseUrl(baseUrl).requestFactory(factory).build();
}
}
@Component
class UserClient {
private final RestClient userRestClient;
UserClient(RestClient userRestClient) {
this.userRestClient = userRestClient;
}
@CircuitBreaker(name = "userService", fallbackMethod = "userUnavailable")
UserResponse findUser(UUID id) {
return userRestClient.get()
.uri("/api/users/{id}", id)
.retrieve()
.body(UserResponse.class);
}
// Same parameters plus the exception; here we fail fast with a clear error
UserResponse userUnavailable(UUID id, Throwable cause) {
throw new UserServiceUnavailableException(id, cause);
}
}Data Consistency Without Shared Transactions
Once orders and payments live in different services with different databases, the single @Transactional from the first section is gone. The standard replacement is a saga: a sequence of local transactions coordinated by events, with compensating actions when a step fails. Below, the order is saved as PENDING and an event is published; the payment service reacts, and the order is later confirmed or cancelled. Two details cause most production bugs. First, saving to the database and sending to the broker are two separate operations, so a crash between them loses the event; the transactional outbox pattern fixes this by writing the event to an outbox table in the same transaction and publishing it afterwards. Second, messages are delivered at least once, so the consumer must be idempotent and handle the same event twice without charging twice.
Java
// order-service
@Transactional
public Order placeOrder(CreateOrderRequest req) {
Order order = orderRepository.save(Order.pending(req)); // status = PENDING
events.publish(new OrderPlaced(order.getId(), order.getTotal())); // ideally via an outbox table
return order;
}
@KafkaListener(topics = "payment-events")
@Transactional
void onPaymentResult(PaymentResult result) {
Order order = orderRepository.findById(result.orderId()).orElseThrow();
if (order.isFinal()) return; // idempotent: ignore duplicate deliveries
if (result.successful()) {
order.confirm();
} else {
order.cancel(); // compensating action
}
}The Cost Nobody Puts in the Diagram
Reliability compounds. If one request has to pass through several services in a row and each is available 99.9% of the time, the request as a whole is less available than any single service. The numbers below assume independent failures and no retries or fallbacks, so they are a rough guide, but they explain why teams that split too finely often see worse uptime than before. Add the other recurring costs: you need distributed tracing and centralized logs to follow one request across services, every service needs its own build pipeline and monitoring, local development means running several applications, and integration testing is harder because contracts between services must be kept compatible.
Text
Availability of one request that needs N services in a row, each 99.9% available:
N = 1 -> 99.90% (about 9 hours of downtime per year)
N = 5 -> 99.50% (about 44 hours per year)
N = 10 -> 99.00% (about 87 hours per year)
Formula: 0.999 ^ NSide-by-Side Comparison
Use the table as a summary, not a verdict. Fault isolation deserves a caveat: a crash in one service stays contained only if callers use timeouts and circuit breakers; otherwise a slow service drags down everything that calls it, which is called a cascading failure. The "modular monolith" column is included because it is often the better answer than either extreme.
Text
Dimension | Monolith | Modular monolith | Microservices
---------------------|-----------------------|-----------------------|------------------------
Deployment unit | One artifact | One artifact | One per service
Database | Shared schema | Shared, split by module| Database per service
Module communication | Method call | Method call via API | Network or messaging
Transactions | ACID across modules | ACID across modules | Sagas, eventual consistency
Scaling | Whole app | Whole app | Per service
Fault isolation | Low | Low | High, if timeouts and breakers exist
Independent releases | No | No | Yes
Operational overhead | Low | Low | High
Debugging | One log, one trace | One log, one trace | Distributed tracing needed
Fits best | Small team, early | Growing team, one domain| Many teams, stable boundariesA Decision Checklist
Answer these honestly before splitting anything. If most answers are "no", stay with a (modular) monolith. A common failure is adopting microservices for a team of five because it looks modern, then spending most of the year on infrastructure instead of the product. The strongest reasons to split are organizational (several teams blocked by each other's releases) and technical (one component needs very different scaling or a different runtime), not fashion.
Text
1. Do multiple teams need to release independently, and are they blocked today?
2. Are the domain boundaries clear and stable, or are you still discovering them?
3. Does one part of the system need to scale very differently from the rest?
4. Is there a team that can run CI/CD, monitoring, tracing and on-call for many services?
5. Can the business accept eventual consistency where a transaction used to be?
6. Have you already tried enforcing module boundaries inside the monolith?
Mostly "no" -> start or stay with a monolith, make it modular.
Mostly "yes" -> extract the first service, starting where the boundary is cleanest.Migrating Gradually: The Strangler Fig Pattern
Rewriting a working monolith in one go is risky because nothing is delivered until everything is. The strangler fig pattern avoids this: put a gateway in front of the monolith, build one new service, and route only that feature's traffic to it while everything else still reaches the monolith. Repeat feature by feature. Pick the first candidate carefully: choose a capability with clear boundaries and few shared tables, not the most tangled core. Routes are evaluated in order, so the specific route must come before the catch-all. Note that the property prefix for Spring Cloud Gateway changed in recent releases (to spring.cloud.gateway.server.webflux), so check the documentation for the version you use.
yaml
spring:
cloud:
gateway:
routes:
# 1. Migrated feature goes to the new service
- id: user-service
uri: http://user-service:8081
predicates:
- Path=/api/users/**
# 2. Everything else still goes to the monolith
- id: legacy-monolith
uri: http://monolith:8080
predicates:
- Path=/api/**Mistakes That Create a Distributed Monolith
The worst outcome is a system with the deployment complexity of microservices and the coupling of a monolith. It usually comes from a few repeatable mistakes. Services that share one database are still coupled through the schema, so a table change breaks several services at once. Services that must be deployed together, or that call each other in long synchronous chains, can't be released independently, which was the whole point. Splitting too finely, such as one service per database table, creates more network calls without any autonomy gain. And skipping observability means the first production incident takes days to diagnose.
Text
Warning signs of a distributed monolith:
- Two services read or write the same database tables
- Releasing service A always requires releasing service B at the same time
- One user request triggers a chain of 5+ synchronous calls
- Nobody can explain which service owns a given piece of data
- There is no trace ID that follows a request across services