Adopt semantic annotations¶
Semantic annotations give ArchLinterNet explicit role and context facts to analyze. They do not add architecture permissions or replace YAML policy. This guide shows the optional annotation path and the existing custom-attribute path.
Release availability:
ArchLinterNet.Annotationsis prepared for stable v0.10.x releases and is omitted from v0.10 preview package sets and earlier releases. Use the package and an annotation-aware ArchLinterNet tool from the compatible v0.10.x line. Generation 1 supports package versions[0.10.0,0.11.0); v0.9.1 tools do not recognize these canonical identities. See the annotation catalog for the complete compatibility details.
Start with a few stable roles¶
The package is optional and source-only. It contributes C# source to each project that directly references it; it adds no annotation runtime DLL. Add a direct reference to every project that declares canonical annotations. Central Package Management can centralize the version, but does not replace the direct project reference.
<ItemGroup>
<PackageReference Include="ArchLinterNet.Annotations" />
</ItemGroup>
Set its version through your repository's normal central package or project version metadata, and choose a version from the compatible range in the catalog. Projects without annotations do not need the package.
Annotate only concepts that have stable meaning in your codebase. The injected attribute types are internal to the authoring project, so adding them does not make your types public or change their CLR visibility.
using ArchLinterNet.Annotations;
namespace Acme.Sales.Domain;
[AggregateRoot(BoundedContext = "Sales", Module = "Orders")]
public sealed class OrderAggregate
{
}
[ValueObject(BoundedContext = "Sales", Module = "Orders")]
public sealed record Money(decimal Amount, string Currency);
AggregateRoot and ValueObject are each a type's one primary semantic role. BoundedContext and Module add context metadata to that role. Do not add a second role annotation to satisfy a different policy rule; author a selector for the fact that expresses the actual intent.
For a modular layered project, an assembly can provide a default layer role while selected types carry their more specific primary role. These roles do not accumulate on one type: a type-level role takes precedence over an assembly-level role.
using ArchLinterNet.Annotations;
[assembly: DomainLayer]
Other types in the same assembly without a more specific role can be classified as DomainLayer; OrderAggregate remains AggregateRoot as shown above.
Inspect facts, then author policy¶
For ordinary .NET projects, make sure the policy's analysis.solution or analysis.projects includes each annotated authoring project, then opt into building and verifying that selected graph before validation. --ensure-built is explicit; validation does not silently rebuild:
arch-linter-net --policy architecture/dependencies.arch.yml --mode audit --format json --ensure-built
The JSON report includes discovered facts in classification_roles and reports catalog compatibility problems in canonical_annotation_diagnostics. If the selected graph is already restored and the build must stay offline, add --no-restore.
Unity projects have a separate build step. Compile or import scripts with Unity first; --ensure-built builds the selected .NET project graph and does not invoke Unity's compiler. Point analysis.target_assemblies and analysis.assembly_search_paths at the freshly compiled Unity assemblies before validating them. Recompile in Unity after changing annotated source.
Use the report to confirm the subject, winning role, metadata, and evidence before changing policy. A compatible annotation creates classification evidence; YAML still decides which evidence a layer, contract, or coverage rule governs.
For example, select Sales aggregates explicitly and opt into semantic-role coverage when the repository is ready to account for classified and unclassified types:
layers:
sales_aggregates:
namespace: Acme.Sales.Domain
selector:
role: AggregateRoot
metadata:
boundedContext: Sales
contracts:
strict_coverage:
- id: semantic-role-coverage
name: semantic-role-coverage
scope: semantic_role
reason: Every discovered semantic fact must be governed or explicitly excluded.
Coverage is opt-in. It reports unclassified first-party types, classified facts that no selector or implemented contextual contract governs, stale selectors, and classification conflicts separately from dependency violations. Review the findings before making a selector broader.
The command explain --source <id> --target <id> explains a dependency path under policy. Use validation's classification_roles output to inspect the discovered semantic facts; explain does not replace that inventory.
Keep custom attributes first-class¶
Use your own attributes and exact full-name YAML mappings when they express your vocabulary better, when the role is outside the bounded canonical set, when you need metadata the canonical attributes do not support, or when a repository must not reference an ArchLinterNet package.
using System;
namespace Acme.Architecture;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)]
public sealed class BusinessAggregateAttribute : Attribute
{
}
[BusinessAggregate]
public sealed class CustomerAccount
{
}
Map the user-owned identity in YAML. Assembly attributes can be mapped separately when they carry assembly-scoped role or metadata evidence:
classification:
attributes:
- attribute: Acme.Architecture.BusinessAggregateAttribute
role: AggregateRoot
metadata:
boundedContext: Sales
The custom path remains available for both classification.attributes and
classification.assembly_attributes; see metadata extraction syntax
and classification precedence
for constructor metadata and source precedence rules. Do not map names in the
reserved ArchLinterNet.Annotations namespace: canonical identities are
recognized by exact FQN and reserved identities cannot be remapped.
Migrate without changing behavior¶
There is no requirement to replace a working custom mapping. If a canonical annotation exactly matches an existing mapping's intent, migrate incrementally:
- Save the current validation report and its
classification_rolesfacts. - Add the compatible package directly to each project that will author annotations.
- Annotate a small set of types with the same primary role and intended context values.
- Run the same tool and policy again; compare the affected subjects' roles and metadata before and after.
- Preserve custom metadata intentionally. Canonical attributes support only the bounded keys listed in the catalog; other metadata can remain in a custom mapping.
- Remove a redundant mapping only after canonical classification parity is established. Keep it when it still expresses distinct intent.
Do not combine annotation migration with unrelated policy weakening, broader selectors, new ignores, or visibility changes. If the facts differ, keep the current mapping and resolve the difference separately.
Roles, context, visibility, and API membership¶
These are different facts with different owners:
| Fact | What it means | What supplies it |
|---|---|---|
| Primary semantic role | One architectural role for a type, such as ValueObject, Entity, or Controller |
Canonical annotation, custom mapping, inheritance, or another implemented classifier |
| Context metadata | Values such as domain, boundedContext, module, feature, platform, or runtime |
Role metadata properties, context-only annotations, or explicit YAML mapping |
| CLR visibility | Whether a declaration is public, internal, or otherwise visible in .NET |
C# declaration and assembly rules; annotations do not change it |
| Reviewed API membership | Whether an exported type is intentionally included in a reviewed API snapshot | The authored strict_public_api_surface or audit_public_api_surface selector and snapshot |
ApiContract is a primary role for types whose role is genuinely an API contract. It does not select public API membership. A ValueObject remains a ValueObject when a separate, user-owned marker selects it into the reviewed snapshot:
[ValueObject(BoundedContext = "Sales")]
[PublicApiContract] // user-owned marker for the reviewed API selector
public sealed record Money(decimal Amount, string Currency);
contracts:
strict_public_api_surface:
- id: sales-public-api
name: sales-public-api-reviewed
assemblies: [Acme.Sales.Contracts]
surface_selector:
has_attribute: Acme.Sales.Architecture.PublicApiContractAttribute
api_snapshot: architecture/api/sales-public-api.txt
api_comparison: exact
reason: Only deliberately marked exported types belong to this reviewed surface.
The marker controls membership only; it does not overwrite the type's role. CLR public visibility alone also does not mean that a type is part of a reviewed snapshot. See public API surface contracts for selector and snapshot behavior.
Context metadata conflicts¶
Role metadata and context-only attributes can both supply the same context key. If they disagree at the same scope, ArchLinterNet reports a deterministic metadata conflict and omits that key while preserving the otherwise valid primary role:
[AggregateRoot(BoundedContext = "Sales")]
[BoundedContext("Inventory")]
public sealed class OrderAggregate
{
}
Choose one value for the type, or place a deliberate default on the assembly and let a type-level value override it. Equal values coalesce. Different values at the same scope are not resolved by source order.
Representative role families¶
The same single-role and evidence-only rules apply across the catalog:
using ArchLinterNet.Annotations;
// CQRS and API
[Command(BoundedContext = "Sales", Feature = "Checkout")]
public sealed record PlaceOrder(string OrderId);
[CommandHandler(BoundedContext = "Sales", Feature = "Checkout")]
public sealed class PlaceOrderHandler
{
}
[Controller(BoundedContext = "Sales")]
public sealed class OrdersController
{
}
// Infrastructure adapter: one role describes the adapter's primary role.
[Adapter(Platform = "PostgreSQL", BoundedContext = "Sales")]
public sealed class PostgresOrderStore
{
}
// Unity/client: labels static architectural intent and does not prove runtime behavior.
[MonoBehaviourAdapter(Platform = "Unity", Runtime = "player")]
public sealed class InventoryViewBridge
{
}
Layer roles such as DomainLayer and ApplicationLayer are assembly-targeted; CompositionRoot supports types and assemblies. Unity assembly roles such as UnityRuntime and UnityEditor are also assembly-targeted. For Unity source-import workflows, the annotation source supports C# 9.0; projects that cannot consume NuGet content files can import all reviewed .cs files from the package's contentFiles/cs/any/ folder into each authoring assembly. This does not guarantee that every Unity project supports NuGet contentFiles installation. The annotations do not inspect framework inheritance, dependency injection, serialized scenes, or runtime behavior.
See the complete canonical annotation catalog for every identity, target, metadata key, and compatibility detail.