Skip to main content

One post tagged with "Distributed System Patterns"

View All Tags

· 14 min read
Byju Luckose

In the rapidly evolving landscape of software development, microservices have emerged as a cornerstone of modern application architecture. By decomposing applications into smaller, loosely coupled services, organizations can enhance scalability, flexibility, and deployment speeds. However, the distributed nature of microservices introduces its own set of challenges, including service discovery, configuration management, and fault tolerance. To navigate these complexities, developers and architects leverage a set of distributed system patterns specifically tailored for microservices. This blog explores these patterns, offering insights into their roles and benefits in building resilient, scalable, and manageable microservices architectures.

1. API Gateway Pattern: The Front Door to Microservices

The API Gateway pattern serves as the unified entry point for all client requests to the microservices ecosystem. It abstracts the underlying complexity of the microservices architecture, providing clients with a single endpoint to interact with. This pattern is pivotal in handling cross-cutting concerns such as authentication, authorization, logging, and SSL termination. It routes requests to the appropriate microservice, thereby simplifying the client-side code and enhancing the security and manageability of the application.

Example:

This example demonstrates setting up a basic API Gateway that routes requests to two microservices: user-service and product-service. For simplicity, the services will be stubbed out with basic Spring Boot applications that return dummy responses.

Step 1: Create the API Gateway Service

  • Setup: Initialize a new Spring Boot project named api-gateway using Spring Initializr. Select Gradle/Maven as the build tool, add Spring Web, and Spring Cloud Gateway as dependencies.

  • Configure the Gateway Routes: In the application.yml or application.properties file of your api-gateway project, define routes to the user-service and product-service. Assuming these services run locally on ports 8081 and 8082 respectively, your configuration might look like this:

