Monolithic Architecture vs Microservices: Key Differences
backend
14 min read
Understand the core differences between monolithic architecture vs microservices architecture, including when to use each, trade-offs, and real-world Java/Spring Boot examples.
Published By: Nelson Djalo | Date: June 24, 2025
Ever found yourself stuck choosing between monolithic architecture vs microservices architecture for your next project? I've been there too. Early in my career, I inherited a sprawling monolith that became a nightmare to scale. Later, I overengineered a tiny app with microservices and spent more time debugging network calls than writing features. Let's break down these architectures so you can avoid my mistakes.
Imagine building a house where the kitchen, bedroom, and bathroom are all one room. That's a monolith. In software terms:
Here's a typical Spring Boot monolith structure:
src/main/java/com/amigoscode/
├── controller/
│ ├── UserController.java
│ ├── OrderController.java
│ └── ProductController.java
├── service/
│ ├── UserService.java
│ ├── OrderService.java
│ └── ProductService.java
├── repository/
│ ├── UserRepository.java
│ ├── OrderRepository.java
│ └── ProductRepository.java
├── model/
│ ├── User.java
│ ├── Order.java
│ └── Product.java
└── config/
└── DatabaseConfig.java
Here's a typical Spring Boot monolith:
// UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public List<User> getUsers() {
return userService.findAll();
}
@PostMapping
public User createUser(@RequestBody User user) {
return userService.save(user);
}
}
// OrderController.java in the SAME codebase
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@Autowired
private UserService userService; // Direct dependency injection
@PostMapping
public Order createOrder(@RequestBody OrderRequest request) {
// Direct method call - no network overhead
User user = userService.findById(request.getUserId());
return orderService.createOrder(user, request.getItems());
}
}
// Shared database configuration
@Configuration
public class DatabaseConfig {
@Bean
public DataSource dataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://localhost:5432/monolith_db")
.username("user")
.password("password")
.build();
}
}
Advantages of Monolithic Architecture:
Disadvantages of Monolithic Architecture:
Now picture a neighborhood where each house has a dedicated purpose (cafe, gym, home). Microservices work similarly:
Microservices Architecture Structure:
services/
├── user-service/
│ ├── src/main/java/com/amigoscode/users/
│ │ ├── UserController.java
│ │ ├── UserService.java
│ │ └── UserRepository.java
│ ├── Dockerfile
│ └── application.yml
├── order-service/
│ ├── src/main/java/com/amigoscode/orders/
│ │ ├── OrderController.java
│ │ ├── OrderService.java
│ │ └── OrderRepository.java
│ ├── Dockerfile
│ └── application.yml
├── product-service/
│ ├── src/main/java/com/amigoscode/products/
│ │ ├── ProductController.java
│ │ ├── ProductService.java
│ │ └── ProductRepository.java
│ ├── Dockerfile
│ └── application.yml
└── api-gateway/
├── src/main/java/com/amigoscode/gateway/
│ └── GatewayConfig.java
├── Dockerfile
└── application.yml
Same features, but split:
// User Service (separate codebase)
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public List<User> getUsers() {
return userService.findAll(); // Connects to USER_DB
}
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
return userService.findById(id);
}
}
// Order Service (another codebase)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@Autowired
private UserServiceClient userServiceClient; // HTTP client
@PostMapping
public Order createOrder(@RequestBody OrderRequest request) {
// Network call to UserService
User user = userServiceClient.getUserById(request.getUserId());
if (user == null) {
throw new UserNotFoundException("User not found");
}
return orderService.createOrder(user, request.getItems()); // Connects to ORDER_DB
}
}
// User Service Client (HTTP client for inter-service communication)
@Component
public class UserServiceClient {
private final WebClient webClient;
public UserServiceClient(@Value("${user.service.url}") String userServiceUrl) {
this.webClient = WebClient.builder()
.baseUrl(userServiceUrl)
.build();
}
public User getUserById(Long id) {
return webClient.get()
.uri("/api/users/{id}", id)
.retrieve()
.bodyToMono(User.class)
.block();
}
}
// Each service has its own database configuration
@Configuration
public class UserDatabaseConfig {
@Bean
@Primary
public DataSource userDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://localhost:5432/user_db")
.username("user")
.password("password")
.build();
}
}
Advantages of Microservices Architecture:
Disadvantages of Microservices Architecture:
Factor | Monolithic | Microservices |
---|---|---|
Development | Easier to start | Complex setup |
Scaling | Vertical scaling only | Horizontal scaling per service |
Deployment | Single deploy | Independent deploys |
Database | Shared database | Database per service |
Failure Impact | System-wide crashes | Isolated failures |
Team Size | Small teams (1-10) | Multiple teams (10+) |
Technology Stack | Single stack | Multiple stacks possible |
Testing | Simple integration tests | Complex distributed testing |
Monitoring | Single application metrics | Distributed tracing needed |
Data Consistency | ACID transactions | Eventual consistency |
Network Calls | Direct method calls | HTTP/gRPC/messaging |
Code Reuse | Shared libraries | Service-specific implementations |
Deployment Risk | High (affects entire system) | Low (isolated to service) |
Performance | Fast (no network overhead) | Slower (network latency) |
I once built a local bakery's inventory system as a monolith. It handled 100 requests/day perfectly. The owner could manage inventory, track sales, and generate reports all from one simple interface. When they needed to add online ordering, we simply added new controllers and services to the existing codebase.
Real-world monolith examples:
Netflix migrated to microservices because their recommendation engine needed 100x more resources than user profiles. Their user service might handle millions of requests per day, while their recommendation service processes complex algorithms that require significant computational resources.
Real-world microservices examples:
Microservices Too Soon
Shared Database in Microservices
Ignoring Observability
Service Granularity Issues
Synchronous Communication Overuse
com.app.user
, com.app.order
/actuator/health
in Spring Boot Actuator// Example of good monolith structure
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<User>> getUsers() {
return ResponseEntity.ok(userService.findAll());
}
}
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<User> findAll() {
return userRepository.findAll();
}
}
// Health check endpoint
@RestController
public class HealthController {
@GetMapping("/health")
public ResponseEntity<Map<String, String>> health() {
Map<String, String> status = new HashMap<>();
status.put("status", "UP");
status.put("timestamp", Instant.now().toString());
return ResponseEntity.ok(status);
}
}
// API Gateway configuration
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("user-service", r -> r
.path("/api/users/**")
.uri("lb://user-service"))
.route("order-service", r -> r
.path("/api/orders/**")
.uri("lb://order-service"))
.build();
}
}
// Circuit breaker implementation
@Service
public class OrderService {
private final UserServiceClient userServiceClient;
@CircuitBreaker(name = "userService", fallbackMethod = "getUserFallback")
public User getUserForOrder(Long userId) {
return userServiceClient.getUserById(userId);
}
public User getUserFallback(Long userId, Exception ex) {
// Return cached user or default user
return User.builder()
.id(userId)
.name("Unknown User")
.build();
}
}
// Event-driven communication
@Component
public class OrderEventHandler {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
OrderEvent orderEvent = new OrderEvent(
event.getOrderId(),
event.getUserId(),
"ORDER_CREATED"
);
kafkaTemplate.send("order-events", orderEvent);
}
}
Modular Monolith: Like a monolith but with clear boundaries (Java 9+ modules)
Serverless: AWS Lambda for event-driven pieces (e.g., image processing)
Service Mesh: Istio for advanced microservice networking
Event Sourcing: Store events instead of state
CQRS (Command Query Responsibility Segregation): Separate read and write models
1. Can I mix both architectures?
Absolutely. Many systems use a monolith for core features and microservices for edge cases (e.g., analytics). This is called the "Strangler Fig Pattern" - gradually replacing parts of a monolith with microservices. For example, you might keep your core business logic in a monolith but extract image processing or analytics into microservices.
2. How do microservices communicate efficiently?
REST is common, but for performance, try gRPC or Kafka for async messaging. gRPC uses Protocol Buffers and HTTP/2 for efficient binary communication, while Kafka provides reliable, scalable messaging for event-driven architectures. Choose based on your latency and throughput requirements.
3. Is Kubernetes mandatory for microservices?
No, but it helps. Start with Docker Compose, then graduate to Kubernetes. Docker Compose is perfect for development and small deployments, while Kubernetes provides orchestration, scaling, and high availability for production environments. Many teams start with simple containerization and evolve to Kubernetes as they scale.
4. What's the biggest cost with microservices?
Operational overhead. You'll need CI/CD, monitoring, and logging for each service. This includes infrastructure costs, development time for operational tools, and ongoing maintenance. The complexity of distributed systems often requires dedicated DevOps teams and sophisticated tooling.
5. Can Spring Boot work for both?
Yes. For monoliths, use standard Spring Boot. For microservices, add Spring Cloud. Spring Boot provides the foundation, while Spring Cloud adds microservices capabilities like service discovery, configuration management, and circuit breakers. The same Spring Boot application can evolve from monolith to microservices.
6. How do I handle data consistency across microservices? Use eventual consistency patterns like Saga, Event Sourcing, or CQRS. Avoid distributed transactions as they don't scale well. Instead, design your services to handle temporary inconsistencies and use compensating transactions to fix issues when they occur.
7. What's the right size for a microservice? Aim for services that can be developed by a small team (2-8 people) in 2-4 weeks. The service should represent a clear business capability and be deployable independently. If a service takes months to develop or requires coordination across multiple teams, it's probably too large.
8. How do I test microservices effectively? Use contract testing (Pact), integration tests, and end-to-end tests. Contract testing ensures services can communicate correctly, integration tests verify service behavior, and end-to-end tests validate the entire system. Also implement comprehensive monitoring and observability.
Choosing between monolithic architecture vs microservices architecture isn't about trends. It's about your team size, project complexity, and growth trajectory. Start simple, then evolve.
If you're building your first backend system, our Building APIs with Spring Boot course walks through monolith design. For scaling challenges, Spring Microservices covers distributed patterns.
Remember: No architecture is perfect, but some are perfectly wrong for your current needs. Choose wisely.
Key Takeaways:
The goal is to build systems that are maintainable, scalable, and deliver value to your users. Whether you choose monolith or microservices, focus on clean code, good testing, and continuous improvement.
Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.