Spring Caching, Scheduling, Async Processing, and Events - Complete Notes

An in-depth and interview-focused guide to Spring Cache, Redis, cache consistency, scheduled work, asynchronous execution, thread pools, application and transactional events, retries, idempotency, and the point where durable messaging becomes necessary.

00. One mental model for four tools

Caching changes where a result comes from. Scheduling changes when work starts. Async processing changes which thread waits. Events change how components learn that something happened.

These features are often discussed together because they move work away from the simplest request-to-database path. They solve different problems and provide different guarantees. A cache is a disposable copy of authoritative data. A scheduler is a clock that asks code to run. An executor is a bounded place where tasks wait for threads. An application event is an in-process notification. None of them is automatically durable, exactly once, distributed, or safe across a process crash.

Think of a restaurant. The cache is a tray of popular prepared items. The scheduler is the wall clock that starts recurring kitchen jobs. The async executor is a limited set of cooks plus a waiting counter. An event is the bell telling nearby staff that an order changed state. A durable broker is a numbered order system whose tickets survive even if the restaurant closes temporarily.

Need Useful Spring tool What it does not promise
Avoid repeating an expensive read Spring Cache with Caffeine or Redis Immediate consistency or permanent storage
Start maintenance at a time or interval @Scheduled and TaskScheduler One execution across a cluster or missed-run recovery
Return to a caller before independent work finishes @Async and a configured TaskExecutor Durability, unlimited capacity, or transaction propagation
Decouple beans inside one application ApplicationEventPublisher and event listeners Cross-service delivery or persistence
Survive restarts and deliver across services Durable broker plus outbox or another reliable publication pattern Exactly-once business effects without idempotent consumers
Start from the required guarantee

Ask whether work may be lost, duplicated, delayed, reordered, or observed before a database commit. Then choose a mechanism. An annotation is convenient configuration, not a new reliability guarantee.

01. Spring Cache abstraction

Spring Cache applies reusable caching rules around method calls while a cache provider stores the actual entries.

The core interfaces are Cache and CacheManager. A Cache is a named region containing key-value entries. A CacheManager finds caches by name. Spring's interceptors connect method annotations to these interfaces. The provider may be a local concurrent map, Caffeine, Redis, JCache implementation, or another supported store.

The common read pattern is cache-aside. On a hit, return the cached value and skip the method. On a miss, invoke the method, usually reading the source of truth, then put the result into the cache. Spring coordinates this method-level flow. The provider controls storage, expiration, eviction, serialization, and distributed behavior.

Layer Responsibility Example decision
Application method Defines the authoritative operation and result findProduct(id) reads PostgreSQL
Spring Cache advice Computes keys and decides get, put, or evict Use cache productById and key id
Provider Stores entries and implements provider features Redis entry TTL is 10 minutes
Operations policy Controls capacity, observability, failure mode, and access Alert on latency, errors, evictions, and memory pressure
Enable caching away from the main application class
@Configuration
@EnableCaching
class CacheConfiguration {
}

@Service
class ProductQueryService {
    private final ProductRepository products;

    ProductQueryService(ProductRepository products) {
        this.products = products;
    }

    @Cacheable(cacheNames = "productById", key = "#productId")
    public ProductView findProduct(UUID productId) {
        return products.findViewById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));
    }
}

The first call for an identifier normally enters findProduct. A later call with the same key may return without executing the method. This means logging, authorization checks, metrics, or other side effects inside the cached method might not run on a hit. Keep a cacheable method side-effect free and cache data only after access-control boundaries are understood.

Local and distributed caches

Property Local cache Distributed Redis cache
Read latency Very low, no network hop Includes network and Redis latency
Data per application instance Each instance has its own copy Instances share a cache
Invalidation Must reach every process One shared entry can be removed
Failure effect Consumes application heap Depends on network and Redis service
Best fit Small, hot, instance-tolerant data Shared cache, larger working set, coordinated keys

A two-level cache can combine both, but it creates two consistency boundaries. Evicting Redis does not automatically evict each process-local copy. Add an invalidation channel, short local TTL, or accept the larger stale window. Complexity is justified only when measurements show the extra layer is valuable.

The proxy boundary

In the default proxy mode, caching annotations are applied only when a call enters through the Spring proxy. A method calling another annotated method on this bypasses the proxy, so the cache advice does not run. The same basic limitation appears with @Async, @Transactional, and proxy-based retry. Move the annotated operation to a separate bean, call it through its injected proxy, or use AspectJ only when its complexity is justified.

02. Cache keys, conditions, and annotations

A cache is correct only when equal keys always represent interchangeable results under the same data and authorization rules.

Default and explicit keys

With no arguments, Spring uses SimpleKey.EMPTY. With one argument, the argument itself is the key. With multiple arguments, Spring creates a SimpleKey containing them. This is convenient, but an explicit key documents which inputs actually determine the result.

Make business identity and result variation visible
@Cacheable(
        cacheNames = "catalogSearch",
        key = "T(String).format('%s:%s:%s', #tenantId, #query, #currency)")
public SearchResult search(
        UUID tenantId,
        String query,
        Currency currency,
        RequestTrace trace) {
    // trace does not affect the result, so it is not part of the key.
    return gateway.search(tenantId, query, currency);
}

A stable key should include tenant, locale, currency, feature version, authorization dimension, query filters, or any other input that can change the returned value. Do not use mutable objects with unstable equals or hashCode. For Redis, prefer short, readable, deterministic keys. Hash very large query objects only after defining a canonical form, because field order and serialization changes can otherwise create different hashes for the same query.

Tenant and permission leaks are cache bugs

If two users have different allowed views but share a key, one user's cached response can be served to another. Cache a permission-neutral object and filter it afterward, or include the security dimension in the key. Never assume a controller authorization check makes shared cached output safe.

What each annotation means

Annotation Method execution Cache effect Typical use
@Cacheable Skipped on a hit Stores a miss result Read-through method
@CachePut Always runs Stores the returned result Refresh cache after update
@CacheEvict Normally runs Removes one key or all entries Invalidate after mutation
@Caching Depends on nested operations Groups several cache operations Evict detail and list caches together
@CacheConfig No operation by itself Shares class-level names or resolvers Reduce repeated annotation metadata
Condition, unless, put, and eviction
@Cacheable(
        cacheNames = "productById",
        key = "#id",
        condition = "#useCache",
        unless = "#result.discontinued()")
