{"record":{"id":"da88190b219d8e96","repo":"fullstackhero/dotnet-starter-kit","slug":"user-userid-is-already-a-member","errorCode":null,"errorMessage":"User {userId} is already a member.","messagePattern":"User (.+?) is already a member\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/Modules/Chat/Modules.Chat/Domain/ChatChannel.cs","lineNumber":180,"sourceCode":"    {\n        if (Type != ChannelType.Channel)\n        {\n            throw new InvalidOperationException(\"Only named Channels can change privacy.\");\n        }\n        IsPrivate = isPrivate;\n        UpdatedAtUtc = DateTime.UtcNow;\n    }\n\n    public ChannelMember AddMember(string userId, string addedByUserId, ChannelMemberRole role = ChannelMemberRole.Member)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(userId);\n        if (Type == ChannelType.DirectMessage)\n        {\n            throw new InvalidOperationException(\"Direct messages have fixed membership.\");\n        }\n        if (_members.Any(m => string.Equals(m.UserId, userId, StringComparison.Ordinal)))\n        {\n            throw new InvalidOperationException($\"User {userId} is already a member.\");\n        }\n\n        var member = ChannelMember.Create(Id, userId, role);\n        _members.Add(member);\n        UpdatedAtUtc = DateTime.UtcNow;\n        AddDomainEvent(DomainEvent.Create((id, ts) =>\n            new ChannelMemberAddedDomainEvent(Id, userId, addedByUserId, id, ts)));\n        return member;\n    }\n\n    public void RemoveMember(string userId, string removedByUserId)\n    {\n        ArgumentException.ThrowIfNullOrWhiteSpace(userId);\n        if (Type == ChannelType.DirectMessage)\n        {\n            throw new InvalidOperationException(\"Direct messages have fixed membership.\");\n        }\n        var member = _members.FirstOrDefault(m => string.Equals(m.UserId, userId, StringComparison.Ordinal))","sourceCodeStart":162,"sourceCodeEnd":198,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Chat/Modules.Chat/Domain/ChatChannel.cs#L162-L198","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Check membership first via channel.IsMember(userId) / Members.Any(m => m.UserId == userId) and make the operation idempotent (no-op if already a member).","Catch the InvalidOperationException in the handler and translate it to a friendly 409/200-with-message instead of a 500.","Serialize membership commands (e.g. unique index on (ChannelId, UserId) in DB + concurrency handling) to avoid concurrent duplicate adds.","Re-fetch the channel aggregate inside the same transaction as the membership change so the check reflects committed state."],"exampleFix":"// before\nchannel.AddMember(request.UserId, currentUserId);\n// after\nif (channel.Members.Any(m => m.UserId == request.UserId))\n{\n    return Result.Success(); // idempotent: already a member\n}\nchannel.AddMember(request.UserId, currentUserId);","handlingStrategy":"validation","validationCode":"public static bool IsMember(ChatChannel c, string userId) => c.Members.Any(m => string.Equals(m.UserId, userId, StringComparison.Ordinal));","typeGuard":"if (channel.Members.Any(m => m.UserId == userId)) return Result.Success();","tryCatchPattern":"try { channel.AddMember(userId, addedBy); } catch (InvalidOperationException ex) when (ex.Message.Contains(\"already a member\")) { return Result.Success(); }","preventionTips":["Make join/invite operations idempotent","Add a unique (ChannelId, UserId) index to catch duplicates at the DB level","Debounce/dedupe invite commands on the client","Re-load the aggregate in the same unit of work before mutating"],"tags":["domain","chat","membership","duplicate","idempotency"],"backgroundTag":"invalid-state-transition","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}