Table of Contents

Analyzers

The GroupeIsa.Neos.CodeAnalysis.Analyzers Roslyn analyzer package ships with every generated business project. RunAnalyzersDuringBuild is deliberately left disabled there, so a plain dotnet build does not run them — but that is not the only place they run:

  • Locally, in Visual Studio, the analyzers run live in the editor regardless of RunAnalyzersDuringBuild (that MSBuild property only gates the Build target, not the IDE's own background analysis) — a developer sees the squiggle as soon as the offending code is written.
  • In CI, through Sonar, when the pipeline's Sonar configuration is set up to pick up the analyzer package — check your pipeline's Sonar step if you expect a NEOS000x finding there and don't see one.
  • NEOS0001-NEOS0003 also run outside any build, on transpiled UI C# specifically: live in the Neos Studio code editor, and while neos generate transpiles that code — never on handwritten server C#, which only ever sees these three through Visual Studio/Sonar.

You can also force a one-off local check without touching pipeline configuration:

dotnet build -p:RunAnalyzersDuringBuild=true -warnaserror --no-incremental

Force the analyzers on for the affected projects, confirm no unexpected NEOS000x remains, then rebuild normally.

Diagnostic Analyzer Category Reports
NEOS0001 OriginAccessAnalyzer Module the accessed symbol's module is not a declared dependency of the accessing project
NEOS0002 OriginAccessAnalyzer Layer the accessed symbol's layer (Domain/Application) is not reachable from the accessing project
NEOS0003 OriginAccessAnalyzer Accessibility the accessed symbol is Internal and the accessing module is not listed in its declaring module's InternalAccessGrantedTo
NEOS0004 MustUseReturnValueAnalyzer Usage a call to a [MustUseReturnValue]-marked method has its return value discarded
NEOS0005 NonCanonicalResourceAccessAnalyzer Resources a string resource is accessed through the cluster's default Resources/AppResources facade instead of its owning module's explicit RootNamespace
NEOS0006 RetrieveItemsAsyncCountAnalyzer Performance IRetrievingRuleArguments.RetrieveItemsAsync is used only to read the result's Length (or a LINQ Count()), loading every matching item just to compute a total
NEOS0007 ImplicitLocalizableStringConversionAnalyzer Localization a string is implicitly converted to a LocalizableString (passed to a parameter, assigned, returned...), so the text only exists in the default language
NEOS0008 EventRuleBaseRuleAnalyzer Usage an entity or entity view event rule registered as a redefinition has no public constructor accepting its base rule, so it fails at runtime
NEOS0009 EventRuleBaseRuleAnalyzer Usage the base implementation of an entity or entity view event rule requests its own rule interface, so it fails at runtime

NEOS0008 and NEOS0009 are reported as errors, the other diagnostics as warnings.

NEOS0001 / NEOS0002 / NEOS0003 — module accessibility

These three diagnostics all stem from the same check: each generated symbol carries an [Origin("{Module}.{Layer}.{Public|Internal}")] attribute, and each generated project carries an AllowedOrigins MSBuild property. OriginAccessAnalyzer reports whenever transpiled UI code references a symbol whose Origin is not covered by AllowedOrigins — see Module accessibility for how Origin/AllowedOrigins are computed. Which of the three diagnostics fires tells you which axis failed:

  • NEOS0001 (Module) — the symbol's module was never declared as a module dependency.
  • NEOS0002 (Layer) — the module is a declared dependency, but not from a layer this project can see (for example, a Domain-layer project referencing another module's Application-layer type).
  • NEOS0003 (Accessibility) — the symbol's module is a declared, reachable dependency, but the symbol itself is Internal and its module does not grant this module access.

Resolving a standard case

Most occurrences are exactly what the analyzer is meant to catch — a real, previously invisible coupling that should be declared explicitly:

  • NEOS0001 → add the missing ModuleAssociation in the module's Dependencies tab.
  • NEOS0002 → reference the symbol from a layer that can actually see it, or reconsider why a layer boundary is being crossed.
  • NEOS0003 → either add InternalAccessGrantedTo on the target element's module for the accessing module (see Module accessibility), or set the target element back to Public if it is meant to be broadly reusable.

Example: several elements in a shared module (images, an entity) are marked Internal; the modules that legitimately depend on them are listed in that module's InternalAccessGrantedTo rather than left unresolved — see Module accessibility for a worked-out example.

In UI code specifically

Transpiled UI C# (a UIView/UIComponent rule, computed, action, or event handler written directly in metadata) is the only place where NEOS0001-NEOS0003 are checked automatically and immediately, independent of whether Visual Studio's live analysis or Sonar happen to be watching: live, as you type, in the embedded C# editor in Neos Studio; and again when neos generate transpiles that same code, if it was saved with the violation still present. Handwritten server C# gets the same three diagnostics too, but only through the general mechanisms above (Visual Studio locally, Sonar in CI) — nothing UI-specific drives it there. Because both UI-code surfaces read the same underlying Origin/AllowedOrigins metadata, resolving the violation once (declare the ModuleAssociation, grant InternalAccessGrantedTo, or suppress with a tracked justification — see below) clears it in both places.

Resolving a genuine architectural conflict

Sometimes the "add the missing ModuleAssociation" fix is itself impossible — most commonly because the target module already depends on the accessing module, so declaring the association back would create a cycle the metadata layer already forbids. In that situation, one of these three patterns resolves the coupling without a permanent suppression:

  1. Relocate the misplaced symbol (an enum, a DTO, a string resource, an icon, ...) to the module that actually needs it. Often the coupling only exists because something was defined in the wrong module to begin with; moving it removes the cross-module reference entirely.
  2. Decouple a bidirectional coupling with a neutral DTO. When module A and module B both need to exchange data and neither can depend on the other, introduce a small data-only type (in whichever module is more neutral, or a shared one) that carries the exchanged values without either side referencing the other's concrete types.
  3. Expose an extension point on the base module instead of reaching into the dependent one. When a base module Core needs behavior that only a dependent module Extension can provide, let Extension contribute to Core — not the other way around:
    • For an event rule (Initialized, Navigating, ...), use the ParentModule pattern: Extension declares its own rule on Core's UI view and calls ParentModule.On...(...) to preserve Core's own logic, instead of Core referencing anything from Extension.
    • For a computed value or a continuously re-evaluated method, have Extension register a delegate (Func<...>/Action<...>) into a registry field owned by Core, once, from Extension's own ParentModule.OnInitialized rule. Core invokes the delegate without ever knowing Extension's concrete type.

The same problem in handwritten server code

Unlike transpiled UI code, handwritten server C# has no dedicated, always-on check — NEOS0001-NEOS0003 only reach it through Visual Studio's live analysis or Sonar (see the note above), so a violation can go unnoticed until one of those actually runs on the code. The same architectural problem occurs there just as often, and the same discipline applies. Two more patterns let a dependent module extend a base module's behavior without the base module ever referencing the dependent module:

Neutral interface, defined and consumed in the base module, implemented independently by each dependent module, aggregated through DI. The base module defines the abstraction and depends on IEnumerable<TAbstraction> instead of any concrete type; each dependent module registers its own implementation in its own composition root. Example — a Catalog module needs search results contributed by every module that has something searchable, without depending on any of them:

// Defined and consumed in the base module (Catalog) — references only the abstraction:
public interface ISearchProvider
{
    void Search(string searchText, List<SearchResultGroup> results);
}

public class ProductQuickSearch
{
    private readonly IEnumerable<ISearchProvider> _searchProviders;

    public ProductQuickSearch(IEnumerable<ISearchProvider> searchProviders)
    {
        _searchProviders = searchProviders; // one instance per module that registered one
    }
}
// Registered independently in each module's own composition root — Catalog's own registration:
services.AddScoped<ISearchProvider, CatalogSearchProvider>();

// A dependent module's registration, in its own Startup.cs, unrelated to Catalog's:
services.AddScoped<ISearchProvider, InvoicingSharedSearchProvider>();

Each concrete *SearchProvider only references its own module's repositories. Catalog's project has no reference to InvoicingShared (or any other dependent module) at all — the composition root resolves the full set of registrations at runtime, and IEnumerable<ISearchProvider> collects every one of them, from whichever modules happen to be present in the cluster.

The framework's built-in code-overriding mechanism (a generated Decorator, via Scrutor's .Decorate<>()) — see Code overriding for the full mechanism. A dependent module authors a class with the same interface as a base module's event rule; the generator wires it as a decorator around the base implementation automatically, based on module dependency order — the base module's own class never references it. Example — OrderProcessing's Saving rule needs Shipping to add its own follow-up logic, without OrderProcessing knowing Shipping exists:

// Base module's own Saving rule (OrderProcessing) — knows nothing about shipping:
public class Saving : ISavingRule<IOrder>
{
    public async Task OnSavingAsync(ISavingRuleArguments<IOrder> args, CancellationToken cancellationToken)
    {
        // base-module-only logic
    }
}
// Dependent module's own Saving rule (Shipping), same interface, in its own project — decorates the base one:
public class Saving : ISavingRule<IOrder>
{
    private readonly ISavingRule<IOrder> _baseRule;
    private readonly IShipmentRepository _shipmentRepository;

    public Saving(ISavingRule<IOrder> baseRule, IShipmentRepository shipmentRepository)
    {
        _baseRule = baseRule;
        _shipmentRepository = shipmentRepository;
    }

    public async Task OnSavingAsync(ISavingRuleArguments<IOrder> args, CancellationToken cancellationToken)
    {
        await _baseRule.OnSavingAsync(args, cancellationToken); // preserve base behavior
        // then schedule the shipment — logic OrderProcessing doesn't need to know exists
    }
}

The generator produces the composition-root wiring for this automatically (one AddScoped for the base rule, one Decorate<> per module that layers its own rule on top) — the developer only writes the second class.

Choosing between the two: the decorator pattern only fits when the extension point is something that can meaningfully "call the base implementation, then do more" (a Saving/Validating rule, for instance). For a write-only operation with nothing to call into and extend, use the neutral-interface-plus-DI-aggregation pattern instead.

Suppressing as a last resort

Only once none of the patterns above apply — most often on a module that is not (yet) published or reused independently, so the real short-term risk is low — suppress the specific occurrence. A suppression is not a resolution: it silences a diagnostic that flags a genuine architectural boundary problem, never a false positive to shrug off. Treat every suppression as tracked technical debt:

  • Never suppress the whole rule (e.g. project-wide in .editorconfig or GlobalSuppressions.cs) — only the specific occurrence.
  • Always give a real justification, not a restatement of the rule text.
  • Link a work item for the real fix, and resolve it as soon as practical — a suppression is meant to be temporary.

In server code (handwritten business assemblies, where you write full member declarations), use [SuppressMessage("Module", "NEOS0001:...", Justification = "...")] on the member, or #pragma warning disable/restore around a smaller span inside it:

[SuppressMessage("Module", "NEOS0001:...", Justification = "Genuinely circular with Pricing (work item #12345); Catalog is not independently published, low short-term risk.")]
public Discount GetActiveDiscount()
{
    return Pricing.Application.DiscountService.GetActiveDiscount();
}

In transpiled UI C# (a UIView/UIComponent rule, computed, or event handler — you only write a method body there, never a member declaration, so [SuppressMessage] has nothing to attach to), #pragma warning disable/restore around the specific lines is the only option:

#pragma warning disable NEOS0001 // Genuinely circular with Pricing (work item #12345); Catalog is not independently published, low short-term risk.
var discount = Pricing.Application.DiscountService.GetActiveDiscount();
#pragma warning restore NEOS0001

NEOS0004 — discarded [MustUseReturnValue] result

Some methods, such as IUnitOfWork.SaveAsync, return a Result that must be inspected: a failed save does not throw, it returns a Result describing the failure. Discarding that return value silently swallows the failure. Such methods are marked [MustUseReturnValue], and NEOS0004 fires when a call to one is left as a bare statement (directly, or through a single await, optionally followed by .ConfigureAwait(false)).

A discarded SaveAsync() call almost always means the code was written assuming a failed save throws — which is exactly what SaveOrThrowAsync does. It is usually the simplest fix, since it returns a plain Task with nothing to discard, so NEOS0004 no longer applies:

await _unitOfWork.SaveOrThrowAsync();

If the caller genuinely needs to branch on success or failure instead of letting an exception propagate, handle the Result explicitly:

Result result = await _unitOfWork.SaveAsync();
if (result.IsFailed)
{
    // handle the failure — log it, return an error to the caller, ...
}
Warning

Never resolve NEOS0004 by making a SaveAsync call fire-and-forget (e.g. _ = _unitOfWork.SaveAsync();) in server code. The scoped DbContext behind IUnitOfWork is tied to the current request's lifetime and is not thread-safe: the request scope can be disposed before the detached save completes, and two operations racing to use the same DbContext concurrently corrupts its state. Use SaveOrThrowAsync or handle the Result, always awaited within the request's scope.

NEOS0005 — resource accessed through the cluster's default namespace shortcut

The generated Resources/AppResources facades expose every module's string resources under the generating cluster's RootNamespace, even when the owning module declares its own explicit, stable RootNamespace. That shortcut works until the module is reused in a cluster with a different RootNamespace, where it silently stops compiling — often discovered late, in an unrelated cluster's generation, far from the commit that introduced the shortcut.

NEOS0005 flags such an access and points to the fully-qualified form to use instead through a [UseInstead("...")] attribute on the shortcut property. Say the InvoicingShared module owns the resource but the generating cluster's own root namespace is Contoso.Storefront — the InvoiceTitle resource is then reachable both ways, but only one is stable across clusters:

// Before — resolves through Contoso.Storefront (the generating cluster's RootNamespace),
// not through the InvoicingShared module's own namespace; breaks if InvoicingShared is reused
// in a cluster whose RootNamespace isn't Contoso.Storefront
var title = Contoso.Storefront.Properties.Resources.Invoicing.InvoiceTitle;

// After — resolves through the InvoicingShared module's own RootNamespace, stable across clusters
var title = MyCompany.Invoicing.Properties.Resources.Invoicing.InvoiceTitle;

In practice the "Before" form is usually written unqualified, as just Resources.Invoicing.InvoiceTitle — it compiles because the code lives inside (or has a using for) the generating cluster's own root namespace, which is exactly what makes the shortcut easy to reach for without noticing it.

NEOS0005 structurally never fires on transpiled UI code at all — it has no driver there, only a real build of handwritten server C#. UI code has two implicit facades available, and the unqualified form is correct practice for both, never something to "fix":

  • UIResources — generated only from resources scoped to the frontend. Use it, unqualified, for a UI-specific resource: UIResources.Invoicing.InvoiceTitle.
  • Resources/AppResources (the same cluster-default shortcut NEOS0005 flags in server code) — covers every resource regardless of scope, and is the only way to reach a resource that isn't frontend-scoped from UI code. For that case, the unqualified form is correct there too: Resources.Invoicing.InvoiceInternalNote — there is no UIResources entry to use instead, and no more-canonical alternative to switch to.

NEOS0006 — RetrieveItemsAsync used only to count items

IRetrievingRuleArguments.RetrieveItemsAsync materializes every matching item in memory. Calling it and then reading only the result's Length (or a LINQ Count()) - directly, or through a local variable never used for anything else - loads the whole result set needlessly just to compute a total:

// Before — loads every matching item just to read its Length
int itemsCount = (await args.RetrieveItemsAsync()).Length;

Use RetrieveCountAsync instead, which issues a COUNT query without loading the items:

// After — counts without materializing the items
int itemsCount = await args.RetrieveCountAsync(cancellationToken);

When both a page of items and the total are needed together, use RetrievePagedItemsAsync in a single call instead of two separate RetrieveItemsAsync calls:

IPagedList<IOrderView> pagedOrders = await args.RetrievePagedItemsAsync(q => q.Where(o => o.IsActive), cancellationToken);
args.SetItems(pagedOrders, pagedOrders.TotalRecordCount);

NEOS0006 does not fire when the array is also used for its items (args.SetItems(items, items.Length), a foreach, ...) — RetrieveItemsAsync remains the correct call whenever the items themselves are needed, not just their count.

NEOS0007 — string implicitly converted to a LocalizableString

A LocalizableString holds a text in every language of the cluster, so that each user sees it in their own language. In server C#, NEOS0007 fires wherever a string - a literal, a variable, a resource property, a string.Format result or an interpolated string - is implicitly converted to a LocalizableString: passed to a LocalizableString parameter, assigned to a LocalizableString variable, field or property (including in an object or collection initializer), returned from a method returning a LocalizableString, used as an operand of ?? whose result is a LocalizableString, and so on. The implicit conversion only fills the cluster's default language, so users working in another language see the text untranslated, even when it comes from a translated resource.

For example, with a user notification:

// Before — each text only exists in the default language
UserNotificationOptions options = new UserNotificationOptions(
    Resources.Sales.OrderShippedTitle,
    string.Format(Resources.Sales.OrderShippedMessage, order.Number),
    "Orders");

Build the texts from a resource, which provides every language of the cluster, with one of these methods:

  • LocalizableString.FromResource or LocalizableString.FromFormattedResource, from a ResourceManager,
  • the Resources.<Module>.GetLocalizableString or Resources.<Module>.GetFormattedLocalizableString extension methods (namespace GroupeIsa.Neos.Domain), as in the example below.

See Getting a LocalizableString from a resource on server side for more details.

// After — each text carries every cluster language
UserNotificationOptions options = new UserNotificationOptions(
    Resources.Sales.GetLocalizableString(_localizationSettings, nameof(Resources.Sales.OrderShippedTitle)),
    Resources.Sales.GetFormattedLocalizableString(_localizationSettings, nameof(Resources.Sales.OrderShippedMessage), order.Number),
    "Orders");

When a text is only known in a single language (for example an exception message or a text typed by a user), call LocalizableString.FromString explicitly: the text still ends up in the default language only, but the intent is visible and NEOS0007 no longer fires.

NEOS0007 does not fire for a value already typed as LocalizableString, for a Dictionary<string, string> converted to a LocalizableString, for an explicit cast ((LocalizableString)text), for a null literal, nor for an empty string ("" or string.Empty), which reads the same in every language.

NEOS0008 / NEOS0009 — event rule base rule injection

Only one implementation of an entity or entity view event rule is its base implementation:

  • for an entity event rule, the one stored in the Domain layer assembly of the entity module,
  • for an entity view event rule, the one stored in the module of the entity view.

Every other implementation (another module, or the Application layer for an entity event rule) is registered as a redefinition of the previous one, which is passed to its constructor — see Code overriding. When the constructor does not match, the code compiles but the rule fails at runtime, only when its event is first raised. This typically happens after moving a rule from one module or layer to another.

NEOS0008 fires on a redefinition with no public constructor accepting its rule interface. At runtime, it fails with A suitable constructor for type ... could not be located:

// Before — a Saved rule moved out of the Customer entity module
public class Saved : ISavedRule<Customer>
{
    public Saved(INeosLogger<ISavedRule<Customer>> logger)
    {
        _logger = logger;
    }
}
// After — the base rule is injected, even if the rule does not call it
public class Saved : ISavedRule<Customer>
{
    public Saved(ISavedRule<Customer> baseRule, INeosLogger<ISavedRule<Customer>> logger)
    {
        _baseRule = baseRule;
        _logger = logger;
    }
}

NEOS0009 fires on the base implementation when one of its public constructors requests its own rule interface: there is no previous implementation to receive, so the rule depends on itself. At runtime, it fails with A circular dependency was detected, or, when the rule is also redefined in another module or layer, the request blocks indefinitely. Remove the parameter, and the call to the base rule with it.

The check relies on the parameter type, not its name, and one public constructor accepting the rule interface is enough. It needs the NeosModuleName and NeosBusinessAssemblyLayer MSBuild properties that neos generate writes into each business assembly's NeosReferences.*.gen.props: regenerate the cluster for the check to start. Neither diagnostic fires on a project without these properties, on an abstract class, or on a rule whose entity or entity view is not generated by Neos.

Neither diagnostic fires either on a rule written in a module that does not depend on the module of the entity or entity view: Neos only looks for event rules in that module and in the modules depending on it, so such a rule is never registered and never runs. Referencing a module that is not a declared dependency is reported by NEOS0001. Declare the module dependency if the rule is meant to run, or delete the rule otherwise.

See also