public ProductView find(UUID id, boolean useCache) {
    return load(id);
}

@CachePut(cacheNames = "productById", key = "#result.id()")
@Transactional
public ProductView rename(UUID id, String name) {
    return mapper.toView(products.rename(id, name));
}

@CacheEvict(cacheNames = "productById", key = "#id")
@Transactional
public void delete(UUID id) {
    products.deleteById(id);
}

condition is checked before method invocation and may prevent caching. An unless expression is checked after invocation and can veto storing the result. #result is therefore useful in unless, @CachePut key expressions, and after-invocation eviction expressions, but not in a pre-invocation condition.

By default, @CacheEvict evicts only after successful completion. If the method throws, the entry remains. With beforeInvocation = true, eviction happens first even if the method later fails. Use before-invocation eviction only when stale cached data is more dangerous than an unnecessary miss and the database failure cannot make the old cache value preferable.

Null and negative caching

Caching "not found" can protect a database from repeated requests for absent identifiers, but it creates a stale absence when the record is later created. Use a short negative TTL and evict on creation. Many providers can adapt null values, while some configurations disable them. An explicit result such as Optional or a small lookup outcome makes the policy easier to understand.

03. Invalidation, TTL, and consistency

Cache consistency is a business policy about how long a copied value may disagree with the source of truth.

A TTL is a safety bound and memory-management tool, not precise synchronization. A 10-minute TTL means a stale entry may remain usable for roughly that duration after the source changes unless explicit invalidation occurs. Expiration can happen before an application reads the entry, and provider eviction can remove it even earlier under memory pressure.

Strategy Write path Strength Main risk
TTL only Do nothing to cache Simple, eventual refresh Known stale window
Cache-aside invalidation Commit source, then remove key Next read reloads authoritative data Crash between commit and eviction
Write-through update Update source and cache Warm cache immediately Two writes can fail or reorder
Versioned key Change version used in new keys No broad delete needed Old keys consume memory until expiry
Event-driven invalidation Publish committed change, consumers evict Coordinates many processes or caches Requires reliable delivery and idempotency

Cache operations and database transactions

Cache and database are separate resources. Putting or evicting before a database transaction commits can expose a state that later rolls back. Putting only after commit is safer, but a process can crash after commit and before the cache operation. For important cross-process invalidation, store an outbox row in the same database transaction and publish it reliably. For a noncritical cache, combine post-commit eviction with a bounded TTL and accept the small failure window.

Delete after database write is usually safer than update in place

A later reader repopulates from the committed source. Updating the cache can race with concurrent readers and writers. It can also cache a representation that differs from the final committed state because of triggers, defaults, or concurrent changes.

Choosing a TTL

  • Start from the maximum stale period the business can tolerate, not a round number.
  • Use shorter TTLs for volatile, sensitive, or frequently corrected data.
  • Use longer TTLs for immutable or versioned reference data.
  • Add randomized jitter so many entries do not expire at exactly the same second.
  • Measure hit rate, origin load, entry size, and change rate before tuning.
  • Do not cache account balances, authorization decisions, or one-time secrets casually.
  • Remember that provider capacity eviction can shorten the effective lifetime.

TTL is normally reset when an entry is created or updated. Time to idle, or TTI, resets expiration on access. Spring Data Redis can emulate TTI with Redis GETEX, but every access path must reset the TTL consistently and Redis 6.2 or later is required. Mixing cache reads with plain GET operations breaks the TTI expectation.

Versioning and schema changes

Cached bytes outlive a deployment. A new class shape may fail to deserialize old entries or, worse, deserialize with incorrect defaults. Add a format version to cache names or key prefixes, deploy readers that tolerate a planned transition, or clear only the affected region. Avoid a global Redis flush because Redis may hold unrelated application data.

04. Redis cache integration

Redis gives application instances a shared in-memory cache, but network, serialization, memory, and security become part of the request path.

Spring Boot can auto-configure a RedisCacheManager when Redis is available and caching is enabled. Cache names, a default TTL, null handling, and prefixes can be configured. For different policies per region, build the manager with per-cache RedisCacheConfiguration values.

Boot properties for a simple shared cache
spring:
  cache:
    type: redis
    cache-names:
      - productById
      - exchangeRates
    redis:
      time-to-live: 10m
      cache-null-values: false
      use-key-prefix: true
  data:
    redis:
      host: ${REDIS_HOST}
      port: ${REDIS_PORT:6379}
      username: ${REDIS_USERNAME:}
      password: ${REDIS_PASSWORD:}
      ssl:
        enabled: true
      connect-timeout: 2s
      timeout: 1s
Different expiration policies by cache
@Configuration
@EnableCaching
class RedisCachingConfiguration {

    @Bean
    RedisCacheManagerBuilderCustomizer cacheTtls() {
        return builder -> builder
                .withCacheConfiguration(
                        "productById",
                        defaults(Duration.ofMinutes(10)))
                .withCacheConfiguration(
                        "exchangeRates",
                        defaults(Duration.ofSeconds(45)));
    }

    private RedisCacheConfiguration defaults(Duration ttl) {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(ttl)
                .disableCachingNullValues()
                .computePrefixWith(name -> "kb:v2:" + name + "::");
    }
}

Keep a cache-name prefix. It prevents identical keys from different caches from colliding. Include an application, environment, tenant model, and data-format version when Redis is shared. A key such as kb:prod:v2:productById::42 is easier to inspect than opaque Java-serialized bytes.

Serialization

The serializer is a compatibility and security decision. JDK native serialization ties data to Java types and has a history of unsafe deserialization when untrusted bytes are accepted. A constrained JSON or another explicit format is easier to inspect, but polymorphic type metadata also needs a strict allowlist. Cache immutable DTOs rather than managed JPA entities. Entities can contain lazy proxies, bidirectional graphs, or fields whose meaning changes with a persistence context.

Define the format contract, control type resolution, limit object size and nesting, and version incompatible changes. Never deserialize bytes writable by an untrusted Redis client. Select the current serializer API supported by the exact Spring Data Redis and Jackson generation in the application rather than copying a deprecated serializer class from an older example.

Expiration and memory pressure

