Redis in a Spring Boot School Management System: A Journey

06 September 2024

Introduction

I was recently assigned to a school management project for a client in Riyadh, Saudi Arabia, by my company. The project was built using Spring Boot for the backend and Angular for the frontend. As a member of the backend team, I was excited to dive into this project and work on building a robust system that could handle the complex requirements of managing a school’s data and operations.

The project journey was engaging, filled with challenges and learning opportunities. However, one thing quickly became clear: the amount of data we were pulling from the database on almost every page was substantial. Information like existing grade years, courses, and academic years was needed everywhere—from the dashboard to student profiles to report cards. Each time a user navigated to a different section of the app, we were hitting the database repeatedly for the same data. It wasn’t long before I realized that this approach was not only inefficient but also putting unnecessary strain on our database.

This is where Redis came into the picture.

Redis: Why It Was the Right Choice

Redis is an in-memory data structure store that’s often used as a cache. It’s incredibly fast because it stores data in memory, meaning retrieval times are minimal compared to querying a database. For our school management system, caching this commonly accessed data made perfect sense. Instead of making recurring queries to the database, we could fetch the necessary information from Redis, speeding up our application and reducing the load on our database.

Implementing Redis in Spring Boot:

Creating the Redis Service Interface

To keep things clean and reusable, I started by creating a Redis service interface that would handle basic operations like saving, retrieving, and deleting data in Redis.

				
					public interface RedisService<T extends BaseDto> {
    String generateKey(T entity);
    void save(T t, Long expiry);
    Object get(String key);
    void delete(String key);
}
				
			

Creating the Redis Service Interface

Next, I implemented this interface in a RedisServiceImpl class. This is where the actual interaction with Redis happens. By extending this class, other services in the application can easily cache and retrieve data without worrying about the underlying Redis operations.

				
					@Service
public class RedisServiceImpl<T extends BaseDto> implements RedisService<T> {

    protected final RedisTemplate<String, Object> redisTemplate;
    protected final ObjectMapper objectMapper;
    public RedisServiceImpl(RedisTemplate<String, Object> redisTemplate,
                            ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    @Override
    public void save(T entity, Long expiry) {
        String key = generateKey(entity);
        redisTemplate.opsForValue().set(key, entity);
        if (expiry != null) {
            redisTemplate.expire(key, expiry, TimeUnit.SECONDS);
        }
    }

    @Override
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }
   
    @Override
    public void delete(String key) {
        redisTemplate.delete(key);
    }
}
				
			

Specialized Service for Grade Years and Courses

One of the key areas where Redis caching was particularly beneficial was in managing grade years and their associated courses. For this, I extended the RedisServiceImpl class in a new service, YearsWithCoursesService. This service not only managed the caching of grade years and courses but also provided methods to retrieve and sort them.

				
					@Service
public class YearsWithCoursesService extends RedisServiceImpl<YearDto> {
    public YearsWithCoursesService(RedisTemplate<String, Object> redisTemplate,
                                   ObjectMapper objectMapper) {
        super(redisTemplate, objectMapper);
    }

    @Override
    public String generateKey(YearDto yearDto) {
        return "year:" + yearDto.getId();
    }

    public YearDto getYear(Long yearId) {
        String key = "year:" + yearId;
        Object value = get(key);
        if (value == null) {
            return null;
        }
        return objectMapper.convertValue(value, YearDto.class);
    }

    public List<YearDto> getYears() {
        List<YearDto> yearDtos = new ArrayList<>();
        Set<String> keys = redisTemplate.keys("year*");

        if (keys != null) {
            for (String key : keys) {
                Object value = redisTemplate.opsForValue().get(key);
                if (value != null) {
                    YearDto yearDto = objectMapper.convertValue(value, YearDto.class);
                    yearDtos.add(yearDto);
                }
            }
        }

        return yearDtos.stream().sorted(Comparator.comparing(YearDto::getId)).toList();
    }
}

				
			

Overcoming Roadblocks: Challenges Faced and How I Tackled Them

Running Redis on a Windows Machine

