Skip to content

Policy Authoring Guide

This guide describes how AI agents should author ArchLinterNet policies safely.

Start With Layers

Define layers from real namespace prefixes:

layers:
  application:
    namespace: MyCompany.Product.Application
  domain:
    namespace: MyCompany.Product.Domain

Layer namespaces are prefix matches. namespace_suffix is available for conventions such as *.Contracts.

Layer definitions also support a constrained * wildcard when it occupies a whole namespace segment:

layers:
  feature_modules:
    namespace: MyCompany.Product.Features.*

  feature_contracts:
    namespace: MyCompany.Product.Features.*
    namespace_suffix: Contracts

Use this only when you need one layer to cover repeated sibling namespaces.

Rules:

  • * matches exactly one namespace segment.
  • Descendants under the resolved prefix still match.
  • With namespace_suffix, the suffix is position-fixed immediately after the full resolved namespace pattern.
  • * must be a full segment. Do not author Feature*, *Feature, or F*eature.
  • Do not author **, ?, character classes, or regex.

Examples:

  • MyCompany.Product.Features.* matches MyCompany.Product.Features.Audio and MyCompany.Product.Features.Audio.Player.
  • namespace: MyCompany.Product.Features.* with namespace_suffix: Contracts matches MyCompany.Product.Features.Audio.Contracts and MyCompany.Product.Features.Audio.Contracts.Dto.
  • That same pattern does not match MyCompany.Product.Features.Audio.Internal.Contracts.

Prefer narrow layers before broad aggregate layers. If a repository has modules such as Sales, Billing, and Inventory, model those modules directly before adding a broad application layer that hides cross-module coupling. Use glob layers as aggregate views, not as a replacement for the concrete layers you need for specific contracts and diagnostics.

Choose Strict Or Audit

Use strict rules for current gates. Add an id for stable CLI and CI references:

contracts:
  strict:
    - id: domain-not-infrastructure
      name: domain-must-not-depend-on-infrastructure
      source: domain
      forbidden: [infrastructure]
      reason: Domain code must remain independent of infrastructure.

Use audit rules for migration discovery and future-state boundaries:

contracts:
  audit:
    - id: audit-ui-to-domain
      name: audit-ui-bypassing-application
      source: ui
      forbidden: [domain]
      reason: Discover UI code that bypasses application use cases before making this strict.

When id is omitted it is derived automatically from name (lowercased with hyphens). Explicit id values are recommended for stable references in CI and AI-agent workflows.

Do not put known-failing future-state rules in strict unless the team explicitly wants a blocking gate.

Use External Dependencies For Vendor Or Framework Leakage

When the target is not a first-party layer but a vendor/framework surface such as Unity, EF Core, or a cloud SDK, model it with external_dependencies and strict_external / audit_external instead of inventing pseudo-layers:

external_dependencies:
  unity_runtime:
    namespace_prefixes:
      - UnityEngine
    type_prefixes: []

contracts:
  strict_external:
    - id: core-no-unity
      name: core-must-not-reference-unity
      source: core
      forbidden: [unity_runtime]
      reason: Pure core must not expose Unity runtime types.

Use external: true on a layer only when you intentionally want layer-style semantics with missing-type suppression. For new vendor/framework controls, prefer external_dependencies.

External dependency contracts detect forbidden references through type-level metadata (base types, interfaces, fields, properties, method signatures, generic arguments) and method-body IL scanning (method calls, constructor calls, field/property access, type references inside method bodies). They do not analyze third-party package internals. This is static reference analysis, not semantic data-flow or runtime validation.

Use External Allow-Only For A Small, Known Vendor Surface

When a layer should only ever use one or a few approved vendor/framework dependency groups instead of an ever-growing forbidden list, use strict_external_allow_only / audit_external_allow_only:

external_dependencies:
  approved_sdk:
    namespace_prefixes:
      - MyApp.Vendor.ApprovedSdk
  legacy_sdk:
    namespace_prefixes:
      - MyApp.Vendor.LegacySdk

