fullstackhero/dotnet-starter-kit · error · NotFoundException

Product not found.

Error message

Product {command.ProductId} not found.

What it means

RestoreProductCommandHandler deliberately ignores the soft-delete filter (IgnoreQueryFilters([QueryFilters.SoftDelete])) so it can find soft-deleted rows, but still throws NotFoundException if no product row with command.ProductId exists at all (HTTP 404). Note: a product that was never deleted is found by this query too; a truly absent (hard-deleted or wrong-tenant/wrong-id) row 404s.

Solutions

  1. Confirm the id exists in the database (including soft-deleted rows) before restoring.
  2. Verify the tenant context matches the product's tenant — restore only bypasses the soft-delete filter, not tenant isolation.
  3. If the row was hard-deleted/purged, restore from backup or recreate the product; the API cannot recover it.
  4. Handle 404 in the trash-bin UI by refreshing the deleted-items list.

Example fix

// before
await api.restoreProduct(deletedId); // 404 if purged
// after
try {
  await api.restoreProduct(deletedId);
  notify('Product restored');
} catch (e) {
  if (e.status === 404) {
    notify('Product no longer recoverable');
    refreshTrashBin();
    return;
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

// validate GUID format before restore attempt
const isGuid = (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
if (!isGuid(productId)) throw new Error('Invalid product id');

Try / catch

try {
  await api.restoreProduct(productId);
} catch (e) {
  if (e.status === 404) {
    notify('Product is no longer recoverable');
    refreshTrashBin();
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: POST restore for an id that was hard-deleted, never existed, is a mistyped GUID, or belongs to another tenant (tenant filter still applies since only the soft-delete filter is bypassed).

Common situations: Admin trash-bin UI restoring an item purged by retention policy; restoring across tenants after switching tenant context; ids copied from logs of another environment.

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/b9a6b5b88212105c. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs:21

using FSH.Modules.Catalog.Contracts.v1.Products;
using FSH.Modules.Catalog.Data;
using Mediator;
using Microsoft.EntityFrameworkCore;

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

public sealed class RestoreProductCommandHandler(CatalogDbContext dbContext)
    : ICommandHandler<RestoreProductCommand, Guid>
{
    public async ValueTask<Guid> Handle(RestoreProductCommand command, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(command);

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

        product.Restore();
        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        return product.Id;
    }
}

View on GitHub (pinned to 3f2959e683)