Redis stores an absolute expiration timestamp for a key. EXPIRE and the expiration options on SET create or update it. Expired keys are removed through access and background expiration work. Separately, when memory exceeds maxmemory, Redis follows maxmemory-policy. An all-keys LRU or LFU policy is common for a dedicated cache. Volatile policies consider only expiring keys and behave like no-eviction if no eligible keys exist.

  • Set an explicit memory limit with headroom for clients, replication, persistence, and OS use.
  • Choose an eviction policy based on measured access patterns.
  • Keep durable data in a separate Redis deployment from disposable cache data when possible.
  • Monitor used_memory, evictions, expirations, hits, misses, latency, and errors.
  • Remember that Redis LRU and LFU policies are approximations designed for efficiency.

Fail-open or fail-closed

A cache is normally an optimization, so a Redis failure may fall back to the database. This fail-open approach can create a database traffic surge exactly when Redis is unavailable. Bound timeouts, limit fallback concurrency, shed nonessential load, and protect the origin with a circuit breaker or bulkhead. If cached data is itself required for correctness, it is no longer a disposable cache and needs stronger storage and recovery design.

05. Cache stampedes and advanced patterns

A stampede occurs when many callers miss the same hot key and perform the expensive load concurrently.

Stampedes commonly follow synchronized expiration, a cold deployment, mass invalidation, or a cache outage. The origin receives traffic multiplied by the number of waiting callers. This can turn one cache miss into a database incident.

Technique How it helps Limit
@Cacheable(sync = true) Provider may let one thread compute a missing key Capability depends on provider and is often process-local
Per-key single-flight Coalesces identical loads in one process Every application instance may still load once
Distributed short lock Coordinates loaders across instances Lease expiry, fencing, crash, and unlock correctness matter
TTL jitter Spreads expirations over time Does not protect one extremely hot key
Refresh ahead Refreshes before hard expiration Background refresh can waste work or fail silently
Stale while revalidate Serves bounded stale data while one caller refreshes Requires explicit stale policy and metadata
Negative caching Stops repeated loads for missing records Can hide newly created data until eviction

Spring's sync = true is a hint to the provider and restricts combinations with other cache attributes. Confirm the provider's exact behavior. A local provider can synchronize only inside one JVM. A Redis-backed cache may still let one loader per application instance reach the database.

Distributed lock warning

A Redis lock normally uses an atomic conditional set with an expiration and a unique owner token. Unlock must delete only when the stored token still belongs to that owner, usually with an atomic script. A lease can expire while slow work is still running, allowing a second loader. Fencing tokens are needed when the protected resource must reject an older worker. Do not describe a simple SET NX as a universal distributed-lock solution.

Cache metrics that answer useful questions

  • Hit and miss count by bounded cache name, not by unbounded key.
  • Load duration, load failure count, and concurrent loader count.
  • Entry count, total bytes, large-value distribution, evictions, and expirations.
  • Redis command latency, connection-pool wait, timeouts, and reconnects.
  • Origin query rate and latency during normal service and cache failure.
  • Stale-value age when a stale-while-revalidate design is used.

A high hit rate is not automatically good. A cheap query might cost less than serialization and a network call. A cache with a 99 percent hit rate can still serve dangerously stale authorization data. Measure end-to-end latency, origin capacity, correctness, and operational cost.

06. Scheduling fundamentals

A scheduler decides when to submit a task. The task still needs a thread, bounded work, error handling, and concurrency rules.

Add @EnableScheduling to a configuration class to detect @Scheduled methods. A scheduled method is normally a no-argument method. Fixed-delay, fixed-rate, cron, and one-time initial-delay triggers express different timing models.

Three different schedules
@Component
class MaintenanceJobs {

    @Scheduled(
            fixedDelayString = "${jobs.cleanup.delay:PT10M}",
            initialDelayString = "${jobs.cleanup.initial-delay:PT1M}")
    void cleanupExpiredSessions() {
        // Next delay starts after this invocation finishes.
    }

    @Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
    void captureQueueDepth() {
        // Target start times are 30 seconds apart.
    }

    @Scheduled(
            cron = "${jobs.invoice.cron:0 15 2 * * *}",
            zone = "${jobs.invoice.zone:UTC}")
    void createDailyInvoices() {
        // Spring cron has six fields, including seconds.
    }
}
Trigger Meaning Best use Important edge case
Fixed delay Wait after the preceding execution completes Polling or cleanup that must not chase a clock Long task shifts every later start
Fixed rate Target start times follow a regular period Sampling and lightweight periodic work Slow tasks can queue or overlap with pool capacity
Cron Calendar-based schedule Daily or weekday business jobs Time zones and daylight-saving transitions
Initial delay only One invocation after startup delay Deferred startup task Runs again after each restart

A fixed rate does not make work complete at a fixed rate. If a single scheduler thread is busy, another scheduled callback waits. With a pool, separate triggers can overlap. A repeatable @Scheduled declaration creates independent triggers, so even the same method may overlap.

Cron and time

Spring cron expressions use six fields: second, minute, hour, day of month, month, and day of week. Always set an explicit zone for a business schedule. UTC avoids daylight-saving ambiguity, but some business rules truly mean local time. During a daylight-saving transition, a local time may not exist or may occur twice. Define the product rule, test the transition, and make the job idempotent.

Inject Clock into business logic instead of repeatedly calling the system clock. Tests can then use Clock.fixed. The scheduler should trigger a small coordinator that passes an explicit time window to a service, such as [lastSuccessfulInstant, currentInstant), instead of hiding time calculations throughout the job.

TaskScheduler and configuration

TaskScheduler is Spring's scheduling abstraction. A ThreadPoolTaskScheduler delegates scheduling to a ScheduledExecutorService and uses scheduler threads to execute tasks. Configure pool size, thread names, shutdown behavior, and an error handler explicitly for important jobs. Spring Boot exposes scheduling settings under spring.task.scheduling.

An explicit scheduler for observable behavior
@Configuration
@EnableScheduling
class SchedulingConfiguration implements SchedulingConfigurer {

    @Bean
    ThreadPoolTaskScheduler jobScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(4);
        scheduler.setThreadNamePrefix("job-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        scheduler.setErrorHandler(error ->
                LoggerFactory.getLogger("scheduled-jobs")
                        .error("Scheduled task failed", error));
        return scheduler;
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        registrar.setTaskScheduler(jobScheduler());
    }
}

07. Production scheduling

Every application replica registers its own schedules, so a three-replica deployment normally runs the same scheduled method three times.

This behavior is correct for per-instance work such as refreshing local state. It is dangerous for global invoicing, emails, cleanup, or settlement. Decide whether the job is per-instance, one active worker, partitioned, or intentionally redundant.

