# Architectural Constraints

Architectural constraints are the second pillar of harness engineering. They are automated, enforceable rules that prevent AI agents from producing structurally incorrect code -- regardless of what the model generates.

Context tells the agent what to do. Constraints ensure it actually does it.

> "Constraints are the bones of good architecture. Without them, everything is soft tissue."

---

## Why Constraints Matter More with AI Agents

Human developers internalize architectural rules over months of working in a codebase. They develop intuition about boundaries, patterns, and conventions. AI agents do not. Every session starts fresh with no accumulated intuition.

This creates two risks:

1. **Boundary violations**: The agent takes a shortcut that crosses a module boundary because it does not "feel" the boundary the way a veteran developer does.
2. **Pattern drift**: The agent produces code that works but uses a different pattern than existing code, creating inconsistency that compounds over time.

Constraints eliminate both risks by making violations mechanically impossible to merge.

---

## Custom Linters with Remediation Instructions

Standard linters catch syntax and style issues. Custom linters catch *your* architectural rules -- the ones specific to your codebase.

### The Key Ingredient: Remediation Instructions

A linter rule without remediation is useless to an agent. The agent sees "error" but does not know how to fix it. A linter rule with remediation is an automated teacher.

```
# Bad: no remediation
error: Invalid import in service layer

# Good: actionable remediation
PRJ001 Application layer must not depend on Infrastructure
  --> src/Project.Application/Orders/PlaceOrderHandler.cs(12,9)
  |
12| using Project.Infrastructure.Stripe;
  |       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = help: Application code must not depend on Infrastructure types directly.
          Define an interface in Project.Application (e.g. IPaymentGateway)
          and let Project.Infrastructure provide the implementation registered
          through DI.

          Instead of:
            var charge = new StripeChargeService(...).Charge(amount);

          Do:
            var charge = await _paymentGateway.ChargeAsync(amount, ct);

          See: docs/architecture.md#layering-rules
```

The agent reads the remediation, understands the architectural rule, and fixes the violation -- all without human intervention.

### Building Custom Lint Rules

#### C# / .NET (Roslyn analyzer)

```csharp
// linters/Project.Analyzers/NoInfrastructureFromApplication/NoInfrastructureFromApplicationAnalyzer.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoInfrastructureFromApplicationAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "PRJ001";

    private static readonly DiagnosticDescriptor Rule = new(
        id: DiagnosticId,
        title: "Application layer must not depend on Infrastructure",
        messageFormat:
            "'{0}' references infrastructure type '{1}'. " +
            "Define an interface in Project.Application and inject the implementation. " +
            "See docs/architecture.md#layering-rules.",
        category: "Architecture",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterSyntaxNodeAction(AnalyzeUsing, SyntaxKind.UsingDirective);
    }

    private static void AnalyzeUsing(SyntaxNodeAnalysisContext context)
    {
        // Implementation: if the file lives under Project.Application/ and the
        // using directive references Project.Infrastructure.* (or any namespace
        // listed in .editorconfig under dotnet_diagnostic.PRJ001.*), report.
    }
}
```

Wire into every consuming `*.csproj`:

```xml
<ItemGroup>
  <ProjectReference Include="..\..\linters\Project.Analyzers\Project.Analyzers.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>
```

Configure severity in `.editorconfig`:

```ini
[*.cs]
dotnet_diagnostic.PRJ001.severity = error
```

#### TypeScript (using ESLint custom rules)

```javascript
// eslint-rules/no-cross-layer-imports.js
module.exports = {
  meta: {
    type: "problem",
    docs: {
      description: "Prevent imports that cross architectural layer boundaries",
    },
    messages: {
      layerViolation:
        "{{ currentLayer }} must not import from {{ importedLayer }}. " +
        "See docs/architecture.md#layering-rules for allowed dependencies.",
    },
  },
  create(context) {
    const filename = context.getFilename();
    const currentLayer = getLayer(filename);

    return {
      ImportDeclaration(node) {
        const importedLayer = getLayer(node.source.value);
        if (!isAllowedDependency(currentLayer, importedLayer)) {
          context.report({
            node,
            messageId: "layerViolation",
            data: { currentLayer, importedLayer },
          });
        }
      },
    };
  },
};
```

#### PowerShell (PSScriptAnalyzer custom rule)

