Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

The Four Eras

"Convention over Configuration was a revolution. But Convention still trusts the developer. What happens when you stop trusting — and start generating?"


The World Before Convention

In the beginning, there was code. All of it. Every wire, every registration, every mapping, every rule — written by hand, maintained by hand, broken by hand.

Then came configuration. XML files, JSON manifests, property files — a step forward because the wiring was separated from the logic. But configuration was verbose, fragile, and disconnected from the code it described. A typo in an XML attribute was a runtime failure, not a compile error.

Then came convention. Ruby on Rails popularized it. ASP.NET Core adopted it. Spring Boot embraced it. The idea was elegant: if you follow naming conventions and folder structures, the framework does the wiring for you. No XML. No explicit registration. Just name your class UserService, put it in the Services/ folder, and the framework finds it.

Convention was progress. Real progress. It eliminated thousands of lines of boilerplate configuration. But it introduced something subtle: invisible rules. Rules that exist in wiki pages, onboarding documents, architecture decision records — everywhere except in the compiler.

And invisible rules have a cost.


The Convention Tax

Convention requires two kinds of work that have nothing to do with building features:

1. Documentation

Every convention must be documented. Not in code — in prose. A wiki page explaining that "services go in the Services/ folder." An ADR explaining that "all commands must have a corresponding validator." An onboarding guide explaining that "entities are named {Name}Entity and live in Domain/Entities/."

This documentation is always out of date. Not because anyone is lazy — because documentation and code are two separate artifacts maintained by two separate processes. They drift. They always drift.

📁 wiki/
├── coding-standards.md          ← Last updated: 8 months ago
├── architecture-decisions/
│   ├── ADR-001-service-layer.md ← References folder structure that changed
│   ├── ADR-007-validation.md    ← Mentions FluentValidation; team moved to MediatR pipeline
│   └── ADR-012-ef-conventions.md ← Describes conventions that 3 new team members never read
├── onboarding/
│   └── new-developer-guide.md   ← 47 pages. Nobody finishes it.
└── code-review-checklist.md     ← 23 items. Reviewers check maybe 5.

The documentation itself becomes a burden. Keeping it accurate is a full-time job. Not keeping it accurate is a source of onboarding confusion, inconsistent implementations, and recurring post-mortem action items that say "remind the team to read the wiki."

2. Enforcement Code

Documentation alone doesn't prevent violations. So teams write code to enforce conventions:

// ArchUnit / NetArchTest — convention enforcement code
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
    var result = Types.InAssembly(typeof(Order).Assembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    result.IsSuccessful.Should().BeTrue();
}

[Fact]
public void Every_Command_Should_Have_A_Validator()
{
    var commandTypes = typeof(CreateOrderCommand).Assembly
        .GetTypes()
        .Where(t => t.Name.EndsWith("Command"));

    var validatorTypes = typeof(CreateOrderCommandValidator).Assembly
        .GetTypes()
        .Where(t => t.Name.EndsWith("Validator"));

    foreach (var command in commandTypes)
    {
        var expectedValidator = command.Name + "Validator";
        validatorTypes.Should().Contain(t => t.Name == expectedValidator,
            $"Command {command.Name} must have a validator named {expectedValidator}");
    }
}

[Fact]
public void All_Entities_Should_Be_In_Entities_Folder()
{
    // Yes, this is a real test that teams write.
    // It checks that files are in the right folder.
    // The compiler doesn't care about folders. This test does.
    var entityTypes = typeof(Order).Assembly
        .GetTypes()
        .Where(t => t.IsSubclassOf(typeof(Entity)));

    foreach (var entity in entityTypes)
    {
        var filePath = GetSourceFilePath(entity); // reflection hack
        filePath.Should().Contain("/Entities/",
            $"Entity {entity.Name} must be in the Entities folder");
    }
}

This is code that exists solely to police other code. It doesn't ship to production. It doesn't implement a feature. It exists because the conventions are invisible to the compiler, so someone had to make them visible through tests.

And this enforcement code has its own maintenance burden:

  • It must be updated when conventions change
  • It uses reflection, which is fragile across refactors
  • It runs at test time, not compile time — so the feedback loop is slow
  • It produces test failures that say "convention violated" without telling you how to fix it
  • New team members must understand both the convention AND the enforcement test

The Double Cost

Convention's total cost for any given domain is:

Convention Cost = Documentation + Enforcement Code + Actual Implementation Code

For a typical domain like "DI registration," this looks like:

Artifact Lines Who Maintains It When Does It Catch Errors?
Wiki: "Service Registration Guide" ~50 Tech lead (when they remember) Never (it's passive documentation)
ADR: "ADR-003: DI Conventions" ~30 Nobody after initial writing Never
ArchUnit test: "Services must be registered" ~40 Any developer who notices drift Test time (minutes after code change)
Scrutor scanning config ~15 Any developer Runtime (if service is missing)
Actual service implementations ~200 Feature developers Compile time (for syntax)
Total convention overhead ~135 3 different audiences 3 different feedback loops

135 lines of documentation and enforcement for a convention that could be expressed as a single attribute.


Era 1: Code (Red)

Everything is explicit. You write the constructor calls, the SQL queries, the JSON serialization, the DI wiring. Nothing is hidden. Nothing is generated. The codebase is a direct representation of every decision.

Strength: No magic. Everything is visible. Weakness: Repetitive. Error-prone. Every new entity requires the same 8 files with the same boilerplate.

// Era 1: Code — manual DI wiring
var repository = new SqlOrderRepository(connectionString);
var validator = new OrderValidator();
var logger = new FileLogger("orders.log");
var service = new OrderService(repository, validator, logger);
var controller = new OrderController(service);
// Repeat for every service. Miss one dependency? Runtime NullReferenceException.

Era 2: Configuration (Orange)

The wiring moves to external files. XML, JSON, YAML — the framework reads the config and builds the object graph. The code is cleaner, but the configuration is verbose and disconnected.

Strength: Separation of concerns. Change wiring without recompiling. Weakness: XML hell. Typos in config are runtime failures. No IntelliSense. No refactoring support.

<!-- Era 2: Configuration — Unity XML container -->
<unity xmlns="http://schemas.microsoft.com/practices/2010/unity">
  <container>
    <register type="IOrderRepository" mapTo="SqlOrderRepository">
      <constructor>
        <param name="connectionString" value="Server=..." />
      </constructor>
    </register>
    <register type="IOrderValidator" mapTo="OrderValidator" />
    <register type="ILogger" mapTo="FileLogger">
      <constructor>
        <param name="path" value="orders.log" />
      </constructor>
    </register>
    <register type="IOrderService" mapTo="OrderService" />
    <register type="OrderController" />
  </container>
</unity>
<!-- Rename a class? The XML doesn't know. Runtime failure. -->

Era 3: Convention (Blue)

The framework discovers services by naming patterns, folder structures, and assembly scanning. No XML. No explicit registration for most cases. But the conventions must be learned, documented, and enforced.

Strength: Minimal boilerplate. Framework does the discovery. Weakness: Invisible rules. Three artifacts to maintain per convention (docs, enforcement, implementation).

// Era 3: Convention — Scrutor assembly scanning
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(classes => classes.InNamespaces("MyApp.Services"))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Convention: "Services go in the Services namespace."
// Where is this documented? Wiki page: "Service Registration Guide"
// How is it enforced? ArchUnit test: "All IService implementations must be in Services namespace"
// What happens when someone puts a service in the wrong namespace? Nothing — until the test runs.
📄 wiki/service-registration-guide.md (50 lines)
📄 tests/ArchitectureTests/ServiceRegistrationTests.cs (40 lines)
📄 src/MyApp/Startup.cs — Scrutor config (15 lines)
────────────────────────────────
Total convention overhead: 105 lines for "services get registered"

Era 4: Contention (Green)

The developer declares intent through an attribute. A Source Generator reads the attribute at compile time and generates the correct code. A Roslyn Analyzer guards the boundaries — not by testing conventions, but by emitting compiler errors for structural violations.

Strength: One attribute. Zero documentation needed. Zero enforcement code needed. The attribute IS the documentation. The generated code IS the implementation. The analyzer IS the enforcement. Weakness: Requires investment in Source Generator and Analyzer infrastructure.

// Era 4: Contention — attribute-driven generation
[Injectable(Lifetime.Scoped)]
public class OrderService : IOrderService
{
    // The [Injectable] attribute is all the developer writes.
    // The Source Generator produces:
    //   - Extension method: services.AddDomainServices()
    //   - Registration: services.AddScoped<IOrderService, OrderService>()
    //   - Health check registration (if IOrderService implements IHealthCheck)
    //
    // The Analyzer enforces:
    //   - REG001: Class implements interface but lacks [Injectable] → compile error
    //   - REG002: [Injectable] on class with no interface → compile warning
    //   - REG003: Lifetime mismatch (Scoped depends on Transient) → compile error
}
📄 No wiki page needed — the attribute documents the intent
📄 No ArchUnit test needed — the analyzer enforces at compile time
📄 No Scrutor config needed — the SG generates the registration
────────────────────────────────
Total convention overhead: 0 lines. One attribute. Done.

The Feedback Loop

When does the developer learn they made a mistake?

Diagram
Era Feedback Delay Who Catches It Cost of Fixing
Code Runtime (minutes to days) QA, users, on-call High — production incident
Configuration Deploy/startup (minutes to hours) CI/CD, staging Medium — rollback + fix
Convention Test time (seconds to minutes) Developer, CI Low — but test must exist
Contention Compile time (instant) Compiler, IDE Zero — red squiggle in the editor

The difference between Convention and Contention is not just speed — it's certainty. Convention catches violations only if someone wrote an enforcement test for that specific violation. Contention catches violations by construction — the analyzer knows every attribute in the compilation, every generated file, every diagnostic rule.


The Trust Spectrum

Who do you trust to get it right?

Diagram

Each era shifts trust further from the developer and closer to the machine:

  • Code: Trust the developer to wire everything correctly
  • Configuration: Trust the developer + ops to keep config in sync with code
  • Convention: Trust the developer + framework + test suite to follow and enforce invisible rules
  • Contention: Trust the compiler. It won't compile if it's wrong.

The compiler doesn't get tired on Friday afternoon. It doesn't skip the checklist. It doesn't onboard new hires who haven't read the wiki. It enforces the same rules on every build, every developer, every branch.


The Rosetta Stone: [AggregateRoot]

To make the four eras concrete, here is the same concept — registering a DDD Aggregate Root with Entity Framework, DI, and repository generation — across all four eras.

Era 1: Code

// 1. Entity configuration — manual
public class OrderEntityConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);
        builder.Property(o => o.CustomerId).IsRequired();
        builder.Property(o => o.TotalAmount).HasPrecision(18, 2);
        builder.HasMany(o => o.LineItems)
            .WithOne()
            .HasForeignKey(li => li.OrderId)
            .OnDelete(DeleteBehavior.Cascade);
        builder.Navigation(o => o.LineItems).AutoInclude();
    }
}