contracts:
  strict_external_allow_only:
    - id: adapter-approved-sdk-only
      name: adapter-may-only-reference-approved-sdk
      source: infrastructure_adapter
      allowed: [approved_sdk]
      reason: This adapter may only use the approved SDK, not any other declared vendor dependency group.

Only groups declared in external_dependencies are ever evaluated — a group not present in allowed is disallowed, but a reference that matches no declared group at all (including BCL/system references, unless a policy author explicitly declares a matching group) is never a violation. A misspelled allowed entry has no relaxing effect: it can only make the contract more restrictive than intended, never less. Detection is type-level only (no method-body IL scanning yet, unlike strict_external). See External allow-only contracts for full semantics.

Use Type Placement For Where A Role Lives And How It's Named

When an architectural role (a controller, a handler, a domain event, a Unity MonoBehaviour) must live in a specific layer/namespace/project/assembly and/or carry a specific naming convention, use strict_type_placement / audit_type_placement instead of trying to express this with dependency or allow-only contracts:

layers:
  api:
    namespace: MyApp.Api

contracts:
  strict_type_placement:
    - id: controllers-in-api
      name: controllers-must-live-in-api-layer
      types_matching:
        name_suffix: Controller
      must_reside_in_layers: [api]
      required_name_suffix: Controller
      reason: Controller types are API boundary types and must be named and placed consistently.

types_matching selects candidate types using only name_suffix, name_prefix, namespace, layer, base_type, implements_interface, and has_attribute — every populated field combines with AND, and there is no regex or expression-language selector. must_reside_in_layers, must_reside_in_namespaces, must_reside_in_projects, and must_reside_in_assemblies together form one set of allowed locations (a match against any one of them satisfies placement). required_name_suffix, required_name_prefix, forbidden_name_suffix, and forbidden_name_prefix check the type's simple name.

A contract must declare at least one placement or naming expectation — declaring only types_matching with no expectation fails policy loading with an actionable error, since such a rule could never produce a violation.

must_reside_in_projects resolves to assembly-name matching via project discovery; it is not physical .csproj-membership tracking, since there is no type-to-project mapping in this tool beyond a project's own assembly name. See Type placement contracts for full semantics.

Use Public API Surface For A Library's Exported Boundary

When a library assembly's exported (public/protected/protected internal) API should be intentional and reviewed before every release, use strict_public_api_surface / audit_public_api_surface instead of trusting default visibility everywhere:

contracts:
  strict_public_api_surface:
    - id: core-public-api
      name: core-public-api-declared
      assemblies: [MyApp.Core]
      declared_api:
        - "class MyApp.Core.Foo"
        - "ctor MyApp.Core.Foo()"
        - "method MyApp.Core.Foo.Bar(System.Int32): System.Void"
      forbid_public_constants_unless_declared: true
      allowed_public_constants: []
      reason: Track Core's exact exported surface before every NuGet release.

assemblies must be non-empty — a contract with nothing to scan fails policy loading with an actionable error. declared_api entries are normalized signature strings (<kind> <FullyQualifiedName>[(<param types>)][: <member type>], e.g. class/ctor/method/property/field/const/event); CLR full type names are used throughout (e.g. System.Int32, not int), and generic type/method parameters are rendered positionally (!N/!!N) so renaming a generic parameter alone never changes a declared signature. protected/protected internal members are treated as exported by default, same as public.

forbid_public_constants_unless_declared is an independent, stricter check — an exported const field can still be a violation even when its full signature is already in declared_api, unless its fully-qualified member name is also present in allowed_public_constants. This only detects undeclared exported surface; it does not detect removed or changed declared signatures and is not a substitute for binary/package compatibility validation. See Public API surface contracts for full semantics.

Use Attribute Usage For Where A Marker Is Allowed To Appear