Approach Useful when Failure to design for
Run on every replica Work is local and independent Unexpected shared side effects
Leader or external platform scheduler One trigger is enough Leader failover and duplicate trigger
Database or lease lock Short global job with shared coordination store Lease expires before task finishes
Quartz with persistent store Persistent triggers, calendars, clustering, misfire policy Operational schema and cluster configuration
Broker-driven work Large work can be split into durable items Duplicates, poison messages, backpressure

Missed, slow, and overlapping runs

The basic in-memory scheduler does not persist a fire while the application is down. On restart, it resumes from current trigger state rather than replaying every missed business period. If every period matters, store job checkpoints, scan for unfinished periods, or use a persistent scheduler or broker.

Design the job around claimable units instead of one huge transaction. For example, claim 100 pending rows with a lease, process them, record results, and repeat. The lease identifies work that another worker may reclaim after a crash. A unique business constraint prevents duplicate output. Keep database transactions short and avoid holding locks during network calls.

A thin scheduled coordinator
@Component
class ExpirationJob {
    private final ExpirationService service;

    ExpirationJob(ExpirationService service) {
        this.service = service;
    }

    @Scheduled(fixedDelayString = "${jobs.expiration.delay:PT30S}")
    void trigger() {
        int processed;
        do {
            processed = service.processNextBatch(100);
        } while (processed == 100);
    }
}

Put transactional work in another Spring bean so its @Transactional proxy is used. Bound the loop by a time budget as well as batch count in latency-sensitive services. Emit one summary log and metrics instead of one high-cardinality log per row.

Job operability checklist

  • Feature flag or property to disable the job safely.
  • Explicit time zone, batch size, timeout, and concurrency.
  • Last start, last success, last failure, duration, processed count, and backlog metrics.
  • Stable job-run identifier propagated to logs and downstream calls.
  • Manual recovery command that uses the same idempotent business service.
  • Alert on missing success as well as explicit failures.
  • Shutdown policy that stops accepting work and bounds drain time.

08. Async execution and thread pools

@Async submits a method invocation to an executor and returns control to the caller. It does not make the work cheaper or durable.

Enable annotation support with @EnableAsync. In proxy mode, a call must cross the proxy. The annotated method normally returns void, Future, or CompletableFuture. The method may accept ordinary arguments. Use an explicit executor name when workloads need different capacity or isolation.

A bounded executor and named async workload
@Configuration
@EnableAsync
class AsyncConfiguration implements AsyncConfigurer {

    @Bean("notificationExecutor")
    ThreadPoolTaskExecutor notificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(12);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(30);
        executor.setThreadNamePrefix("notify-");
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}

@Service
class NotificationService {

    @Async("notificationExecutor")
    public CompletableFuture<DeliveryResult> send(Notification command) {
        DeliveryResult result = gateway.deliver(command);
        return CompletableFuture.completedFuture(result);
    }
}

Pool anatomy and queueing

Setting Meaning Design question
Core pool size Baseline worker count How much steady concurrency can dependencies support?
Queue capacity Tasks waiting before more threads or rejection How much latency and memory may accumulate?
Maximum pool size Upper worker count after a bounded queue fills Can the database or remote service survive this burst?
Keep-alive How long extra idle threads remain Is burst capacity temporary?
Rejection policy Behavior when both pool and queue are full Fail, run on caller, drop, or custom persistence?

With a ThreadPoolExecutor-style bounded queue, tasks generally fill core threads, then the queue, then expand toward maximum threads, then reject. A huge queue can make maxPoolSize irrelevant while requests wait for minutes and consume memory. An unbounded executor can create threads until the process or downstream dependency collapses. Capacity limits make overload visible.

Reasoning about pool size

CPU-heavy work rarely benefits from far more runnable platform threads than available CPU. Blocking I/O can use more concurrency because threads spend time waiting, but the correct limit is also bounded by database connections, downstream rate limits, file descriptors, memory, and service-level objectives. Little's Law gives a useful starting relationship: concurrency = throughput × average time in system. Measure real waiting and service time before tuning.

Virtual threads can reduce the cost of blocking threads, but they do not create downstream capacity. Keep semaphores, connection pools, concurrency limits, deadlines, and backpressure. Avoid mixing CPU-heavy work and slow remote calls in one executor because one workload can starve the other.

Return semantics

A controller returning "accepted" after submitting an in-memory task must be honest: the process may crash immediately and lose that work. If loss is acceptable, document it. If the caller needs a result, return or compose the CompletableFuture and handle timeout and failure. If business completion must survive restarts, persist a job or send a durable message before acknowledging acceptance.

09. Async failures, transactions, and context

Crossing a thread boundary also crosses exception, transaction, logging-context, and request-context boundaries.

Exception handling

For a Future or CompletableFuture, the failure is captured and observed when the caller waits or attaches a completion stage. A void async method cannot return its failure to the caller. Configure an AsyncUncaughtExceptionHandler to log, meter, and route such failures. Logging alone does not retry or recover lost work.

Observe uncaught void failures
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
    return (failure, method, arguments) -> {
        log.error(
                "Async method failed method={} argumentCount={}",
                method.toGenericString(),
                arguments.length,
                failure);
        meterRegistry.counter(
                "application.async.failures",
                "method",
                method.getName()).increment();
    };
}

Do not log complete arguments automatically. They may contain passwords, tokens, personal data, or large payloads. Use safe identifiers and a bounded method-name tag.

Transaction boundaries

An imperative Spring transaction is normally bound to the caller thread. The async worker does not inherit it. If the worker method has @Transactional and is invoked through the appropriate proxy, it starts its own transaction. Passing a managed JPA entity to another thread is unsafe because the persistence context is not transferred and entity state can be mutable. Pass an immutable identifier or command and reload needed state inside the worker's transaction.

Do not submit work before commit and assume it sees committed data

The worker may start immediately, read before the caller commits, or act even if the caller rolls back. Trigger after commit for best-effort local work, or use a transactional outbox when the work must not disappear.

Security and diagnostic context

Thread-local state such as MDC correlation values, locale, request attributes, and SecurityContext does not automatically become correct on reused worker threads. Use a deliberate TaskDecorator or supported context-propagation mechanism that captures only required values, installs them for the task, and clears them in a finally block. Blindly propagating a user security context into long-lived background work can grant permissions after the request should have ended. Prefer explicit service identity and immutable audit data.

Shutdown and cancellation

