Type to search pages and rules.

Browse pages

LS2014: Command Handler Finds or Creates

LS2014 is a compiler check that reports a command handler, the type that runs the state change, that both finds a record and creates it in one method, hiding two use cases behind one name. This page states what the rule reports, where the error sits, and how to split the flow across the boundary that owns it.

Markdown

Reference#

What the Rule Enforces#

LS2014 enforces Implement one outcome path per command (litespec/backend/application#implement-one-outcome-path-per-command). It runs in Application, Infrastructure, and Worker. Its category is LiteSpec.Layers. Severity and enablement follow the Analyzers overview.

What the Rule Reports#

The rule reports a command handler, which is a class whose name ends in CommandHandler or that implements ICommandHandler, with one method that calls a repository method whose name starts in Find and creates the found type, through a static method or a constructor, only where the find came back empty, ignoring Task and nullability. A creation runs where the find came back empty when it sits on the null side of a test of the found value, after a branch that returns or throws when the value was found, or on the null side of ?? or ??=. For a constructor the diagnostic sits on the created type; for a static factory it sits on the invoked member name. Its message names the handler and the created type.

Failing Example#

C#
internal sealed class RegisterReaderCommandHandler(IReaderRepository readerRepository)
{
    public void Handle(RegisterReaderCommand command)
    {
        var reader = readerRepository.FindByCard(command.CardId)
            ?? Reader.Register(command.CardId);
        readerRepository.Store(reader);
    }
}

Passing Example#

C#
internal sealed class RegisterReaderCommandHandler(IReaderRepository readerRepository)
{
    public void Handle(RegisterReaderCommand command)
    {
        var reader = Reader.Register(command.CardId);
        readerRepository.Store(reader);
    }
}

How to Fix#

Split the flow into two use cases: the boundary that owns the flow runs a query first, then sends the command that fits. When the create loses a race, it refuses with its conflict, and the owner sends the other command. Where the record is usually present, the boundary sends the command first and, on the record's not-found refusal, sends the create and then the command again. Keep that sequence in one helper beside the command mediator, such as commands.SendToRecord(command).CreatingItOn<ReaderNotFoundException>(register, cancellationToken), rather than repeating it in each endpoint.

Notes#

What the Rule Ignores#

The rule does not report a handler that only finds, a handler that only creates, a creation of another type than the found one, a creation that runs whether or not the find came back empty, such as a fine statement that reads the reader's previous statement to carry its balance, a creation after the handler refuses a missing record, a creation inside a switch arm keyed on another value than the found one, or a find and a creation in different methods. It does not report query handlers. It does not run in Domain or WebApi.