This comprehensive guide covers building microservices with Spring Boot, including service discovery, API gateway, configuration management, and inter-service communication.
1. Service Discovery with Eureka
Eureka Server
Dependencies (pom.xml)
<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2022.0.4</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
Eureka Server Application
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
Application Configuration (application.yml)
server:
port: 8761
spring:
application:
name: eureka-server
security:
user:
name: admin
password: ${EUREKA_PASSWORD:admin123}
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${spring.security.user.name}:${spring.security.user.password}@localhost:8761/eureka/
server:
enable-self-preservation: true
eviction-interval-timer-in-ms: 15000
renewal-percent-threshold: 0.85
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.httpBasic();
return http.build();
}
}
2. API Gateway with Spring Cloud Gateway
API Gateway Service
Dependencies
<dependencies> <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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot2</artifactId> </dependency> </dependencies>
Gateway Application
@SpringBootApplication
@EnableDiscoveryClient
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
Gateway Configuration (application.yml)
server: port: 8080 spring: application: name: api-gateway cloud: gateway: discovery: locator: enabled: true lower-case-service-id: true routes: - id: user-service uri: lb://user-service predicates: - Path=/api/users/** filters: - name: CircuitBreaker args: name: userService fallbackUri: forward:/fallback/user-service - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 - StripPrefix=1 - id: order-service uri: lb://order-service predicates: - Path=/api/orders/** filters: - name: CircuitBreaker args: name: orderService fallbackUri: forward:/fallback/order-service - StripPrefix=1 - id: product-service uri: lb://product-service predicates: - Path=/api/products/** filters: - StripPrefix=1 default-filters: - name: GlobalFilter - name: RequestLoggingFilter eureka: client: service-url: defaultZone: http://admin:admin123@localhost:8761/eureka/ instance: prefer-ip-address: true resilience4j: circuitbreaker: instances: userService: register-health-indicator: true sliding-window-size: 10 minimum-number-of-calls: 5 permitted-number-of-calls-in-half-open-state: 3 wait-duration-in-open-state: 10s failure-rate-threshold: 50 management: endpoints: web: exposure: include: gateway,health,metrics endpoint: gateway: enabled: true health: show-details: always logging: level: org.springframework.cloud.gateway: DEBUG
Custom Global Filter
@Component
public class GlobalFilter implements GlobalFilter, Ordered {
private static final Logger logger = LoggerFactory.getLogger(GlobalFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// Log incoming request
logger.info("Incoming request: {} {}", request.getMethod(), request.getURI());
// Add correlation ID if not present
String correlationId = request.getHeaders().getFirst("X-Correlation-ID");
if (correlationId == null) {
correlationId = UUID.randomUUID().toString();
}
ServerHttpRequest modifiedRequest = request.mutate()
.header("X-Correlation-ID", correlationId)
.header("X-Gateway-Timestamp", String.valueOf(System.currentTimeMillis()))
.build();
// Add correlation ID to response headers
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().add("X-Correlation-ID", correlationId);
return chain.filter(exchange.mutate().request(modifiedRequest).build());
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
@Component
public class RequestLoggingFilter implements GatewayFilter, Ordered {
private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
long startTime = System.currentTimeMillis();
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
long duration = System.currentTimeMillis() - startTime;
ServerHttpResponse response = exchange.getResponse();
ServerHttpRequest request = exchange.getRequest();
logger.info("Request completed: {} {} - Status: {} - Duration: {}ms",
request.getMethod(), request.getURI(),
response.getStatusCode(), duration);
}));
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
Fallback Controller
@RestController
public class FallbackController {
@GetMapping("/fallback/user-service")
public ResponseEntity<Map<String, Object>> userServiceFallback() {
Map<String, Object> response = new HashMap<>();
response.put("timestamp", LocalDateTime.now());
response.put("message", "User service is temporarily unavailable. Please try again later.");
response.put("status", 503);
return ResponseEntity.status(503).body(response);
}
@GetMapping("/fallback/order-service")
public ResponseEntity<Map<String, Object>> orderServiceFallback() {
Map<String, Object> response = new HashMap<>();
response.put("timestamp", LocalDateTime.now());
response.put("message", "Order service is temporarily unavailable. Please try again later.");
response.put("status", 503);
return ResponseEntity.status(503).body(response);
}
}
3. User Service Implementation
User Service Domain
Dependencies
<dependencies> <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.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.1.0</version> </dependency> </dependencies>
User Entity
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String userId;
@Column(nullable = false)
private String firstName;
@Column(nullable = false)
private String lastName;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String phone;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserStatus status;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// Constructors
public User() {}
public User(String firstName, String lastName, String email, String phone) {
this.userId = UUID.randomUUID().toString();
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phone = phone;
this.status = UserStatus.ACTIVE;
}
// Business methods
public void deactivate() {
this.status = UserStatus.INACTIVE;
this.updatedAt = LocalDateTime.now();
}
public void activate() {
this.status = UserStatus.ACTIVE;
this.updatedAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public String getUserId() { return userId; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public UserStatus getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
enum UserStatus {
ACTIVE, INACTIVE, SUSPENDED
}
Repository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUserId(String userId);
Optional<User> findByEmail(String email);
List<User> findByStatus(UserStatus status);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE u.firstName LIKE %:name% OR u.lastName LIKE %:name%")
List<User> findByNameContaining(@Param("name") String name);
}
Service Layer
public interface UserService {
UserDTO createUser(CreateUserRequest request);
UserDTO getUserById(String userId);
UserDTO getUserByEmail(String email);
List<UserDTO> getAllUsers();
List<UserDTO> getUsersByStatus(UserStatus status);
UserDTO updateUser(String userId, UpdateUserRequest request);
void deleteUser(String userId);
void deactivateUser(String userId);
void activateUser(String userId);
}
@Service
@Transactional
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserServiceImpl(UserRepository userRepository, UserMapper userMapper) {
this.userRepository = userRepository;
this.userMapper = userMapper;
}
@Override
public UserDTO createUser(CreateUserRequest request) {
// Validate email uniqueness
if (userRepository.existsByEmail(request.getEmail())) {
throw new UserAlreadyExistsException("User with email " + request.getEmail() + " already exists");
}
User user = new User(
request.getFirstName(),
request.getLastName(),
request.getEmail(),
request.getPhone()
);
User savedUser = userRepository.save(user);
return userMapper.toDTO(savedUser);
}
@Override
public UserDTO getUserById(String userId) {
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new UserNotFoundException("User not found with ID: " + userId));
return userMapper.toDTO(user);
}
@Override
public UserDTO getUserByEmail(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("User not found with email: " + email));
return userMapper.toDTO(user);
}
@Override
public List<UserDTO> getAllUsers() {
return userRepository.findAll().stream()
.map(userMapper::toDTO)
.collect(Collectors.toList());
}
@Override
public List<UserDTO> getUsersByStatus(UserStatus status) {
return userRepository.findByStatus(status).stream()
.map(userMapper::toDTO)
.collect(Collectors.toList());
}
@Override
public UserDTO updateUser(String userId, UpdateUserRequest request) {
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new UserNotFoundException("User not found with ID: " + userId));
user.setFirstName(request.getFirstName());
user.setLastName(request.getLastName());
user.setPhone(request.getPhone());
User updatedUser = userRepository.save(user);
return userMapper.toDTO(updatedUser);
}
@Override
public void deleteUser(String userId) {
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new UserNotFoundException("User not found with ID: " + userId));
userRepository.delete(user);
}
@Override
public void deactivateUser(String userId) {
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new UserNotFoundException("User not found with ID: " + userId));
user.deactivate();
userRepository.save(user);
}
@Override
public void activateUser(String userId) {
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new UserNotFoundException("User not found with ID: " + userId));
user.activate();
userRepository.save(user);
}
}
Controller
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
UserDTO user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
@GetMapping("/{userId}")
public ResponseEntity<UserDTO> getUser(@PathVariable String userId) {
UserDTO user = userService.getUserById(userId);
return ResponseEntity.ok(user);
}
@GetMapping
public ResponseEntity<List<UserDTO>> getAllUsers(
@RequestParam(required = false) UserStatus status) {
List<UserDTO> users;
if (status != null) {
users = userService.getUsersByStatus(status);
} else {
users = userService.getAllUsers();
}
return ResponseEntity.ok(users);
}
@PutMapping("/{userId}")
public ResponseEntity<UserDTO> updateUser(
@PathVariable String userId,
@Valid @RequestBody UpdateUserRequest request) {
UserDTO user = userService.updateUser(userId, request);
return ResponseEntity.ok(user);
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> deleteUser(@PathVariable String userId) {
userService.deleteUser(userId);
return ResponseEntity.noContent().build();
}
@PatchMapping("/{userId}/deactivate")
public ResponseEntity<Void> deactivateUser(@PathVariable String userId) {
userService.deactivateUser(userId);
return ResponseEntity.ok().build();
}
@PatchMapping("/{userId}/activate")
public ResponseEntity<Void> activateUser(@PathVariable String userId) {
userService.activateUser(userId);
return ResponseEntity.ok().build();
}
}
DTOs and Request/Response Classes
public class UserDTO {
private String userId;
private String firstName;
private String lastName;
private String email;
private String phone;
private UserStatus status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Constructors, getters, and setters
public UserDTO() {}
public UserDTO(String userId, String firstName, String lastName, String email,
String phone, UserStatus status, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.userId = userId;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phone = phone;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
// Getters and setters...
}
public class CreateUserRequest {
@NotBlank(message = "First name is required")
@Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters")
private String firstName;
@NotBlank(message = "Last name is required")
@Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters")
private String lastName;
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Phone number is required")
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$", message = "Invalid phone number format")
private String phone;
// Constructors, getters, and setters...
}
public class UpdateUserRequest {
@NotBlank(message = "First name is required")
@Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters")
private String firstName;
@NotBlank(message = "Last name is required")
@Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters")
private String lastName;
@NotBlank(message = "Phone number is required")
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$", message = "Invalid phone number format")
private String phone;
// Constructors, getters, and setters...
}
Application Configuration
@SpringBootApplication
@EnableDiscoveryClient
@EnableJpaAuditing
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
@Bean
public UserMapper userMapper() {
return new UserMapperImpl();
}
}
Application Properties (application.yml)
server:
port: 8081
spring:
application:
name: user-service
datasource:
url: jdbc:postgresql://localhost:5432/userdb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:password}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
h2:
console:
enabled: true
eureka:
client:
service-url:
defaultZone: http://admin:admin123@localhost:8761/eureka/
instance:
prefer-ip-address: true
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
show-components: always
logging:
level:
com.example.userservice: DEBUG
org.hibernate.SQL: DEBUG
4. Order Service with Feign Client
Order Service Implementation
Order Entity and Domain
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String orderId;
@Column(nullable = false)
private String userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@Column(nullable = false)
private BigDecimal totalAmount;
@Column(nullable = false)
private String currency;
@Embedded
private Address shippingAddress;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// Constructors and methods
public Order() {
this.orderId = UUID.randomUUID().toString();
this.status = OrderStatus.PENDING;
this.currency = "USD";
}
public Order(String userId, Address shippingAddress) {
this();
this.userId = userId;
this.shippingAddress = shippingAddress;
}
public void addItem(OrderItem item) {
item.setOrder(this);
this.items.add(item);
calculateTotal();
}
public void removeItem(OrderItem item) {
this.items.remove(item);
item.setOrder(null);
calculateTotal();
}
private void calculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getItemTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalOrderStateException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
}
public void ship() {
if (this.status != OrderStatus.CONFIRMED) {
throw new IllegalOrderStateException("Only confirmed orders can be shipped");
}
this.status = OrderStatus.SHIPPED;
}
public void deliver() {
if (this.status != OrderStatus.SHIPPED) {
throw new IllegalOrderStateException("Only shipped orders can be delivered");
}
this.status = OrderStatus.DELIVERED;
}
public void cancel() {
if (this.status == OrderStatus.DELIVERED || this.status == OrderStatus.CANCELLED) {
throw new IllegalOrderStateException("Cannot cancel order in current state: " + this.status);
}
this.status = OrderStatus.CANCELLED;
}
// Getters and setters...
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(nullable = false)
private String productId;
@Column(nullable = false)
private String productName;
@Column(nullable = false)
private BigDecimal unitPrice;
@Column(nullable = false)
private Integer quantity;
// Constructors and methods
public OrderItem() {}
public OrderItem(String productId, String productName, BigDecimal unitPrice, Integer quantity) {
this.productId = productId;
this.productName = productName;
this.unitPrice = unitPrice;
this.quantity = quantity;
}
public BigDecimal getItemTotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
// Getters and setters...
}
@Embeddable
public class Address {
@Column(nullable = false)
private String street;
@Column(nullable = false)
private String city;
@Column(nullable = false)
private String state;
@Column(nullable = false)
private String zipCode;
@Column(nullable = false)
private String country;
// Constructors, getters, and setters...
}
enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
Feign Client for User Service
@FeignClient(name = "user-service", path = "/api/users")
public interface UserServiceClient {
@GetMapping("/{userId}")
ResponseEntity<UserDTO> getUserById(@PathVariable("userId") String userId);
@GetMapping("/email/{email}")
ResponseEntity<UserDTO> getUserByEmail(@PathVariable("email") String email);
@GetMapping("/{userId}/exists")
ResponseEntity<Boolean> userExists(@PathVariable("userId") String userId);
}
// Feign configuration
@Configuration
public class FeignConfig {
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
@Bean
public ErrorDecoder errorDecoder() {
return new CustomErrorDecoder();
}
}
@Component
public class CustomErrorDecoder implements ErrorDecoder {
private final ErrorDecoder defaultErrorDecoder = new Default();
@Override
public Exception decode(String methodKey, Response response) {
if (response.status() == 404) {
return new UserNotFoundException("User not found");
}
if (response.status() >= 400 && response.status() <= 499) {
return new FeignClientException("Client error occurred: " + response.status());
}
if (response.status() >= 500) {
return new FeignServerException("Server error occurred: " + response.status());
}
return defaultErrorDecoder.decode(methodKey, response);
}
}
Order Service Implementation
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final UserServiceClient userServiceClient;
public OrderService(OrderRepository orderRepository, UserServiceClient userServiceClient) {
this.orderRepository = orderRepository;
this.userServiceClient = userServiceClient;
}
public OrderDTO createOrder(CreateOrderRequest request) {
// Validate user exists
validateUserExists(request.getUserId());
Order order = new Order(request.getUserId(), request.getShippingAddress());
// Add items to order
request.getItems().forEach(itemRequest -> {
OrderItem item = new OrderItem(
itemRequest.getProductId(),
itemRequest.getProductName(),
itemRequest.getUnitPrice(),
itemRequest.getQuantity()
);
order.addItem(item);
});
Order savedOrder = orderRepository.save(order);
return OrderMapper.toDTO(savedOrder);
}
public OrderDTO getOrder(String orderId) {
Order order = orderRepository.findByOrderId(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
return OrderMapper.toDTO(order);
}
public List<OrderDTO> getOrdersByUser(String userId) {
validateUserExists(userId);
return orderRepository.findByUserId(userId).stream()
.map(OrderMapper::toDTO)
.collect(Collectors.toList());
}
public OrderDTO updateOrderStatus(String orderId, OrderStatus newStatus) {
Order order = orderRepository.findByOrderId(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
switch (newStatus) {
case CONFIRMED:
order.confirm();
break;
case SHIPPED:
order.ship();
break;
case DELIVERED:
order.deliver();
break;
case CANCELLED:
order.cancel();
break;
default:
throw new IllegalArgumentException("Invalid status: " + newStatus);
}
Order updatedOrder = orderRepository.save(order);
return OrderMapper.toDTO(updatedOrder);
}
private void validateUserExists(String userId) {
try {
ResponseEntity<Boolean> response = userServiceClient.userExists(userId);
if (!Boolean.TRUE.equals(response.getBody())) {
throw new UserNotFoundException("User not found: " + userId);
}
} catch (FeignClientException e) {
throw new UserNotFoundException("User not found: " + userId);
}
}
}
Order Controller
@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderDTO> createOrder(@Valid @RequestBody CreateOrderRequest request) {
OrderDTO order = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDTO> getOrder(@PathVariable String orderId) {
OrderDTO order = orderService.getOrder(orderId);
return ResponseEntity.ok(order);
}
@GetMapping("/user/{userId}")
public ResponseEntity<List<OrderDTO>> getUserOrders(@PathVariable String userId) {
List<OrderDTO> orders = orderService.getOrdersByUser(userId);
return ResponseEntity.ok(orders);
}
@PatchMapping("/{orderId}/status")
public ResponseEntity<OrderDTO> updateOrderStatus(
@PathVariable String orderId,
@RequestParam OrderStatus status) {
OrderDTO order = orderService.updateOrderStatus(orderId, status);
return ResponseEntity.ok(order);
}
}
5. Configuration Service
Spring Cloud Config Server
Config Server Application
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
Configuration (application.yml)
server:
port: 8888
spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: https://github.com/your-org/config-repo
search-paths: '{application}'
default-label: main
clone-on-start: true
force-pull: true
management:
endpoints:
web:
exposure:
include: health,metrics,bus-refresh
endpoint:
health:
show-details: always
encrypt:
key: ${CONFIG_SERVER_ENCRYPT_KEY:default-key}
6. Docker Configuration
Docker Compose for Microservices
version: '3.8' services: eureka-server: build: ./eureka-server ports: - "8761:8761" environment: - EUREKA_PASSWORD=admin123 networks: - microservices-network config-server: build: ./config-server ports: - "8888:8888" environment: - CONFIG_SERVER_ENCRYPT_KEY=my-secret-key depends_on: - eureka-server networks: - microservices-network api-gateway: build: ./api-gateway ports: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=docker - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://admin:admin123@eureka-server:8761/eureka/ depends_on: - eureka-server - config-server networks: - microservices-network user-service: build: ./user-service ports: - "8081:8081" environment: - SPRING_PROFILES_ACTIVE=docker - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://admin:admin123@eureka-server:8761/eureka/ - SPRING_CLOUD_CONFIG_URI=http://config-server:8888 - DB_HOST=postgres-db - DB_USERNAME=postgres - DB_PASSWORD=password depends_on: - eureka-server - config-server - postgres-db networks: - microservices-network order-service: build: ./order-service ports: - "8082:8082" environment: - SPRING_PROFILES_ACTIVE=docker - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://admin:admin123@eureka-server:8761/eureka/ - SPRING_CLOUD_CONFIG_URI=http://config-server:8888 - DB_HOST=postgres-db - DB_USERNAME=postgres - DB_PASSWORD=password depends_on: - eureka-server - config-server - postgres-db networks: - microservices-network postgres-db: image: postgres:14 environment: - POSTGRES_DB=microservices - POSTGRES_USER=postgres - POSTGRES_PASSWORD=password ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data networks: - microservices-network redis: image: redis:7-alpine ports: - "6379:6379" networks: - microservices-network volumes: postgres_data: networks: microservices-network: driver: bridge
Dockerfile for Services
# Dockerfile for Spring Boot microservices
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
7. Testing Microservices
Integration Tests
@SpringBootTest
@ActiveProfiles("test")
class UserServiceIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateUser() {
// Given
CreateUserRequest request = new CreateUserRequest(
"John", "Doe", "[email protected]", "+1234567890"
);
// When
ResponseEntity<UserDTO> response = restTemplate.postForEntity(
"/api/users", request, UserDTO.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getEmail()).isEqualTo("[email protected]");
}
@Test
void shouldGetUserById() {
// Given
User user = new User("Jane", "Smith", "[email protected]", "+0987654321");
userRepository.save(user);
// When
ResponseEntity<UserDTO> response = restTemplate.getForEntity(
"/api/users/" + user.getUserId(), UserDTO.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getUserId()).isEqualTo(user.getUserId());
}
}
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceIntegrationTest {
@MockBean
private UserServiceClient userServiceClient;
@Test
void shouldCreateOrderWhenUserExists() {
// Given
when(userServiceClient.userExists(anyString())).thenReturn(ResponseEntity.ok(true));
CreateOrderRequest request = new CreateOrderRequest(
"user-123",
new Address("123 Main St", "New York", "NY", "10001", "USA"),
List.of(new OrderItemRequest("prod-1", "Laptop", new BigDecimal("999.99"), 1))
);
// When
ResponseEntity<OrderDTO> response = restTemplate.postForEntity(
"/api/orders", request, OrderDTO.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getTotalAmount()).isEqualByComparingTo("999.99");
}
}
This comprehensive microservices architecture with Spring Boot provides a solid foundation for building scalable, resilient, and maintainable distributed systems. Each service is independently deployable and follows microservices best practices.