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
- 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.
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
- 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
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
- User is not a member.
- Direct messages have fixed membership.
- Cannot start a DM with yourself.
- Group DM requires at least 3 distinct members.
- All user ids must be non-empty.
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)