```powershell
# linters/PSRules/Measure-NoUnscopedCmdlet.psm1
function Measure-NoUnscopedCmdlet {
    [CmdletBinding()]
    [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
    param ([System.Management.Automation.Language.ScriptBlockAst]$ScriptBlockAst)

    # Flag any Set-* / Remove-* call that omits -WhatIf / -Confirm wiring.
    # Emit a diagnostic with: rule ID, file/line, why it matters, and the fix.
}

Export-ModuleMember -Function Measure-NoUnscopedCmdlet
```

---

## Structural Tests (NetArchTest / xUnit)

Structural tests verify properties of your codebase *structure*, not its runtime behavior. They answer questions like:

- "Do all handlers implement the expected interface?"
- "Are there any circular dependencies between projects?"
- "Does every API endpoint have a corresponding test class?"
- "Are all DbContext access points scoped to the application layer?"

### The Power of Structural Tests

Structural tests are cheap, fast, and deterministic. They run in milliseconds, never flake, and catch violations that no unit test would find.

In the .NET world, the standard libraries are
[NetArchTest.Rules](https://github.com/BenMorris/NetArchTest) and
[ArchUnitNET](https://github.com/TNG/ArchUnitNET). Both run as ordinary
xUnit / NUnit / MSTest tests against your compiled assemblies.

### Examples

#### Test: Domain has no outbound dependencies

```csharp
// tests/Project.ArchitectureTests/DomainHasNoOutboundDependenciesTests.cs
using NetArchTest.Rules;
using Xunit;

public class DomainHasNoOutboundDependenciesTests
{
    [Fact]
    public void Domain_does_not_depend_on_other_layers()
    {
        var result = Types.InAssembly(typeof(Project.Domain.AssemblyMarker).Assembly)
            .Should()
            .NotHaveDependencyOnAny(
                "Project.Application",
                "Project.Infrastructure",
                "Project.Web",
                "Microsoft.EntityFrameworkCore",
                "Microsoft.AspNetCore")
            .GetResult();

        Assert.True(
            result.IsSuccessful,
            $"Domain references forbidden assemblies: " +
            $"{string.Join(\", \", result.FailingTypeNames ?? new List<string>())}. " +
            $"Move side-effecting code into Project.Application or " +
            $"Project.Infrastructure. See docs/architecture.md#dependency-rules.");
    }
}
```

#### Test: Every controller has a corresponding test class

```csharp
// tests/Project.ArchitectureTests/ControllerCoverageTests.cs
using System.Linq;
using System.Reflection;
using Xunit;

public class ControllerCoverageTests
{
    [Fact]
    public void Every_controller_has_a_test_class()
    {
        var controllers = typeof(Project.Web.AssemblyMarker).Assembly
            .GetTypes()
            .Where(t => t.Name.EndsWith("Controller", StringComparison.Ordinal))
            .ToList();

        var testTypes = typeof(ControllerCoverageTests).Assembly
            .GetTypes()
            .Select(t => t.Name)
            .ToHashSet();

        var missing = controllers
            .Where(c => !testTypes.Contains($"{c.Name}Tests"))
            .Select(c => c.FullName)
            .ToList();

        Assert.True(
            missing.Count == 0,
            $"Missing test classes: {string.Join(\", \", missing)}. " +
            $"Create a *Tests class following the pattern in InvoicesControllerTests.cs.");
    }
}
```

#### Test: Controllers stay thin

```csharp
// tests/Project.ArchitectureTests/ControllerSizeTests.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Xunit;

public class ControllerSizeTests
{
    private const int MaxLines = 30;

    [Theory]
    [MemberData(nameof(ActionMethods))]
    public void Action_method_is_under_thirty_lines(
        string controller, string action, int lineCount)
    {
        Assert.True(
            lineCount <= MaxLines,
            $"{controller}.{action} is {lineCount} lines (max {MaxLines}). " +
            $"Move logic into a MediatR handler in src/Project.Application/. " +
            $"Controllers should: parse request, send command/query, return result.");
    }

    public static IEnumerable<object[]> ActionMethods()
    {
        // Walk *.cs files under src/Project.Web/Controllers/, parse with Roslyn,
        // and yield (controller, action, lineCount) tuples.
        yield break;
    }
}
```

---

## The Layering Model

A layering model defines which projects can depend on which other projects. It is the single most impactful architectural constraint you can enforce.

### The Standard Six-Layer Model (Onion / Clean Architecture)

```
Layer 6: Web / Host        (depends on: Composition Root, Application, Infrastructure)
Layer 5: Composition Root  (depends on: Application, Infrastructure)
Layer 4: Application       (depends on: Domain, Contracts)
Layer 3: Infrastructure    (depends on: Application, Domain, Contracts)
Layer 2: Contracts / DTOs  (depends on: Domain primitive types)
Layer 1: Domain            (depends on: nothing)
```

**Key rules**:
- Dependencies flow inward only. Web can reference Application; Application cannot reference Web.
- Skip-layer dependencies inward are allowed (Web can reference Domain).
- Circular dependencies are never allowed.

### Mapping to a Real Project

```
src/
  Project.Domain/             # Layer 1: Entities, value objects, domain events
  Project.Contracts/          # Layer 2: Public DTOs, OpenAPI contracts
  Project.Infrastructure/     # Layer 3: EF Core, Azure SDK, external HTTP clients
  Project.Application/        # Layer 4: Use cases (MediatR handlers, validators)
  Project.Composition/        # Layer 5: DI registration, options binding
  Project.Web/                # Layer 6: ASP.NET Core endpoints, Blazor pages
```

### Enforcing the Layering Model

Encode the layer rules as a structural test:

```csharp
// tests/Project.ArchitectureTests/LayeringTests.cs
using NetArchTest.Rules;
using Xunit;

public class LayeringTests
{
    [Theory]
    [InlineData("Project.Domain",         new string[0])]
    [InlineData("Project.Contracts",      new[] { "Project.Domain" })]
    [InlineData("Project.Infrastructure", new[] { "Project.Domain", "Project.Contracts", "Project.Application" })]
    [InlineData("Project.Application",    new[] { "Project.Domain", "Project.Contracts" })]
    [InlineData("Project.Composition",    new[] { "Project.Domain", "Project.Contracts", "Project.Application", "Project.Infrastructure" })]
    [InlineData("Project.Web",            new[] { "Project.Domain", "Project.Contracts", "Project.Application", "Project.Composition" })]
    public void Layer_only_depends_on_allowed_layers(string layer, string[] allowed)
    {
        var assembly = Assembly.Load(layer);
        var allLayers = new[]
        {
            "Project.Domain", "Project.Contracts", "Project.Infrastructure",
            "Project.Application", "Project.Composition", "Project.Web",
        };
        var forbidden = allLayers.Except(allowed).Where(l => l != layer).ToArray();

        var result = Types.InAssembly(assembly)
            .Should()
            .NotHaveDependencyOnAny(forbidden)
            .GetResult();

        Assert.True(
            result.IsSuccessful,
            $"{layer} violated layering rules. Allowed: [{string.Join(\", \", allowed)}]. " +
            $"Failing types: {string.Join(\", \", result.FailingTypeNames ?? new List<string>())}. " +
            $"See docs/architecture.md#layering-model.");
    }
}
```

---

## Module Boundary Enforcement

Beyond layering, you may want to enforce boundaries between sibling modules -- modules at the same layer that should not know about each other.

### Example: Feature Modules

```
src/Project.Application/
  Payments/        # Handles payment processing
  Inventory/       # Handles stock management
  Notifications/   # Handles email / SMS / push
```

These are all at the application layer, but they should communicate through events on the domain bus, not by importing each other's handlers.

```csharp
// tests/Project.ArchitectureTests/ModuleBoundariesTests.cs
using NetArchTest.Rules;
using Xunit;

public class ModuleBoundariesTests
{
    [Theory]
    [InlineData("Project.Application.Payments",      new[] { "Project.Application.Inventory", "Project.Application.Notifications" })]
    [InlineData("Project.Application.Inventory",     new[] { "Project.Application.Payments", "Project.Application.Notifications" })]
    [InlineData("Project.Application.Notifications", new[] { "Project.Application.Payments", "Project.Application.Inventory" })]
    public void Module_does_not_import_from_sibling(string module, string[] siblings)
    {
        var result = Types.InCurrentDomain()
            .That().ResideInNamespace(module)
            .Should()
            .NotHaveDependencyOnAny(siblings)
            .GetResult();

        Assert.True(
            result.IsSuccessful,
            $"{module} imports from a sibling. Use a domain event or an interface " +
            $"in Project.Application.Abstractions instead. " +
            $"See docs/architecture.md#module-boundaries.");
    }
}
```

---

## Pre-Commit Hooks

Pre-commit hooks run constraints *before* code leaves the developer's machine (or the agent's session). They provide the fastest feedback loop.

### Setup with pre-commit framework

```yaml
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: dotnet-format
        name: dotnet format (whitespace + style)
        entry: dotnet format --verify-no-changes --severity error
        language: system
        pass_filenames: false

      - id: dotnet-build
        name: dotnet build (warnings as errors)
        entry: dotnet build -warnaserror -nologo -clp:NoSummary
        language: system
        pass_filenames: false

      - id: architecture-tests
        name: NetArchTest architecture tests
        entry: dotnet test tests/Project.ArchitectureTests --nologo --no-build
        language: system
        pass_filenames: false

      - id: psscriptanalyzer
        name: PSScriptAnalyzer
        entry: pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings PSScriptAnalyzerSettings.psd1 -EnableExit"
        language: system
        pass_filenames: false
```

### What to Run in Pre-Commit vs CI

| Check | Pre-Commit | CI | Rationale |
|-------|-----------|-----|-----------|
| `dotnet format --verify-no-changes` | Yes | Yes | Fast, catches style issues |
| `dotnet build -warnaserror` | Yes | Yes | Type-checks the whole solution |
| Architecture tests (`Project.ArchitectureTests`) | Yes | Yes | Sub-second, catches boundary violations |
| Unit tests (`Project.UnitTests`) | Selective | Full suite | Full suite may be slow |
| Integration tests (`Project.IntegrationTests`) | No | Yes | Needs Testcontainers / Azure SQL emulator |
| Security scanning (`dotnet list package --vulnerable`) | No | Yes | Slow, needs network |

---

## CI/CD Integration

CI is the final gate. Even if a pre-commit hook is bypassed, CI catches the violation. GitHub Actions and Azure Pipelines are both first-class for .NET.

### GitHub Actions Example

```yaml
# .github/workflows/constraints.yml
name: Architectural Constraints

on: [pull_request]

jobs:
  structural-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.0.x

      - name: Restore
        run: dotnet restore

      - name: Verify formatting
        run: dotnet format --verify-no-changes --severity error

      - name: Build (warnings as errors)
        run: dotnet build --no-restore -warnaserror

      - name: Architecture tests
        run: dotnet test tests/Project.ArchitectureTests --no-build --logger "trx;LogFileName=arch.trx"

      - name: Vulnerable packages
        run: dotnet list package --vulnerable --include-transitive
```

### Azure Pipelines Example

```yaml
# azure-pipelines.yml
trigger: none
pr: ['main']

pool: { vmImage: ubuntu-latest }

steps:
  - task: UseDotNet@2
    inputs: { version: '8.0.x' }

  - script: dotnet restore
    displayName: Restore

  - script: dotnet format --verify-no-changes --severity error
    displayName: Format check

  - script: dotnet build --no-restore -warnaserror
    displayName: Build

  - script: dotnet test tests/Project.ArchitectureTests --no-build
    displayName: Architecture tests
```

### Making CI Output Agent-Friendly

When CI fails, the output should be clear enough for an agent to fix the issue without human help. Roslyn analyzers and `dotnet test` already produce file/line/diagnostic-id formatted output -- preserve that, do not pipe through formatters that strip locations.

```csharp
// Inside structural test failure messages, always include:
// 1. What rule was violated (diagnostic ID or test name)
// 2. Where the violation occurred (file:line, or namespace.Type)
// 3. Why the rule exists
// 4. How to fix it
// 5. Where to find more information

Assert.True(condition,
    $"PRJ001 {file}({line},{col}): {ruleName}. " +
    $"REASON: {whyThisRuleExists}. " +
    $"FIX: {howToFix}. " +
    $"DOCS: {linkToDocumentation}.");
```

---

## "Boring Technology" Selection Criteria

One of the most impactful constraints is controlling which technologies agents are allowed to use.

### The Decision Framework

When evaluating whether a technology is "boring" enough:

| Criterion | Threshold | Rationale |
|-----------|----------|-----------|
| Age | 2+ years in production | Stable API, known failure modes |
| Adoption | First-class on NuGet (1M+ downloads) or part of `Microsoft.*` / `Azure.*` | Large training corpus for models |
| Documentation | Microsoft Learn or official docs + community samples | Agents can find help |
| Maintenance | Active maintainer, regular releases, supported on current LTS | Will not be abandoned |
| Compatibility | Works with current .NET LTS and target Azure region | Integration cost is low |

### Encoding Technology Choices

```markdown
# docs/technology-choices.md

## Approved Dependencies

### Runtime
- .NET 8 LTS
- C# 12 (nullable reference types ON, warnings as errors)

### Data
- Azure SQL Database (primary OLTP store)
- Azure Cache for Redis (caching only, not primary store)
- Entity Framework Core 8

### Libraries
- ASP.NET Core 8 (HTTP / minimal APIs)
- MediatR 12 (in-process messaging)
- FluentValidation 11 (input validation)
- xUnit 2 + FluentAssertions (testing)
- Serilog 4 (structured logging)
- Azure.Identity + Azure.Security.KeyVault.Secrets (secrets)

## Adding New Dependencies
1. Must meet the boring technology criteria above.
2. Must be approved in an ADR (docs/decisions/).
3. Must not duplicate functionality of an existing approved dependency.

## Explicitly Rejected
- Dapper (we standardize on EF Core; mixed ORMs split the team)
- Newtonsoft.Json in new code (use System.Text.Json)
- AutoMapper (explicit mapping is clearer for agents to read and change)
```

---

## Cross-Cutting Concerns via the Providers Pattern

Cross-cutting concerns (logging, authentication, error handling, metrics) should be implemented once and made available through interfaces that DI hands to consumers. Agents then use them consistently.

### The Problem

Without a provider pattern, agents implement cross-cutting concerns ad-hoc in every file. You end up with 15 different ways to log an error.

### The Solution

```csharp
// src/Project.Application/Abstractions/IClock.cs
public interface IClock
{
    DateTimeOffset UtcNow { get; }
}

// src/Project.Infrastructure/SystemClock.cs
public sealed class SystemClock : IClock
{
    public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}

// Composition root: services.AddSingleton<IClock, SystemClock>();
```

```markdown
# In .github/copilot-instructions.md or AGENTS.md

## Cross-Cutting Concerns
- Logging: Inject `ILogger<T>` from Microsoft.Extensions.Logging. Never use Console.WriteLine.
- Time: Inject `IClock`. Never call DateTime.Now or DateTime.UtcNow directly -- it breaks tests.
- Auth: Use `[Authorize]` + the `IAuthorizationService` for fine-grained checks. Never read `HttpContext.User` claims manually.
- Errors: Throw subclasses of `DomainException`. Never throw raw Exception.
- Metrics: Inject `IMeterFactory` (System.Diagnostics.Metrics). Do not create custom counters by hand.
- Secrets: Resolve via `IConfiguration` bound to Azure Key Vault. Never embed secrets in source.
```

Enforce with a Roslyn analyzer:

```csharp
// linters/Project.Analyzers/Forbidden/ForbiddenApisAnalyzer.cs
private static readonly Dictionary<string, string> Forbidden = new()
{
    ["System.Console.WriteLine"]    = "PRJ010 Use ILogger<T> instead of Console.WriteLine.",
    ["System.Console.Error.WriteLine"] = "PRJ010 Use ILogger<T>.LogError instead of Console.Error.",
    ["System.DateTime.Now"]         = "PRJ011 Inject IClock instead of DateTime.Now.",
    ["System.DateTime.UtcNow"]      = "PRJ011 Inject IClock instead of DateTime.UtcNow.",
    ["System.Exception..ctor"]      = "PRJ012 Throw a DomainException subclass, not raw Exception.",
};
```

---

## Building Your First Constraint

If you have zero architectural constraints today, start here:

### Step 1: Identify your most common code review comment

What do you find yourself saying repeatedly in code reviews? "Don't reference that project." "Use the application layer." "That action is too long."

### Step 2: Encode it as a structural test

Write a test that checks for the violation automatically. Use NetArchTest or ArchUnitNET, plus the examples in this guide as templates.

### Step 3: Add a clear error message

The error message must include: what rule was violated, why, and how to fix it. Include the diagnostic ID (e.g. `PRJ001`) so the agent can search the docs.

### Step 4: Add it to pre-commit and CI

Wire the test into your build pipeline so it runs automatically. `dotnet test tests/Project.ArchitectureTests` belongs in both.

### Step 5: Document it

Add the rule to your architecture docs so agents and humans can discover it. Reference the diagnostic ID from `.github/copilot-instructions.md`.

### Step 6: Repeat

Add one new constraint per week based on your most common code review comments. Within two months, you will have eliminated the majority of structural issues from agent output.

---

## Further Reading

- [What Is Harness Engineering?](what-is-harness-engineering.md) -- How constraints fit into the three pillars.
- [Context Engineering](context-engineering.md) -- The first pillar: telling agents what to do.
- [Entropy Management](entropy-management.md) -- The third pillar: maintaining constraints over time.
- [Principles: Enforce Invariants](principles.md#3-enforce-invariants-not-implementations) -- The principle behind constraint design.