// 2. Repository — manual
public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context) => _context = context;

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
        => await _context.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task AddAsync(Order order, CancellationToken ct)
    {
        await _context.Orders.AddAsync(order, ct);
        await _context.SaveChangesAsync(ct);
    }

    public async Task UpdateAsync(Order order, CancellationToken ct)
    {
        _context.Orders.Update(order);
        await _context.SaveChangesAsync(ct);
    }
}

// 3. DI registration — manual
services.AddScoped<IOrderRepository, OrderRepository>();

// 4. Factory — manual
public class OrderFactory
{
    public Order Create(CustomerId customerId) => new Order(OrderId.New(), customerId);
}

services.AddTransient<OrderFactory>();

// Total: ~80 lines per aggregate root. 50 aggregates = 4,000 lines of boilerplate.

Era 2: Configuration

<!-- EF configuration in XML (NHibernate era) -->
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2">
  <class name="Order" table="Orders">
    <id name="Id" column="Id">
      <generator class="guid" />
    </id>
    <property name="CustomerId" not-null="true" />
    <property name="TotalAmount" precision="18" scale="2" />
    <bag name="LineItems" cascade="all-delete-orphan" inverse="true">
      <key column="OrderId" />
      <one-to-many class="LineItem" />
    </bag>
  </class>
</hibernate-mapping>

<!-- DI registration in Unity XML -->
<register type="IOrderRepository" mapTo="OrderRepository" />
<register type="OrderFactory" />

<!-- Total: ~30 lines of XML + ~60 lines of C# implementation = ~90 lines.
     But the XML is disconnected from the code. Rename Order? XML breaks silently. -->

Era 3: Convention

// EF Core conventions — no explicit configuration for simple cases
// Convention: "If a DbSet<T> exists, EF maps T to a table named after the DbSet property"
public class AppDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; } = null!;
    // Convention: Order maps to "Orders" table. Properties map to columns by name.
    // Convention: Navigation properties auto-discovered.
    // Convention: Cascade delete for required relationships.
}

