Aspects: @Timed, @Cacheable, @Retryable, @CircuitBreaker¶
Diana's aspect annotations wrap @Service/@Component/@Repository
methods with cross-cutting behaviour, Spring-AOP style — but resolved at
compile time: the generator emits a proxy subclass ($Aspect$X) whose
overrides nest tested runtime helpers around super.method(...). No
reflection, no interceptor registry.
All four are method-level annotations on public instance methods
returning Future; the class must be open to extension (not
final/base/sealed). They compose freely with each other and with
@Transactional.
@Service()
class Reports {
Reports(this.db);
final Database db;
@Timed()
@Cacheable(ttlSeconds: 30)
@Retryable(maxAttempts: 3, backoffMs: 100, retryOn: [DbUnavailable])
@CircuitBreaker(failureThreshold: 5, resetTimeoutMs: 30000)
@Transactional()
Future<List<Row>> summarize(int year) async { ... }
}
Composition order (fixed)¶
Aspects always nest in the same order, outermost first:
@Timed → @Cacheable → @CircuitBreaker → @Retryable →
@Transactional
@Timedoutermost — measures the latency the caller experiences: cache hits, open-breaker rejections and full retry budgets all show up in the histogram.@Cacheablesecond — a hit short-circuits breaker, retry and transaction, and does not feed the breaker's failure count (a cache hit says nothing about downstream health).@CircuitBreakeroutside@Retryable— one guarded invocation is one full retry budget: transient blips the retry absorbs never trip the breaker, and an open breaker rejects immediately instead of burningmaxAttempts × backoffMsagainst a known-bad dependency (spring-retry's own semantics).@Transactionalinnermost — every retry attempt runs in a fresh transaction; a rolled-back attempt leaves no state behind.
@Timed¶
Records diana_method_duration_seconds{method} and
diana_method_calls_total{method,result} (plus a method.<name> trace
span) through the app's observability provider. The
label defaults to ClassName.methodName; override it with
@Timed(name: 'reports.summary').
Without a DianaObservability provider the aspect is a silent no-op
(the proxy holds a null observer): adding observability later turns the
metrics on with no code change.
@Cacheable¶
In-memory, per-isolate result cache keyed by the argument list in
declaration order. Entries expire after ttlSeconds (default 60) and the
least-recently-used entry is evicted beyond maxEntries (default 1024, per
method). Concurrent misses for the same key share one computation, and
errors are never cached.
Caveats:
- Arguments need value equality (
==/hashCode) for hits to work — primitives and value-type DTOs do; plain mutable objects compare by identity (the same caveat Spring documents for key generation). - The method must return
Future<T>with a non-voidT, and must not declare type parameters. - By default the cache is per isolate: with
server.isolates > 1each isolate warms its own copy (see operations). Declare a pluggable store to share one backend across isolates or processes. - Writes elsewhere in the app are invisible through the cache until the TTL expires — cache reads that must see writes immediately are not a fit.
Pluggable cache store¶
By default @Cacheable uses a built-in per-isolate cache. To share a
backend — Redis, memcached, a process-wide LRU — declare exactly one
provider extending DianaCacheStore and every cached method routes through
it instead:
@Component()
class RedisCacheStore extends DianaCacheStore {
RedisCacheStore(this._client);
final RedisClient _client;
@override
Future<V> run<V>(
CacheSpec spec, List<Object?> key, Future<V> Function() miss) async {
final cacheKey = '${spec.method}:${key.join('|')}'; // your key policy
final cached = await _client.get(cacheKey);
if (cached != null) return _decode<V>(cached);
final value = await miss(); // a throw is never cached
await _client.setex(cacheKey, spec.ttl.inSeconds, _encode(value));
return value;
}
}
The contract is wrap-style: the store owns the whole policy — lookup,
populate, single-flight, TTL and eviction. Diana hands it a CacheSpec
(method for namespacing, plus the annotation's ttl/maxEntries), the
argument list as key, and a miss thunk that computes the value. What
the store must own:
- Key stringification —
keyis the raw argument list; turn it into a cache key however your backend needs. - Serialization — a distributed store round-trips values itself
(
jsonEncode/jsonDecode). Note the caveat: a JSON round-trip returns plain maps/lists, so if the method returns a DTO you must rebuild it (Dto.fromJson(...)) on the way out — the return type is stillV. - Never cache errors — run
missand let a throw propagate uncached, as the built-in default does.
InMemoryCacheStore is exported so a custom store can delegate to it
(logging, metrics, a local-then-remote tier). A DianaCacheStore provider
must be a singleton; declaring two is a build error. Without any store the
built-in per-proxy cache is used, byte-for-byte as before.
@Retryable¶
Retries the method when it throws: maxAttempts total attempts (default
3), a fixed backoffMs delay between them (default 200), and an optional
retryOn: [SomeException, ...] allowlist (subtypes match; empty retries on
anything). The last error is rethrown untouched when attempts run out.
Combined with @Transactional, each attempt gets a fresh transaction
— a failed attempt rolls back cleanly before the next try.
@CircuitBreaker¶
After failureThreshold consecutive failed invocations (default 5) the
circuit opens: calls throw CircuitOpenException immediately without
invoking the method. After resetTimeoutMs (default 30000) one
half-open trial call is allowed — success closes the circuit, failure
re-opens it. State is per method, per isolate.
CircuitOpenException surfaces as an opaque 500 by default; translate it
(e.g. into a 503) with a @ControllerAdvice:
@ControllerAdvice()
class Failures {
@ExceptionHandler(CircuitOpenException)
DianaResponse open(DianaRequest req, CircuitOpenException e) =>
const DianaResponse.json(
{'error': 'unavailable'}, statusCode: 503);
}
How it compiles¶
One proxy subclass per class covers all its aspected methods. Classes
using only @Transactional keep the $Tx$X proxy; any new aspect
switches to $Aspect$X, with per-method state fields (MethodCache,
RetryPolicy, CircuitBreakerPolicy from package:diana) and one-liner
overrides — see generated code. Apps
using no aspects emit byte-identical output.