When a specific attribute type (an ASP.NET routing attribute, a Unity serialization attribute, a custom marker) should only appear in — or must never appear in — a specific layer/namespace/project/assembly, use strict_attribute_usage / audit_attribute_usage:

contracts:
  strict_attribute_usage:
    - id: aspnet-attributes-api-only
      name: aspnet-attributes-must-stay-in-api-layer
      attributes:
        - Microsoft.AspNetCore.Mvc.ApiControllerAttribute
        - Microsoft.AspNetCore.Mvc.RouteAttribute
      allowed_only_in_layers: [api]
      reason: ASP.NET attributes define API boundary concerns.

attributes matches an attribute type's fully-qualified name exactly; attribute_prefixes matches by prefix. A contract must declare at least one of these — a contract with neither fails policy loading with an actionable error. Every declared member (constructor, method, property, field, event) and the type itself are scanned regardless of visibility — unlike public API surface, a private field carrying [SerializeField] is still in scope.

allowed_only_in_layers, allowed_only_in_namespaces, allowed_only_in_projects, and allowed_only_in_assemblies together form an allow-list (a misplaced violation if none match). forbidden_in_layers, forbidden_in_namespaces, forbidden_in_projects, and forbidden_in_assemblies together form a deny-list (a forbidden violation if any match). A contract must declare at least one allow-list or deny-list entry — declaring only an attribute selector with no location expectation fails policy loading with an actionable error. If a single matched attribute usage fails both checks, only one violation is reported, described as forbidden.

This is static marker placement validation only — it does not validate attribute constructor arguments/named properties (no authorization/security correctness checking), and it does not detect the absence of a required marker (e.g. "every controller action must carry [Authorize] or [AllowAnonymous]"); required-marker enforcement is deferred to a documented follow-up. See Attribute usage contracts for full semantics.

Use Project Metadata Contracts For .csproj And Friend-Assembly Governance

When architecture policy must govern package-facing .csproj metadata, friend assembly declarations, or production-to-test project references, use strict_project_metadata / audit_project_metadata:

analysis:
  solution: ArchLinterNet.slnx

contracts:
  strict_project_metadata:
    - id: core-project-governance
      name: core-project-governance
      projects:
        - src/ArchLinterNet.Core/ArchLinterNet.Core.csproj
      required_properties:
        Nullable: enable
        TreatWarningsAsErrors: true
      allowed_friend_assemblies:
        - ArchLinterNet.Core.Tests
      forbidden_project_references:
        - tests/**/*.csproj
      reason: Core must keep package defaults, expose internals only to approved assemblies, and avoid test-project references.

projects matches discovered .csproj paths, so this family requires analysis.solution or analysis.projects to run project discovery first. required_properties and forbidden_properties compare exact scalar MSBuild property values, case-insensitively, using project-local values plus the nearest readable Directory.Build.props chain when discovery can resolve it. allowed_friend_assemblies matches exact InternalsVisibleTo names from project-file items and source-level assembly attributes. forbidden_project_references uses the same project-path glob semantics as analysis.project_include / analysis.project_exclude.

This is static project metadata analysis only — it does not run MSBuild, evaluate arbitrary targets/import graphs, or replace package validation. See Project metadata contracts.

Use Inheritance Contracts For Framework Base Type Boundaries

When types in a protected surface must not derive from a framework or boundary base class (a Unity MonoBehaviour, an EF Core DbContext, an ASP.NET controller base), use strict_inheritance / audit_inheritance:

contracts:
  strict_inheritance:
    - id: domain-no-framework-base-types
      name: domain-must-not-inherit-framework-types
      source_layers: [domain]
      forbidden_base_types:
        - UnityEngine.MonoBehaviour
        - Microsoft.EntityFrameworkCore.DbContext
      reason: Domain types must stay framework-independent.

source_layers names declared layers; source_namespaces adds namespace prefixes — at least one of the two is required. forbidden_base_types matches a base type's fully-qualified name exactly; forbidden_base_type_prefixes matches by prefix (e.g. UnityEngine.) — at least one of the two is required. Missing either selector fails policy loading with an actionable error.

