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.