Graceful shutdown stops new submissions, waits for a bounded interval, and then allows the platform to terminate. Waiting forever can block deployments. Interrupts are cooperative, so code should restore interruption status or exit when interrupted. A cancelled CompletableFuture does not guarantee a remote call or database statement has been undone.

10. Spring application events

An application event says that something happened inside one Spring application context and lets publishers avoid direct knowledge of every listener.

Publish a past-tense immutable fact
public record OrderPlaced(
        UUID eventId,
        UUID orderId,
        UUID customerId,
        Instant occurredAt) {
}

@Service
class OrderService {
    private final ApplicationEventPublisher events;
    private final OrderRepository orders;
    private final Clock clock;

    @Transactional
    public UUID place(PlaceOrder command) {
        Order order = orders.save(Order.create(command));
        events.publishEvent(new OrderPlaced(
                UUID.randomUUID(),
                order.id(),
                order.customerId(),
                clock.instant()));
        return order.id();
    }
}

@Component
class OrderProjectionListener {

    @EventListener
    void record(OrderPlaced event) {
        // Runs synchronously by default.
    }
}

Publishing is a handoff to the application event multicaster, not proof that work is durable. By default, listeners run synchronously in the publisher's thread. Their latency becomes publisher latency, their exception may propagate to the publisher, and they can participate in the current thread-bound transaction. Ordering can be set, but heavily ordered listeners often reveal hidden workflow coupling.

Event design rules

  • Name facts in past tense, such as OrderPlaced, not commands such as SendEmail.
  • Use immutable payloads with stable identifiers and occurrence time.
  • Do not publish mutable JPA entities or request objects.
  • Keep payloads small and avoid secrets or unnecessary personal data.
  • Give events an identifier when duplicate handling or tracing matters.
  • Let listeners own their side effect instead of making the event encode every implementation.

@EventListener(condition = "...") can filter with SpEL. Listener methods can return another event or collection of events for synchronous publication. That return-based publication does not apply to asynchronous listeners, which should inject the publisher if they intentionally emit another event.

Asynchronous listeners

Adding @Async to a listener submits it to an executor. The publisher no longer waits, but the event is still in memory and may be lost on crash or rejected under load. Configure a dedicated bounded executor and failure handling. Use this for best-effort, low-consequence local reactions, not for payments, audit records, or required integration delivery.

11. Transactional and domain events

@TransactionalEventListener binds listener execution to a transaction phase so a listener can react to the transaction outcome.

Phase When listener runs Typical purpose
BEFORE_COMMIT Before commit processing completes Required work that should still fail the transaction
AFTER_COMMIT After a successful commit Best-effort cache eviction or local follow-up
AFTER_ROLLBACK After rollback Rollback-specific cleanup or metrics
AFTER_COMPLETION After commit or rollback Outcome-independent cleanup
Run only after the order transaction commits
@Component
class OrderCacheListener {

    private final CacheManager caches;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void evict(OrderPlaced event) {
        Cache cache = Objects.requireNonNull(
                caches.getCache("customerOrders"));
        cache.evict(event.customerId());
    }
}

The default phase is AFTER_COMMIT. Without an active transaction, a transactional listener is not invoked by default. fallbackExecution = true allows execution when no transaction exists, but then the semantic guarantee changes. Use it only when both paths are explicitly acceptable.

After commit does not mean durable

The database has committed, but the process can fail before or during the listener. Retrying an in-memory listener after restart is not automatic. Also, data-access resources may still appear bound during completion, but new writes in an after-commit listener need an explicit new transaction if they are meant to commit.

Domain events

A domain event expresses a meaningful fact discovered by the domain model, such as SubscriptionExpired. Spring Data aggregate support can expose domain events when an aggregate is saved. This helps keep the model expressive, but repository publication is still an in-process mechanism. Events may not be published if code changes state without the expected repository operation, and they do not become durable automatically.

Keep domain events separate from external integration events. Internal events can use domain language and process-local types. Integration events are versioned contracts for other systems, often containing less data and stronger compatibility rules. An outbox publisher can translate a committed domain outcome into an integration event.

Transactional outbox

The outbox pattern stores the business change and an event row in the same local database transaction. A separate publisher reads committed outbox rows and sends them to a broker, then records publication. A crash can cause a message to be published more than once, so consumers must be idempotent. The pattern closes the dangerous gap where a database commits but direct publication fails.

  1. Validate the command and update business tables.
  2. Insert an outbox row with event ID, type, version, payload, and occurred time.
  3. Commit both changes atomically.
  4. A publisher claims committed rows in bounded batches.
  5. Publish to the durable broker with the event ID.
  6. Mark or delete published rows using a safe retention policy.
  7. Consumers deduplicate by event ID and enforce business constraints.

12. Retry patterns

Retry is correct only for a transient failure, with a bounded attempt budget, safe repeated effects, and backoff that reduces pressure.

Failure Retry? Reason
Connection reset before a safe read completes Often Temporary transport failure
HTTP 429 or 503 with bounded policy Often Remote service signals temporary inability
PostgreSQL serialization failure Complete transaction Concurrent outcome may succeed from a fresh snapshot
Validation failure or HTTP 400 No The same invalid input remains invalid
Authentication or authorization failure Normally no Retry does not grant permission
Non-idempotent payment with unknown outcome Only with provider idempotency key and status recovery First attempt may already have succeeded

A production retry policy

  • Classify a narrow set of transient exceptions.
  • Set a maximum attempt count and total elapsed-time budget.
  • Use exponential backoff so each failure increases the delay.
  • Add randomized jitter so many clients do not retry together.
  • Honor a trustworthy server-provided retry delay where applicable.
  • Set a timeout on each attempt and a deadline for the whole operation.
  • Record attempts, final outcome, cause, and exhausted count with bounded tags.
  • Combine with circuit breaking and concurrency limits when a dependency is unhealthy.

Never multiply retry layers accidentally. Three HTTP-client attempts inside three service attempts inside three message redeliveries can create 27 calls. Define which layer owns retry and include downstream attempt time in the caller's deadline.

Spring Framework 7 and Spring Retry

Spring Framework 7 includes core resilience support such as org.springframework.resilience.annotation.Retryable, enabled with @EnableResilientMethods. Its maxRetries means retries after the initial call, so total attempts equal one plus maxRetries. It supports delay, multiplier, maximum delay, jitter, included and excluded failures, and programmatic retry.

Framework 7 retry with backoff and jitter
@Configuration
@EnableResilientMethods
class ResilienceConfiguration {
}

