I need to expose some configuration in the form of a Set in my Application but I need them to be unmodifiable and I'm looking for the best way to achieve this.
I need to expose this configuration to perform in the business logic operation like mySet.set1().constains("Value")
This is a configuration in the application.yml
app:
my-set:
set-1: 'Value1,Value2'
set-2: 'Value3,Value4'
If I try to expose it in a Record or a class written like this:
@ConfigurationProperties("app.my-set")
public record MySet(Set<String> set1, Set<String> set2) {
}
the 2 set are final, so I can't reassign them, but I can still modify the elements that are inside them, just like this:
@SpringBootTest
@Slf4j
class MySetTest {
@Autowired
private MySet mySet;
@Test
void testMutableSet(){
log.info("Initial set: [{}]", mySet);
assertTrue(mySet.set1().contains("Value1") && mySet.set1().contains("Value2"));
assertTrue(mySet.set2().contains("Value3") && mySet.set2().contains("Value4"));
mySet.set1().remove("Value1");
mySet.set2().remove("Value3");
mySet.set1().add("Custom1");
mySet.set2().add("Custom2");
log.info("Final set: [{}]", mySet);
assertFalse(mySet.set1().contains("Value1"));
assertFalse(mySet.set2().contains("Value3"));
assertTrue(mySet.set1().contains("Custom1") && mySet.set1().contains("Value2"));
assertTrue(mySet.set2().contains("Custom2") && mySet.set2().contains("Value4"));
}
}
To hide the possibility of modify them out of the scope of the class I used a workaround: I implemented the method to return unmodifiableSet:
@ConfigurationProperties("app.my-set")
public record MySet(Set<String> set1, Set<String> set2) {
public Set<String> set1() {
return Collections.unmodifiableSet(set1);
}
public Set<String> set2() {
return Collections.unmodifiableSet(set2);
}
}
With this workaround, when I need to inject it into other classes, I can see only the MyImmutableSet interface and I can't modify the values inside the Sets.
@SpringBootTest
@Slf4j
class MyImmutableSetTest {
@Autowired
private MySet mySet;
@Test
void testImmutable(){
log.info("Initial set: [{}]", mySet);
assertTrue(mySet.set1().contains("Value1") && mySet.set1().contains("Value2"));
assertTrue(mySet.set2().contains("Value3") && mySet.set2().contains("Value4"));
assertThrows(UnsupportedOperationException.class, () -> mySet.set1().remove("Value1"));
assertThrows(UnsupportedOperationException.class, () -> mySet.set2().remove("Value3"));
assertThrows(UnsupportedOperationException.class, () -> mySet.set1().add("Custom1"));
assertThrows(UnsupportedOperationException.class, () -> mySet.set2().add("Custom2"));
log.info("Final set: [{}]", mySet);
assertFalse(mySet.set1().contains("Custom1"));
assertFalse(mySet.set2().contains("Custom2"));
assertTrue(mySet.set1().contains("Value1") && mySet.set1().contains("Value2"));
assertTrue(mySet.set2().contains("Value3") && mySet.set2().contains("Value4"));
}
}
I really don't like this workaround, so I'm looking for a cleaner solution that increase the maintainability, maybe with some annotation.
I already tried also with an interface exposing only the two set and removing the public
access modifier from the Record, but it looks worse than whit the only Record.
Does anyone have some other solution to share?
Thanks in advance for your help!
What I've done myself is to use the record constructor shorthand to replace the input:
@ConfigurationProperties("app.my-set")
public record MySet(Set<String> set1, Set<String> set2) {
public MySet {
set1 = Set.copyOf(set1);
set2 = Set.copyOf(set2);
}
}
If needed you can make those null-safe
: set1 = set1 == null ? Set.of() : Set.copyOf(set1);