// DI registration — Scrutor convention scanning
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderRepository>()
    .AddClasses(classes => classes.AssignableTo(typeof(IRepository<>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// But: where is this documented?
<!-- wiki/ef-conventions.md — 45 lines -->
# Entity Framework Conventions

## Table Naming
- Entity classes map to tables named after the DbSet property
- Override with [Table("name")] attribute

## Relationships
- Navigation properties are auto-discovered
- Required relationships cascade delete by default
- Override in OnModelCreating()

## Value Objects
- Mark with [Owned] attribute
- No separate table — stored inline

## Repository Pattern
- All repositories implement IRepository<TAggregateRoot>
- Registered via Scrutor assembly scanning
- Lifetime: Scoped (one per request)
// tests/ArchitectureTests/RepositoryTests.cs — 35 lines
[Fact]
public void All_AggregateRoots_Must_Have_Repositories()
{
    var aggregateRoots = typeof(Order).Assembly
        .GetTypes()
        .Where(t => t.IsSubclassOf(typeof(AggregateRoot)));

    var repositoryInterfaces = typeof(IOrderRepository).Assembly
        .GetTypes()
        .Where(t => t.IsInterface && t.Name.StartsWith("I") && t.Name.EndsWith("Repository"));

    foreach (var root in aggregateRoots)
    {
        var expectedRepo = $"I{root.Name}Repository";
        repositoryInterfaces.Should().Contain(
            t => t.Name == expectedRepo,
            $"Aggregate root {root.Name} must have a repository interface named {expectedRepo}");
    }
}

[Fact]
public void AggregateRoots_Must_Not_Have_Public_Setters()
{
    var aggregateRoots = typeof(Order).Assembly
        .GetTypes()
        .Where(t => t.IsSubclassOf(typeof(AggregateRoot)));

    foreach (var root in aggregateRoots)
    {
        var publicSetters = root.GetProperties()
            .Where(p => p.SetMethod?.IsPublic == true && p.Name != "Id");

        publicSetters.Should().BeEmpty(
            $"Aggregate root {root.Name} must not expose public setters (DDD encapsulation)");
    }
}
📄 wiki/ef-conventions.md (45 lines)
📄 tests/ArchitectureTests/RepositoryTests.cs (35 lines)
📄 Scrutor DI scanning config (10 lines)
📄 DbContext with DbSet declarations (5 lines)
📄 Actual repository implementation (35 lines)
📄 Actual entity configuration — minimal, conventions handle most (10 lines)
────────────────────────────────────────
Total: ~140 lines, of which 90 are documentation + enforcement

Era 4: Contention

// The developer writes THIS:
[AggregateRoot]
public class Order : Entity<OrderId>
{
    public CustomerId CustomerId { get; private set; }

    [Money]
    public decimal TotalAmount { get; private set; }

    private readonly List<LineItem> _lineItems = new();

    [HasMany(cascade: CascadeType.AllDeleteOrphan)]
    public IReadOnlyCollection<LineItem> LineItems => _lineItems.AsReadOnly();

    private Order() { } // EF constructor

    public Order(OrderId id, CustomerId customerId) : base(id)
    {
        CustomerId = customerId;
    }

    public void AddLineItem(ProductId productId, int quantity, decimal unitPrice)
    {
        _lineItems.Add(new LineItem(LineItemId.New(), Id, productId, quantity, unitPrice));
        TotalAmount = _lineItems.Sum(li => li.Total);
    }
}
// The Source Generator produces ALL of this:

// ── Generated: OrderEntityConfiguration.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
internal class OrderEntityConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);
        builder.Property(o => o.Id)
            .HasConversion(id => id.Value, v => new OrderId(v));
        builder.Property(o => o.CustomerId)
            .IsRequired()
            .HasConversion(id => id.Value, v => new CustomerId(v));
        builder.Property(o => o.TotalAmount)
            .HasPrecision(18, 2); // from [Money] attribute
        builder.HasMany("_lineItems")
            .WithOne()
            .HasForeignKey(nameof(LineItem.OrderId))
            .OnDelete(DeleteBehavior.Cascade);
        builder.Navigation("_lineItems").AutoInclude();
    }
}

