Scaling down microservices

Microservice architecture became very popular in the last years. This approach has a lot of benefits, which are widely praised at conferences. Unfortunately, it is very common to forget about the problems and difficulties we may encounter.

Those who know me a little, know that I have always been skeptical of this approach. In this context, it may seem surprising that in the current project I use this approach with success. In this case, it was a natural process of division of the system into separate microservices and adding new ones when required. As a result, the entire system consists of several to a dozen or so services. The number does not seem to be very high but may become problematic when the system has to be installed on a single, not very powerful machine. It is much easier to share limited resources between modules in a single monolithic application.

I would like to show you how microservice-based architecture can be easily scaled down to effectively support smaller deployments. Examples are using Spring eco-system, but the general idea should work in any environment.

The way of designing microservices architecture

There are two ways to develop microservice-based architecture. Design it upfront or split existing monolith application into separate microservices. Regardless of the approach, it is necessary to define clear interfaces between services.

In case of monolith application, it may turn out that despite clear API definition there are some ‘shortcuts’ which makes it hard to divide it into independent pieces. Error handling based on exceptions may also become a problem.

In case of an upfront design of microservice, it may be a tricky job to define a common, reusable set of data structures.

Building modular applications

Let’s assume that we want to design a system with several modules, which can be easily deployed as a monolith application or a set of distributed microservices. Below I will describe the following steps. As an example, please imagine a complex IoT system. One of its modules is device management service. It is responsible for keeping a registry of devices and managing their configuration.

1. Define a separate API module

It is very important to start with defining a clear interface of a module. It defines main functionalities, common data structures, exceptions. The module with the API definition is the first step. In Spring / Maven environment it should be a separate module or project without any infrastructural dependencies. Just a plain definition of functionality and data structures.

public interface DeviceManager {

	Device registerDevice(DeviceTechnicalInfo info);
	
	Location findDevice(DeviceId id);
	
	Config readConfiguration(DeviceId id);

}
public class Device {

	private final DeviceId id;
	private final DeviceTechnicalInfo technicalInfo;
	
	public Device(DeviceId id, DeviceTechnicalInfo technicalInfo) {
		this.id = id;
		this.technicalInfo = technicalInfo;
	}

}
public class DeviceId {

	private final String serialNumber;

	public DeviceId(String serialNumber) {
		this.serialNumber = serialNumber;
	}	

}
/**
 * Exception is thrown when device is registered in the system, but 
 * it is unreachable at the moment 
 */
public class DeviceNotConnectedException extends RuntimeException {

	public DeviceNotConnectedException(String message) {
		super(message);
	}

}
/**
 * Exception is thrown when device is not registered in the system
 */
public class DeviceNotFoundException extends RuntimeException {

	public DeviceNotFoundException(String message) {
		super(message);
	}

}

It is just a fragment of a complex domain. Some classes are skipped, the others are simplified for brevity.

2. Define client modules

Based on the API module we should define remote and local client modules.

Local client module

The local client module should use the actual implementation of the API module and should embed whole functionality. Adding dependency to the local client module should effectively mean that whole functionality is embedded in the client application. It should be handled like any other library. For convenience, it may reuse Spring configuration of the device module by importing it.

@Configuration
@Import(DeviceManagerConfig.class)
public class LocalDevicesClientConfig {

	@Bean
	public DeviceManager localDeviceManager(DeviceRepository repository) {
		return new DefaultDeviceManager(repository);
	}

}

Remote client module

Remote client module may use eg. HTTP client to realize functionality defined in API. In Spring environment the client can be easily defined with Feign.

@Configuration
@ComponentScan
@EnableFeignClients(basePackages = "com.tratif.devices.client")
@EnableAutoConfiguration
public class RemoteDevicesClientConfig {

	@Bean
	public DeviceManager remoteDeviceManager(DeviceManagementFeignClient feignClient) {
		return new RemoteDeviceManager(feignClient);
	}

}
public class RemoteDeviceManager implements DeviceManager {

	private final DeviceManagementFeignClient feignClient;

	public RemoteDeviceManager(DeviceManagementFeignClient feignClient) {
		this.feignClient = feignClient;
	}

	@Override
	public Device registerDevice(DeviceTechnicalInfo info) {
		return feignClient.registerDevice(info);
	}

	@Override
	public Location findDevice(DeviceId id) {
		return feignClient.findDevice(id);
	}

	@Override
	public Config readConfiguration(DeviceId id) {
		return feignClient.readConfiguration(id);
	}

}
@FeignClient("deviceManagementClient")
interface DeviceManagementClient {

	@PostMapping("/devices")
	Device registerDevice(DeviceTechnicalInfo info);

	@GetMapping("/devices/{id}/location")
	Location findDevice(DeviceId id);

	@GetMapping("/devices/{id}/config")
	Config readConfiguration(DeviceId id);

}

On the other side, there has to be a server-side implementation, which supports all functionalities defined in API.

@RestController
public class DeviceController {
	
	@Autowired
	private DeviceManager deviceManager;

	@PostMapping("/devices")
	public Device registerDevice(DeviceTechnicalInfo info) {
		return deviceManager.registerDevice(info);
	}
	
	@GetMapping("/devices/{id}/location")
	public Location findDevice(DeviceId id) {
		return deviceManager.findDevice(id);
	}
	
	@GetMapping("/devices/{id}/config")
	public Config readConfiguration(DeviceId id) {
		return deviceManager.readConfiguration(id);
	}

}

3. Error handling

It is very important to implement proper error mapping mechanisms. When a remote client is used, error handling based on exceptions is not an easy option. Making it transparent to the API client requires additional mapping of exceptions on a server-side to some transport-layer specific solution. The reverse operation, mapping to exceptions, has to take place on the client-side. Spring environment allows defining ErrorHandlers on the server-side and ErrorDecoders on Feign client side. Additional effort is required to map technical errors related to technical issues, specific to the transport layer.