The full base-class chain is walked, so inheriting through an intermediate class is still a violation. Constructed generic base types match by their generic type definition's CLR name (arity suffix, e.g. App.Repository`1). Interface implementation is not inheritance — use interface implementation contracts for that. See Inheritance contracts.

Use Interface Implementation Contracts For Port Boundaries

When implementations of an interface family must be confined to (or kept out of) a layer — application ports implemented only by adapters, infrastructure abstractions never implemented by domain types — use strict_interface_implementation / audit_interface_implementation:

contracts:
  strict_interface_implementation:
    - id: ports-implemented-only-by-adapters
      name: application-ports-implemented-only-by-adapters
      interface_prefixes: [MyApp.Application.Ports.]
      allowed_only_in_layers: [infrastructure]
      reason: Port implementations belong to the infrastructure boundary.

interfaces matches an interface's fully-qualified name exactly; interface_prefixes matches by prefix — at least one of the two is required. The location fields follow the same allow-list/deny-list semantics as attribute usage (allowed_only_in_*misplaced, forbidden_in_*forbidden, at least one required). A type matches through its full interface set, including interfaces implemented by its base classes; an interface extending a selected interface is never a violation. This is static metadata validation — it does not resolve runtime dependency-injection registrations. See Interface implementation contracts.

Use Composition Contracts For Composition-Root/Service-Locator Boundaries

When composition-root or service-locator APIs (DI registration, service resolution, container Resolve/Register) must be confined to a bootstrap boundary, use strict_composition / audit_composition. A server ASP.NET example:

contracts:
  strict_composition:
    - id: service-locator-confined-to-composition-root
      name: service-locator-confined-to-composition-root
      allowed_only_in_layers: [composition]
      forbidden_apis:
        - System.IServiceProvider.GetService
        - Microsoft.Extensions.DependencyInjection.IServiceCollection.
      reason: Service resolution and DI registration must happen only in the composition root.

A Unity/VContainer-style bootstrap example, using container-specific member names instead of BCL/ASP.NET members:

contracts:
  strict_composition:
    - id: container-confined-to-bootstrap
      name: container-confined-to-bootstrap
      allowed_only_in_namespaces: [MyGame.Bootstrap]
      forbidden_apis:
        - Resolve
        - Register
      reason: Container resolution/registration must happen only during bootstrap.

forbidden_apis uses the same call-pattern vocabulary as method-body contracts (member names, Type.Member, fully qualified members, namespace/type prefixes) — at least one entry is required. allowed_only_in_layers/ allowed_only_in_namespaces/allowed_only_in_projects/ allowed_only_in_assemblies together form the composition boundary — at least one entry across all four is required, since there is no separate forbidden_in_* deny-list (everything outside the allow-list is forbidden by definition). Every loaded type outside the boundary is scanned reflection/IL-only for forbidden calls in its methods and constructors; a type inside the boundary is never scanned. This is static call-site detection — it does not validate runtime dependency-injection resolution or prove every service is registered correctly. See Composition contracts.

Use Transitive Depth For Indirect Coupling

When a dependency should be blocked at any depth (direct or indirect), use dependency_depth: transitive. This follows the type dependency graph via BFS and reports violations with full path diagnostics:

contracts:
  strict:
    - id: cli-not-transitively-testing
      name: cli-must-not-transitively-depend-on-testing
      source: cli
      forbidden: [testing]
      dependency_depth: transitive
      reason: CLI must not have any transitive dependency path into Testing.

Transitive mode is more expensive than direct mode. Use it when auditing indirect coupling across module boundaries. The default is direct, which checks only immediate type references.

Prefer Allow-Only For Pure Layers

Use strict_allow_only for pure layers where every first-party dependency should be known:

contracts:
  strict_allow_only:
    - id: domain-pure
      name: domain-allowed-dependencies
      source: domain
      allowed: []
      reason: Domain must not depend on other first-party layers.

