Decorating Spring @Components
It is a common situation when an existing Spring component requires some extensions. We have to add new functionality to the existing application. I would like to share my experience on how to deal with such situations in order to keep the application code clean and maintainable.
Adding small extensions
The first thought may be: “I will just add a single if to the existing component, an additional couple of lines of code or another dependency”. It is very tempting to do it this way because it is easy and straightforward. If the preexisting method or component is implemented cleanly and methods are short it does not seem to be a bad idea. The final solution is still not that bad.
But what about existing tests? Our change may require an addition of another mocked dependency to keep existing tests green. Sometimes we have to take into account new corner cases to verify if old functionality works fine. It may seem to somebody that this is not a bad solution either. The tests are only a bit more complicated, the number of corner cases which should be verified is just a bit higher.
In my opinion, the fact that the code seems only a bit more complicated than before the change is the most dangerous. The quality of the code is decreasing slowly, so we do not feel very bad about it. The problem is that after a couple of rounds of such enhancements, the code is much worse than at the beginning. How can we avoid it?
Maintaining high quality
The open-closed principle can be a good starting point here. We need a solution which enables us to easily extend existing functionalities (“open for extension”), but will not require modification of the existing implementation and tests (“closed for modifications”). There are some classical design patterns which can be used in this situation. One of them is decorator pattern. I will present some examples how decorator pattern can be applied to Spring components.
A typical usage example of decorator pattern is adding transaction handling functionality to existing code. It is a cliche, though, Spring developers dealt with it years ago and usually there is no need to revisit it. In real life, most of the enhancements will not be so generic and commonly used and it is not worth to prepare annotations and annotation processors or aspect-oriented approach to deal with them. Let’s take a look at the examples below.
Let’s assume that we have a simple service which is responsible for saving products in a persistent storage.
public class BasicProductService implements ProductService {
private final ProductRepository productRepo;
public BasicProductService(ProductRepository productRepo) {
this.productRepo = productRepo;
}
@Override
public Product createProduct(String productName) {
// simplified implementation for brevity
Product product = new Product(productName);
productRepo.save(product);
return product;
}
}
Due to some requirements change we may need to introduce a full-text search capability for products stored in the system. Adding this functionality to ProductService
will break a single responsibility rule and will increase the number of mocked services in tests. Please take a look at the unit test before and after adding new functionality:
public class BasicProductServiceTest {
private ProductService productService;
private ProductRepository productRepo;
@Before
public void setup() {
productRepo = mock(ProductRepository.class);
productService = new BasicProductService(productRepo);
}
@Test
public void createsProductWithGivenName() throws Exception {
String productName = "new product";
Product product = productService.createProduct(productName);
assertThat(product.getName()).isEqualTo(productName);
verify(productRepo, times(1)).save(product);
}
}
public class BasicProductServiceTest {
private ProductService productService;
private ProductRepository productRepo;
private FullTextSearchIndexService fullTextSearchIndexService;
@Before
public void setup() {
productRepo = mock(ProductRepository.class);
fullTextSearchIndexService = mock(FullTextSearchIndexService.class);
// creation of mocked FullTextSearchIndexService is required
// to keep old tests working
productService = new BasicProductService(
productRepo,
fullTextSearchIndexService
);
}
@Test
public void createsProductWithGivenName() throws Exception {
String productName = "new product";
Product product = productService.createProduct(productName);
assertThat(product.getName()).isEqualTo(productName);
verify(productRepo, times(1)).save(product);
}
@Test
public void indexesProductDuringCreation() throws Exception {
// it's assumed that the same setup of mocks works fine
// for both tests, more complex example may require
// separate setup for each test
String productName = "new product";
Product product = productService.createProduct(productName);
assertThat(product.getName()).isEqualTo(productName);
verify(fullTextSearchIndexService, times(1)).index(product);
}
}
The example is very simplified, but you can easily imagine what will happen after adding more and more functionalities toBasicProductService
. I have presented the example of unit tests only. In case of integration tests, the increase of complexity caused by adding new dependency will be much higher.
We can easily decorate the original ProductService
in order to decouple the two functionalities (persistence and full-text search). We can even separate the indexing functionality itself from its usage while creating Product
. In addition, existing tests may remain unchanged.
public class FullTextSearchIndexedDecorator implements ProductService {
private final ProductService wrappedProductService;
private final FullTextSearchIndexService fullTextSearchIndexService;
public FullTextSearchIndexedDecorator(ProductService wrappedProductService,
FullTextSearchIndexService fullTextSearchIndexService) {
this.wrappedProductService = wrappedProductService;
this.fullTextSearchIndexService = fullTextSearchIndexService;
}
@Override
public Product createProduct(String productName) {
Product product = wrappedProductService.createProduct(productName);
fullTextSearchIndexService.index(product);
return product;
}
}
FullTextSearchIndexService
is responsible for creating indexes and full-text searching and FullTextSearchIndexedDecorator
is responsible for indexing product during creation.
Building @Components with decorators
There are multiple ways to organize @Components
with decorators in Spring environment. I would like to present some of them.
Multiple implementations of an interface in a single Spring context
One of the options is to create multiple implementations of a base interface. It would be one basic implementation and a set of required decorated ones. In such a case each component which requires the implementation of the interface has to define which one should be used. It can be achieved with @Qualifier
annotation as follows:
@Configuration
public class AppConfiguration {
@Bean(name = "basicService")
ProductService basicService(ProductRepository productRepository) {
return new BasicProductService(productRepository);
}
@Bean(name = "decoratedService")
ProductService decoratedService(
@Qualifier("basicService") ProductService basicProductService,
ElkFullTextSearchIndexService indexService) {
return new FullTextIndexedDecorator(basicProductService, indexService);
}
@Bean
ServiceRequiringDecoratedService serviceRequiringDecoratedService(
@Qualifier("decoratedService") ProductService productService) {
return new ServiceRequiringDecoratedService(productService);
}
@Bean
ServiceRequiringBasicService serviceRequiringBasicService(
@Qualifier("basicService") ProductService productService) {
return new ServiceRequiringBasicService(productService);
}
}
A single implementation of an interface in Spring context
Another option is to provide just a single implementation of the interface in the Spring context. It can be some base implementation which can be later decorated in a component which uses an interface. Please take a look at examples:
- only the basic implementation is created as a bean, other components can use base implementation and decorate it if needed
@Configuration
public class AppConfiguration {
@Bean
ProductService basicService(ProductRepository productRepository) {
return new BasicProductService(productRepository);
}
@Bean
ServiceRequiringDecoratedService serviceRequiringDecoratedService(
ProductService productService,
ElkFullTextSearchIndexService indexService) {
ProductService decoratedProductService =
new FullTextIndexedDecorator(productService, indexService);
return new ServiceRequiringDecoratedService(decoratedProductService);
}
@Bean
ServiceRequiringBasicService serviceRequiringBasicService(
ProductService productService) {
return new ServiceRequiringBasicService(productService);
}
}
- only the decorated implementation is created as a bean, all other components which are using auto-wiring will get the decorated implementation
@Configuration
public class AppConfiguration {
@Bean
ProductService decoratedService(ProductRepository productRepository,
ElkFullTextSearchIndexService indexService) {
ProductService productService = basicService(productRepository);
return new FullTextIndexedDecorator(productService, indexService);
}
@Bean
ServiceRequiringDecoratedService serviceRequiringDecoratedService(
ProductService productService) {
return new ServiceRequiringDecoratedService(productService);
}
}
Summary
This approach – decorating services with additional functionality – is extremely useful when the number of those additional functionalities is rising. We can easily combine and dynamically add new decorators to existing base functionalities. The big advantage of such solution is that we do not modify existing code. It lowers the risk that some new bugs can occur after a change. At the same time, each decorator can be easily tested separately and independently to the others. Of course, the whole system was modified and requires additional integration and end-to-end tests. Regardless of the approach chosen, such tests must still be added.
In my opinion, using decorators in Spring environment is a good way to develop reliable and maintainable systems. It is worth to remember about this classical design pattern and apply it in everyday work. Good luck!