# ADR: A request is a query or a command

Status: accepted, 2026-10-23, at 1.0.4.39. Every request the API answers is a record named `<Verb><Noun>Query` or `<Verb><Noun>Command`, with exactly one handler that implements `IQueryHandler` or `ICommandHandler`. A query handler is handed reading ports only. Nothing in the Application ring is named `*Service`. `CqrsTests` fails the build when any of that stops being true.

## Reading list

1. Martin Fowler, [CQRS](https://martinfowler.com/bliki/CQRS.html) (2011): one model to update and another to read, and his warning that for most systems the full pattern adds risky complexity. Read 2026-10-23.
2. Microsoft Learn, [CQRS pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs) (updated 2025-02-21): the basic form keeps one store, and separate stores, events and eventual consistency are the advanced form with their own costs. Read 2026-10-23.
3. Greg Young, [CQRS Documents](https://cqrs.wordpress.com/wp-content/uploads/2010/11/cqrs_documents.pdf): CQRS as Meyer's command-query separation lifted from methods to objects, with event sourcing a pairing and never a requirement. Read 2026-10-23.
4. Jimmy Bogard, [Vertical Slice Architecture](https://www.jimmybogard.com/vertical-slice-architecture/) (2018): organise by request, and the request is already a command or a query. Read 2026-10-23.
5. [ADR: The onion cut into vertical slices](https://theyard.stevenstout.biz/?doc=adr-the-onion-cut-into-vertical-slices) and [ADR: Onion and SOLID](https://theyard.stevenstout.biz/?doc=adr-onion-and-solid): the slices and the rings this record adds a rule inside.
6. [CQRS, the practices](https://theyard.stevenstout.biz/?doc=cqrs): the same decision as a page to learn from, with the counts this build has.

## In plain words

Every request to this site either asks a question or asks for something to change. Asking which vehicles are live is a question. Placing a bid is a change. From this version the code says which one each request is, in its name, and the build refuses a question that has been handed the means to change anything.

That is the whole of it here. There is one database per store, as before, and no queue or bus between the two halves. The value is that a reader can tell from one word whether a piece of code can change anything, and a test holds that word to be true.

## Context

Measured at 1.0.4.38: 27 handlers in the Application ring, each a `sealed class <Verb><Noun>Handler` with a `HandleAsync`, and no type named `*Query` or `*Command`. A handler's request was a loose list of arguments or a record with a free name (`PlaceBid`, `VehicleSearch`, `BlogOpen`). Beside the handlers sat three services and the class that composed them: `BidService` in four files placed bids, reset them and read standings; `InventoryService` in five files was reads only; `MarketService` ran the simulated room; and `Auction` held all three, so every bid and vehicle handler took `Auction` and could reach every write any of them had.

The ports had the same shape. `IBlogStore`, `IAccountStore` and `ILinkOpens` each carried the reads and the writes in one interface, so the query that lists blog posts was handed the method that saves one.

## Decision

1. Two interfaces in `api/TheYard.Application/Shared/Requests.cs`, and nothing else is added: no bus, no mediator, no pipeline. A slice's `Module` maps its route to its handler, as it did.

```csharp
/// <summary>Answers one query: reads, and writes nothing. Its constructor takes reader ports only (CqrsTests).</summary>
/// <typeparam name="TQuery">The query, a record named <c>&lt;Verb&gt;&lt;Noun&gt;Query</c>.</typeparam>
/// <typeparam name="TResult">What the query answers.</typeparam>
public interface IQueryHandler<in TQuery, TResult>
{
    /// <summary>The answer to one query.</summary>
    /// <param name="query">What is asked.</param>
    /// <param name="cancellation">The request's cancellation.</param>
    Task<TResult> HandleAsync(TQuery query, CancellationToken cancellation);
}

/// <summary>Carries out one command: changes something, and answers what it did. It may take reader and writer ports.</summary>
/// <typeparam name="TCommand">The command, a record named <c>&lt;Verb&gt;&lt;Noun&gt;Command</c>.</typeparam>
/// <typeparam name="TResult">What the command answers: the outcome, or whether it did anything.</typeparam>
public interface ICommandHandler<in TCommand, TResult>
{
    /// <summary>Carries out one command.</summary>
    /// <param name="command">What is to be done.</param>
    /// <param name="cancellation">The request's cancellation.</param>
    Task<TResult> HandleAsync(TCommand command, CancellationToken cancellation);
}
```
*Live from [`api/TheYard.Application/Shared/Requests.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Application/Shared/Requests.cs#L9-L29), region `requests`, read from this build at 085b3b4.*

2. Every request is a sealed record in its slice's Application folder, named `<Verb><Noun>Query` when it reads and `<Verb><Noun>Command` when it changes something, and its handler is named the same with `Handler` in place of the last word. A request with nothing to say is an empty record (`ReadFacetsQuery`, `ListPostsQuery`) rather than a missing one, so every handler has the same shape.
3. A query handler's constructor takes no parameter whose type ends in `Store` or `Writer`. A command handler may take both sides.
4. A port that a query needs only half of is split: the reading side is an interface ending in `Reader` or `Catalogue`, and the writing side extends it or stands beside it. `IBlogStore : IBlogReader`, `IAccountStore : IAccountReader` and `ILinkOpens : ILinkOpensReader` in this version.
5. The three services and `Auction` go. Each store's auction is three parts, each implementing a reading and a writing interface (`api/TheYard.Application/Shared/Auction/AuctionParts.cs`): `Stock` (the catalogue, read only, so `IStockReader` alone), `BidBook` (`IBidReader`, `IBidWriter`) and `Room` (`IRoomReader`, `IRoomWriter`). `StandingReader` lays the three reading sides over each other in the one order the rules need, and is the only way a query reads a vehicle. The round of the room and a buyer's start-over move into their command handlers.
6. Nothing in the Application ring's root or its slices is named `*Service`.

One query, the three the catalogue answers, each taking the reading side only:

```csharp
/// <summary>
/// A page of the catalogue (ADR: The onion cut into vertical slices, rule 7): filtered, sorted and
/// paged in the store, each vehicle at its standing price with its sold flag, so a listing never
/// composes the bids, the room or the sale itself.
/// </summary>
/// <param name="standings">The request store's vehicles as they stand, and the caller's badges: the reading side only.</param>
public sealed class SearchVehiclesHandler(StandingReader standings) : IQueryHandler<SearchVehiclesQuery, StandingPage>
{
    /// <summary>One page.</summary>
    /// <param name="request">The filter, the clock it was read on, the sort and the page.</param>
    /// <param name="cancellation">The request's cancellation.</param>
    public Task<StandingPage> HandleAsync(SearchVehiclesQuery request, CancellationToken cancellation) =>
        standings.SearchAsync(request.Filter, request.Clock, request.Sort, request.Limit, request.Offset, cancellation);
}

/// <summary>The distinct values behind the filters, across the whole catalogue.</summary>
/// <param name="inventory">The catalogue, reading side.</param>
public sealed class ReadFacetsHandler(IStockReader inventory) : IQueryHandler<ReadFacetsQuery, InventoryFacets>
{
    /// <summary>The makes, body styles, title statuses and provinces.</summary>
    /// <param name="query">The facets, which take nothing.</param>
    /// <param name="cancellation">The request's cancellation.</param>
    public Task<InventoryFacets> HandleAsync(ReadFacetsQuery query, CancellationToken cancellation) => inventory.FacetsAsync(cancellation);
}

/// <summary>The values behind the filters; there is nothing to ask beyond the asking.</summary>
public sealed record ReadFacetsQuery;

/// <summary>One vehicle at its standing price, on the server's clock.</summary>
/// <param name="standings">The request store's vehicles as they stand, and the caller's badges: the reading side only.</param>
/// <param name="clock">The server's clock.</param>
public sealed class FindVehicleHandler(StandingReader standings, Func<AuctionClock> clock) : IQueryHandler<FindVehicleQuery, FoundVehicle>
{
    /// <summary>The vehicle and the clock its facts are derived on, or no vehicle.</summary>
    /// <param name="query">Which vehicle.</param>
    /// <param name="cancellation">The request's cancellation.</param>
    public async Task<FoundVehicle> HandleAsync(FindVehicleQuery query, CancellationToken cancellation) =>
        new(await standings.FindAsync(query.Id, cancellation), clock());
}

/// <summary>One vehicle by its id.</summary>
/// <param name="Id">The vehicle's id.</param>
public sealed record FindVehicleQuery(string Id);
```
*Live from [`api/TheYard.Application/Features/Vehicles/VehicleHandlers.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Application/Features/Vehicles/VehicleHandlers.cs#L10-L52), region `vehicle-handlers`, read from this build at 085b3b4.*

One command, the bid, which reads the vehicle as it stands and writes through the bids' writing side:

```csharp
/// <summary>
/// Places a bid, or buys now, for one signed-in bidder (ADR: The onion cut into vertical slices,
/// rule 7). The order of the three questions is the contract the browser relies on: the session's
/// store first, then the vehicle, then the rules. The vehicle is read as it stands and the bid is
/// written through the bids' writing side, each handed in by the slice's Module; the clock is the server's.
/// </summary>
/// <param name="standings">The request store's vehicles as they stand, and the caller's badges: the reading side only.</param>
/// <param name="bids">Everybody's bids, writing side: the bid and the buy-now purchase.</param>
/// <param name="clock">The server's clock, read once per request.</param>
public sealed class PlaceBidHandler(StandingReader standings, IBidWriter bids, Func<AuctionClock> clock) : ICommandHandler<PlaceBidCommand, BidAnswer>
{
    /// <summary>Answers one bid or Buy Now.</summary>
    /// <param name="request">Who, on which vehicle, and the amount or none for Buy Now.</param>
    /// <param name="cancellation">The request's cancellation.</param>
    public async Task<BidAnswer> HandleAsync(PlaceBidCommand request, CancellationToken cancellation)
    {
        // A session bids where its account is. The token names the store that opened it, so
        // this check costs no lookup; a bid on the other store would be a row under an id that
        // store has no account for.
        if (!request.SessionOnThisStore)
        {
            return BidAnswer.Refused(BidAnswerKind.SessionElsewhere, null);
        }
        var now = clock();
        cancellation.ThrowIfCancellationRequested();
        if (await standings.FindAsync(request.VehicleId, cancellation) is not { } vehicle)
        {
            return BidAnswer.Refused(BidAnswerKind.NoSuchVehicle, null);
        }
        // The bid is measured against the vehicle as it stands, the room's price included: measuring
        // against the dataset's figure alone would let a buyer retake the lead below the going rate.
        var outcome = request.Amount is { } amount
            ? await bids.PlaceBidAsync(vehicle.Vehicle, amount, now, request.UserId, cancellation)
            : await bids.BuyNowAsync(vehicle.Vehicle, now, request.UserId, cancellation);
        if (outcome.Kind == BidOutcomeKind.Rejected)
        {
            return BidAnswer.Refused(BidAnswerKind.Rejected, outcome.Reason);
        }
        // The room's answer rides back with the bid, so the badge is right the moment the
        // response lands. TryGetValue, because a reset from the same account in a second tab can
        // land between the bid and this read. The vehicle is read again after the bid, so the
        // price and the sold flag are the ones it left.
        return new BidAnswer(
            BidAnswerKind.Placed,
            outcome,
            null,
            standings.BidsOf(request.UserId).TryGetValue(request.VehicleId, out var standing) ? standing : null,
            // Read with no token: the bid has been taken and this is the answer describing
            // what it left, so a caller who has gone must not turn a placed bid into an error.
            await standings.FindAsync(request.VehicleId, CancellationToken.None) ?? vehicle,
            now);
    }
}
```
*Live from [`api/TheYard.Application/Features/Bids/PlaceBidHandler.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Application/Features/Bids/PlaceBidHandler.cs#L10-L62), region `place-bid`, read from this build at 085b3b4.*

The three parts and their two sides:

```csharp
/// <summary>The catalogue as a query reads it: one vehicle, several, the first live ones, the filters' values and a page.</summary>
public interface IStockReader
{
    /// <summary>One vehicle by id, at the dataset's figures, or null when the catalogue has no such id.</summary>
    Task<Vehicle?> FindAsync(string id, CancellationToken cancellation);

    /// <summary>The vehicles with these ids that the catalogue holds, in no promised order.</summary>
    Task<IReadOnlyList<Vehicle>> FindManyAsync(IReadOnlyCollection<string> ids, CancellationToken cancellation);

    /// <summary>The first live auctions in the catalogue's own order, at most <paramref name="take"/>.</summary>
    Task<IReadOnlyList<Vehicle>> FirstLiveAsync(AuctionClock clock, int take, CancellationToken cancellation);

    /// <summary>The values behind the catalogue's filters.</summary>
    Task<InventoryFacets> FacetsAsync(CancellationToken cancellation);

    /// <summary>One page of the catalogue, filtered and sorted, with the overlay laid on before either.</summary>
    Task<SearchResult> SearchAsync(VehicleFilter filter, AuctionClock clock, VehicleSort sort = VehicleSort.EndingSoonest, int limit = int.MaxValue, int offset = 0, ListingOverlay? overlay = null, CancellationToken cancellation = default);
}

/// <summary>Everybody's bids as a query reads them: the standing on each vehicle, one buyer's bids, and whether a vehicle is sold.</summary>
public interface IBidReader
{
    /// <summary>Whether nobody has bid on anything, so a cold listing pays for no overlay.</summary>
    bool IsEmpty { get; }

    /// <summary>One buyer's own bids, by vehicle id.</summary>
    IReadOnlyDictionary<string, BidState> SnapshotFor(string userId);

    /// <summary>Every vehicle's standing, by id.</summary>
    IReadOnlyDictionary<string, VehicleStanding> Standing();

    /// <summary>Whether anybody has bought this vehicle outright.</summary>
    bool IsSold(string vehicleId);

    /// <summary>The standing in the shape the room reads it, one bid per vehicle.</summary>
    IReadOnlyDictionary<string, BidState> StandingAsBids();

    /// <summary>The vehicle with everybody's bids laid on its dataset figures.</summary>
    Vehicle Apply(Vehicle vehicle);
}

/// <summary>The writes that move everybody's bids: a bid, a buy-now purchase and one buyer's start-over.</summary>
public interface IBidWriter
{
    /// <summary>One bid from one buyer, measured against the vehicle as it stands.</summary>
    /// <remarks>The token is honoured up to the gate and not past it: once a bid's turn comes
    /// the store is written and memory follows, and that pair finishes (ADR: The ports learn to
    /// wait, the addendum on the token).</remarks>
    Task<BidOutcome> PlaceBidAsync(Vehicle vehicle, int amount, AuctionClock clock, string userId, CancellationToken cancellation);

    /// <summary>A buy-now purchase from one buyer, at the vehicle's buy-now price.</summary>
    /// <remarks>Cancellable up to the gate, for the reason a bid is.</remarks>
    Task<BidOutcome> BuyNowAsync(Vehicle vehicle, AuctionClock clock, string userId, CancellationToken cancellation);

    /// <summary>One buyer's bids removed; answers the vehicles nobody else is bidding on any more.</summary>
    /// <remarks>Cancellable up to the gate, for the reason a bid is.</remarks>
    Task<IReadOnlyList<string>> ResetAsync(string userId, CancellationToken cancellation);
}

/// <summary>The room's bids as a query reads them.</summary>
public interface IRoomReader
{
    /// <summary>Whether the room has bid on nothing yet.</summary>
    bool IsEmpty { get; }

    /// <summary>The room's standing bid on every vehicle it has bid on, by id.</summary>
    IReadOnlyDictionary<string, MarketBid> Snapshot();

    /// <summary>The vehicle with the room's bid laid on it.</summary>
    Vehicle Apply(Vehicle vehicle);
}

/// <summary>The writes that move the room: one round of bidding, and forgetting the vehicles a reset left alone.</summary>
public interface IRoomWriter
{
    /// <summary>One round over these candidates against everybody's standing; answers the ids it raised.</summary>
    IReadOnlyList<string> Tick(IReadOnlyList<Vehicle> candidates, IReadOnlyDictionary<string, BidState> buyerBids, AuctionClock clock, int maxBids = 3);

    /// <summary>The room's answers on these vehicles dropped.</summary>
    void Forget(IEnumerable<string> vehicleIds);
}
```
*Live from [`api/TheYard.Application/Shared/Auction/AuctionParts.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Application/Shared/Auction/AuctionParts.cs#L12-L92), region `auction-parts`, read from this build at 085b3b4.*

## What holds it

`CqrsTests`, new, red on 1.0.4.38 with only its file applied:

```csharp
/// <summary>
/// A request is a query or a command (ADR-115). A request is a record named <c>&lt;Verb&gt;&lt;Noun&gt;Query</c> or
/// <c>&lt;Verb&gt;&lt;Noun&gt;Command</c> in its slice's Application folder, with exactly one handler of the same name,
/// and every handler is one of the two interfaces in Shared/Requests.cs. A query handler is handed nothing that
/// writes: no parameter of its constructor is a store or a writer, only a reader. There is no bus: the slice's
/// Module maps a route to its one handler, and the route takes that handler and nothing else this codebase defines.
/// </summary>
public sealed class CqrsTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    /// <summary>The Application ring, where every request and its handler live.</summary>
    private static readonly Assembly Application = typeof(IQueryHandler<,>).Assembly;

    /// <summary>The slices' namespace in the Application ring.</summary>
    private const string Features = "TheYard.Application.Features";

    /// <summary>Every request in the slices whose name ends in this word: Query or Command.</summary>
    private static List<Type> Requests(string word) =>
        [.. Application.GetTypes().Where(type => type.IsClass && type.Namespace?.StartsWith(Features, StringComparison.Ordinal) == true && type.Name.EndsWith(word, StringComparison.Ordinal))];

    /// <summary>Every class in the Application ring's root or its slices whose name ends in Service.</summary>
    private static List<string> Services() =>
        [.. Application.GetTypes()
            .Where(type => type.IsClass && !type.Name.StartsWith('<') && type.Name.EndsWith("Service", StringComparison.Ordinal)
                && (type.Namespace == "TheYard.Application" || type.Namespace?.StartsWith(Features, StringComparison.Ordinal) == true))
            .Select(type => type.Name)];

    /// <summary>Every handler in the Application ring: a class whose name ends in Handler.</summary>
    private static List<Type> HandlerTypes() =>
        [.. Application.GetTypes().Where(type => type.IsClass && !type.IsAbstract && type.Name.EndsWith("Handler", StringComparison.Ordinal) && type.Namespace?.StartsWith(Features, StringComparison.Ordinal) == true)];

    /// <summary>The query or command interfaces a type implements, with the request each names.</summary>
    private static List<(Type Contract, Type Request, bool Query)> Contracts(Type handler) =>
        [.. handler.GetInterfaces()
            .Where(contract => contract.IsGenericType && (contract.GetGenericTypeDefinition() == typeof(IQueryHandler<,>) || contract.GetGenericTypeDefinition() == typeof(ICommandHandler<,>)))
            .Select(contract => (contract, contract.GetGenericArguments()[0], contract.GetGenericTypeDefinition() == typeof(IQueryHandler<,>)))];

    [Fact]
    public void Every_handler_is_exactly_one_query_or_command_handler_named_for_its_request()
    {
        var handlers = HandlerTypes();
        Assert.True(handlers.Count >= 27, $"only {handlers.Count} handlers were found");
        var wrong = new List<string>();
        foreach (var handler in handlers)
        {
            var contracts = Contracts(handler);
            if (contracts.Count != 1)
            {
                wrong.Add($"{handler.Name} implements {contracts.Count} of IQueryHandler and ICommandHandler; it implements exactly one");
                continue;
            }
            var (_, request, query) = contracts[0];
            string expected = handler.Name[..^"Handler".Length] + (query ? "Query" : "Command");
            if (request.Name != expected)
            {
                wrong.Add($"{handler.Name} handles {request.Name}; its request is named {expected}");
            }
        }
        Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
    }

    [Fact]
    public void Every_query_and_command_has_exactly_one_handler()
    {
        var requests = Requests("Query").Concat(Requests("Command")).ToList();
        Assert.True(requests.Count >= 27, $"only {requests.Count} requests were found");
        var handled = HandlerTypes().SelectMany(Contracts).GroupBy(contract => contract.Request).ToDictionary(group => group.Key, group => group.ToList());
        var wrong = new List<string>();
        foreach (var request in requests)
        {
            int count = handled.TryGetValue(request, out var found) ? found.Count : 0;
            if (count != 1)
            {
                wrong.Add($"{request.Name} has {count} handlers; it has exactly one");
            }
            else if (request.Name.EndsWith("Query", StringComparison.Ordinal) != found![0].Query)
            {
                wrong.Add($"{request.Name} is handled as a {(found[0].Query ? "query" : "command")}; its name says otherwise");
            }
            if (!request.IsSealed)
            {
                wrong.Add($"{request.Name} is not sealed");
            }
        }
        Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
    }

    [Fact]
    public void A_query_handler_is_handed_no_store_and_no_writer()
    {
        // Interface Segregation as a rule: a query reads, so the ports it takes end in Reader or Catalogue,
        // or are none of the two writing kinds; a store or a writer handed to a query is a write it could make.
        static string Bare(Type type) => (Nullable.GetUnderlyingType(type) ?? type).Name;
        var wrong = HandlerTypes()
            .Where(handler => Contracts(handler).Any(contract => contract.Query))
            .SelectMany(handler => handler.GetConstructors().SelectMany(constructor => constructor.GetParameters())
                .Where(parameter => Bare(parameter.ParameterType).EndsWith("Store", StringComparison.Ordinal) || Bare(parameter.ParameterType).EndsWith("Writer", StringComparison.Ordinal))
                .Select(parameter => $"{handler.Name} takes {Bare(parameter.ParameterType)} {parameter.Name}; a query takes the reader"))
            .ToList();
        Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
    }

    // #region a-reader-is-known-by-what-it-can-do
    [Fact]
    public void A_query_handler_is_handed_no_port_that_can_write()
    {
        // The rule above reads a port's name, so a port named ...Reader that also writes passes it. This one
        // reads what the port can do: no type a query handler takes by constructor, among this codebase's own,
        // has a member whose name starts with a writing verb, counting every member of every interface it
        // inherits. A port that both reads and writes is split, and the query takes the reading half.
        var wrong = HandlerTypes()
            .Where(handler => Contracts(handler).Any(contract => contract.Query))
            .SelectMany(handler => handler.GetConstructors().SelectMany(constructor => constructor.GetParameters())
                .Select(parameter => (handler, parameter, writes: WritingMembers(Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType)))
                .Where(found => found.writes.Count > 0)
                .Select(found => $"{found.handler.Name} takes {found.parameter.ParameterType.Name} {found.parameter.Name}, which can {string.Join(", ", found.writes)}; a query takes a port that only reads"))
            .Where(line => !ExemptReaders.Any(exempt => line.StartsWith(exempt.Key + " ", StringComparison.Ordinal)))
            .ToList();
        Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
    }

    /// <summary>The verbs a member that writes starts with; a reader has none of them.</summary>
    private static readonly Regex WritingVerb = new(@"^(Keep|Write|Save|Upsert|Delete|Add|Remove|Record|Put|Set)(?=[A-Z_]|$)", RegexOptions.Compiled);

    /// <summary>
    /// Query handlers that still take a port that writes, each with why and the lane that splits it; the
    /// list only shrinks.
    /// </summary>
    private static readonly Dictionary<string, string> ExemptReaders = new(StringComparer.Ordinal);

    /// <summary>Every member of one of this codebase's types, and of every interface it inherits, whose name starts with a writing verb.</summary>
    private static List<string> WritingMembers(Type port)
    {
        if (port.Namespace?.StartsWith("TheYard.", StringComparison.Ordinal) != true)
        {
            return [];
        }
        IEnumerable<Type> contracts = port.IsInterface ? port.GetInterfaces().Prepend(port) : [port];
        return [.. contracts
            .SelectMany(contract => contract.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
            .Where(member => member is not MethodInfo { IsSpecialName: true } && WritingVerb.IsMatch(member.Name))
            .Select(member => member.Name)
            .Distinct(StringComparer.Ordinal)
            .Order(StringComparer.Ordinal)];
    }
    // #endregion a-reader-is-known-by-what-it-can-do

    [Fact]
    public void No_type_in_the_Application_ring_s_slices_or_root_is_a_service()
    {
        // A service is a bag of use cases behind one name; a request has one handler instead. BidService,
        // InventoryService and MarketService were the last three, and Auction composed them: they are
        // BidBook, Stock and Room behind a reading and a writing interface each, and the handlers.
        var services = Services();
        Assert.True(services.Count == 0, "types named *Service: " + string.Join(", ", services));
    }

    [Fact]
    public void Every_route_that_asks_a_handler_asks_a_query_or_a_command_handler()
    {
        // HandlerRuleTests holds a route to one handler; this holds that handler to one of the two shapes, so a
        // route can never ask a bag of use cases. The routes HandlerRuleTests names as exempt read the host's own
        // state and ask no handler yet; the Admin reads among them become queries in ADR-115's fourth rung.
        var routes = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>()
            .Select(endpoint => endpoint.Metadata.GetMetadata<MethodInfo>())
            .OfType<MethodInfo>()
            .Where(answer => answer.DeclaringType?.Namespace?.StartsWith("TheYard.Api.Features.", StringComparison.Ordinal) == true)
            .ToList();
        Assert.NotEmpty(routes);
        var wrong = routes
            .SelectMany(answer => answer.GetParameters()
                .Where(parameter => parameter.ParameterType.Namespace?.StartsWith(Features, StringComparison.Ordinal) == true && parameter.ParameterType.Name.EndsWith("Handler", StringComparison.Ordinal))
                .Where(parameter => Contracts(parameter.ParameterType).Count != 1)
                .Select(parameter => $"{answer.DeclaringType!.Name}.{answer.Name} asks {parameter.ParameterType.Name}, which is not a query or a command handler"))
            .ToList();
        Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
    }

    [Fact]
    public void The_practices_page_quotes_the_counts_this_build_has()
    {
        // A number in prose goes stale silently, so the page's table is read back against the build, the
        // way SealedByDefaultTests reads SEALED.md.
        string page = File.ReadAllText(Path.Combine(Repo.Root(), "docs", "best-practices", "CQRS.md"));
        var counted = new Dictionary<string, int>(StringComparer.Ordinal)
        {
            ["queries"] = Requests("Query").Count,
            ["commands"] = Requests("Command").Count,
            ["handlers"] = HandlerTypes().Count,
            ["services"] = Services().Count,
        };
        var wrong = new List<string>();
        foreach (var (shape, actual) in counted)
        {
            var quoted = Regex.Match(page, $@"^\| {shape} \| (\d+) \|", RegexOptions.Multiline);
            if (!quoted.Success)
            {
                wrong.Add($"docs/best-practices/CQRS.md has no row for {shape}");
            }
            else if (int.Parse(quoted.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture) != actual)
            {
                wrong.Add($"docs/best-practices/CQRS.md says {quoted.Groups[1].Value} {shape} and this build has {actual}");
            }
        }
        Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
    }
}
```
*Live from [`api/TheYard.Tests/CqrsTests.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Tests/CqrsTests.cs#L11-L216), region `cqrs-tests`, read from this build at 085b3b4.*

- every handler implements exactly one of the two interfaces and is named for its request;
- every `*Query` and `*Command` under `Features/` has exactly one handler, is sealed, and is handled as what its name says;
- a query handler is handed no store and no writer;
- no type in the Application ring's root or its slices is named `*Service`;
- every route that asks a handler asks a query or a command handler, beside `HandlerRuleTests`, which holds a ruled route to one handler;
- the counts on [CQRS, the practices](https://theyard.stevenstout.biz/?doc=cqrs) are this build's.

`HandlerRuleTests.Every_handler_is_tested_with_fakes_and_no_host` covers the renamed handlers unchanged: each is still built in a test with fakes at its ports and no host.

## What was considered

MediatR, or a hand-written dispatcher. Rejected: it puts a lookup between the route and the code it runs, so "go to definition" on a route lands on an interface. The `Module` already maps one route to one handler and the compiler checks it; a bus would add a moving part to say what the map says now.

A second store for reads, or events between the halves. Rejected for this codebase. The catalogue is one hundred thousand rows a page at a time, read by the store that holds it (ADR: The store does the work), and both stores answer a page inside the budget the site already keeps. Microsoft's own page names the cost: two stores that must be kept in step, and a read side that can be behind. Nothing here is slow enough to buy that.

Keeping the services and naming their methods as queries and commands. Rejected: the point of the split is what a handler can reach. A query handed `Auction` could place a bid whatever its name said; a query handed `StandingReader` cannot.

Moving every line of `BidService` into the three bid handlers. Rejected for this version: the book of bids is one instance per store that the bid, the reset, the room and every listing share, with a gate and a replay from the store at startup. Its body moved whole and its two sides are now interfaces; cutting it by job is the 300-line rule's rung (ADR-088's addendum of that version).

## Consequences

Every handler and its request were renamed, so the diff is wide and shallow: 27 records added, 27 handlers renamed or re-typed, the route bodies changed only in the record they build. The three services' files were renamed (`BidService*.cs` to `BidBook*.cs`, `InventoryService*.cs` to `Stock*.cs`, `MarketService.cs` to `Room.cs`), and every live fence and link that named them was repointed; prose in older records that names them was left as written. `Auction.cs` is gone; its two regions that records quote, `overlays` and `views`, live in `StandingReader.cs` under the same names. The handler tests build each handler over the three parts the way the slices' Modules do.

What this does not do: the 24 Admin reads that `HandlerRuleTests` still names as exempt ask no handler yet. They become queries in the next version (ADR-075's row for this record says so), and from then every route is one of the two.

## Where it sits

Application ring only, and the host's Modules that build the handlers. Single responsibility: ADR-107 says what a slice is and ADR-114 where a file lives; this record says what a request is. What would change it: a read that the store holding the writes cannot answer in time, which is the case Microsoft's page gives for a second store.

## Files

- [`api/TheYard.Application/Shared/Requests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Shared/Requests.cs) - the two interfaces (new)
- [`api/TheYard.Application/Shared/Auction/AuctionParts.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Shared/Auction/AuctionParts.cs) - the auction's three parts, a reading and a writing side each (new)
- [`api/TheYard.Application/Shared/Auction/StandingReader.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Shared/Auction/StandingReader.cs) - a vehicle as every query reads it (new; what `Auction` read)
- [`api/TheYard.Application/Shared/Auction/BidBook.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Shared/Auction/BidBook.cs), [`Stock.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Shared/Auction/Stock.cs), [`Room.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Shared/Auction/Room.cs) - the three services, renamed, with their sides
- [`api/TheYard.Application/Features/`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Application/Features) - every request and its handler
- [`api/TheYard.Tests/CqrsTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/CqrsTests.cs) - holds this record (new)
- [`docs/best-practices/CQRS.md`](https://github.com/SteveStout/TheYard/blob/main/docs/best-practices/CQRS.md) - the practices page (new)

## Addendum, 2026-10-23 (1.0.4.43): the Admin tab's 24 reads are queries

The record's first version left the Admin tab's reads asking no handler, named as exempt in `HandlerRuleTests`. From 1.0.4.43 each is a query or a command with one handler: 21 queries (the SQL, store and log rings, the timings, the kept rings, the activity report, the visitor rows, the kept log, the machines, memory against traffic, Azure's view of this container, the peer, the telemetry, the arrival, the store bar, the page sweep, the gate's counts and its file, the proof's status, the costs, and a served document) and three commands (start the page sweep, start the proof, run the partition key experiment), in `Application/Features/Admin/Operations`, `Proof`, `Admin/Costs` and `Docs`. Each handler takes a port split by what it reads (`IRingReadings`, `IActivityReadings`, `IMachineReadings`, `IAzureReadings`, `IEvidenceReadings` and the rest), answered by the host part that holds the data (`OperationsReadings.cs`, `MachineCardReadings.cs`, `DocumentReadings.cs`): the bodies are the routes' bodies, moved, so every card reads what it read before. What a route decided in its body, a handler decides now: the window, the card and the kind it can read, and what to say when there is nothing; the answer is one `Reading` the route turns into its status. The operator's key and whether the visitor rows are served stay checks the route makes before it asks (`AdminSettings` joins `AdminKey` beside the handlers).

`AdminQueryTests` builds all 24 handlers over one fake at every port with no host, and `AdminQueryRouteTests` holds the store bar's 200, the gate's counts' 200 and 404 and a document's 200 and 404 through the real host. `Reading`, the answer every one of these handlers gives, is in the Application ring's `Shared/` folder, because the operations, the costs, the proof and the documents all answer with it and rule 1 of the slices keeps one slice out of another's folder. Steve's ruling of 11:37 that day was the reason: the Foreman reads exactly these routes, and a read with no handler has no fake to test against.

Still exempt, each with its reason in `HandlerRuleTests`: fifteen routes where the host serves its own files (the pictures, the drawings, the Bicep, the resume, the about page, the recommendations), its health and version, the pulse, and its error rings. They are not reads of the business or of the Admin cards, and they stay where they are until a lane gives them a reason to be queries.


## Addendum, 2026-10-25 (1.0.4.68): a reader port is known by what it can do

The rule that keeps a store out of a query read the port's name: a parameter type ending in Store or Writer was red. A port named "...Reader" that wrote passed. From this version `CqrsTests.A_query_handler_is_handed_no_port_that_can_write` reads the port itself: no type a query handler takes by constructor, among this codebase's own, has a member, counting every member of every interface it inherits, whose name starts with Keep, Write, Save, Upsert, Delete, Add, Remove, Record, Put or Set. The name check stays beside it.

It was green on 1.0.4.67 with only its file applied, and that green is the finding: the ports the queries take were already split, the visitors' reader from its store (`IVisitorReader` under `IVisitorStore`) and the blog's reader from its store among them. To prove it can fail, a writing member planted on `IVisitorReader` turned it red at once ("ReadPeopleCountHandler takes IVisitorReader visitors, which can KeepProbeAsync"). The test carries an exemption list, empty, that only shrinks. docs/best-practices/CQRS.md says how the rule reads a port.

```csharp
[Fact]
public void A_query_handler_is_handed_no_port_that_can_write()
{
    // The rule above reads a port's name, so a port named ...Reader that also writes passes it. This one
    // reads what the port can do: no type a query handler takes by constructor, among this codebase's own,
    // has a member whose name starts with a writing verb, counting every member of every interface it
    // inherits. A port that both reads and writes is split, and the query takes the reading half.
    var wrong = HandlerTypes()
        .Where(handler => Contracts(handler).Any(contract => contract.Query))
        .SelectMany(handler => handler.GetConstructors().SelectMany(constructor => constructor.GetParameters())
            .Select(parameter => (handler, parameter, writes: WritingMembers(Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType)))
            .Where(found => found.writes.Count > 0)
            .Select(found => $"{found.handler.Name} takes {found.parameter.ParameterType.Name} {found.parameter.Name}, which can {string.Join(", ", found.writes)}; a query takes a port that only reads"))
        .Where(line => !ExemptReaders.Any(exempt => line.StartsWith(exempt.Key + " ", StringComparison.Ordinal)))
        .ToList();
    Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
}

/// <summary>The verbs a member that writes starts with; a reader has none of them.</summary>
private static readonly Regex WritingVerb = new(@"^(Keep|Write|Save|Upsert|Delete|Add|Remove|Record|Put|Set)(?=[A-Z_]|$)", RegexOptions.Compiled);

/// <summary>
/// Query handlers that still take a port that writes, each with why and the lane that splits it; the
/// list only shrinks.
/// </summary>
private static readonly Dictionary<string, string> ExemptReaders = new(StringComparer.Ordinal);

/// <summary>Every member of one of this codebase's types, and of every interface it inherits, whose name starts with a writing verb.</summary>
private static List<string> WritingMembers(Type port)
{
    if (port.Namespace?.StartsWith("TheYard.", StringComparison.Ordinal) != true)
    {
        return [];
    }
    IEnumerable<Type> contracts = port.IsInterface ? port.GetInterfaces().Prepend(port) : [port];
    return [.. contracts
        .SelectMany(contract => contract.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
        .Where(member => member is not MethodInfo { IsSpecialName: true } && WritingVerb.IsMatch(member.Name))
        .Select(member => member.Name)
        .Distinct(StringComparer.Ordinal)
        .Order(StringComparer.Ordinal)];
}
```
*Live from [`api/TheYard.Tests/CqrsTests.cs`](https://github.com/SteveStout/TheYard/blob/085b3b4/api/TheYard.Tests/CqrsTests.cs#L113-L154), region `a-reader-is-known-by-what-it-can-do`, read from this build at 085b3b4.*

## Addendum, 2026-10-25 (1.0.4.75): what CQRS means in the job hunt's Function

The Function answers no request. Its timers each run one use case on a clock, so there is no request record to name `...Query` or `...Command` and no handler to count, and this record's naming rule does not carry over. The half that does carry over is the one that matters: a use case that only reads cannot write.

- **The ports are split.** `IMergeStore` (the job-hunt share) extends `IShareReader` (exists, read, list), and `IInbox` extends `IInboxReader` (find, read, the thread's senders and draft); the archive and the share's writes are on the extending halves only.
- **The reading use cases take the reading halves.** `InboxTriage` (the triage window) takes `IShareReader` and `IInboxReader`; `MergerHealth.ReadAsync` takes `IShareReader`.
- **Nothing is a service** in any of the three rings.

The Function's `CqrsTests` holds all three, reading a port by what it can do with the same writing-verb rule the site's queries are held to (the addendum of 1.0.4.68), widened by the Function's own verbs (Append, Move, Replace, Ensure, Archive, Mark, Block). It was red with the split reverted: "InboxTriage takes IMergeStore store, which can AppendAsync, EnsureFolderAsync, MoveAsync, ReplaceAsync, WriteAsync" and "InboxTriage takes IInbox inbox, which can ArchiveAsync".

## Addendum, 2026-10-30 (1.0.4.127): the Function's read-only use cases are found, not listed

The addendum above is still the rule. What changed is its subject. It named two use cases,
`InboxTriage` and `MergerHealth`, and the Function's `CqrsTests` held exactly those two to reading
ports through a dictionary kept by hand. So the rule was as good as somebody's memory: a use case
written next week that reads the share and is handed `IMergeStore` rather than `IShareReader` would
never be asked the question, because it would not be in the dictionary. A rule whose subject is a
list only ever covers the list, and this list had not grown in the month since it was written.

The dictionary is gone. `tests/rules/UseCaseRule.cs` reads the Application ring for every type handed
a port and asks the compiled code which of them write; the rule runs over the ones that do not. It
finds the same two, and that is the result worth recording: **the number did not change, the way of
arriving at it did**, and the next read-only use case is covered the day it is written rather than the
day somebody remembers it.

### Proved both ways, with one plant

A read-only use case handed the writing half of the share's port was planted under
`Features/Triage/`: `TriageBacklog(IMergeStore store)`, which lists the triage folder, asks whether
`seen.tsv` is there, and does nothing else.

- The hand-kept test **passed**, 3 of 3, with the plant in the tree. That is the defect, not a
  surprise: it iterates its dictionary, and `TriageBacklog` is not in it.
- Discovery **failed** on the same tree: *"TriageBacklog only reads and takes IMergeStore store by its
  constructor, which can AppendAsync, EnsureFolderAsync, MoveAsync, ReplaceAsync, WriteAsync; a use
  case that only reads takes the reading half"*.

### What "writes" means, and the near-miss that would have shipped a false red

Whether a use case writes is read from its compiled code and never from its name, because a name is a
promise and nothing checks a promise. A use case writes when the code it owns reaches a call to a
member whose name starts with a writing verb on one of its family's types. *Reaches* is the whole
difficulty, and two hops are invisible to a scan of one method body.

The first is the **state machine**. An `async` method's own body is builder plumbing; the calls a
reader sees in the source live in the `MoveNext` of a nested compiler-generated type that the builder
starts generically, so no call instruction joins the two. The edge is taken from
`AsyncStateMachineAttribute`, which names that type.

The second is **delegation**, and it is the one that nearly shipped. Measured one method deep, the
first run of the discovery read `TrackerMerger` as a use case that only reads while holding
`IMergeStore` - this rule's own failure message, aimed at the type whose entire job is writing.
`TrackerMerger` calls nothing that writes: it makes a `MergeContext` and the merge steps and lets them
write. It was caught by reading the whole printed list by eye rather than its total, which is what
ADR-122's addendum of 1.0.4.125 asks of a rung, and it was caught before a line of the rule was
written. So a write now travels backwards along every call a method makes.

The walk is over **methods and not types**, deliberately. Collapsed to types, `MergerHealth` would
read as a writer for calling `TrackerMerger.IsSpoolFile`, a static that tests a file name.

### What is a port, and the two limits left in place

A port is one of the family's interfaces, or one of its classes that is not a record - which is how
`MergeContext`, a class holding the share's writing half behind two safe ways to change the master, is
a port and is read as a writer. A **record is never a port**: its compiler-generated equality takes
its own type, so every record in the ring would read as a type handed one of the family's types, and
twenty-two of them did on the first measurement.

Two limits are named rather than quietly fixed, because fixing a measurement inside the commit that
depends on it is the move ADR-122 exists to refuse:

- **`TrackerFrame.Record` matches the writing-verb pattern as the verb "record" where it is the noun**,
  the CSV record text. It cannot bite, because a record is never a port, but it is the same shape of
  defect as the three ratchet numbers this lane has found mismeasuring.
- **A write reached through a delegate is invisible.** A port passed as a `Func<>` or an action and
  invoked there leaves no call instruction naming the member. Nothing in the Function does it, and the
  one delegate in the ring is a clock.

The counts the build holds - eighteen use cases, two of them read-only - are read back out of
`docs/best-practices/CQRS.md` by the Function's own `CqrsTests`, so the page cannot go stale either.