Allow-only contracts permit the source layer itself and the listed allowed layers. allowed_types is an exact full type-name exception list, not a glob or namespace rule.

Use Ordered Layers Carefully

Layer order contracts list layers from outermost to innermost:

contracts:
  strict_layers:
    - id: clean-layering
      name: clean-architecture-layering
      layers:
        - ui
        - infrastructure
        - application
        - domain
      reason: Dependencies must point inward toward domain.

Do not mix parent aggregate layers and child layers in one ordered contract unless each entry maps to a distinct namespace slice. Overlapping layers can make diagnostics confusing.

Use Layer Templates For Repeated Shapes

When multiple modules or features share the same internal architecture, use strict_layer_templates instead of duplicating ordered-layer contracts:

contracts:
  strict_layer_templates:
    - name: feature-clean-architecture
      containers:
        - MyApp.Features.Fishing
        - MyApp.Features.Inventory
        - MyApp.Features.Map
      layers:
        - name: Presentation
        - name: Application
          optional: true
        - name: Domain
      reason: Every feature follows the same internal dependency direction.

Each containers entry is a raw namespace prefix — layer names are resolved by prepending the container. For container MyApp.Features.Fishing, the template above produces layers [MyApp.Features.Fishing.Presentation, ...].

Optional layers (optional: true) produce no diagnostic when absent. If present, they must still obey the dependency direction.

Use audit_layer_templates for audit-mode templates. Templates coexist with direct strict_layers / audit_layers contracts.

Exhaustive container coverage

When a template declares exhaustive: true, the runner verifies that every immediate child namespace under each container that contains loaded types is mapped to a declared layer. Any unmapped sibling namespace produces a violation.

This catches new modules added under an existing container root without corresponding layer declarations — a common governance gap in growing codebases.

contracts:
  strict_layer_templates:
    - name: feature-clean-architecture
      containers:
        - MyApp.Features.Fishing
        - MyApp.Features.Inventory
        - MyApp.Features.Map
      layers:
        - name: Presentation
        - name: Application
        - name: Domain
      exhaustive: true
      reason: Every feature must declare all internal layers; new modules must not silently bypass the architecture.

When exhaustive: true, template layer names must be single namespace segments (e.g. Domain, not Domain.Models). The layer name is prepended to the container to form the full namespace, so dotted names would produce a namespace deeper than an immediate child and cannot be validated correctly.

Only namespaces that contain at least one loadable type are checked. Empty child namespaces are silently ignored.

Exhaustive works in both strict and audit modes. Use strict for blocking gates and audit for discovery. The check only runs on expanded template contracts (with a ContainerNamespace), not on direct layer contracts.

Model Modules With Independence Or Cycles

Use strict_independence when modules must not reference each other at all. Use strict_cycles when cross-references may exist but directed cycles are not allowed.

contracts:
  strict_independence:
    - id: modules-independent
      name: modules-must-be-independent
      layers: [sales, billing, inventory]
      reason: Bounded contexts communicate through explicit public contracts.

Use strict_assembly_independence when the boundary you need to enforce is a compiled .NET assembly rather than a namespace/layer — for example, feature assemblies or plugin packages whose ownership doesn't map cleanly onto namespace prefixes. Every assembly listed must also appear in analysis.target_assemblies. Detection is direct-reference-only.

contracts:
  strict_assembly_independence:
    - id: feature-assemblies-independent
      name: feature-assemblies-must-remain-independent
      assemblies: [MyApp.Features.Billing, MyApp.Features.Shipping]
      reason: Feature assemblies must not directly reference each other.

This is a different mechanism from strict_independence (namespace-based) and from Unity strict_asmdef/audit_asmdef (Unity .asmdef manifest checks) — see Assembly independence contracts for the distinction.

