fullstackhero/dotnet-starter-kit · error · InvalidOperationException

User is not a member.

Error message

User {userId} is not a member.

What it means

ChatChannel.RemoveMember requires the target user to actually be a member; when no ChannelMember matches the given UserId (ordinal comparison), the aggregate throws InvalidOperationException naming the user. The guard uses the ?? throw pattern after FirstOrDefault over _members.

Solutions

  1. Check membership before removing (Members.Any(m => m.UserId == userId)) and treat 'not a member' as a no-op for idempotent removal.
  2. Catch the InvalidOperationException in the handler and return 404/409 with a clear message instead of a 500.
  3. Verify the channel id and user id in the request (ordinal-exact match); normalize user ids to a canonical case at creation.
  4. Load the aggregate fresh in the same unit of work so the membership check sees committed data.

Example fix

// before
channel.RemoveMember(request.UserId, currentUserId);
// after
if (channel.Members.All(m => m.UserId != request.UserId))
{
    return Result.Success(); // idempotent: nothing to remove
}
channel.RemoveMember(request.UserId, currentUserId);
Defensive patterns

Strategy: validation

Validate before calling

public static bool IsMember(ChatChannel c, string userId) => c.Members.Any(m => string.Equals(m.UserId, userId, StringComparison.Ordinal));

Type guard

if (!channel.Members.Any(m => m.UserId == userId)) return Result.Success();

Try / catch

try { channel.RemoveMember(userId, removedBy); } catch (InvalidOperationException ex) when (ex.Message.Contains("is not a member")) { throw new NotFoundException(nameof(ChannelMember), userId); }

Prevention

When it happens

Trigger: Calling chatChannel.RemoveMember(userId, removedByUserId) where userId has no membership in the channel — wrong channel id in the request, the user already left/was removed (retried command), or case-mismatched user identifiers.

Common situations: Duplicate 'remove member' requests (first succeeded, second throws); client removing a user from the wrong channel; legacy users whose ids changed casing; moderation scripts iterating stale member lists.

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

Appendix: source

Thrown at src/Modules/Chat/Modules.Chat/Domain/ChatChannel.cs:199

        }

        var member = ChannelMember.Create(Id, userId, role);
        _members.Add(member);
        UpdatedAtUtc = DateTime.UtcNow;
        AddDomainEvent(DomainEvent.Create((id, ts) =>
            new ChannelMemberAddedDomainEvent(Id, userId, addedByUserId, id, ts)));
        return member;
    }

    public void RemoveMember(string userId, string removedByUserId)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
        if (Type == ChannelType.DirectMessage)
        {
            throw new InvalidOperationException("Direct messages have fixed membership.");
        }
        var member = _members.FirstOrDefault(m => string.Equals(m.UserId, userId, StringComparison.Ordinal))
            ?? throw new InvalidOperationException($"User {userId} is not a member.");
        _members.Remove(member);
        UpdatedAtUtc = DateTime.UtcNow;
        AddDomainEvent(DomainEvent.Create((id, ts) =>
            new ChannelMemberRemovedDomainEvent(Id, userId, removedByUserId, id, ts)));
    }

    public void MarkRead(string userId, Guid messageId)
    {
        var member = _members.FirstOrDefault(m => string.Equals(m.UserId, userId, StringComparison.Ordinal))
            ?? throw new InvalidOperationException($"User {userId} is not a member.");
        member.MarkRead(messageId);
    }

    public void TouchLastMessage(DateTime utcNow)
    {
        LastMessageAtUtc = utcNow;
        UpdatedAtUtc = utcNow;
    }

View on GitHub (pinned to 3f2959e683)