← Back to list

IMap listeners as Spring beans: less wiring, clearer event handling

Hazelcast Toolkit series: Part 3.

Buzov Artur · 2026-05-05 11:32 · 2 claps · 4.8 min read
#hazelcast #spring-boot #hibernate #cache #java
Open on Medium ↗

IMap listeners as Spring beans: less wiring, clearer event handling

Hazelcast Toolkit series: Part 3.

Hazelcast IMap listeners are useful in the exact places where Spring applications already have structure: services, components, transactions, metrics, and domain-specific event handlers.

But the usual wiring often pulls listener registration away from the listener itself.

You define a listener class in one place, register it in a ClientConfig bean somewhere else, and then keep both sides synchronized as maps and event types change. It works, but it feels unlike the rest of a Spring Boot application.

Hazelcast Toolkit makes the listener itself the registration point:

@Component
@HzIMapListener(map = "users")
public class UserEvents implements EntryAddedListener<String, UserProfile> {

    @Override
    public void entryAdded(EntryEvent<String, UserProfile> event) {
        log.info("User added: {}", event.getKey());
    }
}

The bean is discovered after Spring finishes creating singletons, registered against the target IMap, and deregistered when the context shuts down.

The manual version

Manual listener registration usually looks like this:

@Bean
public HazelcastInstance hazelcastClient(UserEvents userEvents) {
    ClientConfig config = new ClientConfig();
    config.getNetworkConfig().addAddress("127.0.0.1:5701");

    HazelcastInstance client = HazelcastClient.newHazelcastClient(config);

    IMap<String, UserProfile> users = client.getMap("users");
    users.addEntryListener(userEvents, true);

    return client;
}

This is fine for one listener. It becomes noisy when you have several maps:

users.addEntryListener(userEvents, true);
sessions.addEntryListener(sessionEvents, false);
orders.addLocalEntryListener(orderEvents);
invoices.addEntryListener(invoiceEvents, true);

The registration list becomes an infrastructure registry that needs to know about application behavior. In a Spring app, the listener bean can describe that better itself.

The annotation-driven version

With Hazelcast Toolkit, a listener is a normal Spring bean plus one annotation:

package com.example.users;

import com.hazelcast.core.EntryEvent;
import com.hazelcast.map.listener.EntryAddedListener;
import io.github.javaquasar.hazelcast.toolkit.annotation.HzIMapListener;
import org.springframework.stereotype.Component;

@Component
@HzIMapListener(map = "users")
public class UserCreatedListener implements EntryAddedListener<String, UserProfile> {

    @Override
    public void entryAdded(EntryEvent<String, UserProfile> event) {
        // publish domain event, update read model, record metric, etc.
    }
}

The annotation has three attributes:

Example with key-only events:

@Component
@HzIMapListener(map = "sessions", includeValue = false)
public class SessionTouchedListener implements EntryUpdatedListener<String, SessionState> {

    @Override
    public void entryUpdated(EntryEvent<String, SessionState> event) {
        log.debug("Session changed: {}", event.getKey());
    }
}

Example with a local-only listener:

@Component
@HzIMapListener(map = "orders", localOnly = true)
public class LocalOrderListener implements EntryRemovedListener<String, OrderSnapshot> {

    @Override
    public void entryRemoved(EntryEvent<String, OrderSnapshot> event) {
        log.info("Local partition removed order {}", event.getKey());
    }
}

When localOnly = true, Hazelcast registers the listener with IMap.addLocalEntryListener(…). In that mode the includeValue flag is ignored by Hazelcast.

What gets registered

The annotated bean must implement a Hazelcast listener type:

import com.hazelcast.map.listener.MapListener;

or:

import com.hazelcast.core.EntryListener;

The toolkit validates this during context startup. A bean annotated with ***@HzIMapListener ***but not implementing a listener interface fails fast instead of silently doing nothing.

Сonceptually, registration looks like this:

Map<String, Object> candidates =
        beanFactory.getBeansWithAnnotation(HzIMapListener.class);

for (Object bean : candidates.values()) {
    Class<?> targetClass = AopUtils.getTargetClass(bean);
    HzIMapListener metadata =
            AnnotationUtils.findAnnotation(targetClass, HzIMapListener.class);

    IMap<Object, Object> map = hazelcastInstance.getMap(metadata.map());

    if (metadata.localOnly()) {
        map.addLocalEntryListener((MapListener) bean);
    } else {
        map.addEntryListener((MapListener) bean, metadata.includeValue());
    }
}

The real implementation also tracks listener IDs and removes them during shutdown.

Why this works well with Spring

Putting the listener registration on the bean has a few nice side effects.

First, the wiring is local. The class that handles users events says that it handles users events.

Second, the listener can use normal Spring dependencies:

@Component
@HzIMapListener(map = "users")
public class UserProjectionListener implements EntryAddedListener<String, UserProfile> {

    private final UserProjection projection;
    private final MeterRegistry meterRegistry;

    public UserProjectionListener(UserProjection projection, MeterRegistry meterRegistry) {
        this.projection = projection;
        this.meterRegistry = meterRegistry;
    }