// ── Generated: IOrderRepository.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
public interface IOrderRepository : IRepository<Order, OrderId>
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task UpdateAsync(Order order, CancellationToken ct = default);
    Task DeleteAsync(OrderId id, CancellationToken ct = default);
}

// ── Generated: OrderRepository.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
internal class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context) => _context = context;

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
        => await _context.Set<Order>().FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task AddAsync(Order order, CancellationToken ct)
    {
        await _context.Set<Order>().AddAsync(order, ct);
        await _context.SaveChangesAsync(ct);
    }

    public async Task UpdateAsync(Order order, CancellationToken ct)
    {
        _context.Set<Order>().Update(order);
        await _context.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(OrderId id, CancellationToken ct)
    {
        var entity = await GetByIdAsync(id, ct);
        if (entity is not null)
        {
            _context.Set<Order>().Remove(entity);
            await _context.SaveChangesAsync(ct);
        }
    }
}

// ── Generated: OrderFactory.g.cs ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
public static class OrderFactory
{
    public static Order Create(CustomerId customerId)
        => new Order(OrderId.New(), customerId);
}

// ── Generated: DomainServiceRegistration.g.cs (partial, appended per aggregate) ──
[GeneratedCode("Cmf.Ddd.Generators", "1.0.0")]
public static partial class DomainServiceRegistration
{
    static partial void AddOrderServices(IServiceCollection services)
    {
        services.AddScoped<IOrderRepository, OrderRepository>();
    }
}
// The Analyzer enforces:

// DDD001: [AggregateRoot] class has public setter
//   → Error: "Property 'Order.TotalAmount' has a public setter.
//     Aggregate roots must encapsulate state. Use 'private set' or 'init'."

// DDD002: Class inherits Entity<T> but lacks [AggregateRoot] or [Entity]
//   → Warning: "Class 'Order' inherits Entity<OrderId> but has no DDD attribute.
//     Add [AggregateRoot] or [Entity] to enable code generation."

// DDD003: [AggregateRoot] references another [AggregateRoot] directly
//   → Error: "Property 'Order.Customer' references aggregate root 'Customer'.
//     Aggregate roots should reference each other by ID, not by navigation property.
//     Use 'CustomerId' instead of 'Customer'."

// DDD004: [HasMany] collection is not IReadOnlyCollection<T>
//   → Error: "Property 'Order.LineItems' is marked [HasMany] but returns List<LineItem>.
//     Use IReadOnlyCollection<LineItem> with a private backing field."
📄 No wiki page needed — [AggregateRoot] is self-documenting
📄 No ArchUnit test needed — DDD001-DDD004 analyzers enforce at compile time
📄 No Scrutor config needed — SG generates DomainServiceRegistration
📄 No repository boilerplate — SG generates IOrderRepository + OrderRepository
📄 No EF config — SG generates OrderEntityConfiguration
📄 No factory — SG generates OrderFactory
────────────────────────────────────────
Developer wrote: 25 lines (the domain class with attributes)
SG generated: ~120 lines (EF config + repo interface + repo impl + factory + DI registration)
Analyzer enforces: 4 rules (DDD001-DDD004) at compile time
Convention overhead: 0 lines

The Convention Tax — Visualized

Diagram

In Convention, three artifacts must stay synchronized: documentation, enforcement code, and implementation. Each is maintained by a different person at a different cadence. Drift is inevitable.

In Contention, the attribute is the single source of truth. The SG reads it and generates the implementation. The analyzer reads it and enforces the rules. There is one artifact to maintain. There is nothing to drift.


Why "Contention"?

The word is deliberate. It has three relevant meanings:

  1. The compiler contends with the developer. It pushes back. It refuses to compile incorrect code. Unlike Convention, which trusts and then tests, Contention resists from the start.

  2. The attribute contends for the developer's intent. It declares what the developer wants ([AggregateRoot]), not how to achieve it (80 lines of EF config, repository, factory, DI registration). The SG handles the "how."

  3. This is a contentious claim. Convention over Configuration is beloved. Saying "that's not enough" will be met with resistance. Good. The evidence speaks for itself across 10 domains.


Source Generators and Analyzers: A Brief Primer

For readers unfamiliar with the Roslyn toolchain, here is the minimum context needed for the rest of this series.

Source Generators