@Service
class RateClient {

    @Retryable(
            includes = {SocketTimeoutException.class},
            maxRetries = 3,
            delay = 100,
            multiplier = 2,
            maxDelay = 1_000,
            jitter = 50)
    public ExchangeRates loadRates() {
        return remoteClient.load();
    }
}

The separate Spring Retry project uses its own @EnableRetry, org.springframework.retry.annotation.Retryable, @Backoff, and @Recover model. It is in maintenance-only status because Spring Framework 7 supersedes it for new Framework 7 applications. Existing Spring Boot applications on earlier Framework generations may still use Spring Retry. Do not mix the two annotations or assume that similarly named attributes count attempts in the same way.

Proxy and transaction placement

Proxy-based retry needs an external call through the proxy. For a transaction retry, place retry outside the transaction so each attempt receives a new transaction and persistence context. If retry advice executes inside one transaction already marked rollback-only, later attempts cannot repair it. Verify advice order with an integration test instead of relying on annotation order in source code.

13. Idempotency and duplicate work

An idempotent operation produces the intended business state when the same logical request is processed more than once.

Retries, scheduler overlap, broker redelivery, client timeouts, and lease expiry all create duplicates. "Exactly once" transport language does not remove the need for business idempotency because a database commit and acknowledgement can fail at different moments.

Technique Example Protection
Natural unique constraint One invoice per account and billing period Concurrent duplicate creation
Idempotency key table Client key maps to request hash and stored response Repeated API command
Processed-event record Unique consumer name plus event ID Message redelivery
State transition guard Update only where status is PENDING Repeated transition
Provider idempotency key Stable payment request identifier Unknown remote outcome

Safe API idempotency flow

  1. Require a high-entropy key scoped to authenticated client and operation.
  2. Compute a canonical request hash and store it with the key.
  3. Atomically claim the key using a unique database constraint.
  4. Reject reuse of the same key with a different request hash.
  5. Store the terminal status and response needed for an identical replay.
  6. Handle an in-progress duplicate with wait, conflict, or status lookup semantics.
  7. Retain records for at least the documented client retry window.

A Redis lock alone is not a complete idempotency record. It can expire, be evicted, or disappear during failover. For important financial or externally visible operations, enforce the invariant in the authoritative database or remote provider.

Idempotent event consumer

Insert the consumer and event ID into a table with a unique constraint in the same transaction as the business mutation. If the insert conflicts, the event was already applied and can be acknowledged. If the transaction rolls back, neither marker nor mutation remains. Keep the marker long enough to cover broker retention and replay procedures.

14. When to move work to a durable message broker

Use a durable broker when accepted work must survive process failure, cross service boundaries, absorb bursts, or be retried independently.

Requirement In-memory async or events Durable broker
Survive application restart No Designed to, subject to broker durability configuration
Cross-service delivery No Yes
Replay history No Available in log-oriented systems and retained queues
Backpressure Bounded local queue and rejection Durable backlog plus consumer flow control
Independent retry and dead-letter handling Custom and process-local Common broker and consumer capability
Very low local handoff latency Excellent Network and persistence overhead

Strong signals for a broker include payment fulfillment, required email or webhook delivery, inventory reservation, audit integration, long-running media processing, bursty fan-out, multi-service workflows, and work that needs an operator-visible backlog. Keep local events for decoupling code inside one process when loss is acceptable or the listener remains part of the same synchronous transaction.

Broker realities

  • Messages can be delivered more than once, so consumers must be idempotent.
  • Ordering is usually limited to a partition, key, or queue, not every message globally.
  • Acknowledgement should follow the durable business effect.
  • Retry topics or delayed queues need a bounded policy and dead-letter destination.
  • Poison messages need inspection, redaction, repair, and replay procedures.
  • Schema versions need backward and forward compatibility plans.
  • Outbox or change-data-capture closes the database-to-broker dual-write gap.

15. Putting the tools together

A production design assigns one clear responsibility and one failure policy to each boundary.

Read path with shared cache

  1. Authorize the request before returning protected data.
  2. Compute a versioned, tenant-safe key.
  3. Read Redis with a short timeout.
  4. On a hit, return the immutable cached DTO.
  5. On a miss, coalesce hot-key loads and query the authoritative database.
  6. Store with TTL and jitter, then return.
  7. On Redis failure, apply a bounded origin fallback rather than unlimited database traffic.

Write path with durable invalidation

  1. Validate an idempotency key and business rules.
  2. Update business data and insert an outbox event in one database transaction.
  3. Commit, then return the committed result.
  4. An outbox publisher sends the versioned change event to a broker.
  5. Consumers evict shared and local caches idempotently.
  6. A short TTL limits stale data if invalidation is delayed.

Scheduled batch path

  1. An external or leader-aware scheduler triggers a lightweight coordinator.
  2. The coordinator claims a bounded batch using database leases.
  3. Workers process units with an idempotent state transition.
  4. Transient remote failures use bounded retry and jitter.
  5. Durable follow-up is published through an outbox.
  6. Metrics report lag, claim age, completion rate, retry, and terminal failure.
A useful architecture review question

At every arrow, ask what happens if the process stops immediately before it, during it, and immediately after it. If the answer loses required work or repeats an unsafe effect, add a durable record, idempotency guard, or changed acknowledgement boundary.

16. Testing strategies

Test business logic without time or threads first, then test Spring interception and real providers at the smallest useful integration boundary.

Caching tests

  • Unit-test key policy, TTL policy, and invalidation decisions as ordinary functions.
  • Use a Spring integration test to prove the call crosses a proxy and a hit skips the loader.
  • Verify different tenant, locale, and permission inputs never share an entry.
  • Verify mutation evicts or updates every affected detail, list, and aggregate cache.
  • Use a real Redis test container for TTL, serialization, prefix, connection failure, and concurrency.
  • Test cold-cache load and mass-expiration behavior, not only warm hits.
Test observable caching behavior
@SpringBootTest
class ProductCacheTest {
    @Autowired ProductQueryService service;
    @MockBean ProductRepository repository;

    @Test
    void repeatedLookupLoadsOnce() {
        UUID id = UUID.randomUUID();
        when(repository.findViewById(id))
                .thenReturn(Optional.of(new ProductView(id, "Keyboard")));

        assertThat(service.findProduct(id))
                .isEqualTo(service.findProduct(id));

        verify(repository, times(1)).findViewById(id);
    }
}

