Durable Entity Interface Restrictions
ID |
csharp.durable_entity_interface_restrictions |
Severity |
critical |
Remediation Complexity |
medium |
Remediation Risk |
low |
Remediation Effort |
medium |
Resource |
Code Smell |
Language |
CSharp |
Tags |
api-design, azure, code_smell |
Description
Reports an Azure Durable Entity interface that breaks one of the restrictions the generated proxy
imposes. Such an interface must declare methods only, take no type parameters, give every method at
most one parameter, and return void, Task or Task<T> from each of them.
Only an interface this file gives real evidence about is examined. Two signals count: the interface
is named as the type argument of a Durable Entities proxy API (SignalEntityAsync<IFoo>,
SignalEntity<IFoo>, CreateEntityProxy<IFoo>), or a class in the same file lists the interface in
its base list and is itself dispatched as an entity — a method taking an IDurableEntityContext, or
a parameter marked [EntityTrigger]. An interface with neither signal is left alone.
The framework’s further requirement that the interface live in the same assembly as its client is not detected, since a single-file analysis cannot see assembly boundaries.
Rationale
An entity is never called directly. The framework generates a proxy implementation of the interface at run time, turns each call into one serialised operation message carrying one serialised input, and delivers it to the entity. Every restriction follows from that shape: an operation message has no room for a second argument, a property getter has no operation to map onto, an event has nobody to raise it, and a result must arrive asynchronously because the entity may not be running yet.
None of this is checked by the compiler. It is validated when the proxy is built, so the first
symptom is an InvalidOperationException the moment the entity is signalled — from a code path unit
tests typically bypass, because they call the implementation class directly and never go through the
proxy at all.
public interface ICounter
{
void Add(int amount); // OK
Task Reset(); // OK
Task<int> Get(); // OK
int Snapshot { get; set; } // FLAW - a property has no operation to map onto
Task Configure(int min, int max); // FLAW - one operation carries one input
int Peek(); // FLAW - the result must be awaitable
}
public class Counter : ICounter
{
[FunctionName(nameof(Counter))]
public static Task Run([EntityTrigger] IDurableEntityContext context)
=> context.DispatchAsync<Counter>();
// ...
}
Remediation
Replace a property with a pair of operations — a Task<T> GetX() and a Task SetX(T value) — which
is what the proxy can actually dispatch, and drop events entirely: an entity notifies the outside
world by signalling another entity or starting an orchestration, not by raising an event on an
interface nobody holds a reference to.
Where an operation needs more than one value, wrap them in a single serialisable argument type; that
is also what makes the operation’s payload versionable as the entity evolves. Make every operation
that produces a result return Task<T>, and take the type parameters off the interface — the entity
name is a string chosen at run time, so a generic entity interface has no way to be instantiated by
the framework.