    @Override
    public void entryAdded(EntryEvent<String, UserProfile> event) {
        projection.create(event.getKey(), event.getValue());
        meterRegistry.counter("users.cache.events", "type", "added").increment();
    }
}

Third, AOP-proxied beans are handled by resolving the target class before reading the annotation. That matters if your listener also uses Spring features such as ***@Transactional***.

@Component
@HzIMapListener(map = "invoices")
public class InvoiceCacheListener implements EntryUpdatedListener<String, InvoiceSnapshot> {

    @Transactional
    @Override
    public void entryUpdated(EntryEvent<String, InvoiceSnapshot> event) {
        // The bean may be proxied, but the toolkit still finds @HzIMapListener.
    }
}

Registration lifecycle

The listener lifecycle follows the Spring context lifecycle:

This avoids registering listeners too early, before dependencies are ready. It also avoids leaking listener registrations when a test context or application context is closed.

A complete example

Configuration:

spring:
  application:
    name: event-service

hazelcast:
  client:
    cluster-name: dev
    network:
      cluster-members:
        - 127.0.0.1:5701

Cache value:

import io.github.javaquasar.hazelcast.toolkit.annotation.HzCompact;

@HzCompact
public class UserProfile {

    private String userId;
    private String displayName;

    public UserProfile() {
    }

    public UserProfile(String userId, String displayName) {
        this.userId = userId;
        this.displayName = displayName;
    }

    public String getUserId() {
        return userId;
    }

    public String getDisplayName() {
        return displayName;
    }
}

Listener:

import com.hazelcast.core.EntryEvent;
import com.hazelcast.map.listener.EntryAddedListener;
import io.github.javaquasar.hazelcast.toolkit.annotation.HzIMapListener;
import org.springframework.stereotype.Component;

@Component
@HzIMapListener(map = "users", includeValue = true)
public class UserProfileListener implements EntryAddedListener<String, UserProfile> {

    @Override
    public void entryAdded(EntryEvent<String, UserProfile> event) {
        System.out.println("New user in cache: " + event.getValue().getDisplayName());
    }
}

Producer:

import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.map.IMap;
import org.springframework.stereotype.Service;

@Service
public class UserProfileCache {

    private final IMap<String, UserProfile> users;

    public UserProfileCache(HazelcastInstance hazelcastInstance) {
        this.users = hazelcastInstance.getMap("users");
    }

    public void save(UserProfile profile) {
        users.put(profile.getUserId(), profile);
    }
}

No central listener registry. No separate config class just to call addEntryListener.

When to use includeValue=false

The default includeValue = true is convenient because event handlers can read the new value directly:

@HzIMapListener(map = "users", includeValue = true)

But values can be large. If the handler only needs the key, turn values off:

@HzIMapListener(map = "users", includeValue = false)

This reduces serialization and network overhead for distributed listener events. It is a good fit for invalidation, key-based refresh, counters, and async follow-up work.

When to use localOnly=true

Use localOnly = true when you only want events for partitions owned by the local Hazelcast member:

@HzIMapListener(map = "jobs", localOnly = true)
public class LocalJobListener implements EntryAddedListener<String, JobState> {
    // ...
}

This can be useful for partition-aware processing and for reducing cross-member event traffic. It is not a replacement for a global event stream. If every service instance must observe every change, keep localOnly = false.

Operational notes

Listener code runs in the path of cache events, so keep handlers boring:

  • avoid blocking calls inside listener methods;
  • move slow work to a queue or executor;
  • use includeValue = false when the value is not needed;
  • make handlers idempotent where possible;
  • log enough to diagnose event flow, but not on every hot-path event at INFO.

The annotation removes registration boilerplate. It does not remove the normal distributed-systems responsibility of designing event handlers carefully.

Resources

Gradle:

implementation 'io.github.javaquasar:hazelcast-toolkit-spring-boot3:0.3.0'

Maven:

<dependency>
    <groupId>io.github.javaquasar</groupId>
    <artifactId>hazelcast-toolkit-spring-boot3</artifactId>
    <version>0.3.0</version>
</dependency>

Closing thought

IMap listeners are application behavior. They should look like application behavior.

With ***@HzIMapListener***, listener registration becomes part of the Spring bean itself. The Hazelcast API is still there, but the repetitive wiring moves into the starter where it belongs.

Previous: Part 2 — Auto-registering Hazelcast Compact serialization in Spring Boot

Next: Part 4 — Hazelcast Hibernate L2 cache in real Spring Boot apps


메타데이터
post_id
446cb4bbc3d0
slug
imap-listeners-as-spring-beans-less-wiring-clearer-event-handling-446cb4bbc3d0
url
https://medium.com/@artur.buzov/imap-listeners-as-spring-beans-less-wiring-clearer-event-handling-446cb4bbc3d0
canonical_url
https://medium.com/@artur.buzov/imap-listeners-as-spring-beans-less-wiring-clearer-event-handling-446cb4bbc3d0
author_url
https://medium.com/@artur.buzov
status
ok
fetched_at
2026-07-24 15:22:40