Use strict_assembly_dependency when the boundary you need is directional and assembly-scoped — for example, MyApp.Domain must never reference MyApp.Infrastructure. Use strict_assembly_allow_only when a source assembly should only reference an explicit allow-list of other declared assemblies — for example, an application assembly that may depend on abstractions but not concrete adapters. Both are direct-reference-only, and every assembly name referenced (source, forbidden, allowed) must appear in analysis.target_assemblies. Both accept an optional dependency_depth field that only supports direct (the default) in this release — do not author dependency_depth: transitive for these two families; it fails policy loading with an actionable error rather than being silently ignored.

contracts:
  strict_assembly_dependency:
    - id: domain-no-infrastructure
      name: domain-must-not-reference-infrastructure
      source: MyApp.Domain
      forbidden: [MyApp.Infrastructure]
      reason: Domain must stay free of infrastructure concerns.
  strict_assembly_allow_only:
    - id: application-allowed-refs
      name: application-may-only-reference-abstractions
      source: MyApp.Application
      allowed: [MyApp.Domain, MyApp.Domain.Abstractions]
      reason: Application may depend on abstractions, not concrete adapters.

These are different from strict_assembly_independence (mutual, not directional) — see Assembly dependency contracts for the distinction.

Use strict_acyclic_siblings when you want to automatically discover sibling namespaces under one or more ancestor namespaces and ensure they don't form dependency cycles. This is useful for feature-group architectures where siblings are added over time without updating policy definitions.

contracts:
  strict_acyclic_siblings:
    - id: features-acyclic
      name: feature-siblings-must-be-acyclic
      ancestors:
        - MyApp.Features
        - MyApp.Modules
      reason: New feature siblings should not introduce cycles.

Keep Ignores Narrow

ignored_violations is a frozen-debt baseline. Each entry should identify a specific source type and forbidden reference, with a reason or issue link.

ignored_violations:
  - source_type: MyCompany.Product.Application.Legacy.LegacyUseCase
    forbidden_reference: MyCompany.Product.Infrastructure.LegacyGateway
    reason: Existing migration debt tracked in #1234.

Avoid broad patterns such as source_type: "*" or forbidden_reference: "MyCompany.Product.Infrastructure.*" unless a human has explicitly accepted the debt baseline.

When analysis.unmatched_ignored_violations is enabled (default error), the linter warns about ignored_violations entries that match no current violation. Remove stale entries proactively to keep the baseline trustworthy and avoid CI failures. Use warn during migration cleanup, then switch to error.

Policy Consistency Checks

Separately from scanning code, the linter always runs a policy-consistency pass over the policy document itself, looking for internal contradictions: duplicate contract IDs (including those produced by layer-template expansion), allow-only contracts that conflict with a forbidding contract for the same layer pair, independence contracts contradicted by an explicit allowed dependency, protected-surface allowed_importers that conflict with a strict forbidding rule, overlapping internal layer definitions, and contracts that reference a structurally unreachable layer. analysis.policy_consistency (default error) controls whether these findings fail validation (error), are reported without failing (warn), or are suppressed entirely (off).

Use Automated Baselines For Existing Codebases

When adding architecture rules to an existing codebase with existing violations, use the automated baseline generation workflow instead of hand-writing ignored_violations entries:

arch-linter-net baseline generate \
  --config architecture/dependencies.arch.yml \
  --output baseline.yml \
  --reason "Initial baseline"

Then validate with the baseline:

arch-linter-net --policy architecture/dependencies.arch.yml \
  --baseline baseline.yml --mode strict

The baseline file is a separate YAML file that is merged into the policy's ignores at runtime. This keeps the policy file clean and makes the baseline lifecycle explicit — entries are added by the generator and removed as violations are fixed. See Migration Baselines for the full lifecycle.

Validate Before PR

Run strict validation for current gates and audit validation for migration visibility:

arch-linter-net --policy architecture/dependencies.arch.yml --mode strict
arch-linter-net --policy architecture/dependencies.arch.yml --mode audit

When authoring with AI, also validate the YAML shape against schema/dependencies.arch.schema.json because the current runtime loader ignores unsupported fields.