Many companies work with user-sensitive data that can’t be stored permanently due to legal restrictions. Usually, this can happen in fintech companies. The data must not be stored for longer than a predefined time period and preferably should be deleted after it was used for the service purposes. There are multiple possible options to solve this problem. In this post, I would like to present a simplified example of an application that handles sensitive data leveraging Spring and Redis.
Redis is a high-performance NoSQL database. Usually, it is used as an in-memory caching solution because of its speed. However, in this example, we will be using it as the primary datastore. It perfectly fits our problem’s needs and has a good integration with Spring Data.
We will create an application that manages a user's full name and card details (as an example of sensitive data). Card details will be passed (POST request) to the application as an encrypted string (just a normal string for simplicity). The data will be stored in the DB for 5 minutes only. After the data is read (GET request) it will be automatically deleted.
The app is designed as an internal microservice of the company without public access. The user’s data can be passed from a user-facing service. Card details can then be requested by other internal microservices, ensuring sensitive data is kept secure and inaccessible from external services.
Let’s start creating the project with Spring initializr. We will need Spring Web, Spring Data Redis, Lombok. I also added Spring Boot Actuator as it would definitely be useful in a real microservice.
After initializing the service we should add other dependencies. To be able to delete the data automatically after it has been read we will be using AspectJ. I also added some other dependencies that are helpful for the service and make it look more realistic (for a real-world service you would definitely add some validation for example).
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.3'
id 'io.spring.dependency-management' version '1.1.6'
id "io.freefair.lombok" version "8.10.2"
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(22)
}
}
repositories {
mavenCentral()
}
ext {
springBootVersion = '3.3.3'
springCloudVersion = '2023.0.3'
dependencyManagementVersion = '1.1.6'
aopVersion = "1.9.19"
hibernateValidatorVersion = '8.0.1.Final'
testcontainersVersion = '1.20.2'
jacksonVersion = '2.18.0'
javaxValidationVersion = '3.1.0'
}
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}"
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation "org.aspectj:aspectjweaver:${aopVersion}"
implementation "com.fasterxml.jackson.core:jackson-core:${jacksonVersion}"
implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
implementation "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}"
implementation "jakarta.validation:jakarta.validation-api:${javaxValidationVersion}"
implementation "org.hibernate:hibernate-validator:${hibernateValidatorVersion}"
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage'
}
testImplementation "org.testcontainers:testcontainers:${testcontainersVersion}"
testImplementation 'org.junit.jupiter:junit-jupiter'
}
tasks.named('test') {
useJUnitPlatform()
}
spring:
data:
redis:
host: localhost
port: 6379
CardInfo is the data object that we will be working with. To make it more realistic let’s make card details to be passed in the service as encrypted data. We need to decrypt, validate, and then store incoming data. There will be 3 layers in domain:
DTO is converted to Model and vice versa in
Model is converted to Entity and vice versa in
We use Lombok for convenience.
@Builder
@Getter
@ToString(exclude = "cardDetails")
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class CardInfoRequestDto {
@NotBlank
private String id;
@Valid
private UserNameDto fullName;
@NotNull
private String cardDetails;
}
@Builder
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserNameDto {
@NotBlank
private String firstName;
@NotBlank
private String lastName;
}
Card details here represent an encrypted string and fullName is a separate object that is passed as it is. Notice how cardDetails field is excluded from toString() method. Since the data is sensitive it shouldn’t be accidentally logged.
@Data
@Builder
public class CardInfo {
@NotBlank
private String id;
@Valid
private UserName userName;
@Valid
private CardDetails cardDetails;
}
@Data
@Builder
public class UserName {
private String firstName;
private String lastName;
}
CardInfo is the same as CardInfoRequestDto except cardDetails (converted in CardInfoEntityMapper). CardDetails now is a decrypted object that has two sensitive fields: pan (card number) and CVV (security number):
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = {"pan", "cvv"})
public class CardDetails {
@NotBlank
private String pan;
private String cvv;
}
See again that we excluded sensitive pan and cvv fields from toString() method.
@Getter
@Setter
@ToString(exclude = "cardDetails")
@NoArgsConstructor
@AllArgsConstructor
@Builder
@RedisHash
public class CardInfoEntity {
@Id
private String id;
private String cardDetails;
private String firstName;
private String lastName;
}
In order Redis creates hash key of an entity one needs to add @RedisHash annotation along with @Id annotation.
public CardInfo toModel(@NonNull CardInfoRequestDto dto) {
final UserNameDto userName = dto.getFullName();
return CardInfo.builder()
.id(dto.getId())
.userName(UserName.builder()
.firstName(ofNullable(userName).map(UserNameDto::getFirstName).orElse(null))
.lastName(ofNullable(userName).map(UserNameDto::getLastName).orElse(null))
.build())
.cardDetails(getDecryptedCardDetails(dto.getCardDetails()))
.build();
}
private CardDetails getDecryptedCardDetails(@NonNull String cardDetails) {
try {
return objectMapper.readValue(cardDetails, CardDetails.class);
} catch (IOException e) {
throw new IllegalArgumentException("Card details string cannot be transformed to Json object", e);
}
}
In this case, for simplicity, method getDecryptedCardDetails just maps string to CardDetails object. In a real application one would have the decryption logic here.
Spring Data is used to create Repository. The card info in the service is retrieved by id, so there is no need to define custom methods and the code looks like this:
@Repository
public interface CardInfoRepository extends CrudRepository<CardInfoEntity, String> {
}
We need the entity to be stored only for 5 minutes. To achieve this, we have to set up TTL (time to leave). We can do it by introducing a field in CardInfoEntity and adding the annotation @TimeToLive on top. It can also be achieved by adding the value to @RedisHash: @RedisHash(timeToLive = 5*60).
Both ways have some flaws. In the first case, we have to introduce a field that doesn’t relate to business logic. In the second case, the value is hardcoded. There is another option: implement KeyspaceConfiguration. With this approach we can use property in application.yml to set ttl and if needed other Redis properties.
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
public class RedisConfiguration {
private final RedisKeysProperties properties;
@Bean
public RedisMappingContext keyValueMappingContext() {
return new RedisMappingContext(
new MappingConfiguration(new IndexConfiguration(), new CustomKeyspaceConfiguration()));
}
public class CustomKeyspaceConfiguration extends KeyspaceConfiguration {
@Override
protected Iterable<KeyspaceSettings> initialConfiguration() {
return Collections.singleton(customKeyspaceSettings(CardInfoEntity.class, CacheName.CARD_INFO));
}
private <T> KeyspaceSettings customKeyspaceSettings(Class<T> type, String keyspace) {
final KeyspaceSettings keyspaceSettings = new KeyspaceSettings(type, keyspace);
keyspaceSettings.setTimeToLive(properties.getCardInfo().getTimeToLive().toSeconds());
return keyspaceSettings;
}
}
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class CacheName {
public static final String CARD_INFO = "cardInfo";
}
}
To make Redis delete entities with TTL one has to add enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP to @EnableRedisRepositories annotation. I introduced CacheName class to use constants as entity names and to reflect that there can be multiple entities that can be configured differently if needed.
@Data
@Component
@ConfigurationProperties("redis.keys")
@Validated
public class RedisKeysProperties {
@NotNull
private KeyParameters cardInfo;
@Data
@Validated
public static class KeyParameters {
@NotNull
private Duration timeToLive;
}
}
Here there is only cardInfo but there can be other entities.
redis:
keys:
cardInfo:
timeToLive: PT5M
Let’s add API to the service to be able to store and access the data by HTTP.
@RestController
@RequiredArgsConstructor
@RequestMapping( "/api/cards")
public class CardController {
private final CardService cardService;
private final CardInfoConverter cardInfoConverter;
@PostMapping
@ResponseStatus(CREATED)
public void createCard(@Valid @RequestBody CardInfoRequestDto cardInfoRequest) {
cardService.createCard(cardInfoConverter.toModel(cardInfoRequest));
}
@GetMapping("/{id}")
public ResponseEntity<CardInfoResponseDto> getCard(@PathVariable("id") String id) {
return ResponseEntity.ok(cardInfoConverter.toDto(cardService.getCard(id)));
}
}
We want the entity to be deleted right after it was successfully read with a GET request. It can be done with AOP and AspectJ. We need to create Spring Bean and annotate it with @Aspect.
@Aspect
@Component
@RequiredArgsConstructor
@ConditionalOnExpression("${aspect.cardRemove.enabled:false}")
public class CardRemoveAspect {
private final CardInfoRepository repository;
@Pointcut("execution(* com.cards.manager.controllers.CardController.getCard(..)) && args(id)")
public void cardController(String id) {
}
@AfterReturning(value = "cardController(id)", argNames = "id")
public void deleteCard(String id) {
repository.deleteById(id);
}
}
@Pointcut defines in what place the logic is applied. Or in other words, what triggers the logic to execute. deleteCard method is where the logic is defined. It deletes cardInfo entity by id using CardInfoRepository. @AfterReturning annotation means that the method should run after a successful return from the method that is defined in the value attribute.
I also annotated the class with @ConditionalOnExpression to be able to switch on/off this functionality from properties.
We will write web tests using MockMvc and Testcontainers.
public abstract class RedisContainerInitializer {
private static final int PORT = 6379;
private static final String DOCKER_IMAGE = "redis:6.2.6";
private static final GenericContainer REDIS_CONTAINER = new GenericContainer(DockerImageName.parse(DOCKER_IMAGE))
.withExposedPorts(PORT)
.withReuse(true);
static {
REDIS_CONTAINER.start();
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);
registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(PORT));
}
}
With @DynamicPropertySource we can set properties from the started Redis Docker container. Afterwards, the properties will be read by the app to set up a connection to Redis.
public class CardControllerTest extends BaseTest {
private static final String CARDS_URL = "/api/cards";
private static final String CARDS_ID_URL = CARDS_URL + "/{id}";
@Autowired
private CardInfoRepository repository;
@BeforeEach
public void setUp() {
repository.deleteAll();
}
@Test
public void createCard_success() throws Exception {
final CardInfoRequestDto request = aCardInfoRequestDto().build();
mockMvc.perform(post(CARDS_URL)
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(request)))
.andExpect(status().isCreated())
;
assertCardInfoEntitySaved(request);
}
@Test
public void getCard_success() throws Exception {
final CardInfoEntity entity = aCardInfoEntityBuilder().build();
prepareCardInfoEntity(entity);
mockMvc.perform(get(CARDS_ID_URL, entity.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(entity.getId())))
.andExpect(jsonPath("$.cardDetails", notNullValue()))
.andExpect(jsonPath("$.cardDetails.cvv", is(CVV)))
;
}
}
@Test
@EnabledIf(
expression = "${aspect.cardRemove.enabled}",
loadContext = true
)
public void getCard_deletedAfterRead() throws Exception {
final CardInfoEntity entity = aCardInfoEntityBuilder().build();
prepareCardInfoEntity(entity);
mockMvc.perform(get(CARDS_ID_URL, entity.getId()))
.andExpect(status().isOk());
mockMvc.perform(get(CARDS_ID_URL, entity.getId()))
.andExpect(status().isNotFound())
;
}
I annotated this test with @EnabledIf as AOP logic can be switched off in properties and the annotation determines whether the test should be run.
The source code of the full version of this service is available on