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 theBuildtarget, 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
NEOS000xfinding there and don't see one. NEOS0001-NEOS0003also run outside any build, on transpiled UI C# specifically: live in the Neos Studio code editor, and whileneos generatetranspiles 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 |
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, aDomain-layer project referencing another module'sApplication-layer type).NEOS0003(Accessibility) — the symbol's module is a declared, reachable dependency, but the symbol itself isInternaland 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 missingModuleAssociationin the module'sDependenciestab.NEOS0002→ reference the symbol from a layer that can actually see it, or reconsider why a layer boundary is being crossed.NEOS0003→ either addInternalAccessGrantedToon the target element's module for the accessing module (see Module accessibility), or set the target element back toPublicif 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:
- 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.
- Decouple a bidirectional coupling with a neutral DTO. When module
Aand moduleBboth 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. - Expose an extension point on the base module instead of reaching into the dependent one. When a base module
Coreneeds behavior that only a dependent moduleExtensioncan provide, letExtensioncontribute toCore— not the other way around:- For an event rule (
Initialized,Navigating, ...), use theParentModulepattern:Extensiondeclares its own rule onCore's UI view and callsParentModule.On...(...)to preserveCore's own logic, instead ofCorereferencing anything fromExtension. - For a computed value or a continuously re-evaluated method, have
Extensionregister a delegate (Func<...>/Action<...>) into a registry field owned byCore, once, fromExtension's ownParentModule.OnInitializedrule.Coreinvokes the delegate without ever knowingExtension's concrete type.
- For an event rule (
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
.editorconfigorGlobalSuppressions.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 shortcutNEOS0005flags 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 noUIResourcesentry to use instead, and no more-canonical alternative to switch to.
See also
- Module accessibility — the
Accessibilitymetadata property and howOrigin/AllowedOriginsare generated. - Generation message level configuration — the equivalent checks for UI templates (UI template references) and free-text
.NET typemetadata properties (.NET type references across modules), run byneos generaterather thancheck-metadata.