Worked example — a .NET minimal API under SAMA v2

This is the same shape as /sama/v2/example-crud and /sama/v2/example-wordpress, but in C# against .NET idioms — minimal API route handlers, dependency injection, record/enum domain types, model binding, and a test project per layer. The four canonical layers and the import Law are unchanged.

What .NET brings that the TypeScript and PHP examples cannot: the compiler can enforce a large part of the Law for free. In TS the import law is a graph analysis run after the fact (regex on from "./...ts"); in PHP it is a use-statement audit. In .NET you make Layer 1 physically unable to see Layer 2 by simply not adding a ProjectReference — the violating edge then does not compile. That is stronger than any verifier, because it is not a check, it is an impossibility. This page documents the architectural shape and proposes how a C# profile expresses it; the example domain is the same orders API as the CRUD page.

Note on the verifier: the live verifier at /sama/v2/verify currently scans TypeScript only. A C#-aware verifier is a separate piece of work, and most of it would be a thin wrapper over dotnet itself — see §"How the Law is enforced" below. This page documents the architectural shape and two proposed v2.1 dialects; per §6 they are falsifiable hypotheses, not promoted rules. No cross-repo §5 data exists for C# yet.

#The profile

A single sama.profile.toml at the repo root. It uses the directory-layout dialect (§6.1) because the natural unit of a layer in .NET is a project (assembly), not a filename prefix — and it adds two proposed dialect flags discussed at the bottom of the page.

sama_version = "2.1"
profile = "dotnet-minimal-api"

layout = "directory"               # §6.1 — layers are projects, not filename prefixes
tests = "mirror-project"           # PROPOSED dialect — see below
law_enforcement = "compile"        # PROPOSED dialect — see below

# Order in each array = dependency order. A later project may reference an
# earlier one; never the reverse. The ProjectReference graph must match this.

[layers.0]                          # Pure — references nothing
packages = ["OrderApi.Pure"]

[layers.1]                          # Core — references Pure only
sublayers = [
  { name = "policy",  package = "OrderApi.Core.Policy" },
  { name = "service", package = "OrderApi.Core.Service" },   # service may reference policy
]

[layers.2]                          # Adapter — references Pure + Core
sublayers = [
  { name = "repository", package = "OrderApi.Adapter.Persistence" },
  { name = "gateway",    package = "OrderApi.Adapter.Gateways" },
  { name = "controller", package = "OrderApi.Adapter.Http" },  # controller → gateway → repository
]

[layers.3]                          # Entry — references Pure + Core + Adapter
packages = ["OrderApi.Entry"]

Layer order: 0 < 1 < 1 < 2 < 2 < 2 < 3. The reviewer's analogue of ls src/ | sort is dotnet list reference per project, or one glance at the solution's project graph — it reads top-to-bottom in dependency order.

A smaller team can collapse the sublayer projects into one project per canonical layer (OrderApi.Pure, OrderApi.Core, OrderApi.Adapter, OrderApi.Entry) and enforce the sublayer ordering with an analyzer or an architecture test instead. The four-project floor is what the compiler enforces; the sublayer split is the profile's dialect.

#The directory

OrderApi.sln
├── sama.profile.toml
├── src/
   ├── OrderApi.Pure/                    # Layer 0 — references NOTHING
      ├── Money.cs                      # value type + arithmetic
      ├── OrderId.cs                    # strongly-typed id
      ├── OrderStatus.cs                # enum
      ├── Order.cs                      # aggregate record
      └── Ports.cs                      # IOrderRepository, IPaymentGateway, IClock
   ├── OrderApi.Core.Policy/             # Layer 1 (b1) — references Pure
      └── OrderPolicy.cs                # CanCancel, IsEligibleForRefund
   ├── OrderApi.Core.Service/            # Layer 1 (b2) — references Pure + Policy
      └── PlaceOrderService.cs          # orchestrators
   ├── OrderApi.Adapter.Persistence/     # Layer 2 (c1) — references Pure + Core
      └── OrderRepository.cs            # EF Core / Dapper impl of IOrderRepository
   ├── OrderApi.Adapter.Gateways/        # Layer 2 (c2) — references Pure + Core
      └── StripePaymentGateway.cs       # outbound HTTP
   ├── OrderApi.Adapter.Http/            # Layer 2 (c3) — references Pure + Core
      ├── CreateOrderRequest.cs         # the wire DTO
      └── OrderRequestParser.cs         # DTO → typed domain input (the boundary)
   └── OrderApi.Entry/                   # Layer 3 — references everything below
       ├── Program.cs                    # builder, DI wiring, MapPost
       └── OrderEndpoints.cs             # route handlers