Clear the relevant cache between tests because Spring's test context can be reused. Do not make tests order-dependent. A mock-only unit test of the service cannot prove that cache advice runs.

Scheduling tests

Put real work in a service method that accepts a time window and batch size. Unit-test it with a fixed Clock. Test cron calculation around month boundaries and daylight-saving transitions. Keep annotation wiring tests small. Avoid sleep-based assertions; use latches, Awaitility-style bounded polling, or directly invoke the coordinator when timing itself is not under test.

Async tests

  • Unit-test worker logic synchronously without an executor.
  • Integration-test that invocation returns and executes on the named thread or executor.
  • Use latches or controllable executors to make order deterministic.
  • Fill a tiny test pool and assert the selected rejection behavior.
  • Assert future failures, void exception handling, context cleanup, and graceful shutdown.
  • Test that the worker starts its own transaction and reloads data by identifier.

Events, outbox, and retry tests

  • Use @RecordApplicationEvents when the event contract itself matters.
  • Verify an after-commit listener does not run after rollback.
  • Test no-transaction behavior with and without fallbackExecution.
  • Commit a business change and assert the outbox row exists in the same outcome.
  • Simulate publish-before-mark failure and prove duplicate publication is safe.
  • Use a fake clock and scripted transient failures to test exact retry delays and exhaustion.
  • Prove permanent failures are attempted once and duplicate event IDs change state once.

17. Security, performance, and operations

Background and cached paths need the same security review, capacity model, observability, and recovery discipline as synchronous request paths.

Security practices

  • Use Redis authentication, TLS, network isolation, least-privilege ACLs, and secret rotation.
  • Do not expose Redis directly to untrusted networks or treat it as a public API.
  • Never place passwords, access tokens, one-time codes, or unnecessary personal data in cache.
  • Include tenant and authorization dimensions in keys where output differs.
  • Prevent unsafe polymorphic deserialization and restrict writable Redis clients.
  • Validate event payloads at trust boundaries and sign webhooks where required.
  • Run scheduled and async work with an explicit service identity and auditable actor.
  • Redact task arguments, cache values, event payloads, and broker messages from logs.

Capacity and performance

  • Give separate executors to workloads with different latency and criticality.
  • Keep every queue bounded and make rejection behavior part of the API contract.
  • Limit concurrency by the narrowest downstream resource, often database connections.
  • Batch carefully: too small wastes overhead, while too large increases locks and recovery work.
  • Use cache entries smaller than the network and serialization cost they save.
  • Spread TTL and recurring-job start times with jitter when exact alignment is unnecessary.
  • Apply deadlines across retries so backoff does not exceed caller usefulness.

Operational signals

Area Metrics and diagnostics Alert example
Cache Hit ratio, load latency, Redis latency, error, eviction, memory Origin load surges while Redis errors increase
Scheduler Last success, duration, backlog, processed count, overlap No successful run within expected interval
Executor Active threads, pool size, queue depth, oldest task, rejection Queue age violates the completion objective
Events and outbox Published, listener failure, unpublished rows, oldest row age Oldest outbox row continues to age
Retry Attempts, recovered, exhausted, delay, final exception class Retry volume rises before final failures rise
Broker Consumer lag, redelivery, dead letter, publish latency Lag or dead-letter growth exceeds recovery capacity

Thread dumps reveal executor starvation, lock waits, and blocked network calls. Give pools recognizable thread-name prefixes. Do not tag metrics with cache key, event ID, customer ID, or full exception message because unbounded dimensions can overload the monitoring system.

18. Common mistakes and corrections

Mistake Why it fails Correction
Calling a cached or async method on this Default proxy advice is bypassed Move the operation to another injected bean
Caching a JPA entity Lazy proxies, mutable state, and schema coupling escape Cache an immutable, versioned DTO
Key omits tenant or locale Wrong or protected result is reused Model every result-varying dimension
One TTL for every cache Volatility and stale tolerance differ Set policy per named cache
Clearing all Redis keys Unrelated workloads lose state and origin load spikes Use versioned prefixes or targeted region eviction
Assuming sync = true is cluster single-flight Provider semantics may be JVM-local Verify provider or add appropriate coordination
Using default single scheduler thread unknowingly One slow task delays unrelated jobs Configure and isolate measured workloads
Running a global job on every replica Side effects duplicate Use idempotency plus leader, lock, or durable work queue
Using an unbounded async executor Threads, tasks, or downstream calls grow without control Bound pool and queue, define rejection
Ignoring void async exceptions Caller cannot observe failure Return a future or configure uncaught handling
Passing a managed entity to an async thread Persistence context and thread safety do not transfer Pass an immutable ID and reload
Publishing an event before commit for required work Listener can observe rollback or uncommitted data Use transaction phase or an outbox
Calling an after-commit event durable Process failure can lose it Persist an outbox in the original transaction
Retrying every exception immediately Permanent errors repeat and outages amplify Classify, bound, back off, and add jitter
Assuming a broker means exactly-once effects Commit and acknowledgement can be separated by failure Build idempotent consumers and constraints

19. Practice exercises

  1. Implement a tenant-safe productById cache. Give products and missing products different TTL policies. Prove with tests that tenants cannot share entries.
  2. Add Redis with a versioned prefix and JSON format. Start with a prior cached schema and test one rolling-deployment compatibility strategy.
  3. Create a hot-key load test. Compare no coordination, local single-flight, TTL jitter, and stale while revalidate. Record origin concurrency and P99 latency.
  4. Build a daily job that accepts a time window and a fixed Clock. Test a daylight-saving gap and overlap in a chosen business time zone.
  5. Configure a two-thread, two-entry async executor. Fill it deliberately and verify the exact rejection response seen by the caller.
  6. Publish an application event inside a transaction. Prove ordinary listener, after-commit listener, and rollback behavior with integration tests.
  7. Implement an outbox table and publisher. Crash the publisher after send but before marking the row, then prove the consumer processes the redelivered event once.
  8. Define a retry matrix for one remote API. Include exception classification, maximum elapsed time, backoff, jitter, idempotency key, and terminal recovery.
  9. Create an idempotent invoice command backed by a unique account-period constraint. Run two workers concurrently and verify only one invoice exists.
  10. Draw failure points for database commit, cache eviction, event publication, and broker acknowledgement. State the recovery and duplication behavior at every point.

20. Interview questions and answers

1. What does Spring Cache abstract?