Mapping exceptions on the server-side

@ControllerAdvice
public class DeviceManagementExceptionHandler {

	@ExceptionHandler(value = DeviceNotFoundException.class)
	@ResponseStatus(HttpStatus.NOT_FOUND)
	@ResponseBody
	protected Object handle(DeviceNotFoundException ex, WebRequest request) {
		return new ErrorResponse(ex);
	}

	@ExceptionHandler(value = DeviceNotConnectedException.class)
	@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
	@ResponseBody
	protected Object handle(DeviceNotConnectedException ex, WebRequest request) {
		return new ErrorResponse(ex);
	}

	@ExceptionHandler(value = RuntimeException.class)
	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
	@ResponseBody
	protected Object handle(RuntimeException ex, WebRequest request) {
		return new ErrorResponse(ex);
	}

}
public class ErrorResponse {

	private final ErrorCode errorCode;
	private final String message;

	@JsonCreator
	public ErrorResponse(@JsonProperty(value = "errorCode") ErrorCode errorCode,
			     @JsonProperty(value = "message") String message) {
		this.errorCode = errorCode;
		this.message = message;
	}

	public ErrorResponse(Exception exception) {
		this.errorCode = ErrorCode.getByExceptionType(exception.getClass());
		this.message = exception.getMessage();
	}

	// getters omitted for brevity

}
public enum ErrorCode {

	DEVICE_NOT_FOUND(DeviceNotFoundException.class, DeviceNotFoundException::new), 
	DEVICE_NOT_CONNECTED(DeviceNotConnectedException.class, DeviceNotConnectedException::new), 
	
	OTHER(RuntimeException.class, RuntimeException::new);

	private Function<String, Exception> exceptionFactoryMethod;
	private Class<? extends Exception> exceptionClass;

	// constructor omitted for brevity

	public static ErrorCode getByExceptionType(Class<? extends Exception> clazz) {
		return Stream.of(values())
		    .filter(errorCode -> errorCode.getExceptionClass().equals(clazz)).findFirst()
		    .orElse(ErrorCode.OTHER);
	}

	// getters omitted for brevity

}

Decoding on the client-side

On the client-side, we have to define ErrorDecoder, which will be used by FeignClients to create Exceptions based on received responses.

public class FeignClientErrorDecoder implements ErrorDecoder {

	private final ObjectMapper objectMapper;

	public FeignClientErrorDecoder(ObjectMapper objectMapper) {
		this.objectMapper = objectMapper;
	}

	@Override
	public Exception decode(String methodKey, Response response) {
		if (response.status() >= 400 && response.status() <= 599) {

			ErrorResponse errorResponse = null;
			String responseBody = null;
			try {
				responseBody = StreamUtils.copyToString(
						response.body().asInputStream(), Charset.defaultCharset());
				errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class);
			} catch (Exception e) {
				throw new RuntimeException("error during error decoding, "
						+ "response body: " + responseBody, e);
			}
			return errorResponse.getException();
		}
		return errorStatus(methodKey, response);
	}

}

It requires some small enhancement of ErrorResponse:

public class ErrorResponse {

	// existing code ommited for brevity

	@JsonIgnore
	public Exception getException() {
		return errorCode.createException(message);
	}
}

It is important to update the remote client configuration to enable usage of ErrorDecoder by Feign clients.

@Configuration
@ComponentScan
@EnableFeignClients(basePackages = "com.tratif.devices.client", defaultConfiguration = FeignClientConfiguration.class)
@EnableAutoConfiguration
public class RemoteDevicesClientConfig {

	@Bean
	public DeviceManager remoteDeviceManager(DeviceManagementFeignClient feignClient) {
		return new RemoteDeviceManager(feignClient);
	}

}
@Configuration
public class FeignClientConfiguration {

	@Bean
	public ErrorDecoder feignAbisClientErrorDecoder(ObjectMapper objectMapper) {
		return new FeignClientErrorDecoder(objectMapper);
	}

}

4. Using client modules

First, let’s take a look at the dependencies between modules we have already prepared and their responsibilities:

  • devices-api – contains all interfaces and data structures
  • devices-impl – contains the implementation of all device module features
  • local-devices-client – contains local devices module client which is simply using an interface implementation from “devices-impl” module
  • remote-devices-client – contains remote devices module client which is using HTTP protocol to communicate with “standalone-devices-app”
  • standalone-devices-app – it is a standalone application which provides HTTP API to access devices module features

Let’s assume we have a module A, which depends on features provided by devices module. We want to have an option to deploy module A as a monolith, with devices module embedded, or both modules as separate applications.

We can achieve that by defining separate applications that aggregate module A and a specific device module client. It’s just a matter of defining a high-level configuration which adds a specific dependency to @Configuration with remote or local implementation of interfaces defined in the API and using it in Spring Boot application.

@Configuration
@Import({ ModuleAConfig.class, LocalDevicesClientConfig.class })
public class ModuleALocalDevicesClient {

}
@Configuration
@Import({ ModuleAConfig.class, RemoteDevicesClientConfig.class })
public class ModuleARemoteDevicesClient {

}

Such a solution assumes that module A depends only on the device module API. It is a responsibility of an aggregate configuration to provide an implementation of the API, local or remote one.

Summary

Microservice architecture has a lot of benefits, but it is crucial to remember that it adds additional complexity from the operational point of view. I have shared some hints about how you can easily replace a remote call to external microservice with calling the method from an external library embedded into the application. I hope you enjoyed it.

Related Post

Leave a Reply

Your email address will not be published. Required fields are marked *