A Source Generator is a C# compiler plugin that runs during compilation. It reads the syntax trees of your code, finds specific patterns (like attributes), and emits new C# source files into the compilation. The generated code is compiled alongside your handwritten code — same assembly, same type checking, same IntelliSense.

[Generator]
public class InjectableGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Find all classes with [Injectable] attribute
        var provider = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyApp.InjectableAttribute",
                predicate: (node, _) => node is ClassDeclarationSyntax,
                transform: (ctx, _) => GetInjectableInfo(ctx));

        // 2. Generate registration code
        context.RegisterSourceOutput(provider.Collect(), (spc, injectables) =>
        {
            var source = GenerateRegistrationExtension(injectables);
            spc.AddSource("DomainServiceRegistration.g.cs", source);
        });
    }
}

Key properties:

  • Compile-time only — no runtime reflection, no startup overhead
  • Incremental — only re-runs when the inputs change (fast builds)
  • Additive — can only add code, not modify existing code (safe by design)
  • Visible — generated files appear in the IDE under "Dependencies > Analyzers"

Roslyn Analyzers

A Roslyn Analyzer is a compiler plugin that inspects code and emits diagnostics (errors, warnings, info messages). Unlike tests, analyzers run during compilation — the developer sees the diagnostic in the IDE as they type, before they even save the file.

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AggregateRootPublicSetterAnalyzer : DiagnosticAnalyzer
{
    private static readonly DiagnosticDescriptor Rule = new(
        id: "DDD001",
        title: "Aggregate root has public setter",
        messageFormat: "Property '{0}.{1}' has a public setter. Use 'private set' or 'init'.",
        category: "DDD",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true);

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

    public override void Initialize(AnalysisContext context)
    {
        context.RegisterSymbolAction(AnalyzeProperty, SymbolKind.Property);
    }

    private void AnalyzeProperty(SymbolAnalysisContext context)
    {
        var property = (IPropertySymbol)context.Symbol;
        if (property.SetMethod?.DeclaredAccessibility != Accessibility.Public) return;

        var containingType = property.ContainingType;
        if (!containingType.GetAttributes().Any(a =>
            a.AttributeClass?.Name == "AggregateRootAttribute")) return;

        context.ReportDiagnostic(Diagnostic.Create(
            Rule, property.Locations[0],
            containingType.Name, property.Name));
    }
}

Key properties:

  • IDE-integrated — red squiggles, lightbulb fixes, hover tooltips
  • Pre-build — the developer sees the error before they compile
  • Configurable — severity can be adjusted per project in .editorconfig
  • Composable — multiple analyzers can inspect the same code independently

The SG + Analyzer Pair

The power of Contention comes from using SG and Analyzers together:

Diagram
  • The attribute declares intent
  • The SG generates the correct implementation from that intent
  • The analyzer catches structural violations that the SG can't fix (wrong access modifiers, illegal dependencies, missing attributes)
  • Together, they replace: the convention (attribute IS the convention), the documentation (attribute IS self-documenting), and the enforcement code (analyzer IS the enforcement)

What This Series Will Show

The next 10 parts take this pattern through 10 real domains:

Part Domain Convention Tax (docs + enforcement) Contention (attribute)
II Dependency Injection Wiki + Scrutor config + test [Injectable]
III Validation Wiki + "all commands have validators" test [Validated]
IV API Contracts API guidelines doc + custom analyzer [TypedEndpoint]
V Database Mapping EF conventions doc + NetArchTest [AggregateRoot]
VI Testing & Requirements Test plan + naming convention + scanner [ForRequirement]
VII Architecture ADR + NetArchTest + CI check [Layer]
VIII Configuration Options doc + validation test [StronglyTypedOptions]
IX Error Handling Error guide + review checklist [MustHandle]
X Logging & Security Logging standard + security policy doc [LoggerMessage] / [RequiresPermission]

In every domain, the pattern is the same:

  1. Convention required documentation (wiki, ADR, onboarding guide)
  2. Convention required enforcement code (ArchUnit, NetArchTest, custom test)
  3. Contention requires one attribute — and the SG + analyzer does the rest

The Convention Tax is real. It is measurable. And it is eliminable.


The Principle

Don't put the burden on developers. Don't ask them to follow invisible rules. Don't ask them to read the wiki. Don't ask them to maintain enforcement tests. Don't ask them to keep three artifacts in sync.

Give them an attribute. Let the compiler do the rest.

Convention over Configuration was progress. Contention over Convention is the destination.