yaml
spring:
cloud:
gateway:
routes:
- id: user-service
uri: http://localhost:8081
predicates:
- Path=/users/**
- id: product-service
uri: http://localhost:8082
predicates:
- Path=/products/**
  • Run the Application: Start the api-gateway application. Spring Cloud Gateway will now route requests to /users/** to the user-service and /products/** to the product-service.

Step 2: Stubbing Out the Microservices

For user-service and product-service, you'll create two simple Spring Boot applications. Here's how you can stub them out:

  • Create Spring Boot Projects: Use Spring Initializr to create two projects, user-service and product-service, with Spring Web dependency.

  • Implement Basic Controllers: For each service, implement a basic REST controller that defines endpoints to return dummy data.

User Service

java
@RestController
@RequestMapping("/users")
public class UserController {

@GetMapping
public ResponseEntity<String> listUsers() {
return ResponseEntity.ok("Listing all users");
}
}

Product Service

java
@RestController
@RequestMapping("/products")
public class ProductController {

@GetMapping
public ResponseEntity<String> listProducts() {
return ResponseEntity.ok("Listing all products");
}
}

  • Configure and Run the Services: Ensure user-service runs on port 8081 and product-service on port 8082. You can specify the server port in each project's application.properties file: For user-service:
properties
server.port=8081

For product-service:

properties
server.port=8082

Run both applications.

Testing the Setup

With api-gateway, user-service, and product-service running, you can test the API Gateway pattern:

  • Accessing http://localhost:<gateway-port>/users should route the request to the user-service and return "Listing all users".
  • Accessing http://localhost:<gateway-port>/products should route the request to the product-service and return "Listing all products".

Replace <gateway-port\> with the actual port your api-gateway application is running on, usually 8080 if not configured otherwise.

This example illustrates the API Gateway pattern's fundamentals, providing a central point for routing requests to various microservices based on paths. For production scenarios, consider adding security, logging, and resilience features to your gateway.

2. Service Discovery: Dynamic Connectivity in a Microservice World

Microservices often need to communicate with each other, but in a dynamic environment where services can move, scale, or fail, hard-coding service locations becomes impractical. The Service Discovery pattern enables services to dynamically discover and communicate with each other. It can be implemented via client-side discovery, where services query a registry to find their peers, or server-side discovery, where a router or load balancer queries the registry and directs the request to the appropriate service.

Example:

Implementing Service Discovery in a microservices architecture enables services to dynamically discover and communicate with each other. This is essential for building scalable and flexible systems. Spring Cloud Netflix Eureka is a popular choice for Service Discovery within the Spring ecosystem. In this example, we'll set up Eureka Server for service registration and discovery, and then create two simple microservices (client-service and server-service) that register themselves with Eureka and demonstrate how client-service discovers and calls server-service.

Step 1: Setup Eureka Server

  • Initialize a Spring Boot Project: Use Spring Initializr to create a new project named eureka-server. Choose Spring Boot version (make sure it's compatible with Spring Cloud), add Spring Web, and Eureka Server dependencies.

  • Enable Eureka Server: In the main application class, use @EnableEurekaServer annotation.

java
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
  • Configure Eureka Server: In application.properties or application.yml, set the application port and disable registration with Eureka since the server doesn't need to register with itself.
properties
server.port=8761
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
  • Run Eureka Server: Start your Eureka Server application. It will run on port 8761 and provide a dashboard accessible at http://localhost:8761.

Step 2: Create Microservices

Now, create two microservices, client-service and server-service, that register themselves with the Eureka Server.

Server Service

  • Setup: Initialize a new Spring Boot project with Spring Web and Eureka Discovery Client dependencies.

  • Enable Eureka Client: Use @EnableDiscoveryClient or @EnableEurekaClient annotation in the main application class.

java
@SpringBootApplication
@EnableDiscoveryClient
public class ServerServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ServerServiceApplication.class, args);
}
}
  • Configure and Register with Eureka: In application.properties, set the port and application name, and configure the Eureka server location.
properties
@RestController
server.port=8082
spring.application.name=server-service
eureka.client.service-url.defaultZone=http://localhost:8761/eureka/
  • Implement a Simple REST Controller: Create a controller with a simple endpoint to simulate a service.
java
@RestController
public class ServerController {

@GetMapping("/greet")
public String greet() {
return "Hello from Server Service";
}
}

Client Service Repeat the steps for creating a microservice for client-service, with a slight modification in step 4 to discover and call server-service.

  • Implement a REST Controller to Use RestTemplate and DiscoveryClient:
java
@RestController
public class ClientController {

@Autowired
private RestTemplate restTemplate;

@Autowired
private DiscoveryClient discoveryClient;

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}

@GetMapping("/call-server")
public String callServer() {
List<ServiceInstance> instances = discoveryClient.getInstances("server-service");
if (instances.isEmpty()) return "No instances found";
String serviceUri = String.format("%s/greet", instances.get(0).getUri().toString());
return restTemplate.getForObject(serviceUri, String.class);
}
}

Testing Service Discovery

  • Start Eureka Server: Ensure it's running and accessible.

  • Start Both Microservices: client-service and server-service should register themselves with Eureka and be visible on the Eureka dashboard.

  • Call the Client Service: Access http://localhost:<client-service-port>/call-server. This should internally call the server-service through service discovery and return "Hello from Server Service".

Replace <client-service-port> with the actual port where client-service is running, typically 8080 if you haven't specified otherwise.

This example illustrates the basic setup of Service Discovery in a microservices architecture using Spring Cloud Netflix Eureka. By dynamically discovering services, this approach significantly simplifies the communication and scalability of microservices-based applications.

3. Circuit Breaker: Preventing Failure Cascades

The Circuit Breaker pattern is a crucial fault tolerance mechanism that prevents a network or service failure from cascading through the system. When a microservice call fails repeatedly, the circuit breaker "trips," and further calls to the service are halted or redirected, allowing the failing service time to recover. This pattern ensures system stability and resilience, protecting the system from a domino effect of failures.

Example:

Implementing a Circuit Breaker pattern in a microservices architecture helps to prevent failure cascades, allowing a system to continue operating smoothly even when one or more services fail. In the Spring ecosystem, Resilience4J is a popular choice for implementing the Circuit Breaker pattern, thanks to its lightweight, modular, and flexible design. Here's how you can integrate a circuit breaker into a microservice calling another service, using Spring Boot with Resilience4J.

Step 1: Add Dependencies

For the client service that calls another service (let's continue with the client-service example), you need to add Resilience4J and Spring Boot AOP dependencies to your pom.xml.

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
<version>{resilience4j.version}</version>
</dependency>

Replace {resilience4j.version} with the latest version of Resilience4J compatible with your Spring Boot version.

Step 2: Configure the Circuit Breaker

Resilience4J allows you to configure circuit breakers in application.yml or application.properties. You can define parameters like failure rate threshold, wait duration, and ring buffer size.

application.yml configuration:

yaml
resilience4j.circuitbreaker:
instances:
callServerCircuitBreaker:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 10s
failureRateThreshold: 50
eventConsumerBufferSize: 10

This configuration sets up a circuit breaker for calling the server service, with a 50% failure rate threshold and a 10-second wait duration in the open state before it transitions to half-open for testing if the failures have been resolved.

Step 3: Implement Circuit Breaker with Resilience4J

In your client-service, use the @CircuitBreaker annotation on the method that calls the server-service. This annotation tells Resilience4J to monitor this method for failures and open/close the circuit according to the defined rules.

java
@RestController
public class ClientController {

@Autowired
private RestTemplate restTemplate;

@Autowired
private DiscoveryClient discoveryClient;

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}

@CircuitBreaker(name = "callServerCircuitBreaker", fallbackMethod = "fallback")
@GetMapping("/call-server")
public String callServer() {
List<ServiceInstance> instances = discoveryClient.getInstances("server-service");
if (instances.isEmpty()) return "No instances found";
String serviceUri = String.format("%s/greet", instances.get(0).getUri().toString());
return restTemplate.getForObject(serviceUri, String.class);
}

public String fallback(Throwable t) {
return "Fallback Response: Server Service is currently unavailable.";
}
}

The fallback method is invoked when the circuit breaker is open, providing an alternative response to avoid cascading failures.

Step 4: Test the Circuit Breaker

  • Start Both Microservices: Make sure both client-service and server-service are running. Ensure server-service is registered with Eureka and discoverable by client-service.

  • Simulate Failures: You can simulate failures by stopping server-service or introducing a method in server-service that always throws an exception.

  • Observe the Circuit Breaker in Action: Call the client-service endpoint repeatedly. Initially, it should successfully call server-service. After reaching the failure threshold, the circuit breaker should open, and subsequent calls should immediately return the fallback response without attempting to call server-service.

  • Recovery: After the wait duration, the circuit breaker will allow a limited number of test requests through. If these succeed, the circuit breaker will close again, and client-service will resume calling server-service normally.

This example demonstrates the basic usage of Resilience4J's Circuit Breaker in a microservices architecture, providing an effective means of preventing failure cascades and enhancing system resilience.

4. Config Server: Centralized Configuration Management

Microservices architectures often face challenges in managing configurations across services, especially when they span multiple environments. The Config Server pattern addresses this by centralizing external configurations. Services fetch their configuration from a central source at runtime, simplifying configuration management and ensuring consistency across environments.

Example:

Creating a centralized configuration management system using Spring Cloud Config Server allows microservices to fetch their configurations from a central location, simplifying the management of application settings and ensuring consistency across environments. This example will guide you through setting up a Config Server and demonstrating how a client microservice can retrieve its configuration.

Step 1: Setup Config Server

  • Initialize a Spring Boot Project: Use Spring Initializr to create a new project named config-server. Choose the necessary Spring Boot version, and add Config Server as a dependency.

  • Enable Config Server: In your main application class, use the @EnableConfigServer annotation.

java
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
  • Configure the Config Server: Define the location of your configuration repository (e.g., a Git repository) in application.properties or application.yml. For simplicity, you can start with a local Git repository or even a file system-based repository.
properties
server.port=8888
spring.cloud.config.server.git.uri=file://$\{user.home\}/config-repo

This example uses a local Git repository located at ${user.home}/config-repo. You'll need to create this repository and add configuration files for your client services.

  • Start the Config Server: Run your application. The Config Server will start on port 8888 and serve configurations from the specified repository.

Step 2: Prepare Configuration Repository

  • Create a Git Repository: At the location specified in your Config Server (${user.home}/config-repo), initialize a new Git repository and add configuration files for your services.

  • Add Configuration Files: Create application property files named after your client services. For example, if you have a service named client-service, create a file named client-service.properties or client-service.yml with the necessary configurations.

  • Commit Changes: Commit and push your configuration files to the repository.

Step 3: Setup Client Service to Use Config Server

  • Initialize a Spring Boot Project: Create a new project for your client service, adding dependencies for Spring Web, Config Client, and any others you require.

  • Bootstrap Configuration: In src/main/resources, create a bootstrap.properties or bootstrap.yml file (this file is loaded before application.properties), specifying the application name and Config Server location.

properties
spring.application.name=client-service
spring.cloud.config.uri=http://localhost:8888
  • Access Configuration Properties: Use @Value annotations or @ConfigurationProperties in your client service to inject configuration properties.
java
@RestController
public class ClientController {

@Value("${example.property}")
private String exampleProperty;

@GetMapping("/show-config")
public String showConfig() {
return "Configured Property: " + exampleProperty;
}
}

Step 4: Testing

  • Start the Config Server: Ensure it's running and accessible at http://localhost:8888.

  • Start Your Client Service: Run the client service application. It should fetch its configuration from the Config Server during startup.

  • Verify Configuration Retrieval: Access the client service's endpoint (e.g., http://localhost:<client-port>/show-config). It should display the value of example.property fetched from the Config Server.

This example demonstrates setting up a basic Spring Cloud Config Server and a client service retrieving configuration properties from it. This setup enables centralized configuration management, making it easier to maintain and update configurations across multiple services and environments.

5. Bulkhead: Isolating Failures

Inspired by the watertight compartments (bulkheads) in a ship, the Bulkhead pattern isolates elements of an application into pools. If one service or resource pool fails, the others remain unaffected, ensuring the overall system remains operational. This pattern enhances system resilience by preventing a single failure from bringing down the entire application.

6. Sidecar: Enhancing Services with Auxiliary Functionality

The Sidecar pattern involves deploying an additional service (the sidecar) alongside each microservice. This sidecar handles orthogonal concerns such as monitoring, logging, security, and network traffic control, allowing the main service to focus on its core functionality. This pattern promotes operational efficiency and simplifies the development of microservices by abstracting common functionalities into a separate entity.

7. Backends for Frontends: Tailored APIs for Diverse Clients

Different frontend applications (web, mobile, etc.) often require different backends to efficiently meet their specific requirements. The Backends for Frontends (BFF) pattern addresses this by providing dedicated backend services for each type of frontend. This approach optimizes the backend to frontend communication, enhancing performance and user experience.

8. Saga: Managing Transactions Across Microservices

In distributed systems, maintaining data consistency across microservices without relying on traditional two-phase commit transactions is challenging. The Saga pattern offers a solution by breaking down transactions into a series of local transactions. Each service performs its local transaction and publishes an event; subsequent services listen to these events and perform their transactions accordingly, ensuring overall data consistency.

9. Event Sourcing: Immutable Event Logs

The Event Sourcing pattern captures changes to an application's state as a sequence of events. This approach not only facilitates auditing and debugging by providing a historical record of all state changes but also simplifies communication between microservices. By publishing state changes as events, services can react to these changes asynchronously, enhancing decoupling and scalability.

10. CQRS: Separation of Concerns for Performance and Scalability

Command Query Responsibility Segregation (CQRS) pattern separates the read (query) and write (command) operations of an application into distinct models. This separation allows optimization of each operation, potentially improving performance, scalability, and security. CQRS is particularly beneficial in systems where the complexity and performance requirements for read and write operations differ significantly.

Conclusion

The distributed system patterns discussed in this blog form the backbone of effective microservices architectures. By leveraging these patterns, developers can build systems that are not only scalable and flexible but also resilient and manageable. However, it's crucial to understand that each pattern comes with its trade-offs and should be applied based on the specific requirements and context of the application. As the world of software continues to evolve, so too will the patterns and practices that underpin the successful implementation of microservices, guiding developers through the complexities of distributed systems architecture.