└── tests/                                # mirror-project dialect
    ├── OrderApi.Core.Policy.Tests/
    ├── OrderApi.Core.Service.Tests/
    ├── OrderApi.Adapter.Persistence.Tests/
    ├── OrderApi.Adapter.Gateways.Tests/
    └── OrderApi.Adapter.Http.Tests/

#Layer 0 — Pure

Domain types as record/enum, value objects, pure transforms. No I/O, no framework, no clock. No DateTime.Now (it is a hidden clock read — take an IClock from a higher layer). No using Microsoft.*, no EF Core, no HttpContext. The project references nothing, so most of those are not even on the compile path.

// Money.cs — value type, no I/O
namespace OrderApi.Pure;

public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);

    public Money Add(Money other) => other.Currency == Currency
        ? this with { Amount = Amount + other.Amount }
        : throw new InvalidOperationException("Currency mismatch");
}

public enum OrderStatus { Pending, Paid, Fulfilled, Cancelled }

// Ports.cs — the interfaces Layer 1 calls THROUGH. Concrete implementations
// live in Layer 2; declaring them here lets the Core projects reference only
// types, never an Adapter project (no upward edge).
public interface IOrderRepository
{
    Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct);
    Task SaveAsync(Order order, CancellationToken ct);
}

public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(OrderId id, Money total, CancellationToken ct);
}

public interface IClock
{
    DateTimeOffset UtcNow { get; }   // the only place "now" enters the system
}

The crucial Layer 0 move, identical to the other examples: adapter interfaces declared here, not in Layer 2. IClock matters because DateTime.Now, DateTimeOffset.UtcNow, and Stopwatch are all forms of I/O the spec keeps out of Layers 0 and 1. The Layer 2 implementation reads the real clock; tests construct a FixedClock.

#Layer 1 — Core

Business logic. Two sublayers — policy (pure decisions over domain state) and service (orchestrators that compose policy and call through ports). The service project references the policy project; policy must never reference service. With separate projects, that reverse reference does not compile.

// OrderPolicy.cs (OrderApi.Core.Policy) — pure, references Pure only
namespace OrderApi.Core.Policy;
using OrderApi.Pure;

public static class OrderPolicy
{
    public static bool CanCancel(Order order, DateTimeOffset now) =>
        order.Status is OrderStatus.Pending or OrderStatus.Paid
        && now - order.CreatedAt < TimeSpan.FromHours(24);   // 24-hour window
}

// PlaceOrderService.cs (OrderApi.Core.Service) — orchestrator
namespace OrderApi.Core.Service;
using OrderApi.Pure;
using OrderApi.Core.Policy;

public sealed class PlaceOrderService(IOrderRepository orders, IPaymentGateway payments, IClock clock)
{
    public async Task<OrderId> PlaceAsync(Money total, CancellationToken ct)
    {
        if (total.Amount <= 0)
            throw new ArgumentException("Order total must be positive");

        var id = OrderId.New();
        var charge = await payments.ChargeAsync(id, total, ct);
        // ... compose policy, persist via orders.SaveAsync ...
        return id;
    }
}

Notice what PlaceOrderService does not reference: any Adapter or Entry project. It takes ports through the primary constructor; the DI container wires the concretes in Layer 3.

#Layer 2 — Adapter

The boundary. Three sublayers — repository (DB), gateway (outbound HTTP), controller (inbound request parsing). This is the only layer where external input is parsed into typed domain values — never cast, never reached for HttpContext from elsewhere.

// OrderRepository.cs (OrderApi.Adapter.Persistence) — implements IOrderRepository
namespace OrderApi.Adapter.Persistence;
using OrderApi.Pure;

public sealed class OrderRepository(OrdersDbContext db) : IOrderRepository
{
    public async Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct) =>
        await db.Orders.FindAsync([id.Value], ct) is { } row ? RowToOrder(row) : null;

    public Task SaveAsync(Order order, CancellationToken ct) { /* ... */ }
}

