fullstackhero/dotnet-starter-kit · error · InvalidOperationException

User is already a member.

Error message

User {userId} is already a member.

What it means

ChatChannel.AddMember refuses to add a user who is already present in _members (compared by exact, ordinal UserId match). Duplicate membership is an invariant violation, so the aggregate throws InvalidOperationException with the user id embedded. It is idempotence protection for the AddMember operation.

Solutions

  1. Check membership first via channel.IsMember(userId) / Members.Any(m => m.UserId == userId) and make the operation idempotent (no-op if already a member).
  2. Catch the InvalidOperationException in the handler and translate it to a friendly 409/200-with-message instead of a 500.
  3. Serialize membership commands (e.g. unique index on (ChannelId, UserId) in DB + concurrency handling) to avoid concurrent duplicate adds.
  4. Re-fetch the channel aggregate inside the same transaction as the membership change so the check reflects committed state.

Example fix

// before
channel.AddMember(request.UserId, currentUserId);
// after
if (channel.Members.Any(m => m.UserId == request.UserId))
{
    return Result.Success(); // idempotent: already a member
}
channel.AddMember(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.AddMember(userId, addedBy); } catch (InvalidOperationException ex) when (ex.Message.Contains("already a member")) { return Result.Success(); }

Prevention

When it happens

Trigger: Calling chatChannel.AddMember(userId, addedByUserId, role) when a ChannelMember with the identical UserId already exists in the channel — e.g. a user joins twice, a retried command, or concurrent add requests racing past the in-memory duplicate check.

Common situations: Double-click on an 'Add' button sending two invite commands; command retries after a timeout; a user self-joining a public channel they are already in; bulk-import scripts that don't dedupe member lists.

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

Appendix: source

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

    {
        if (Type != ChannelType.Channel)
        {
            throw new InvalidOperationException("Only named Channels can change privacy.");
        }
        IsPrivate = isPrivate;
        UpdatedAtUtc = DateTime.UtcNow;
    }

    public ChannelMember AddMember(string userId, string addedByUserId, ChannelMemberRole role = ChannelMemberRole.Member)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
        if (Type == ChannelType.DirectMessage)
        {
            throw new InvalidOperationException("Direct messages have fixed membership.");
        }
        if (_members.Any(m => string.Equals(m.UserId, userId, StringComparison.Ordinal)))
        {
            throw new InvalidOperationException($"User {userId} is already a member.");
        }

        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))

View on GitHub (pinned to 3f2959e683)