I've spent the last decade building enterprise systems — first as a developer writing giant Spring monoliths, then as an architect pulling them apart into services, and now as an engineering manager who has to explain to leadership why the rewrite that was supposed to take three months has taken eighteen. Microservices done well are genuinely powerful. Microservices done wrong are a distributed monolith with YAML files.
This guide walks through building a real microservices system with Spring Boot from first principles. Not a toy. Not a "hello world" with three services that all live in the same IntelliJ project. A structure you could actually evolve in production.
Why Microservices — and When NOT To
The canonical reasons are well known: independent deployability, team autonomy, targeted scaling, technology flexibility. I won't repeat them. What I will say is that I've watched teams reach for microservices before they had the operational maturity to run them, and it consistently makes things worse.
You probably don't need microservices if:
- Your team is smaller than 6–8 engineers
- You're still figuring out your domain model (split too early and you'll split wrong, then spend years compensating)
- You don't have CI/CD that can independently deploy a service in under 10 minutes
- You don't have distributed tracing, centralized logging, and alerting that works per-service
The sweet spot is a system that has outgrown a single team's ability to coordinate changes safely, where you can draw a clean seam in the domain model, and where at least one part of the system has materially different scaling characteristics than the rest.
For this guide, we're building an e-commerce backend: Order Service, Product Service, and Notification Service, sitting behind an API Gateway, discoverable via Eureka.
Architecture Overview
The gateway is the single entry point — clients never talk to services directly. Services register with Eureka on startup, and Feign clients resolve service addresses through the registry at call time. Notification is fire-and-forget from Order (we'll use async calls here, no message broker to keep the scope manageable).
Project Setup
I use a Maven multi-module structure. One parent POM manages dependency versions; each service is its own module. This keeps things organized without coupling deployment — each module produces its own JAR and Docker image.
microservices-demo/
├── pom.xml ← parent POM
├── api-gateway/
├── discovery-server/
├── order-service/
├── product-service/
└── notification-service/
Parent pom.xml:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>in.buildingai</groupId>
<artifactId>microservices-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>discovery-server</module>
<module>api-gateway</module>
<module>order-service</module>
<module>product-service</module>
<module>notification-service</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
</parent>
<properties>
<java.version>21</java.version>
<spring-cloud.version>2023.0.3</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
Service Decomposition
The hardest part of microservices isn't the technology — it's drawing the right seams. I use Domain-Driven Design bounded contexts as my primary guide. Each service owns its data and its domain logic. No service queries another service's database. If Order Service needs product information, it calls Product Service's API.
The services in this system:
- Product Service: CRUD for products, inventory counts. The source of truth for what exists and what it costs.
- Order Service: Orchestrates order placement — validates with Product Service, persists the order, notifies Notification Service.
- Notification Service: Sends emails/SMS. Receives events, doesn't initiate business logic.
This decomposition follows the natural team boundaries: a catalog team owns Product, a commerce team owns Order, and a platform team owns Notification.
Eureka Service Discovery
The discovery server is essentially a Spring Boot app with one annotation.
discovery-server/pom.xml additions:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
DiscoveryServerApplication.java:
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServerApplication.class, args);
}
}
application.yml:
server:
port: 8761
spring:
application:
name: discovery-server
eureka:
instance:
hostname: localhost
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
The register-with-eureka: false and fetch-registry: false tell the server not to treat itself as a client — otherwise it tries to register with itself and logs confusing errors on startup.
API Gateway
Spring Cloud Gateway routes incoming requests to the right downstream service. It also handles cross-cutting concerns — auth token validation, rate limiting, CORS — so services don't have to.
api-gateway/pom.xml additions:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
application.yml:
server:
port: 8080
spring:
application:
name: api-gateway
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
routes:
- id: product-service
uri: lb://product-service
predicates:
- Path=/api/products/**
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
The lb:// prefix tells the gateway to use Eureka-aware load balancing. Spring Cloud LoadBalancer (included transitively) resolves product-service to a live instance address at request time. You get load balancing for free.
Product Service
pom.xml additions:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Product.java:
@Entity
@Table(name = "products")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private BigDecimal price;
@Column(nullable = false)
private Integer stockQuantity;
}
ProductController.java:
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping
public List<ProductResponse> getAllProducts() {
return productService.getAllProducts();
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable String id) {
return productService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse createProduct(@RequestBody @Valid CreateProductRequest request) {
return productService.createProduct(request);
}
@PutMapping("/{id}/stock")
public ResponseEntity<Void> updateStock(
@PathVariable String id,
@RequestParam Integer quantity) {
productService.updateStock(id, quantity);
return ResponseEntity.noContent().build();
}
}
application.yml for product-service:
server:
port: 8082
spring:
application:
name: product-service
datasource:
url: jdbc:postgresql://localhost:5433/productdb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: update
show-sql: false
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
Order Service with Feign
Order Service calls Product Service to validate inventory before placing an order. This is where inter-service communication gets real.
pom.xml additions for order-service:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
ProductClient.java — the Feign interface:
@FeignClient(name = "product-service", path = "/api/products")
public interface ProductClient {
@GetMapping("/{id}")
Optional<ProductResponse> getProduct(@PathVariable("id") String productId);
@PutMapping("/{id}/stock")
void updateStock(@PathVariable("id") String productId,
@RequestParam("quantity") Integer quantity);
}
Feign reads the name attribute, resolves it through Eureka, and makes the HTTP call. From the Order Service's perspective, it's just a method call.
OrderService.java:
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ProductClient productClient;
private final NotificationClient notificationClient;
public OrderResponse placeOrder(PlaceOrderRequest request) {
// Validate product exists and has stock
ProductResponse product = productClient.getProduct(request.getProductId())
.orElseThrow(() -> new ProductNotFoundException(request.getProductId()));
if (product.getStockQuantity() < request.getQuantity()) {
throw new InsufficientStockException(request.getProductId());
}
BigDecimal totalAmount = product.getPrice()
.multiply(BigDecimal.valueOf(request.getQuantity()));
Order order = Order.builder()
.productId(request.getProductId())
.productName(product.getName())
.quantity(request.getQuantity())
.totalAmount(totalAmount)
.status(OrderStatus.PLACED)
.customerEmail(request.getCustomerEmail())
.build();
orderRepository.save(order);
// Deduct inventory
productClient.updateStock(request.getProductId(),
product.getStockQuantity() - request.getQuantity());
// Async notification — fire and forget
notificationClient.sendOrderConfirmation(
new OrderNotificationRequest(order.getId(), request.getCustomerEmail(), totalAmount)
);
return OrderResponse.from(order);
}
}
Enable Feign in the main class:
@SpringBootApplication
@EnableFeignClients
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
One thing I've learned the hard way: always configure Feign timeouts explicitly. The defaults are way too permissive for a system where one slow service can cascade.
spring:
cloud:
openfeign:
client:
config:
default:
connect-timeout: 2000
read-timeout: 5000
Docker Compose
Each service needs a Dockerfile. I use the layered JAR approach with Spring Boot's built-in layer support for faster image rebuilds.
Dockerfile (same structure for all services, path varies):
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
docker-compose.yml at the project root:
version: '3.8'
services:
postgres-order:
image: postgres:16-alpine
environment:
POSTGRES_DB: orderdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- order-data:/var/lib/postgresql/data
postgres-product:
image: postgres:16-alpine
environment:
POSTGRES_DB: productdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5433:5432"
volumes:
- product-data:/var/lib/postgresql/data
discovery-server:
build: ./discovery-server
ports:
- "8761:8761"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider",
"http://localhost:8761/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
api-gateway:
build: ./api-gateway
ports:
- "8080:8080"
environment:
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
depends_on:
discovery-server:
condition: service_healthy
product-service:
build: ./product-service
ports:
- "8082:8082"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-product:5432/productdb
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
depends_on:
discovery-server:
condition: service_healthy
postgres-product:
condition: service_started
order-service:
build: ./order-service
ports:
- "8081:8081"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-order:5432/orderdb
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
depends_on:
discovery-server:
condition: service_healthy
postgres-order:
condition: service_started
product-service:
condition: service_started
notification-service:
build: ./notification-service
ports:
- "8083:8083"
environment:
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
depends_on:
discovery-server:
condition: service_healthy
volumes:
order-data:
product-data:
A few things I want to call out here:
Service startup order matters. Eureka takes 15–30 seconds to fully start. Services that register with it before it's ready will fail with connection errors. The healthcheck + depends_on: condition: service_healthy pattern handles this — but only if your images have wget or curl. Alpine-based images have wget, JRE-only images often don't. Add wget to the Dockerfile if you hit this.
Don't share databases. Notice each Postgres instance has its own volume and its own port. This is intentional. Shared databases are how microservices turn back into monoliths — through the back door of schema coupling.
Running the System
# Build all JARs
mvn clean package -DskipTests
# Start everything
docker compose up --build
# Test via gateway
curl http://localhost:8080/api/products
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"productId":"abc123","quantity":2,"customerEmail":"user@example.com"}'
Hit the Eureka dashboard at http://localhost:8761 — you should see all four services registered.
What's Next
This setup is a solid foundation but not production-complete. The next layers I'd add:
Resilience. Add Resilience4j circuit breakers to the Feign clients. When Product Service is slow or down, Order Service should fail fast rather than holding threads. Pattern: circuit breaker wrapping the Feign call, with a fallback that returns a cached or degraded response.
Observability. Integrate Micrometer + OpenTelemetry for distributed tracing. Without this, debugging a request that touches three services is guesswork. Jaeger or Tempo as the backend, Grafana for dashboards.
Message broker. The fire-and-forget Feign call to Notification Service is fragile. If Notification is down, the notification is lost. Replace with Kafka: Order Service publishes an order.placed event, Notification Service consumes it with at-least-once delivery guarantee.
Kubernetes. Docker Compose is fine for local dev and small deployments. For anything beyond a handful of instances, Kubernetes gives you self-healing, rolling deployments, and proper resource management. The services are already containerized — Kubernetes manifests are the next step.
The hardest part of microservices is the organizational discipline to maintain the boundaries you draw at the start. The technology is tractable. Resisting the temptation to make a "quick" cross-service database join when the deadline is close — that's where most microservices architectures quietly become distributed monoliths over time.
Keep the seams clean. The tech will follow.