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
dotnetitself — 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:
- No upward reference can exist, because
OrderApi.Core.*has noProjectReferencetoOrderApi.Adapter.*. An agent that writesusing OrderApi.Adapter.Persistence;inside a Core file gets a compile error, not a lint warning. - The graph is acyclic by construction — the .NET SDK rejects circular project references outright.
- 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 callsservices.AddScoped. Interfaces are declared in Layer 0, concretes in Layer 2, wired in Layer 3. DateTime/TimeProvider— both are I/O. InjectIClock(or .NET 8'sTimeProvider) 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 inAdapter.Persistence. Layers 0–1 know theIOrderRepositoryport, neverDbContext. The EF entity classes are an Adapter detail, distinct from the Layer 0 domainrecords (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-wideglobal usingthat launders cross-layer access"; aGlobalUsings.csthat 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 (Foo → Foo.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 — the canonical spec this profile instantiates
- /sama/v2/example-crud — the same shape for a Bun/TypeScript HTTP service
- /sama/v2/example-wordpress — the same shape for a PHP WordPress plugin
- /sama/v2#6a-v21-dialects-provisional — the existing provisional dialects these two extend
- /contributing — how to submit this as an audit/example