One of the unexpected challenges I faced during the project was that Redis is primarily designed to run on Unix-based systems like Ubuntu. However, I was developing on a Windows machine, which meant I couldn’t just install Redis directly as I would on Linux.

Solution: Using Windows Subsystem for Linux (WSL)

To get Redis up and running on my Windows machine, I decided to use the Windows Subsystem for Linux (WSL). WSL allows you to run a Linux distribution on Windows, which gave me the flexibility to install and manage Redis just as if I were on a Linux machine. If you would like to know more about how to set up Redis on Windows using WSL check out my blog [reference to blog] !

This setup allowed me to develop and test the Redis integration on my Windows machine without any issues. The use of WSL bridged the gap between the Unix-based nature of Redis and the Windows environment I was working in.

Handling Data Inconsistency

One of the challenges I anticipated was ensuring that the cached data in Redis remained consistent with the database. Since Redis doesn’t automatically sync with the database, I needed a strategy to handle updates, particularly when a user added or modified grade years or courses.

To solve this, I employed a cache invalidation strategy. Whenever there was a change in the database, I forced the system to refresh the Redis cache by setting a forceDB flag. Here’s how I implemented this in a YearService class:

				
					@Service
public class YearService {
    private final YearsWithCoursesService yearsWithCoursesService;

    @Transactional(readOnly = true)
    public List<YearDto> findYearsWithCourses(Long schoolId, boolean forceDB) {

        List<YearDto> yearDtos = new ArrayList<>();

        if (!forceDB) {
            yearDtos = yearsWithCoursesService.getYears();
        }

        if (yearDtos.isEmpty()) {
            List<Year> years = yearRepository.findAll();
            List<Course> courses = courseRepository.findByYearIdIn(
                years.stream().map(Year::getId).toList());

            Map<Long, List<CourseDto>> coursesByYear = courses.stream()
                    .collect(Collectors.groupingBy(
                            course -> course.getYear().getId(),
                            Collectors.mapping(
                                    itm -> createCourse(itm, schoolId),
                                    Collectors.toList()
                            )
                    ));

            yearDtos = yearMapper.yearsToYearDtos(years);
            List<YearDto> existingYearDtos = yearsWithCoursesService.getYears();
            for (YearDto dto : existingYearDtos) {
                String key = yearsWithCoursesService.generateKey(dto);
                yearsWithCoursesService.delete(key);
            }
            for (YearDto yearDto : yearDtos) {
                yearDto.setCourses(coursesByYear.get(yearDto.getId()));
                if (yearDto.getCourses() == null) {
                    yearDto.setCourses(new ArrayList<>());
                }
                yearDto.setStudentsCount(yearDto.getCourses().stream()
                    .map(CourseDto::getStudentsCount).reduce(0, Integer::sum));
                yearsWithCoursesService.save(yearDto, null);
            }
        } else {
            yearDtos = yearsWithCoursesService.getYears();
            
        }

        return yearDtos;
    }
}

				
			

In this setup, if the forceDB flag is set to true, the service fetches fresh data from the database, deletes the outdated cache entries, and saves the updated information in Redis. This way, the application always serves the most accurate and up-to-date data to the users.

Final Thoughts

Using Redis in our Spring Boot project significantly improved the performance of our school management system. By caching frequently accessed data like grade years, courses, and academic years we reduced the load on the database and sped up response times for end users. Implementing the Redis service as a reusable component allowed us to easily manage caching throughout the application.

While there were challenges, like ensuring data consistency and handling serialization, the overall experience was positive. Redis proved to be a powerful tool that, when used correctly, can greatly enhance the performance of any data-intensive application. If you’re dealing with similar challenges, I highly recommend considering Redis as a solution. It’s fast, flexible, and relatively easy to integrate into a Spring Boot project.

And that’s it! I hope you enjoyed joining me on this journey through Redis and Spring Boot and found some useful insights and maybe even a bit of inspiration for your own projects. Happy coding!

Muhammad Aaliyan Khan

Software Engineer at Qavi Technologies