@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) {
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);
productClient.updateStock(request.getProductId(),
product.getStockQuantity() - request.getQuantity());
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
mvn clean package -DskipTests
docker compose up --build
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.