It abstracts method-level cache operations through Cache and CacheManager. It does not provide the physical store or standardize every provider feature such as TTL, eviction, serialization, or distributed locking.

2. How does @Cacheable work?

A Spring interceptor computes a cache and key before the method call. On a hit it returns the cached value and skips the method. On a miss it invokes the method and normally stores the result. In default proxy mode, self-invocation bypasses this behavior.

3. What is the difference between @Cacheable and @CachePut?

@Cacheable can skip method execution on a hit. @CachePut always invokes the method and stores its result. Put is useful when a completed update produces the exact cached representation.

4. Why is cache invalidation difficult?

The source and cache are separate systems, concurrent readers and writers can interleave, and a process can fail between database commit and cache change. TTL bounds staleness, while durable event-driven invalidation can close larger coordination gaps but introduces delivery and idempotency work.

5. What is a cache stampede?

Many callers miss the same key and perform the expensive load concurrently. Mitigations include single-flight, sync = true where provider semantics fit, TTL jitter, refresh ahead, stale while revalidate, and carefully designed distributed coordination.

6. Local cache or Redis?

A local cache is faster and simpler but each application instance has a different copy. Redis shares entries and capacity across instances but adds network, serialization, availability, and security concerns. The consistency and latency requirements decide.

7. What is the difference between TTL and TTI?

TTL normally counts from create or update and does not reset on read. TTI resets on access. Redis natively uses expiration, while Spring Data Redis can emulate TTI using GETEX if all access paths follow the same policy.

8. Fixed delay versus fixed rate?

Fixed delay waits the configured duration after the previous run finishes. Fixed rate targets start times separated by a period. Pool capacity and task duration still determine whether actual starts overlap or wait.

9. Does @Scheduled run once in a cluster?

No. Each application context registers its schedule. Use per-instance semantics intentionally or add leader election, an external scheduler, a distributed coordination mechanism, Quartz clustering, or durable queued work. Keep the business action idempotent.

10. What happens to missed scheduled runs during downtime?

The basic in-memory scheduler does not persist and replay every missed business run. Store checkpoints, scan for missing periods, or use a persistent scheduler or broker when missed work must be recovered.

11. What does @Async guarantee?

It submits an intercepted invocation to an executor so the caller need not execute it on the same thread. It does not guarantee persistence, eventual completion, inherited transaction, context propagation, or unlimited capacity.

12. Why must an async executor be bounded?

Incoming work can exceed processing capacity. A bounded pool and queue cap memory and downstream concurrency and make overload observable through rejection. An unbounded resource converts overload into growing latency and eventual failure.

13. How are async exceptions handled?

A future carries failure to a caller that observes it. A void async method has no return channel, so an AsyncUncaughtExceptionHandler is needed for observation. Required recovery usually belongs in durable workflow design rather than logs alone.

14. Does a transaction propagate into @Async?

An imperative thread-bound transaction normally does not. The async method may start its own transaction when its annotated call crosses the correct proxy. Pass IDs or immutable commands, not managed entities.

15. Are Spring application events synchronous?

By default, yes. Listeners execute in the publisher thread through the application event multicaster. They add latency and may propagate exceptions. They can be made asynchronous, but that makes the handoff in-memory rather than durable.

16. What does @TransactionalEventListener solve?

It binds a listener to a transaction phase, most commonly after successful commit. It prevents a reaction intended for committed state from running after rollback. It does not persist the event or guarantee execution after a crash.

17. Domain event versus integration event?

A domain event represents a fact meaningful inside the domain model. An integration event is a versioned contract for another process or service. Translation and an outbox keep internal design separate from external compatibility and delivery concerns.

18. What problem does the outbox pattern solve?

It atomically records business state and publication intent in one database transaction, avoiding the gap between database commit and direct broker send. The publisher can send more than once, so consumers still need idempotency.

19. When should an operation be retried?

Retry a classified transient failure only when another attempt has a meaningful chance of succeeding and repeated effects are safe. Bound attempts and elapsed time, add exponential backoff and jitter, and do not retry validation or authorization errors.

20. How do Spring Framework 7 retry counts differ from common Spring Retry examples?

Framework 7's maxRetries counts retry attempts after the initial call. The separate Spring Retry project's maxAttempts counts total attempts. They are different packages and configuration models.

21. Why is jitter important?

Without jitter, many clients that fail together use identical backoff and call the dependency together again. Random variation spreads attempts and reduces synchronized load spikes.

22. What is idempotency?

It is the property that processing the same logical operation more than once reaches the intended business outcome without duplicate effects. Implement it with unique constraints, request keys, state guards, processed-event records, and provider-supported keys.

23. When is a durable broker better than @Async?

Use a broker when accepted work must survive restart, cross processes, absorb a durable backlog, be retried independently, or support replay and dead-letter handling. @Async is best for bounded, process-local work where loss semantics are acceptable.

24. Can message processing be exactly once?

A transport may offer strong delivery or transactional features, but end-to-end business effects still cross failure boundaries. Design for redelivery with idempotent consumers, database constraints, stable event IDs, and correct acknowledgement placement.

25. How would you diagnose an async backlog?

Inspect active thread count, pool maximum, queue depth and age, rejection count, task duration, thread dumps, downstream latency, connection-pool waits, and incoming rate. Fix the bottleneck or reduce admission before merely increasing threads.

21. Concise cheat sheet

Caching
  • @Cacheable skips work on hit; @CachePut always runs.
  • Key every dimension that changes the result, especially tenant and permission.
  • Use immutable DTOs, versioned prefixes, targeted invalidation, TTL, and jitter.
  • Redis is shared but adds network, serialization, memory, and security concerns.
  • Plan cold start, stampede, cache outage, and origin protection.
Scheduling and async
  • Fixed delay measures after completion; fixed rate targets periodic starts.
  • Set an explicit cron zone and design for daylight-saving behavior.
  • Every application replica schedules independently.
  • Use named, bounded pools and queues with explicit rejection and shutdown.
  • Transactions and thread-local context do not automatically cross async boundaries.
Events, retry, and durability
  • Application events are synchronous and in-process by default.
  • @TransactionalEventListener selects transaction phase, not durability.
  • Outbox plus broker handles required cross-process publication.
  • Retry only transient failures with bounds, backoff, jitter, deadlines, and idempotency.
  • Assume duplicate work can happen and enforce business invariants at the source of truth.

22. Official references

Last reviewed · July 2026 · part of knowledge-base