# Onion architecture, the practices

TheYard is a used-vehicle auction platform built in the open: a .NET 10 API, a React front end, and
two databases underneath it. Its server is built as an onion, and the record of that decision, with
every ring, every rule the build runs and what each one stops, is
[ADR: Onion and SOLID, how this codebase holds them](https://theyard.stevenstout.biz/?doc=adr-onion-and-solid).
This page is the practical half. Each section is one practice you can copy, shown with a drawing or a
whole file read from the running build, and each section has an address of its own so a link can
land on it.

## In plain words

The server is built in rings. The auction's rules sit in the middle, the code that carries out what a
visitor asks for wraps them, and the database code and the web endpoints sit on the outside. Code may
use the rings inside it and never the ones outside it. This page follows one bid through those rings,
shows how the inner ring can save a bid without knowing which database it is talking to, why the
rules never read the clock, how ten tests keep the shape from slipping, and how a small codebase gets
the same shape from folders instead of projects.

What that is worth: a developer can copy each practice into their own codebase this week, and the
organization gets business rules that are tested in milliseconds with no database, plus a structure
the build defends on every push instead of one that depends on a reviewer noticing.

### The words this page uses

- **Ring**: one layer of the code. Here each inner ring is its own .NET project; in a smaller
  codebase a ring can be a folder.
- **Dependency**: code in one place using a type from another, through a project reference, a
  `using` line, or a call inside a method body.
- **Domain**: the rules of the business, here the auction's: who may bid, how much, and when.
- **Use case**: one thing a visitor does, such as placing a bid, written as a method that runs the
  rules and the stores in the right order.
- **Port**: an interface the inner code declares for something it needs from outside, such as a
  place to keep bids.
- **Adapter**: a class in an outer ring that implements a port against one real thing, such as SQL
  Server or Cosmos DB.
- **Host**: the web application itself, the endpoints that read a request and write the answer.
- **Composition root**: the one place, at startup, where the real classes are created and handed to
  the code that needs them.
- **BCL**: the .NET base class library, the `System` namespaces every .NET program has.

## The rule in one sentence

**Dependencies point inward: code may use the rings inside it and never the ones outside it.**

[![TheYard's rings: Data at the centre, then Domain, then Application, with the host and the adapters outside, every dependency pointing inward](/api/docs/images/rings.svg#1400x860)](https://theyard.stevenstout.biz/api/docs/diagrams/rings)

*A preview. [Open the rings diagram in a new page](https://theyard.stevenstout.biz/api/docs/diagrams/rings) to zoom in and follow it.*

Innermost first: Data (the dataset's shapes, with no behaviour), Domain (the rules), Application
(the use cases and the ports), and outside them the adapters and the host. What lives in each ring
is listed in the record, under
[The rings](https://theyard.stevenstout.biz/?doc=adr-onion-and-solid#the-rings), and is not
repeated here.

Jeffrey Palermo, who named the pattern in 2008, put the same rule this way: "All code can depend on
layers more central, but code cannot depend on layers further out from the core." Everything else on
this page is a way of keeping that sentence true once the code is large and the week is busy.

## A request walks the rings

The rule is easiest to see by following one request. A signed-in buyer bids on a vehicle with
`POST /api/vehicles/{id}/bids`, and the code meets the rings in this order:

[![One bid through TheYard's rings: the endpoint asks Application, Application asks Domain, Domain decides, Application saves through the IBidStore port, an adapter writes the store, and the endpoint answers](/api/docs/images/bid-walk.svg#1400x930)](https://theyard.stevenstout.biz/api/docs/diagrams/bid-walk)

*A preview. [Open the bid's walk in a new page](https://theyard.stevenstout.biz/api/docs/diagrams/bid-walk) to zoom in and follow it.*

1. **The host reads.** The endpoint checks that the session belongs to this store (401 if not) and
   that the vehicle exists (404 if not), reads the server's clock once, and asks `Auction`, the one
   class an endpoint asks about the auction.
2. **Application composes.** `Auction` hands the bid to `BidService`, which keeps everybody's bids. It
   lets one bid through at a time and folds everybody's bids into the vehicle, so the rules see the
   price as it stands.
3. **Application asks Domain.** `BidService` calls `BidRules.ResolveBid` with the vehicle, the amount,
   the clock and whether anybody has already bought it.
4. **Domain decides.** The answer is a `BidOutcome`: refused because the vehicle is sold, the auction
   is not live or the bid is under the minimum; won at the buy-now price; or accepted. It is a pure
   function, so the same inputs give the same answer every time.
5. **Application saves through the port.** On yes, `BidService` calls `IBidStore.SaveAsync` and only
   then records the bid in memory, so a failed write means the bid did not happen anywhere.
6. **An adapter writes.** Whichever adapter the composition root wired in at startup, for SQL or for
   Cosmos DB, writes the row or the document.
7. **The host answers.** A refusal becomes a 400 with the reason; a bid that stands becomes a 200 with
   the bid and the vehicle as it now stands.

**The practice:** an endpoint reads the request, asks one use case, and writes the answer. It does not
decide anything and does not put services together itself. Two of the ten architecture tests hold that
(`Endpoints_never_reach_a_store_or_the_service_container` and
`Endpoints_reach_the_auction_only_through_the_Application_ring`).

The code in the order the bid meets it: three files whole, and the one method between them. The
endpoint,
[`api/TheYard.Api/Endpoints/BidEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/BidEndpoints.cs):

```csharp
using System.ComponentModel;
using Microsoft.AspNetCore.Http.HttpResults;
using TheYard.Application;
using TheYard.Domain;

namespace TheYard.Api;

/// <summary>
/// Bidding and buying now, the caller's standing and history, one round of the
/// simulated room, and one person's start-over. Every write needs a signed-in
/// bidder; the domain's BidRules decide, on the server's clock.
/// </summary>
public static class BidEndpoints
{
    /// <summary>Maps the bidding routes under /api/vehicles/{id}, /api/bids and /api/market.</summary>
    public static IEndpointRouteBuilder MapBidEndpoints(this IEndpointRouteBuilder app)
    {
        #region bid-endpoints
        // Bidding. Every write needs a signed-in bidder, and the domain's BidRules decide on the
        // server's clock. Nothing here is only the caller's to change: a bid moves the price
        // everybody sees, and it is kept in the store.
        app.MapPost("/api/vehicles/{id}/bids", PlaceBid)
            .RequireAuthorization()
            .WithName("PlaceBid")
            .WithTags("Bids")
            .WithSummary("Bid on a vehicle")
            .WithDescription("Whole dollars. The rules run on the server: a sold vehicle takes no bid, an amount at or above "
                + "the buy-now price wins outright at that price, and anything else has to clear the minimum next bid "
                + "measured against the room's standing price. A session bids on the store it was opened on.")
            .ProducesProblem(StatusCodes.Status400BadRequest)
            .ProducesProblem(StatusCodes.Status401Unauthorized)
            .ProducesProblem(StatusCodes.Status404NotFound);

        // No body: a purchase names the vehicle in its address and nothing else.
        app.MapPost("/api/vehicles/{id}/buy-now", BuyNow)
            .RequireAuthorization()
            .WithName("BuyNow")
            .WithTags("Bids")
            .WithSummary("Buy a vehicle outright")
            .WithDescription("No body: the vehicle is named in the address. Refused when the vehicle has no buy-now price, "
                + "is already sold, or its auction is not live.")
            .ProducesProblem(StatusCodes.Status400BadRequest)
            .ProducesProblem(StatusCodes.Status401Unauthorized)
            .ProducesProblem(StatusCodes.Status404NotFound);

        #region market-endpoints
        // The buyer's bids, each one answering the question the badge asks: am I still
        // winning this? The server owns that answer because it owns both sides of it.
        // Signed out, this is an empty map rather than a 401: the page asks for it on
        // every load, and "you have no bids" is the true answer for somebody who has
        // not signed in. The endpoints that change something are the ones that refuse.
        app.MapGet("/api/bids", MyBids)
            .WithName("GetMyBids")
            .WithTags("Bids")
            .WithSummary("The caller's standing on every vehicle they have bid on")
            .WithDescription("Keyed by vehicle id. Signed out, this is an empty map rather than a 401: the page asks for it "
                + "on every load, and no bids is the true answer for a visitor with no session.");

        #region history
        // The account page's list, newest first, with the vehicle each bid is on. The
        // only query the bids table serves that is not "load everything at startup";
        // the primary key leads with the user column, so it needs no index of its own.
        app.MapGet("/api/bids/history", History)
            .RequireAuthorization()
            .WithName("GetBidHistory")
            .WithTags("Bids")
            .WithSummary("The caller's bids, newest first, with the vehicle each is on")
            .ProducesProblem(StatusCodes.Status401Unauthorized);
        #endregion history

        // One round of bidding by the room, driven by the page rather than a timer. The room
        // bids on the server's clock, the same one every visitor is served, so the auctions it
        // can see are the ones the visitor sees.
        app.MapPost("/api/market/tick", Tick)
            .RequireAuthorization()
            .WithName("TickRoom")
            .WithTags("Bids")
            .WithSummary("One round of bidding by the room")
            .WithDescription("The simulated room bids against the caller's auctions and a page of live ones, on the server's "
                + "clock. Driven by the page rather than a timer, and only by a signed-in visitor, because moving the "
                + "room moves everybody's prices.")
            .ProducesProblem(StatusCodes.Status401Unauthorized);
        #endregion market-endpoints

        app.MapDelete("/api/bids", ResetMine)
            .RequireAuthorization()
            .WithName("ResetMyBids")
            .WithTags("Bids")
            .WithSummary("Forget the caller's bids, and the room's answers to them")
            .WithDescription("One person's start-over. Nobody else's bids move.")
            .ProducesProblem(StatusCodes.Status401Unauthorized);
        #endregion bid-endpoints

        return app;
    }

    private static Task<Results<Ok<BidResult>, ProblemHttpResult>> PlaceBid(CurrentBackend current, HttpContext http, string id, BidRequest request) =>
        HandleBid(current, http, id,
            (auction, vehicle, clock) => auction.PlaceBidAsync(vehicle, request.Amount, clock, http.UserId()));

    private static Task<Results<Ok<BidResult>, ProblemHttpResult>> BuyNow(CurrentBackend current, HttpContext http, string id) =>
        HandleBid(current, http, id,
            (auction, vehicle, clock) => auction.BuyNowAsync(vehicle, clock, http.UserId()));

    private static Ok<IReadOnlyDictionary<string, BidView>> MyBids(CurrentBackend current, HttpContext http) =>
        TypedResults.Ok(BidsOf(current.Auction, http));

    private static Ok<BidHistory> History(CurrentBackend current, HttpContext http) =>
        TypedResults.Ok(current.Auction.HistoryOf(http.UserId()));

    // The room's round, and the caller's own badges riding back with it, so a page does not need
    // a second request to find out it has been outbid.
    private static Ok<TickResult> Tick(CurrentBackend current, HttpContext http)
    {
        var auction = current.Auction;
        var raised = auction.RoomRound(Clocks.Now());
        return TypedResults.Ok(new TickResult(raised.Count, BidsOf(auction, http)));
    }

    private static async Task<Results<NoContent, ProblemHttpResult>> ResetMine(CurrentBackend current, HttpContext http)
    {
        if (http.UserIdOrNull() is not { } userId)
        {
            return TypedResults.Problem(detail: "Sign in to reset your bids.", statusCode: 401, title: "Not signed in");
        }
        await current.Auction.ResetAsync(userId);
        return TypedResults.NoContent();
    }

    /// <summary>The caller's badges, or an empty map for a visitor with no session: no bids is the true answer for them.</summary>
    private static IReadOnlyDictionary<string, BidView> BidsOf(Auction auction, HttpContext http) =>
        http.UserIdOrNull() is { } me ? auction.BidsOf(me) : new Dictionary<string, BidView>(StringComparer.Ordinal);

    #region bid-handling
    // One handler behind both bid endpoints, answering three questions in order: is this session
    // on this store, does the vehicle exist, does the domain accept the action on the server's
    // clock. The status codes are the contract the browser relies on: 401, 404, then 400.
    private static async Task<Results<Ok<BidResult>, ProblemHttpResult>> HandleBid(
        CurrentBackend current, HttpContext http, string id, Func<Auction, StandingVehicle, AuctionClock, Task<BidOutcome>> action)
    {
        string userId = http.UserId();
        // #region session-per-store
        // A session bids where its account is. The header can put one request on the other
        // store, and a bid there would be a row under an id that store has no account for. The
        // token names the store that opened it, so this check costs no lookup.
        if (!http.SessionIsOn(current.Backend))
        {
            return TypedResults.Problem(
                detail: $"This session was opened on another store. Sign in on {current.Backend.Name} to bid here.",
                statusCode: 401, title: "The bid was rejected");
        }
        // #endregion session-per-store
        var auction = current.Auction;
        var clock = Clocks.Now();
        if (auction.Find(id) is not { } vehicle)
        {
            return TypedResults.Problem(detail: "No vehicle has that id.", statusCode: 404, title: "No such vehicle");
        }
        var outcome = await action(auction, vehicle, clock);
        if (outcome.Kind == BidOutcomeKind.Rejected)
        {
            return TypedResults.Problem(detail: outcome.Reason, statusCode: 400, title: "The bid was rejected");
        }
        return TypedResults.Ok(
            new BidResult(
                outcome.Kind.ToString().ToLowerInvariant(),
                outcome.Amount,
                // The room's answer rides back with the bid, so the badge is right the moment the
                // response lands. TryGetValue rather than the indexer, because a reset from the
                // same account in a second tab can land between the bid and this read.
                auction.BidsOf(userId).TryGetValue(id, out var view) ? view : null,
                // Read again after the bid, so the price and the sold flag are the ones it left.
                VehicleWire.ToWire(auction.Find(id) ?? vehicle, clock)));
    }
    #endregion bid-handling
}

/// <summary>
/// Bid submission: the amount, and nothing else. The schedule is the server's, so a request
/// never names a time.
/// </summary>
/// <param name="Amount">Whole dollars, at or above the vehicle's min_next_bid.</param>
public sealed record BidRequest([property: Description("Whole dollars, at or above the vehicle's min_next_bid.")] int Amount);
```
*Live from [`api/TheYard.Api/Endpoints/BidEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Endpoints/BidEndpoints.cs#L1-L183), region `*`, read from this build at 1d6b50b.*

The use cases an endpoint asks,
[`api/TheYard.Application/Auction.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Auction.cs):

```csharp
// One store's auction as a visitor meets it: the catalogue, everybody's bids and the simulated
// room, composed here and nowhere else. Endpoints ask this class and never compose the three
// services themselves. A vehicle leaves it already standing (the highest of the dataset, the bids
// and the room) with its sold flag beside it, so no handler can show a price without the room or
// a bought vehicle as open, and the price a bid is measured against has one copy.
using TheYard.Data;
using TheYard.Domain;

namespace TheYard.Application;

/// <summary>
/// The use cases a visitor drives on one store: list and read vehicles at the price they stand at,
/// place a bid, buy now, start over, move the room, and read their own badges.
/// </summary>
/// <param name="inventory">The store's catalogue.</param>
/// <param name="bids">Everybody's bids on that catalogue.</param>
/// <param name="market">The simulated competing bidders on that catalogue.</param>
public sealed class Auction(InventoryService inventory, BidService bids, MarketService market)
{
    /// <summary>How many live auctions the room looks at in one round, beside the ones somebody is bidding on.</summary>
    private const int RoomRoundLiveCount = 40;

    // #region overlays
    /// <summary>
    /// The overlay a listing applies to every row: everybody's bids, then the room's. Each one only
    /// raises the price (StandingRules.RaisedTo), so the page shows the highest of the dataset, the
    /// bids and the room, and a competitor who has gone higher is what the buyer sees. Null when
    /// nobody has bid at all, so a cold listing pays for neither. IsEmpty rather than a snapshot
    /// count, because a snapshot copies the whole dictionary just to ask whether it is empty.
    /// </summary>
    private Func<Vehicle, Vehicle>? Overlay() => (bids.IsEmpty, market.IsEmpty) switch
    {
        (true, true) => null,
        (false, true) => bids.Apply,
        (true, false) => market.Apply,
        _ => AsItStands,
    };

    /// <summary>The vehicle at the price it stands at: the dataset, then everybody's bids, then the room's.</summary>
    private Vehicle AsItStands(Vehicle vehicle) => market.Apply(bids.Apply(vehicle));
    // #endregion overlays

    /// <summary>One vehicle by id, standing at its price and with its sold flag, or null when the catalogue has no such id.</summary>
    public StandingVehicle? Find(string vehicleId) =>
        inventory.GetById(vehicleId) is { } vehicle ? new StandingVehicle(AsItStands(vehicle), bids.IsSold(vehicleId)) : null;

    /// <summary>Whether anybody has bought this vehicle outright, which closes it to everybody.</summary>
    public bool IsSold(string vehicleId) => bids.IsSold(vehicleId);

    /// <summary>The values behind the catalogue's filters: every make, body style, province and the rest.</summary>
    public InventoryFacets Facets() => inventory.Facets();

    /// <summary>One page of the catalogue, filtered and sorted, each vehicle at the price it stands at and with its sold flag.</summary>
    public StandingPage Search(VehicleFilter filter, AuctionClock clock, VehicleSort sort, int limit, int offset)
    {
        var page = inventory.Search(filter, clock, sort, limit, offset, Overlay());
        return new StandingPage(page.Total, [.. page.Vehicles.Select(vehicle => new StandingVehicle(vehicle, bids.IsSold(vehicle.Id)))]);
    }

    // #region bidding
    /// <summary>
    /// One bid from one buyer, measured against the room's standing price as well as everybody's
    /// bids. Measuring against the dataset's figure alone would let a buyer retake the lead with a
    /// bid below the going rate.
    /// </summary>
    public Task<BidOutcome> PlaceBidAsync(StandingVehicle vehicle, int amount, AuctionClock clock, string userId) =>
        bids.PlaceBidAsync(vehicle.Vehicle, amount, clock, userId);

    /// <summary>A buy-now purchase from one buyer, at the vehicle's buy-now price, against the same standing.</summary>
    public Task<BidOutcome> BuyNowAsync(StandingVehicle vehicle, AuctionClock clock, string userId) =>
        bids.BuyNowAsync(vehicle.Vehicle, clock, userId);

    /// <summary>
    /// One buyer's start-over: their bids go, and so do the room's answers on the vehicles nobody
    /// else is bidding on any more. Leaving the room's counter-bid standing would make the reset
    /// clear one side of an auction and not the other.
    /// </summary>
    public async Task ResetAsync(string userId) => market.Forget(await bids.ResetAsync(userId));
    // #endregion bidding

    // #region room-round
    /// <summary>
    /// One round of bidding by the room. The room answers a price rather than a person, so it is
    /// handed everybody's standing, not one account's. Its candidates are every vehicle somebody
    /// is bidding on plus the first live auctions in the catalogue, so the grid moves even for a
    /// visitor who has bid on nothing. The first ones rather than a search, because a search
    /// derives a status for every row and sorts the matches to keep a handful of them.
    /// </summary>
    /// <returns>The ids the room raised.</returns>
    public IReadOnlyList<string> RoomRound(AuctionClock clock)
    {
        var standing = bids.StandingAsBids();
        var contested = standing.Keys
            .Select(inventory.GetById)
            .OfType<Vehicle>();
        var live = inventory.GetAll()
            .Where(v => AuctionSchedule.StatusFor(v.Id, clock) == AuctionStatus.Live)
            .Take(RoomRoundLiveCount);
        var candidates = contested.Concat(live).DistinctBy(v => v.Id).ToList();
        return market.Tick(candidates, standing, clock);
    }
    // #endregion room-round

    // #region views
    /// <summary>
    /// One buyer's bids, each with everybody else's answer folded in. A vehicle bought outright is
    /// never outbid, whatever anyone does afterwards: the sale already happened.
    /// <para>The buyer's own bids are read first and everybody else's second. The reads are not
    /// atomic, so a bid placed between them shows in one and not the other; reading the others
    /// second means the stale answer is "you have been outbid" rather than "you are winning", and
    /// only the second of those can cost somebody an auction.</para>
    /// </summary>
    public IReadOnlyDictionary<string, BidView> BidsOf(string userId)
    {
        var mine = bids.SnapshotFor(userId);
        var standing = bids.Standing();
        var room = market.Snapshot();
        var views = new Dictionary<string, BidView>(StringComparer.Ordinal);
        foreach (var (id, bid) in mine)
        {
            standing.TryGetValue(id, out var held);
            room.TryGetValue(id, out var against);

            int highest = Math.Max(held?.Amount ?? bid.Amount, against?.Amount ?? 0);
            bool someoneElseHolds = held is not null && held.Amount > bid.Amount;
            bool roomHolds = against is not null && against.Amount > bid.Amount;
            bool outbid = !bid.WonBuyNow && (someoneElseHolds || roomHolds);

            views[id] = new BidView(
                bid.Amount,
                // The count everybody has added to, not one buyer's, so the count never falls
                // when a buyer retakes a lead somebody else raised.
                Math.Max(Math.Max(bid.BidCount, held?.BidCount ?? 0), against?.BidCount ?? 0),
                bid.WonBuyNow,
                bid.AtMs,
                outbid,
                against?.Amount,
                highest);
        }
        return views;
    }

    /// <summary>
    /// One buyer's history for the account page, newest first: each vehicle's title beside the
    /// buyer's standing on it, or "(withdrawn)" when the vehicle has left the catalogue.
    /// </summary>
    public BidHistory HistoryOf(string userId)
    {
        var entries = BidsOf(userId)
            .OrderByDescending(entry => entry.Value.AtMs)
            .Select(entry => new BidHistoryEntry(
                entry.Key,
                inventory.GetById(entry.Key) is { } v ? $"{v.Year} {v.Make} {v.Model}" : "(withdrawn)",
                entry.Value))
            .ToList();
        return new BidHistory(entries.Count, entries);
    }
    // #endregion views
}
```
*Live from [`api/TheYard.Application/Auction.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/Auction.cs#L1-L159), region `*`, read from this build at 1d6b50b.*

The method that asks the rules and then calls the port, from the part of `BidService` that moves a
price,
[`api/TheYard.Application/BidService.Bidding.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/BidService.Bidding.cs):

```csharp
/// <summary>
/// Places one bid for one buyer. The rules run against the vehicle as
/// everybody's bids leave it, under the gate, and an accepted bid is
/// written to the store before it is recorded in memory.
/// </summary>
public async Task<BidOutcome> PlaceBidAsync(Vehicle vehicle, int amount, AuctionClock clock, string userId)
{
    await LoadAsync();
    await _gate.WaitAsync();
    try
    {
        var merged = Apply(vehicle);
        // Sold is read under the same gate as the write that makes it true,
        // so two buyers cannot both find it false.
        var outcome = BidRules.ResolveBid(merged, amount, clock, IsSold(vehicle.Id));
        if (outcome.Kind != BidOutcomeKind.Rejected)
        {
            var state = new BidState(
                outcome.Amount,
                merged.BidCount + 1,
                WonBuyNow: outcome.Kind == BidOutcomeKind.Won,
                AtMs: clock.NowMs);
            // The store first, then memory. The other order looks harmless
            // and is not: a store that throws would leave the dictionaries
            // holding a bid the caller was just told had failed, shown as
            // winning until the next restart deleted it. This way a failed
            // write means the bid did not happen anywhere, which is the
            // answer the caller already has.
            await _store.SaveAsync(userId, vehicle.Id, state);
            Record(userId, vehicle.Id, state);
        }
        return outcome;
    }
    finally
    {
        _gate.Release();
    }
}
```
*Live from [`api/TheYard.Application/BidService.Bidding.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/BidService.Bidding.cs#L14-L51), region `place`, read from this build at 1d6b50b.*

The rules,
[`api/TheYard.Domain/BidRules.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/BidRules.cs).
Nothing in it is `async`, nothing in it reads a file, and the clock arrives as an argument:

```csharp
using System.Globalization;
using TheYard.Data;

namespace TheYard.Domain;

public enum BidOutcomeKind
{
    Rejected,
    Accepted,
    Won,
}

/// <summary>The result of a bid or buy-now attempt.</summary>
/// <param name="Kind">Whether the attempt was rejected, accepted or won the vehicle.</param>
/// <param name="Amount">The amount that stands, in whole dollars; zero when rejected.</param>
/// <param name="Reason">Why the attempt was rejected, or null when it was not.</param>
public readonly record struct BidOutcome(BidOutcomeKind Kind, int Amount, string? Reason)
{
    public static BidOutcome Rejected(string reason) => new(BidOutcomeKind.Rejected, 0, reason);
    public static BidOutcome Accepted(int amount) => new(BidOutcomeKind.Accepted, amount, null);
    public static BidOutcome Won(int amount) => new(BidOutcomeKind.Won, amount, null);
}

/// <summary>
/// The auction bidding rules, server-side and authoritative:
///  - tiered increments: under $5k +$100, $5k–$19,999 +$250, $20k and up +$500;
///  - the minimum next bid is the high bid plus its tier's increment, or the
///    opening ask before any bids exist;
///  - bids are valid only while the auction is live;
///  - a bid at or above buy_now_price wins outright at the buy-now price,
///    even when it fails the minimum-increment check;
///  - a vehicle somebody has bought is sold, to everybody: no bid and no second
///    purchase, whatever the clock says.
/// </summary>
public static class BidRules
{
    public static int Increment(int currentBid) =>
        currentBid < 5_000 ? 100 : currentBid < 20_000 ? 250 : 500;

    public static int MinNextBid(Vehicle vehicle) =>
        vehicle.CurrentBid is { } bid ? bid + Increment(bid) : vehicle.StartingBid;

    // #region sold
    /// <summary>
    /// The sentence a sold vehicle answers every bid and every purchase with.
    /// One string, because the store's fact and the page's words should not be
    /// able to drift apart.
    /// </summary>
    public const string SoldReason = "This vehicle has been sold.";

    /// <summary>
    /// <paramref name="sold"/> is whether anybody has bought this vehicle
    /// outright, which the rules cannot know from the vehicle alone: the
    /// dataset has no such field and the schedule has no such state. The caller
    /// that holds everybody's standing says so, and it is asked before the
    /// clock and before the buy-now shortcut, so a second account bidding the
    /// buy-now price on a vehicle already bought is refused, not told it has
    /// won (ADR: Accounts and per-user bids, the addendum on the second buyer).
    /// </summary>
    public static BidOutcome ResolveBid(Vehicle vehicle, int amount, AuctionClock clock, bool sold)
    {
        if (sold)
        {
            return BidOutcome.Rejected(SoldReason);
        }
        var status = AuctionSchedule.StatusFor(vehicle.Id, clock);
        if (status == AuctionStatus.Live && vehicle.BuyNowPrice is { } buyNow && amount >= buyNow)
        {
            // Instant win, charged the buy-now price rather than the overbid.
            return BidOutcome.Won(buyNow);
        }
        if (status == AuctionStatus.Upcoming)
        {
            return BidOutcome.Rejected("This auction has not started yet.");
        }
        if (status == AuctionStatus.Ended)
        {
            return BidOutcome.Rejected("This auction has ended.");
        }

        int min = MinNextBid(vehicle);
        return amount < min
            ? BidOutcome.Rejected($"Bid must be at least ${min.ToString("N0", CultureInfo.InvariantCulture)}.")
            : BidOutcome.Accepted(amount);
    }

    public static BidOutcome ResolveBuyNow(Vehicle vehicle, AuctionClock clock, bool sold)
    {
        if (sold)
        {
            return BidOutcome.Rejected(SoldReason);
        }
        if (vehicle.BuyNowPrice is not { } buyNow)
        {
            return BidOutcome.Rejected("This vehicle has no Buy Now price.");
        }
        return AuctionSchedule.StatusFor(vehicle.Id, clock) == AuctionStatus.Live
            ? BidOutcome.Won(buyNow)
            : BidOutcome.Rejected("Buy Now is only available while the auction is live.");
    }
    // #endregion sold
}
```
*Live from [`api/TheYard.Domain/BidRules.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/BidRules.cs#L1-L102), region `*`, read from this build at 1d6b50b.*

## The inner ring owns the interface

The rule in one sentence has an obvious problem. Application has to save a bid, and the database is
outside it. The answer is dependency inversion, the D in SOLID: the inner ring declares the interface
it needs, in its own words, and the outer ring implements it. At compile time every arrow points
inward, at the interface. Only the call at runtime travels outward, and it travels through a type the
inner ring owns.

[![The inner ring owns the interface: Application declares IBidStore and uses it, EfBidStore and CosmosBidStore implement it from the outer ring, and the composition root decides which one each store gets](/api/docs/images/port-adapter.svg#1400x860)](https://theyard.stevenstout.biz/api/docs/diagrams/port-adapter)

*A preview. [Open the port and its adapters in a new page](https://theyard.stevenstout.biz/api/docs/diagrams/port-adapter) to zoom in and follow it.*

Microsoft's architecture guide for .NET describes the same move: "defining abstractions, or
interfaces, in the Application Core, which are then implemented by types defined in the
Infrastructure layer." TheYard runs it twice over, because two stores sit behind the same three
ports, and the rules have no idea which one is answering.

**The practice:** name the port for what the inner ring needs (`IBidStore`) and keep the technology
out of the name (`ISqlBidRepository` would leak it), and keep every type the port mentions inside the
inner ring. Give
it a do-nothing implementation beside it, as `NullBidStore` is here, and the use cases can be tested
with no database and no mocking framework.

The ports, whole,
[`api/TheYard.Application/Ports.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Ports.cs):

```csharp
using TheYard.Data;

namespace TheYard.Application;

// #region ports
// The three seams the layers meet at. Application declares what it needs and
// never learns where the data lives; Infrastructure implements these against
// EF Core and SQLite, against Cosmos DB, the tests against in-memory arrays,
// and the 100,000-record scale-up is a decorator over IVehicleSource that
// nothing above it can see.
//
// Every member returns a Task, because the document store has no synchronous
// driver, and a port that held a request thread while a cloud store answered
// would stall a one-vCPU container (ADR: The ports learn to wait).
/// <summary>Port: where the vehicle dataset comes from.</summary>
public interface IVehicleSource
{
    Task<IReadOnlyList<Vehicle>> LoadAsync();
}

/// <summary>Port: where the photo manifest comes from.</summary>
public interface IPhotoManifestSource
{
    Task<IReadOnlyList<PhotoEntry>> LoadAsync();
}

/// <summary>
/// Port: where everybody's bids are kept between one run of this process and
/// the next. Read once at startup and written through on every accepted bid,
/// which is the shape the bidding path can afford (ADR: The relational store).
/// A bid belongs to a user (ADR: Accounts and per-user bids), so the key is the
/// pair and not the vehicle.
/// </summary>
public interface IBidStore
{
    Task<IReadOnlyList<StoredBid>> LoadAsync();

    Task SaveAsync(string userId, string vehicleId, BidState state);

    /// <summary>
    /// Forget one person's bids. Not everybody's: this is what the reset button
    /// on a page a stranger can also be looking at is allowed to do
    /// (ADR: Reset is one person's start-over).
    /// </summary>
    Task ClearAsync(string userId);
}

/// <summary>
/// The port wired to nothing: bids live in memory and end with the process.
/// The unit tests use it, and so does a backend whose store has not come up
/// yet; those bids are dropped when the store attaches.
/// </summary>
public sealed class NullBidStore : IBidStore
{
    public static readonly NullBidStore Instance = new();

    private NullBidStore()
    {
    }

    public Task<IReadOnlyList<StoredBid>> LoadAsync() => Task.FromResult<IReadOnlyList<StoredBid>>([]);

    public Task SaveAsync(string userId, string vehicleId, BidState state) => Task.CompletedTask;

    public Task ClearAsync(string userId) => Task.CompletedTask;
}
// #endregion ports
```
*Live from [`api/TheYard.Application/Ports.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/Ports.cs#L1-L67), region `*`, read from this build at 1d6b50b.*

The relational adapters, whole: EF Core over Azure SQL Database or SQLite,
[`api/TheYard.Infrastructure/EfSources.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/EfSources.cs).
`EfBidStore` is the last class in the file:

```csharp
// The three ports, answered from the database: the seed catalogue, the photo manifest, and the
// bids. These are the adapters the composition root wires when the relational store opens.
// The rest of the relational plumbing has a file of its own beside this one:
//   VehicleRows.cs     row to domain and back
//   DatabaseState.cs   whether the store is usable, and one sentence about why
//   YardDatabase.cs    bringing the database up once at startup
//   SeedResult.cs      what the first boot found
//   YardSeed.cs        filling an empty catalogue from the JSON files
using Microsoft.EntityFrameworkCore;
using TheYard.Application;
using TheYard.Data;

namespace TheYard.Infrastructure;

/// <summary>
/// Adapter: the seed catalogue, out of the database. Same port the JSON file
/// reader implements, and the synthetic scale-up still wraps it, so nothing
/// above this layer depends on which storage is underneath.
/// </summary>
public sealed class EfVehicleSource(IDbContextFactory<YardDbContext> factory) : IVehicleSource
{
    // #region ef-sources
    /// <summary>Every seed vehicle, in the order it was seeded, read once without change tracking.</summary>
    public async Task<IReadOnlyList<Vehicle>> LoadAsync()
    {
        using var db = await factory.CreateDbContextAsync();
        // Read once, in seed order, tracking nothing: these rows are a
        // catalogue this process will never write back.
        return await db.Vehicles.AsNoTracking().OrderBy(row => row.Seq).Select(row => row.ToVehicle()).ToListAsync();
    }
    // #endregion ef-sources
}

/// <summary>Adapter: the photo manifest, out of the database.</summary>
public sealed class EfPhotoManifestSource(IDbContextFactory<YardDbContext> factory) : IPhotoManifestSource
{
    /// <summary>Every photo entry, in manifest order, read once without change tracking.</summary>
    public async Task<IReadOnlyList<PhotoEntry>> LoadAsync()
    {
        using var db = await factory.CreateDbContextAsync();
        return await db.Photos.AsNoTracking().OrderBy(row => row.Seq)
            .Select(row => new PhotoEntry(row.File, row.Style, row.Title))
            .ToListAsync();
    }
}

/// <summary>
/// Adapter: bids, which are the only thing here that survives a restart
/// because they are the only thing a visitor creates.
/// </summary>
public sealed class EfBidStore(IDbContextFactory<YardDbContext> factory) : IBidStore
{
    // #region bid-store
    /// <summary>Every stored bid, for BidService to replay into its indexes at startup.</summary>
    public async Task<IReadOnlyList<StoredBid>> LoadAsync()
    {
        using var db = await factory.CreateDbContextAsync();
        return await db.Bids.AsNoTracking()
            .Select(row => new StoredBid(
                row.UserId,
                row.VehicleId,
                new BidState(row.Amount, row.BidCount, row.WonBuyNow, row.AtMs)))
            .ToListAsync();
    }

    /// <summary>
    /// One row per buyer per vehicle, replaced rather than appended. Called
    /// inside BidService's lock, which is what makes "read the row, decide,
    /// write the row" safe here without the database needing an opinion about
    /// it.
    /// </summary>
    public async Task SaveAsync(string userId, string vehicleId, BidState state)
    {
        // Three tries, then the exception travels. A conflict here means
        // another writer moved this same buyer's row between the read and the
        // write, which takes the one account bidding from two containers at
        // once. The retry reads the row again and writes this bid over it: the
        // rules already ran on this side against the standing this container
        // held, and what the row held in between is not re-examined. Two buyers
        // racing each other are two rows and never meet here: the store keeps no
        // per-vehicle standing, so the compare-and-set standing that would join
        // them is a decision still open (ADR: Three readers with no memory of the project). A fresh
        // context per attempt, because a context that has just thrown a
        // concurrency exception is holding the values that lost.
        for (int attempt = 1; ; attempt++)
        {
            try
            {
                await WriteAsync(userId, vehicleId, state);
                return;
            }
            catch (DbUpdateConcurrencyException) when (attempt < 3)
            {
                // Deliberately empty. The retry is the handling.
            }
        }
    }

    /// <summary>
    /// One attempt at the write: insert the buyer's row for this vehicle, or
    /// update it in place, on a fresh context, and move the concurrency token
    /// where the provider does not move it itself.
    /// </summary>
    private async Task WriteAsync(string userId, string vehicleId, BidState state)
    {
        using var db = await factory.CreateDbContextAsync();
        var existing = await db.Bids.FindAsync(userId, vehicleId);
        if (existing is null)
        {
            existing = new BidRow
            {
                UserId = userId,
                VehicleId = vehicleId,
                Amount = state.Amount,
                BidCount = state.BidCount,
                WonBuyNow = state.WonBuyNow,
                AtMs = state.AtMs,
            };
            db.Bids.Add(existing);
        }
        else
        {
            existing.Amount = state.Amount;
            existing.BidCount = state.BidCount;
            existing.WonBuyNow = state.WonBuyNow;
            existing.AtMs = state.AtMs;
        }

        // SQL Server keeps the token itself; SQLite has no rowversion type, so
        // the store is what moves it. Setting it here rather than in a trigger
        // keeps the difference between the two providers in one readable place
        // (ADR: The SQL Server backend).
        if (db.Database.IsSqlite())
        {
            existing.RowVersion = Guid.NewGuid().ToByteArray();
        }

        await db.SaveChangesAsync();
    }

    /// <summary>Deletes one buyer's bids, every vehicle, in one statement.</summary>
    public async Task ClearAsync(string userId)
    {
        using var db = await factory.CreateDbContextAsync();
        // One person's rows and nobody else's. A delete over the whole table,
        // on a site two strangers can be looking at, would let either of them
        // delete the other's bids (ADR: Reset is one person's start-over).
        await db.Bids.Where(bid => bid.UserId == userId).ExecuteDeleteAsync();
    }
    // #endregion bid-store
}
```
*Live from [`api/TheYard.Infrastructure/EfSources.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure/EfSources.cs#L1-L151), region `*`, read from this build at 1d6b50b.*

The document store adapters, whole: the Cosmos DB SDK,
[`api/TheYard.Infrastructure.Cosmos/CosmosSources.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/CosmosSources.cs).
`CosmosBidStore` is the last class in this one too:

```csharp
using System.Net;
using Microsoft.Azure.Cosmos;
using TheYard.Application;
using TheYard.Data;

namespace TheYard.Infrastructure.Cosmos;

/// <summary>
/// Adapter: the seed catalogue, out of the document store. The same port the
/// JSON file reader and the relational adapter implement, and the synthetic
/// scale-up still wraps it, so nothing above this line can tell which store
/// answered (ADR: A second store on Cosmos DB, and what it costs).
/// </summary>
public sealed class CosmosVehicleSource(CosmosStore store) : IVehicleSource
{
    // #region cosmos-sources
    public async Task<IReadOnlyList<Vehicle>> LoadAsync()
    {
        // The whole container, once, as one cross-partition query, and ordered
        // here rather than by the store. ORDER BY needs an index on the sorted
        // path, and an index on seq would be paid for on every write to save a
        // sort of two hundred documents that costs nothing (ADR: The partition
        // key).
        var documents = await store.QueryAsync<VehicleDocument>(
            store.Vehicles, new QueryDefinition("SELECT * FROM c"), partitionKey: null, "cross-partition");
        return documents.OrderBy(d => d.Seq).Select(d => d.ToVehicle()).ToList();
    }
    // #endregion cosmos-sources
}

/// <summary>Adapter: the photo manifest, out of the document store.</summary>
public sealed class CosmosPhotoManifestSource(CosmosStore store) : IPhotoManifestSource
{
    public async Task<IReadOnlyList<PhotoEntry>> LoadAsync()
    {
        var documents = await store.QueryAsync<PhotoDocument>(
            store.Photos, new QueryDefinition("SELECT * FROM c"), partitionKey: null, "cross-partition");
        return documents.OrderBy(d => d.Seq).Select(d => d.ToEntry()).ToList();
    }
}

/// <summary>
/// Adapter: bids, in a container partitioned on the buyer. Every operation
/// here names the partition, so every one of them is a point operation or a
/// query inside one partition; the only fan-out is the read at startup
/// (ADR: The partition key).
/// </summary>
public sealed class CosmosBidStore(CosmosStore store) : IBidStore
{
    private const string Partition = "pinned to the buyer";

    // #region cosmos-bid-store
    public async Task<IReadOnlyList<StoredBid>> LoadAsync()
    {
        var documents = await store.QueryAsync<BidDocument>(
            store.Bids, new QueryDefinition("SELECT * FROM c"), partitionKey: null, "cross-partition");
        return documents.Select(d => d.ToStoredBid()).ToList();
    }

    /// <summary>
    /// One document per buyer per vehicle, replaced rather than appended. A
    /// point read, then a create if there was nothing or a replace carrying the
    /// etag the read returned. Three tries, then the exception travels: a 412
    /// means another writer moved this buyer's document between the read and
    /// the write, a 409 means another writer created it first, and both take
    /// the one account bidding from two containers at once. The retry reads
    /// again and writes this bid over what it finds, the same as the relational
    /// store (ADR: The SQL Server backend): the rules ran on this side already,
    /// and what the document held in between is not re-examined. Two buyers
    /// racing each other are two documents and never meet here: the store keeps
    /// no per-vehicle standing, so the join that would catch them is a decision
    /// still open (ADR: Three readers with no memory of the project).
    /// </summary>
    public async Task SaveAsync(string userId, string vehicleId, BidState state)
    {
        for (int attempt = 1; ; attempt++)
        {
            try
            {
                await WriteAsync(userId, vehicleId, state);
                return;
            }
            catch (CosmosException ex) when (attempt < 3
                && ex.StatusCode is HttpStatusCode.PreconditionFailed or HttpStatusCode.Conflict)
            {
                // Deliberately empty. The retry is the handling.
            }
        }
    }

    private async Task WriteAsync(string userId, string vehicleId, BidState state)
    {
        var existing = await store.ReadAsync<BidDocument>(store.Bids, vehicleId, userId, Partition);
        var document = new BidDocument
        {
            Id = vehicleId,
            UserId = userId,
            Amount = state.Amount,
            BidCount = state.BidCount,
            WonBuyNow = state.WonBuyNow,
            AtMs = state.AtMs,
        };
        if (existing is null)
        {
            await store.CreateAsync(store.Bids, document, userId, Partition);
        }
        else
        {
            await store.ReplaceAsync(store.Bids, document, vehicleId, userId, existing.ETag, Partition);
        }
    }

    /// <summary>
    /// One person's documents: a query inside that person's partition for the
    /// ids, then the deletes as an atomic batch. There is no ExecuteDelete on
    /// this side; the batch is what a partition offers instead
    /// (ADR: Reset is one person's start-over).
    /// </summary>
    public async Task ClearAsync(string userId)
    {
        var ids = await store.QueryAsync<string>(
            store.Bids,
            new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.user_id = @user").WithParameter("@user", userId),
            partitionKey: userId,
            Partition);
        if (ids.Count > 0)
        {
            await store.DeleteBatchAsync(store.Bids, userId, ids, Partition);
        }
    }
    // #endregion cosmos-bid-store
}
```
*Live from [`api/TheYard.Infrastructure.Cosmos/CosmosSources.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure.Cosmos/CosmosSources.cs#L1-L132), region `*`, read from this build at 1d6b50b.*

And the one place that names an adapter: the composition root,
[`api/TheYard.Api/Composition/StoreRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/StoreRegistration.cs),
building the relational store's backend and then the document store's. Look for
`new BidService(new EfBidStore(contexts))` in the first and `new BidService(new CosmosBidStore(store))`
in the second:

```csharp
// The relational store, on SQL Server or SQLite. A factory rather than a
// scoped context: the two sources and the bid store are singletons that each
// want a context for one operation, and there is no request scope at startup
// when the catalogue is read. The interceptor puts every statement on the Admin
// tab; it is attached here so YardConnection stays a description of where the
// database is.
// How long the first of the five asks waits after a refusal (two seconds unless the
// configuration shortens it, which only a test that proves the fallback does).
var firstWait = StorePrepare.FirstWaitFrom(builder.Configuration);
var sqlStartup = new StartupTimings();
var sqlState = await sqlStartup.Time("prepare", () => StorePrepare.WithTriesAsync(() => YardDatabase.PrepareAsync(yard, seedVehicles, seedPhotos), firstWait));
// The backend stands on the files until its store is attached: the same two
// ports answered out of the JSON, the null bid store, no accounts, with the
// synthetic scale-up still decorating the vehicle source (ADR: The relational
// store). Attached at once below when the store came up, or by the second
// chance when it comes up later (ADR: The relational store, the addendum on
// the second chance).
var sqlBackend = new Backend
{
    Key = "sql",
    Name = yard.Describe(),
    Database = sqlState,
    Startup = sqlStartup,
    Inventory = new InventoryService(new SyntheticVehicleSource(seedVehicles, targetCount), seedPhotos),
    Bids = new BidService(NullBidStore.Instance),
    Market = new MarketService(builder.Configuration.GetValue("Market:GraceSeconds", MarketService.DefaultGraceSeconds)),
    Probe = () => Task.FromResult(false),
    UserStore = _ => null,
};
// Everything that stands on the relational store, built from a state that
// says it came up. The loggers are attached the moment the host exists (the
// factory is read at call time, so an attach after startup gets them too).
StoreAttachment SqlAttachment(DatabaseState state)
{
    var sqlOptions = new DbContextOptionsBuilder<YardDbContext>();
    yard.Configure(sqlOptions);
    sqlOptions.AddInterceptors(new SqlLogInterceptor(sqlLog, currentRequest));
    var contexts = new ContextFactory(sqlOptions.Options);
    // The same database without the interceptor, for the activity counters:
    // a batch every five seconds would fill the SQL log with the feature that
    // reads the SQL log, the observer effect the request ring designed out
    // (ADR: Site activity, and the line an address does not cross).
    var quietOptions = new DbContextOptionsBuilder<YardDbContext>();
    yard.Configure(quietOptions);
    var quietContexts = new ContextFactory(quietOptions.Options);
    if (host.HostLoggers is not null)
    {
        contexts.Attach(host.HostLoggers);
    }
    return new StoreAttachment(
        state,
        contexts,
        quietContexts,
        new InventoryService(new SyntheticVehicleSource(new EfVehicleSource(contexts), targetCount), new EfPhotoManifestSource(contexts)),
        new BidService(new EfBidStore(contexts)),
        new EfActivityStore(quietContexts),
        () => YardDatabase.HasSeedAsync(contexts),
        // Identity's own store over the accounts tables, given a context from the
        // request's scope. The type is the one AddEntityFrameworkStores would have
        // registered for a user type with no roles; naming it here is what lets
        // the other backend register a different one under the same interface.
        services => new Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore<YardUser, YardDbContext, string>(
            services.GetRequiredService<YardDbContext>(),
            services.GetService<IdentityErrorDescriber>()));
}
if (sqlState.Ready)
{
    sqlBackend.Attach(SqlAttachment(sqlState));
}
backendList.Add(sqlBackend);
```
*Live from [`api/TheYard.Api/Composition/StoreRegistration.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Composition/StoreRegistration.cs#L95-L164), region `sql-backend`, read from this build at 1d6b50b.*

```csharp
// The document store, when there is an account to talk to. Same question, same
// answer shape, other store: are the containers there with the keys this code
// was written for, and is the seed in them. A refusal falls through to the
// file-backed catalogue exactly as a missing schema does on SQL Server.
CosmosStore? cosmos = null;
StoreSecondChance.Plan? cosmosSecondChance = null;
if (cosmosConfigured)
{
    cosmos = CosmosStore.Connect(
        configuredCosmos!,
        builder.Configuration["Cosmos:Database"] ?? "theyard",
        builder.Configuration["Cosmos:ContainerPrefix"] ?? "",
        // "managed-identity" on the deployed container; anything else, which
        // is what a developer's machine and the test runner have, means the
        // signed-in Azure CLI session.
        builder.Configuration["Cosmos:Credential"] ?? "azure-cli",
        YardComposition.AzureClientId(builder.Configuration),
        storeLog);
    // Filed under the request that caused it from the first operation on: the
    // describer exists before the store does now, so no operation is
    // attributed to nobody (ADR: What the store is actually doing).
    cosmos.CurrentRequest = currentRequest;
    var cosmosStartup = new StartupTimings();
    var cosmosState = await cosmosStartup.Time("prepare", () => StorePrepare.WithTriesAsync(() => cosmos.PrepareAsync(seedVehicles, seedPhotos), firstWait));
    var store = cosmos;
    // The same shape as the relational backend: the files until the store is
    // attached, at once when it came up, by the second chance when it did not
    // (ADR: A second store on Cosmos DB, and what it costs).
    var cosmosBackend = new Backend
    {
        Key = "cosmos",
        Name = "Azure Cosmos DB",
        Database = cosmosState,
        Startup = cosmosStartup,
        Cosmos = cosmos,
        Inventory = new InventoryService(new SyntheticVehicleSource(seedVehicles, targetCount), seedPhotos),
        Bids = new BidService(NullBidStore.Instance),
        Market = new MarketService(builder.Configuration.GetValue("Market:GraceSeconds", MarketService.DefaultGraceSeconds)),
        Probe = () => Task.FromResult(false),
        UserStore = _ => null,
    };
    StoreAttachment CosmosAttachment(DatabaseState state) => new(
        state,
        null,
        null,
        // The same three ports, answered out of the document store.
        new InventoryService(new SyntheticVehicleSource(new CosmosVehicleSource(store), targetCount), new CosmosPhotoManifestSource(store)),
        new BidService(new CosmosBidStore(store)),
        new CosmosActivityStore(store),
        store.ProbeAsync,
        // One document per account and one per address, and none of Identity's
        // seven tables (ADR: Accounts on a document store).
        _ => new CosmosUserStore(store));
    if (cosmosState.Ready)
    {
        cosmosBackend.Attach(CosmosAttachment(cosmosState));
    }
    backendList.Add(cosmosBackend);
    cosmosSecondChance = new StoreSecondChance.Plan(
        cosmosBackend,
        () => store.PrepareAsync(seedVehicles, seedPhotos),
        async state =>
        {
            var attachment = CosmosAttachment(state);
            await attachment.Inventory.WarmAsync();
            await attachment.Bids.LoadAsync();
            cosmosBackend.Attach(attachment);
        });
}
```
*Live from [`api/TheYard.Api/Composition/StoreRegistration.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Composition/StoreRegistration.cs#L168-L236), region `cosmos-backend`, read from this build at 1d6b50b.*

## The rules take the clock as a value

A rule that reads `DateTime.UtcNow` for itself gives a different answer every time it runs, and a
test of it can only check what happens "now". A rule that is handed the time gives the same answer for
the same inputs, so a test can pin any moment it likes: one millisecond before an auction opens, the
millisecond it opens, the last millisecond before it ends.

TheYard reads the clock at the edge, in the host. Its
[`api/TheYard.Api/Clocks.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Clocks.cs)
turns the current moment into an `AuctionClock` (now, and the UTC midnight the day's auctions are
scheduled from), and every endpoint that needs the time passes that value inward:

```csharp
using TheYard.Domain;

namespace TheYard.Api;

/// <summary>
/// The AuctionClock for a request: now, and the UTC midnight that began the
/// day, the same for every caller. A request never names a day, because an
/// auction that depended on the caller's clock would be a different auction
/// for every visitor.
/// </summary>
public static class Clocks
{
    /// <summary>The auction's clock for this request: now, and the UTC midnight that began the day.</summary>
    public static AuctionClock Now() => AuctionClock.Utc(UtcNow());

    /// <summary>
    /// The server's UTC clock, the one place an endpoint reads the time, so a handler that
    /// stamps a record or picks a reporting window reads the same clock the auction does.
    /// </summary>
    public static DateTimeOffset UtcNow() => DateTimeOffset.UtcNow;
}
```
*Live from [`api/TheYard.Api/Clocks.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Clocks.cs#L1-L21), region `*`, read from this build at 1d6b50b.*

The clock itself is a value in Domain,
[`api/TheYard.Domain/AuctionClock.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/AuctionClock.cs):

```csharp
namespace TheYard.Domain;

/// <summary>
/// The two instants auction scheduling needs: the current moment, and the
/// midnight the schedule anchors to. The anchor is the server's, the current
/// UTC day's midnight, so every visitor is in the same auction and no request
/// can name a day. An anchor the caller chose would put two visitors in
/// different zones in different auctions, and would let a caller that sent
/// yesterday's midnight bid on a vehicle that has ended for everyone else.
/// </summary>
/// <param name="NowMs">The current moment, as Unix epoch milliseconds.</param>
/// <param name="AnchorMs">The UTC midnight the schedule anchors to, as Unix epoch milliseconds.</param>
public readonly record struct AuctionClock(long NowMs, long AnchorMs)
{
    // #region utc
    /// <summary>The clock for a moment: that moment, and the UTC midnight that began its day.</summary>
    public static AuctionClock Utc(DateTimeOffset utcNow)
    {
        var midnight = new DateTimeOffset(utcNow.UtcDateTime.Date, TimeSpan.Zero);
        return new AuctionClock(utcNow.ToUnixTimeMilliseconds(), midnight.ToUnixTimeMilliseconds());
    }
    // #endregion utc
}
```
*Live from [`api/TheYard.Domain/AuctionClock.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/AuctionClock.cs#L1-L23), region `*`, read from this build at 1d6b50b.*

And the rule that uses it, whole,
[`api/TheYard.Domain/AuctionSchedule.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/AuctionSchedule.cs).
Every auction's opening and closing times are worked out from the vehicle's id and the anchor, so
nothing about the schedule is stored, and the status is a comparison of two numbers:

```csharp
namespace TheYard.Domain;

public enum AuctionStatus
{
    Upcoming,
    Live,
    Ended,
}

/// <summary>An auction's open/close instants as Unix epoch milliseconds.</summary>
/// <param name="StartsAtMs">When the auction opens, as Unix epoch milliseconds.</param>
/// <param name="EndsAtMs">When the auction closes, as Unix epoch milliseconds.</param>
public readonly record struct AuctionWindow(long StartsAtMs, long EndsAtMs);

/// <summary>
/// Derives each vehicle's auction window from its id: an FNV-1a seed, end
/// times spread across the two days before through five days after the
/// midnight anchor, two to four day runs. The anchor is the server's UTC
/// midnight (AuctionClock.Utc), one for every visitor. This is the only place
/// the window is computed; the browser receives the instants on the wire and
/// formats them (src/lib/auction.ts recomputes the status from those instants
/// and the clock, never the window), so a daylight-saving change cannot move
/// the two sides' windows apart.
/// </summary>
public static class AuctionSchedule
{
    private const long HourMs = 60L * 60 * 1000;
    private const long DayMs = 24 * HourMs;

    /// <summary>End times land within [anchor - Lookback, anchor - Lookback + Span).</summary>
    private const long ScheduleLookbackMs = 2 * DayMs;
    private const long ScheduleSpanMs = 7 * DayMs;

    /// <summary>Each auction runs 2–4 days from open to close.</summary>
    private const long MinDurationMs = 2 * DayMs;
    private const long ExtraDurationMs = 2 * DayMs;

    /// <summary>Deterministic per id and anchor (the UTC midnight that began the day).</summary>
    public static AuctionWindow Window(string vehicleId, long anchorMs)
    {
        uint hash = Fnv1a.Hash(vehicleId);
        double endFraction = hash % 100_000 / 100_000.0;
        double durationFraction = hash / 100_000 % 1_000 / 1_000.0;
        long endsAt = (long)Math.Round(anchorMs - ScheduleLookbackMs + endFraction * ScheduleSpanMs);
        long durationMs = (long)Math.Round(MinDurationMs + durationFraction * ExtraDurationMs);
        return new AuctionWindow(endsAt - durationMs, endsAt);
    }

    /// <summary>Live from StartsAt (inclusive) until EndsAt (exclusive).</summary>
    public static AuctionStatus Status(AuctionWindow window, long nowMs)
    {
        if (nowMs < window.StartsAtMs)
        {
            return AuctionStatus.Upcoming;
        }
        return nowMs < window.EndsAtMs ? AuctionStatus.Live : AuctionStatus.Ended;
    }

    public static AuctionStatus StatusFor(string vehicleId, AuctionClock clock) =>
        Status(Window(vehicleId, clock.AnchorMs), clock.NowMs);
}
```
*Live from [`api/TheYard.Domain/AuctionSchedule.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/AuctionSchedule.cs#L1-L61), region `*`, read from this build at 1d6b50b.*

One test, from
[`api/TheYard.Tests/AuctionScheduleTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/AuctionScheduleTests.cs),
pins the moment one millisecond either side of each edge of a window. `Clock` is built once at the top
of the class from a fixed date, so the anchor never depends on the day the test runs. With a clock the
rule read for itself, this test could not be written:

```csharp
// The rule is handed the moment as a number, so the test can name the exact millisecond on
// each side of both edges of a window: one before it opens, the one it opens, the last one
// before it closes, and the one it closes. A rule that read the clock for itself could not
// be asked about any of these four.
[Fact]
public void Status_boundaries_are_start_inclusive_and_end_exclusive()
{
    var window = AuctionSchedule.Window("some-id", Clock.AnchorMs);

    Assert.Equal(AuctionStatus.Upcoming, AuctionSchedule.Status(window, window.StartsAtMs - 1));
    Assert.Equal(AuctionStatus.Live, AuctionSchedule.Status(window, window.StartsAtMs));
    Assert.Equal(AuctionStatus.Live, AuctionSchedule.Status(window, window.EndsAtMs - 1));
    Assert.Equal(AuctionStatus.Ended, AuctionSchedule.Status(window, window.EndsAtMs));
}
```
*Live from [`api/TheYard.Tests/AuctionScheduleTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/AuctionScheduleTests.cs#L44-L57), region `boundaries`, read from this build at 1d6b50b.*

**The practice:** read the clock once, at the edge, and pass it inward as a value. The build holds
this one too: `Domain_never_reaches_the_disk_the_network_or_the_clock` reads every method body in
Domain for a call to `DateTime.Now`, `DateTime.UtcNow`, `DateTime.Today` or their `DateTimeOffset`
twins, and fails on the first it finds. .NET 8 added `TimeProvider` for code that needs a clock it can
swap, and the record says why a plain value is enough here
([What is kept on purpose](https://theyard.stevenstout.biz/?doc=adr-onion-and-solid#what-is-kept-on-purpose)).

## The build holds the rings

A rule held by code review alone slips the first busy week. TheYard's is held by ten tests that read
the compiled code with NetArchTest, a library that opens .NET assemblies (the compiled `.dll` files)
and checks which types use which. Reading compiled code matters: it sees method bodies and the hidden
types the compiler writes for lambdas and `async` methods, so a dependency tucked inside a lambda
fails the build as surely as a `using` line would. This repository uses the maintained fork,
NetArchTest.eNhancedEdition.

The ten rules, each with what it stops, are a table in the record, under
[How the build holds them](https://theyard.stevenstout.biz/?doc=adr-onion-and-solid#how-the-build-holds-them).
Each one was watched failing before it was trusted. Four failed against the code as it stood when
they were written:

- the two endpoint rules found `IServiceProvider` in the account and admin handlers, and the bid
  services in the bid, vehicle and admin handlers;
- the document store rule found the Cosmos DB adapter using the relational adapter's `DatabaseState`;
- the database rule found three queries in the host, one of them in a lambda inside the composition
  root.

The other six were proved by planting a forbidden reference and watching each go red. The whole file,
[`api/TheYard.Tests/OnionTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/OnionTests.cs):

```csharp
using Mono.Cecil;
using NetArchTest.Rules;
using TheYard.Api;
using TheYard.Application;
using TheYard.Data;
using TheYard.Domain;
using TheYard.Infrastructure;
using TheYard.Infrastructure.Cosmos;

namespace TheYard.Tests;

/// <summary>
/// The onion, held by the build. Dependencies point inward: Data at the centre, then Domain,
/// then Application, with the adapters (Infrastructure for the relational store,
/// Infrastructure.Cosmos for the document store) and the host (Api) outside them. Each rule reads
/// the compiled assemblies with NetArchTest, so a reference that only shows up in a method body,
/// a fully qualified name or a generic argument is caught as surely as a using line. An adapter
/// can never use the host because the host references the adapters and the compiler refuses a
/// cycle, so that line needs no test of its own.
/// (ADR: Onion and SOLID, how this codebase holds them)
/// </summary>
public sealed class OnionTests
{
    // #region rings
    /// <summary>The namespaces every ring may use: the .NET base class library and nothing else.</summary>
    private const string BaseClassLibrary = "System";

    /// <summary>
    /// The tracker the coverage collector writes into every assembly it measures. CI runs these
    /// tests with coverage on, so without this the rings would fail on code nobody wrote.
    /// </summary>
    private const string CoverageTracker = "Coverlet.Core.Instrumentation";

    /// <summary>
    /// The parts of the base class library that reach the disk, the network or a database. The
    /// rules above allow the whole library, so these are held out of the inner rings by name.
    /// </summary>
    private static readonly string[] TheDiskAndTheNetwork =
        ["System.IO.File", "System.IO.Directory", "System.IO.FileStream", "System.IO.StreamReader", "System.IO.StreamWriter", "System.Net", "System.Data"];

    [Fact]
    public void Data_depends_on_nothing_outside_the_BCL()
    {
        var result = InRing(typeof(Vehicle), "TheYard.Data")
            .ShouldNot().HaveDependencyOtherThan(BaseClassLibrary, CoverageTracker, "TheYard.Data")
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }

    [Fact]
    public void Domain_depends_on_nothing_outside_the_BCL_and_Data()
    {
        var result = InRing(typeof(BidRules), "TheYard.Domain")
            .ShouldNot().HaveDependencyOtherThan(BaseClassLibrary, CoverageTracker, "TheYard.Data", "TheYard.Domain")
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }

    [Fact]
    public void Application_depends_only_on_Domain_Data_and_the_BCL()
    {
        var result = InRing(typeof(Auction), "TheYard.Application")
            .ShouldNot().HaveDependencyOtherThan(BaseClassLibrary, CoverageTracker, "TheYard.Data", "TheYard.Domain", "TheYard.Application")
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }

    [Fact]
    public void Domain_never_reaches_the_disk_the_network_or_the_clock()
    {
        // A rule is a function of what it is handed: the vehicle, the bids and the clock. Reading
        // the time, a random number or a file itself would make it untestable with a fixed clock.
        var result = InRing(typeof(BidRules), "TheYard.Domain")
            .ShouldNot().HaveDependencyOnAny([.. TheDiskAndTheNetwork, "System.Random", "System.TimeProvider", "System.Environment"])
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
        Assert.Empty(ClockReads(typeof(BidRules).Assembly.Location, "TheYard.Domain"));
    }

    [Fact]
    public void Application_never_reaches_the_disk_or_the_network()
    {
        // A use case reaches a store or a service through a port; the disk and the network belong
        // to the adapters behind those ports.
        var result = InRing(typeof(Auction), "TheYard.Application")
            .ShouldNot().HaveDependencyOnAny(TheDiskAndTheNetwork)
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }

    [Fact]
    public void The_document_store_adapter_borrows_only_the_shared_user_from_the_relational_one()
    {
        // Identity's user entity is the one type both stores keep accounts as. Anything else from
        // the relational adapter, and Entity Framework above all, stays on its own side.
        string[] relational = typeof(YardDbContext).Assembly.GetTypes()
            .Where(type => type.IsPublic && type != typeof(YardUser))
            .Select(type => type.FullName!)
            .Append("Microsoft.EntityFrameworkCore")
            .ToArray();
        var result = Types.InAssembly(typeof(CosmosStore).Assembly)
            .ShouldNot().HaveDependencyOnAny(relational)
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }
    // #endregion rings

    // #region host
    [Fact]
    public void Endpoints_never_reach_a_store_or_the_service_container()
    {
        // An endpoint reads the request, asks the Application ring, and writes the answer. A
        // database context, a Cosmos DB client or a service looked up by hand would make it a
        // second place where the rules and the wiring live.
        var result = Types.InAssembly(typeof(BidEndpoints).Assembly)
            .That().MeetCustomRule(IsPartOfAnEndpointClass)
            .ShouldNot().HaveDependencyOnAny(
                "Microsoft.EntityFrameworkCore",
                "Microsoft.Azure.Cosmos",
                "Microsoft.Data.SqlClient",
                "TheYard.Infrastructure.Cosmos",
                "TheYard.Infrastructure.YardDbContext",
                "TheYard.Infrastructure.ContextFactory",
                "System.IServiceProvider",
                "Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions")
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }

    [Fact]
    public void Endpoints_reach_the_auction_only_through_the_Application_ring()
    {
        // The order bids are composed in (the buyer's first, the room's second) and the price a
        // bid is measured against are Application's, in Auction. An endpoint that held the
        // services itself could compose them in another order.
        var result = Types.InAssembly(typeof(BidEndpoints).Assembly)
            .That().MeetCustomRule(IsPartOfAnEndpointClass)
            .ShouldNot().HaveDependencyOnAny(
                "TheYard.Application.BidService",
                "TheYard.Application.MarketService",
                "TheYard.Application.InventoryService")
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }

    [Fact]
    public void The_host_never_queries_a_database_itself()
    {
        // A statement against a store belongs to the adapter for that store; the host asks the
        // adapter. Entity Framework's design-time factory and the composition root may name the
        // context type, and neither runs a query.
        var result = Types.InAssembly(typeof(BidEndpoints).Assembly)
            .ShouldNot().HaveDependencyOnAny(
                "Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions",
                "Microsoft.EntityFrameworkCore.RelationalQueryableExtensions",
                "Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions",
                "Microsoft.EntityFrameworkCore.DbSet",
                "Microsoft.Data.SqlClient",
                "Microsoft.Azure.Cosmos.QueryDefinition",
                "Microsoft.Azure.Cosmos.Container")
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }

    [Fact]
    public void Services_are_registered_only_in_the_composition_root()
    {
        // Registration classes in Composition/ and Program.cs are where the container is filled.
        // Anywhere else, a registration would be wiring hidden inside behaviour.
        var result = Types.InAssembly(typeof(BidEndpoints).Assembly)
            .That().MeetCustomRule(type => !IsCompositionRoot(type))
            .ShouldNot().HaveDependencyOnAny(
                "Microsoft.Extensions.DependencyInjection.IServiceCollection",
                "Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions",
                "Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions")
            .GetResult();
        Assert.True(result.IsSuccessful, Explain(result));
    }
    // #endregion host

    /// <summary>
    /// Every type in one ring's assembly and namespace, including the types the compiler writes
    /// inside them for lambdas and async methods, which carry the method bodies.
    /// </summary>
    private static PredicateList InRing(Type marker, string ring) =>
        Types.InAssembly(marker.Assembly).That().MeetCustomRule(type => InNamespace(Outermost(type).Namespace, ring));

    /// <summary>
    /// Every place a ring's method bodies read the system clock (DateTime.Now, DateTime.UtcNow,
    /// DateTime.Today and the DateTimeOffset ones). The type is allowed, because a rule takes the
    /// time as a value; reading it is not, and only a scan of the calls can tell the two apart.
    /// </summary>
    private static List<string> ClockReads(string assemblyPath, string ring)
    {
        using var module = ModuleDefinition.ReadModule(assemblyPath);
        return [.. module.GetTypes()
            .Where(type => InNamespace(Outermost(type).Namespace, ring))
            .SelectMany(type => type.Methods.Where(method => method.HasBody))
            .SelectMany(method => method.Body.Instructions
                .Select(instruction => instruction.Operand)
                .OfType<MethodReference>()
                .Where(called => called.DeclaringType.FullName is "System.DateTime" or "System.DateTimeOffset"
                    && called.Name is "get_Now" or "get_UtcNow" or "get_Today")
                .Select(called => $"{method.DeclaringType.FullName}.{method.Name} reads {called.DeclaringType.Name}.{called.Name[4..]}"))];
    }

    /// <summary>True for the ring's namespace and every namespace under it.</summary>
    private static bool InNamespace(string name, string ring) =>
        name == ring || name.StartsWith(ring + ".", StringComparison.Ordinal);

    /// <summary>True for an endpoint class and for the types the compiler writes inside one (its async state machines and lambdas).</summary>
    private static bool IsPartOfAnEndpointClass(TypeDefinition type) => Outermost(type).Name.EndsWith("Endpoints", StringComparison.Ordinal);

    /// <summary>True for Program and for a registration class in Composition/, and for the types the compiler writes inside them.</summary>
    private static bool IsCompositionRoot(TypeDefinition type)
    {
        string name = Outermost(type).Name;
        return name is "Program" or "<Program>$" || name.EndsWith("Registration", StringComparison.Ordinal);
    }

    /// <summary>The type a nested or compiler-written type sits inside, all the way out.</summary>
    private static TypeDefinition Outermost(TypeDefinition type)
    {
        while (type.DeclaringType is { } outer)
        {
            type = outer;
        }
        return type;
    }

    /// <summary>The failing types and why, so a red test names the line to fix rather than only saying no.</summary>
    private static string Explain(TestResult result) =>
        result.IsSuccessful
            ? ""
            : string.Join(Environment.NewLine, (result.FailingTypes ?? []).Select(type => $"{type.FullName}: {type.Explanation}"));
}
```
*Live from [`api/TheYard.Tests/OnionTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/OnionTests.cs#L1-L236), region `*`, read from this build at 1d6b50b.*

### Watch one go red on your own repository

1. Add the package to your test project: `dotnet add <your test project> package NetArchTest.eNhancedEdition`.
2. Write one rule: your Domain project may depend on `System` and on itself, and nothing else (the test is below).
3. Run `dotnet test` and watch it pass.
4. Plant a forbidden reference: add `Microsoft.Extensions.Logging.Abstractions` to the Domain project and write `_ = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;` in any Domain method.
5. Run `dotnet test` again: it fails and names the type and the dependency it found. Delete the line, and keep the test.

```csharp
using NetArchTest.Rules;
using Xunit;

public sealed class OnionTests
{
    [Fact]
    public void Domain_depends_on_nothing_outside_the_BCL()
    {
        // Every type in the Domain assembly, including the ones the compiler writes for lambdas and
        // async methods, may use the base class library and Domain itself. Nothing else.
        var result = Types.InAssembly(typeof(YourApp.Domain.SomeRule).Assembly)
            .ShouldNot().HaveDependencyOtherThan("System", "YourApp.Domain")
            .GetResult();

        // A red run names each type that broke the rule and what it reached for, so the message
        // points at the line to fix.
        Assert.True(
            result.IsSuccessful,
            string.Join(Environment.NewLine, (result.FailingTypes ?? []).Select(type => $"{type.FullName}: {type.Explanation}")));
    }
}
```

If your continuous integration collects code coverage, add `"Coverlet.Core.Instrumentation"` to the
allowed list: the coverage tool writes a tracker into every assembly it measures, and the rule would
otherwise fail on code nobody wrote.

## One project, rings as folders

Folders can draw the rings as well as projects can. The Shed, a file and folder browser that lives
beside TheYard in this repository
([`samples/theshed`](https://github.com/SteveStout/TheYard/tree/main/samples/theshed)) and runs at
[theshed.stevenstout.biz](https://theshed.stevenstout.biz), is one project. Its rings are folders, each folder is a namespace, and the
tests select a ring by namespace instead of by project: `Data`, `Domain`, `Application`,
`Infrastructure`, `Controllers`, `Documentation` and `Composition`. The reasoning for keeping it to one
project is its own record,
[One project, seven folders, dependencies inward](https://github.com/SteveStout/TheYard/blob/main/samples/theshed/docs/ADR-002-one-project-four-folders-dependencies-inward.md).

One project costs one thing. Across projects, the compiler refuses a reference that would make a
cycle, so an adapter can never use the host and nobody has to test it. Inside one project the compiler
allows anything, so every direction is a test's job, and so is a type that lands in a folder no rule
names. The last test in the file below exists for exactly that: a new folder or a missing namespace
line would otherwise be checked by none of the rules.

**The practice:** in a small codebase, start with folders and these tests, and split a ring into a
project of its own the day a second store or a second host appears. This is the section to copy first.

The Shed's tests, whole,
[`samples/theshed/tests/TestProject.Tests/OnionTests.cs`](https://github.com/SteveStout/TheYard/blob/main/samples/theshed/tests/TestProject.Tests/OnionTests.cs):

```csharp
using Mono.Cecil;
using NetArchTest.Rules;
using TestProject.Domain;

namespace TestProject.Tests;

/// <summary>
/// Checks the onion by reading the compiled app. The folders are rings, innermost first: Data,
/// Domain, Application, then Infrastructure and Controllers on the outside, and Composition, which
/// wires them together. A ring may use the rings inside it and never one outside it. NetArchTest
/// reads every type, including the ones the compiler writes for lambdas and async methods, so a
/// reference in a method body or a fully written name is caught as surely as a using line.
/// (more in docs/ADR-013-onion-and-solid.md)
/// </summary>
public sealed class OnionTests
{
    /// <summary>The tracker a coverage run writes into the app, which is nobody's dependency.</summary>
    private const string Coverage = "Coverlet.Core.Instrumentation";

    private static readonly string[] DataMayUse = ["System", Coverage, "TestProject.Data"];
    private static readonly string[] DomainMayUse = ["System", Coverage, "TestProject.Data", "TestProject.Domain"];
    private static readonly string[] ApplicationMayUse = ["System", Coverage, "TestProject.Data", "TestProject.Domain", "TestProject.Application"];
    private static readonly string[] InfrastructureMustNotUse = ["TestProject.Controllers", "TestProject.Composition", "TestProject.Documentation", "Microsoft.AspNetCore"];
    private static readonly string[] TheDisk = ["System.IO.File", "System.IO.Directory", "System.IO.FileInfo", "System.IO.DirectoryInfo", "System.IO.FileStream", "System.IO.FileSystemInfo", "System.IO.StreamReader", "System.IO.StreamWriter"];
    private static readonly string[] ControllersMustNotUse = [.. TheDisk, "TestProject.Infrastructure", "TestProject.Composition", "TestProject.Application.IFileStore", "TestProject.Domain.HomePath", "Microsoft.AspNetCore.Mvc.PhysicalFileResult"];
    private static readonly string[] DocumentationMustNotUse = ["TestProject.Controllers", "TestProject.Composition", "TestProject.Infrastructure", "Microsoft.AspNetCore"];
    private static readonly string[] Registration = ["Microsoft.Extensions.DependencyInjection.IServiceCollection", "Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions"];

    // #region rings
    [Fact]
    public void Data_depends_on_nothing_outside_the_BCL() =>
        AssertHolds(InRing("TestProject.Data").ShouldNot().HaveDependencyOtherThan(DataMayUse).GetResult());

    [Fact]
    public void Domain_depends_on_nothing_outside_the_BCL_and_Data() =>
        AssertHolds(InRing("TestProject.Domain").ShouldNot().HaveDependencyOtherThan(DomainMayUse).GetResult());

    [Fact]
    public void Application_depends_only_on_Domain_Data_and_the_BCL() =>
        AssertHolds(InRing("TestProject.Application").ShouldNot().HaveDependencyOtherThan(ApplicationMayUse).GetResult());

    [Fact]
    public void Application_never_touches_the_disk_itself() =>
        AssertHolds(InRing("TestProject.Application").ShouldNot().HaveDependencyOnAny(TheDisk).GetResult());

    [Fact]
    public void Infrastructure_depends_on_nothing_outside_it() =>
        AssertHolds(InRing("TestProject.Infrastructure").ShouldNot().HaveDependencyOnAny(InfrastructureMustNotUse).GetResult());

    [Fact]
    public void Controllers_never_touch_the_disk_or_the_file_store() =>
        AssertHolds(InRing("TestProject.Controllers").ShouldNot().HaveDependencyOnAny(ControllersMustNotUse).GetResult());

    [Fact]
    public void Documentation_reaches_only_Data_and_the_framework() =>
        AssertHolds(InRing("TestProject.Documentation").ShouldNot().HaveDependencyOnAny(DocumentationMustNotUse).GetResult());

    [Fact]
    public void Services_are_registered_only_in_Composition() =>
        AssertHolds(Types.InAssembly(typeof(HomePath).Assembly)
            .That().MeetCustomRule(type => Outermost(type).Namespace != "TestProject.Composition")
            .ShouldNot().HaveDependencyOnAny(Registration)
            .GetResult());

    [Fact]
    public void Every_type_sits_in_one_of_the_folders_the_rings_name()
    {
        // A type in a namespace no rule names (a new folder, a typo, a missing namespace line)
        // would be checked by none of the rules above. The compiler's own helpers are skipped:
        // their names start with "<", and the attributes it writes sit under System or Microsoft.
        // Program alone sits in the project's own namespace, above every folder.
        using var module = ModuleDefinition.ReadModule(typeof(HomePath).Assembly.Location);
        var strays = module.Types
            .Where(type => !type.Name.StartsWith('<'))
            .Where(type => !type.Namespace.StartsWith("System", StringComparison.Ordinal) && !type.Namespace.StartsWith("Microsoft", StringComparison.Ordinal) && !type.Namespace.StartsWith("Coverlet", StringComparison.Ordinal))
            .Where(type => type.Namespace != "TestProject" && !Folders.Any(ring => InNamespace(type.Namespace, ring)))
            .Select(type => type.FullName)
            .ToList();
        Assert.True(strays.Count == 0, "outside every ring: " + string.Join(", ", strays));
    }
    // #endregion rings

    /// <summary>The folders, each one a ring and a namespace.</summary>
    private static readonly string[] Folders = ["TestProject.Data", "TestProject.Domain", "TestProject.Application", "TestProject.Infrastructure", "TestProject.Controllers", "TestProject.Documentation", "TestProject.Composition"];

    /// <summary>
    /// Every type in one ring, including the ones the compiler writes inside them. A ring is a
    /// folder, and each folder is a namespace; a folder inside it belongs to the same ring.
    /// </summary>
    private static PredicateList InRing(string ring) =>
        Types.InAssembly(typeof(HomePath).Assembly).That().MeetCustomRule(type => InNamespace(Outermost(type).Namespace, ring));

    /// <summary>True for the ring's namespace and every namespace under it.</summary>
    private static bool InNamespace(string name, string ring) =>
        name == ring || name.StartsWith(ring + ".", StringComparison.Ordinal);

    /// <summary>The type a nested or compiler-written type sits inside, all the way out.</summary>
    private static TypeDefinition Outermost(TypeDefinition type)
    {
        while (type.DeclaringType is { } outer)
        {
            type = outer;
        }
        return type;
    }

    /// <summary>Passes when the rule held, and names each failing type and why when it did not.</summary>
    private static void AssertHolds(TestResult result) =>
        Assert.True(
            result.IsSuccessful,
            string.Join(Environment.NewLine, (result.FailingTypes ?? []).Select(type => $"{type.FullName}: {type.Explanation}")));
}
```
*Live from [`samples/theshed/tests/TestProject.Tests/OnionTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/samples/theshed/tests/TestProject.Tests/OnionTests.cs#L1-L112), region `*`, read from this build at 1d6b50b.*

## When not to build it this way

The rings cost projects, interfaces and tests, and they pay that back only when there is something to
protect: here, two stores behind the same rules, a host with an operator's tab, and rules worth
testing on their own. A small service over one table, a spike written to learn something quickly, or
a script that reads, changes and writes is clearer flat, and gains a ring the day a second store or a
rule worth testing alone turns up. The record draws that line in more detail, under
[When not to build it this way](https://theyard.stevenstout.biz/?doc=adr-onion-and-solid#when-not-to-build-it-this-way),
and The Shed above is the middle road between the two.

## References

- [Jeffrey Palermo, The Onion Architecture: part 1](https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/)
  (2008): the post that named the pattern, with the rule this page opens on and the case for putting
  interfaces in the core.
- [Common web application architectures, .NET architecture guides](https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures):
  Microsoft's description of clean architecture in .NET, including the difference between a
  compile-time dependency and a call at runtime that the bid's drawing shows.
- [NetArchTest.eNhancedEdition](https://github.com/NeVeSpl/NetArchTest.eNhancedEdition): the
  library the ten tests are written in, a maintained fork of NetArchTest with the failure explanations
  the tests above print.
- [Robert C. Martin, The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
  (2012): the same idea stated as The Dependency Rule, that source code dependencies can only point
  inward, and the argument for why it keeps frameworks and databases replaceable.