// OrderRequestParser.cs (OrderApi.Adapter.Http) — the boundary. THIS is the
// only place an untyped/external shape becomes a typed domain value.
namespace OrderApi.Adapter.Http;
using OrderApi.Pure;

public static class OrderRequestParser
{
    public static Result<Money> ParseTotal(CreateOrderRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.Currency) || req.Currency.Length != 3)
            return Result.Fail<Money>("invalid currency");
        if (req.Amount <= 0)
            return Result.Fail<Money>("amount must be positive");
        return Result.Ok(new Money(req.Amount, req.Currency.ToUpperInvariant()));
    }
}

In .NET the boundary is unusually visible: it is exactly where JsonSerializer.Deserialize, model binding ([FromBody]), IFormCollection, or a cast off object/dynamic turns bytes into a CLR shape. A C# §4.4 detector pins that set instead of TS's JSON.parse / new URL. Within Layer 2, the controller project may reference the gateway and repository projects; repositories never reference controllers.

#Layer 3 — Entry

Program.cs, DI registration, and route handlers. Owns zero business logic — every handler is the same three-step composition: parse via the Layer 2 parser, invoke a Layer 1 service, serialize the result.

// Program.cs (OrderApi.Entry) — references every project below
using OrderApi.Pure;
using OrderApi.Core.Service;
using OrderApi.Adapter.Persistence;
using OrderApi.Adapter.Gateways;
using OrderApi.Adapter.Http;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<PlaceOrderService>();

var app = builder.Build();

app.MapPost("/orders", (CreateOrderRequest req, PlaceOrderService svc, CancellationToken ct) =>
{
    var total = OrderRequestParser.ParseTotal(req);   // c3: bytes → typed
    if (total.IsFailure)
        return Results.BadRequest(total.Error);

    var id = svc.PlaceAsync(total.Value, ct);          // b2: orchestration
    return Results.Created($"/orders/{id}", new { id });
});

app.Run();

DI registration is the .NET form of "wiring adapters into services." It is intrinsically a Layer 3 concern: it is the one place that names both the interface (Layer 0) and the concrete (Layer 2) in the same breath. Layer 3 is exercised by WebApplicationFactory integration tests, not unit tests — so, as in the other examples, the Modeled-tests rule (§4.3) does not require sibling tests for the Entry layer.

#How the Law is enforced (the C# advantage)

Under law_enforcement = "compile", the §4.6 Law check is largely discharged by the build:

  1. No upward reference can exist, because OrderApi.Core.* has no ProjectReference to OrderApi.Adapter.*. An agent that writes using OrderApi.Adapter.Persistence; inside a Core file gets a compile error, not a lint warning.
  2. The graph is acyclic by construction — the .NET SDK rejects circular project references outright.
  3. What the compiler does not catch is the sublayer ordering within a collapsed single-project layer, and the boundary rule. Those remain for an analyzer or an architecture test.

A minimal C# verifier is therefore mostly: parse sama.profile.toml, run dotnet list reference per project, and assert the reference graph matches the declared layer order. The expensive graph analysis the TS verifier performs is replaced by a question the build system already answers.

An architecture test makes the same guarantee visible in CI and covers the collapsed-project case:

// ArchitectureTests.cs — using NetArchTest.Rules
[Fact]
public void Core_must_not_depend_on_Adapter_or_Entry()
{
    var result = Types.InAssembly(typeof(PlaceOrderService).Assembly)
        .Should()
        .NotHaveDependencyOnAny("OrderApi.Adapter.Persistence",
                                "OrderApi.Adapter.Gateways",
                                "OrderApi.Adapter.Http",
                                "OrderApi.Entry")
        .GetResult();

    Assert.True(result.IsSuccessful, string.Join("\n", result.FailingTypeNames ?? []));
}

#Common mistakes — and the check that catches them

