fullstackhero/dotnet-starter-kit · error · NotFoundException

Product not found.

Error message

Product {command.ProductId} not found.

What it means

SetProductThumbnailCommandHandler.Handle looks up the product by ProductId and throws FSH's NotFoundException when no row matches, so the API returns a framework-mapped 404 instead of a null-reference/500. It is the module's contract for 'the product this command targets does not exist'.

Solutions

  1. Verify the ProductId exists (query the products list/GET endpoint) and resend with a valid id.
  2. Check the request is going to the correct tenant — tenant isolation filters Products per tenant.
  3. If the id comes from a previous screen, refresh the data instead of reusing a stale id.
  4. If the product should exist, confirm it was not deleted by a concurrent user or cleanup job.

Example fix

// before
await mediator.Send(new SetProductThumbnailCommand(productId, imageId));
// after
var exists = await dbContext.Products.AnyAsync(p => p.Id == productId, ct);
if (!exists) return; // or surface 404 to the user before sending
await mediator.Send(new SetProductThumbnailCommand(productId, imageId), ct);
Defensive patterns

Strategy: validation

Validate before calling

// C# caller
bool exists = await dbContext.Products.AnyAsync(p => p.Id == command.ProductId, ct);
if (!exists) throw new NotFoundException($"Product {command.ProductId} not found.");

Type guard

var product = await dbContext.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null) return null; // narrow before use

Try / catch

try { await mediator.Send(cmd, ct); }
catch (NotFoundException ex) { logger.LogWarning(ex, "Product missing"); return Results.NotFound(); }

Prevention

When it happens

Trigger: Mediator command SetProductThumbnailCommand dispatched with a ProductId that has no row in dbContext.Products (hard delete, wrong tenant, or fabricated/typo id). FirstOrDefaultAsync returns null and the ?? throw fires.

Common situations: Client cached a product id after the product was deleted; id passed from another tenant under tenant isolation; test fixtures using random Guids; caller swapped ImageId/ProductId fields in the payload.

Understand the failure class

Background: "Not found" and "does not exist" errors: why "Task not found", "No such folder", and "Can't find" fire when a lookup comes back empty — this error's family across 14 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/e2415a4417ade015. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs:19

using FSH.Framework.Core.Exceptions;
using FSH.Modules.Catalog.Contracts.v1.Products.SetProductThumbnail;
using FSH.Modules.Catalog.Data;
using Mediator;
using Microsoft.EntityFrameworkCore;

namespace FSH.Modules.Catalog.Features.v1.Products.SetProductThumbnail;

public sealed class SetProductThumbnailCommandHandler(CatalogDbContext dbContext)
    : ICommandHandler<SetProductThumbnailCommand, Unit>
{
    public async ValueTask<Unit> Handle(SetProductThumbnailCommand command, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(command);

        var product = await dbContext.Products
            .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken)
            .ConfigureAwait(false)
            ?? throw new NotFoundException($"Product {command.ProductId} not found.");

        // Domain throws InvalidOperationException for unknown imageId; translate to a
        // framework-aware 404 so the API surfaces NotFound rather than a 500.
        if (!product.Images.Any(i => i.Id == command.ImageId))
        {
            throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}.");
        }

        product.SetThumbnail(command.ImageId);
        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        return Unit.Value;
    }
}

View on GitHub (pinned to 3f2959e683)