fullstackhero/dotnet-starter-kit · error · CustomException

Setting this parent would create a cycle.

Error message

Setting this parent would create a cycle.

What it means

UpdateCategory walks the ancestor chain starting at the proposed parent, tracking visited ids; if the walk revisits an id (or reaches the category itself) the new parent would create a cycle, so it throws CustomException with 400 BadRequest. This prevents parent loops like A→B→A in the category tree.

Solutions

  1. Choose a parent that is not a descendant of the category being moved.
  2. Move the intermediate nodes in the correct order (detach subtree first, then re-parent).
  3. Render the tree in the UI with descendants of the edited node disabled as parent options.
  4. If a legitimate restructure is needed, perform multiple sequential updates via a valid intermediate state.

Example fix

// before: move A under its descendant B
await api.updateCategory({ id: a, parentCategoryId: b }); // 400 cycle

// after: move B under A first (if that's the real intent), or pick a non-descendant parent
await api.updateCategory({ id: b, parentCategoryId: a });
Defensive patterns

Strategy: validation

Validate before calling

// client-side: disallow selecting any descendant as parent
const descendants = collectDescendants(tree, categoryId);
if (descendants.includes(payload.parentCategoryId)) {
  return formError('Cannot move a category under its own descendant.');
}

Try / catch

try { await api.updateCategory(payload); }
catch (e) { if (e.status === 400 && /cycle/i.test(e.message)) formError('That parent would create a cycle'); else throw e; }

Prevention

When it happens

Trigger: PATCH update setting parent P such that P's ancestor chain already contains (or leads back to) the category being updated — e.g. moving A under its own descendant B.

Common situations: Reorganizing a tree by moving a whole subtree under one of its children; concurrent edits where two users swap parents of nested categories; import scripts that don't topologically order rows.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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

Appendix: source

Thrown at src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs:39

        if (command.ParentCategoryId is { } parentId)
        {
            if (parentId == category.Id)
            {
                throw new CustomException(
                    "A category cannot be its own parent.",
                    (IEnumerable<string>?)null,
                    HttpStatusCode.BadRequest);
            }

            // Walk parent chain to detect cycles (parent → ancestor of self)
            var visited = new HashSet<Guid> { category.Id };
            Guid? cursor = parentId;
            while (cursor is { } cur)
            {
                if (!visited.Add(cur))
                {
                    throw new CustomException(
                        "Setting this parent would create a cycle.",
                        (IEnumerable<string>?)null,
                        HttpStatusCode.BadRequest);
                }
                cursor = await dbContext.Categories
                    .Where(c => c.Id == cur)
                    .Select(c => c.ParentCategoryId)
                    .FirstOrDefaultAsync(cancellationToken)
                    .ConfigureAwait(false);
            }
        }

        category.Update(command.Name, command.Description, command.ParentCategoryId);

        bool slugTaken = await dbContext.Categories
            .AnyAsync(c => c.Slug == category.Slug && c.Id != category.Id, cancellationToken)
            .ConfigureAwait(false);
        if (slugTaken)

View on GitHub (pinned to 3f2959e683)