What an agent might do Which v2 check catches it
using OrderApi.Adapter.Persistence; inside PlaceOrderService.cs #6 Law (§1.2) — and under law_enforcement = "compile" it does not compile at all (no ProjectReference)
Read DateTime.Now inside OrderPolicy.cs #6 Law + #7 Consistency — the clock is I/O; the Pure/Core projects must take IClock. An analyzer flags the banned API
JsonSerializer.Deserialize<T>(body) inside OrderEndpoints.cs #4 Modeled (boundary) — deserialization of external input is a Layer 2 concern; move it into the Http parser
PlaceOrderService references the repository concrete instead of IOrderRepository #6 Law (§1.2) — Layer 1 → Layer 2 is upward; depend on the port in Pure
OrderApi.Core.Policy adds a ProjectReference to OrderApi.Core.Service #6 Law (§2.2) — reversed sublayer order; policy may not reference service. With separate projects this is a compile error
PlaceOrderService.cs has no test in OrderApi.Core.Service.Tests #3 Modeled (tests) — Layer 1 behaviour requires a mirror-project test (see dialect below)
A 900-LOC OrderRepository.cs of EF Core queries #5 Atomic — over the ~700 cap; split per aggregate. (But see the declarative-exemption note for a file that is purely record/config definitions)
A new Shared/Helpers.cs project referenced by every layer #2 Architecture — a "shared" project that everything points at is an unlabelled layer; its types belong in Pure (Layer 0)

#.NET idioms that fit cleanly under v2

  • Dependency injection — registration lives in Layer 3 (Program.cs). The container is the composition root; no other layer calls services.AddScoped. Interfaces are declared in Layer 0, concretes in Layer 2, wired in Layer 3.
  • DateTime / TimeProvider — both are I/O. Inject IClock (or .NET 8's TimeProvider) from Layer 2; never read the ambient clock in Layers 0–1. Tests pass a fake.
  • EF Core DbContext — a Layer 2 concern, lives only in Adapter.Persistence. Layers 0–1 know the IOrderRepository port, never DbContext. The EF entity classes are an Adapter detail, distinct from the Layer 0 domain records (map between them in the repository).
  • Model binding & [ApiController] — the request DTO and its validation are the Layer 2 boundary. The minimal-API handler in Layer 3 receives the bound DTO and immediately hands it to the Layer 2 parser; it does not inspect raw bodies.
  • global using — the nearest C# analogue of a TS barrel re-export. The Atomic rule's "no barrels" maps to "no project-wide global using that launders cross-layer access"; a GlobalUsings.cs that pulls in a higher layer is the smell to catch.

#Proposed v2.1 dialects this profile relies on

Both are framed as §6 falsifiable hypotheses. Each defaults to v2.0 behaviour when its flag is absent, and a verifier MAY decline to activate the relaxed semantics until cross-repo §5 evidence exists.

#law_enforcement = "compile"

Relaxes: §4.6 Law — "the import graph is acyclic and every edge satisfies §1.2."

Property protected: no upward or cyclic dependency edge exists in the program.

How the dialect preserves it: under layout = "directory" in a language with a compile-time project/assembly graph (.NET, also JVM/Gradle, Cargo), the Law check verifies the ProjectReference (or equivalent) graph matches the declared layer order and is acyclic — both of which the build system already guarantees. The property is identical; the surface check moves from "analyze imports" to "read the reference graph the compiler already enforces." This is a stronger guarantee than the TS verifier provides, because a violation is a build failure, not a finding.

Falsifiable experiment: audit a corpus of agent-authored C# commits. The dialect is invalidated if compile-mode reports a different Law violation set than a full import-graph analysis of the same commits — i.e. if assembly references miss a defect that source-level using analysis catches. (Candidate gap to probe: InternalsVisibleTo, reflection, and dynamic can create runtime edges the reference graph does not show.)

#tests = "mirror-project"

Relaxes: §4.3 Modeled (tests) — "every Layer 1 and Layer 2 behaviour file has a sibling test file."

Property protected: every behavioural source unit has a discoverable, attached test that cannot silently drift away.

How the dialect preserves it: .NET convention is neither sibling files (Jest/PHPUnit) nor inline tests (Rust's #[cfg(test)]), but a parallel test project per source project (FooFoo.Tests). Under tests = "mirror-project", the Modeled-tests check verifies each Layer 1/2 project has a corresponding *.Tests project and that each public behavioural type has at least one referencing test. Where the test lives changes (mirror project vs sibling file); that every behavioural unit has an attached, mechanically discoverable test does not.

Falsifiable experiment: audit a .NET corpus under both tests = "sibling" (which the convention does not produce, so it would report near-total failure) and tests = "mirror-project". The dialect is invalidated if mirror-mode systematically classifies types as tested that a stricter per-type check would not — i.e. if "has a .Tests project" is allowed to stand in for "this type is actually tested."

#See also

← /sama/v2 · ← /sama