Table of Contents

Class GuildService

Namespace
FishMMO.Database.Npgsql.Services
Assembly
FishMMO-DB.dll

Base class for database services that execute EF Core operations with consistent execution behavior (transactional and read-only), retry behavior for transient failures, and standardized error mapping into DatabaseResult.

public sealed class GuildService : BaseService<GuildEntity>, IGuildService, IExistsByKeyAction<string>, IPersistAction<string, long?>, IDeleteByKeyAction<long>, IFetchByKeyAction<long, GuildData?>, IFetchByKeyAction<string, GuildData?>
Inheritance
GuildService
Implements
Inherited Members

Remarks

This base type provides three primary execution paths:

  • ExecuteTransactionAsync(Func<Task>, CancellationToken) and ExecuteTransactionAsync<TResult>(Func<Task<TResult>>,string,CancellationToken) create a fresh NpgsqlDbContext, begin an explicit transaction, execute the delegate, then call SaveChangesAsync(CancellationToken) and commit.
  • ExecuteWriteAsync(Func<Task>,string,CancellationToken) and ExecuteWriteAsync<TResult>(Func<Task<TResult>>,string,CancellationToken) create a fresh NpgsqlDbContext, execute the delegate, then call SaveChangesAsync(CancellationToken) without starting an explicit transaction.
  • ExecuteReadAsync(Func<Task>,string,CancellationToken) and ExecuteReadAsync<TResult>(Func<Task<TResult>>,string,CancellationToken) create a fresh NpgsqlDbContext but do not start an explicit transaction and do not call SaveChanges.

Standalone Execution (no ambient scope): A new context is created per attempt to avoid EF change-tracker state leaking across retries. Transient database failures are retried with exponential backoff. Optimistic concurrency conflicts (Version-based authority) and StaleStateException are never retried; they are returned as non-transient failures so the caller can re-read and decide how to proceed.

Ambient Scope Execution (inside an existing Unit of Work): When an operation detects an active FishMMO.Database.Npgsql.Services.DatabaseExecutionScope, it reuses the ambient NpgsqlDbContext and does NOT retry on transient failures. This is by design for the following reasons:

  1. Transaction State Corruption: PostgreSQL aborts the entire transaction on most transient failures. The connection and transaction become unusable, making retry with the same context impossible.
  2. Context State Pollution: The DbContext's change tracker accumulates state. Retrying with a polluted change tracker can cause duplicate key violations or incorrect updates.
  3. Semantic Correctness: If operation A succeeded and operation B failed transiently, retrying B alone without re-evaluating A's preconditions could violate business invariants.

When a transient failure occurs inside an ambient scope, the failure is returned immediately so the caller can restart the entire unit of work with fresh state. Savepoints are used for nested atomicity but cannot recover from connection-level failures.

Constructors

GuildService(INpgsqlDbContextFactory)

Initializes a new instance of GuildService.

public GuildService(INpgsqlDbContextFactory dbContextFactory)

Parameters

dbContextFactory INpgsqlDbContextFactory

DbContext factory for creating contexts.

Exceptions

ArgumentNullException

Thrown when dbContextFactory is null.

Methods

DeleteAsync(long, CancellationToken)

Deletes the entity identified by the given key.

public Task<DatabaseResult> DeleteAsync(long guildId, CancellationToken cancellationToken = default)

Parameters

guildId long
cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult>

Remarks

Atomicity:

This operation uses a single DELETE statement. CASCADE delete constraints automatically remove related data:

  • All character guild memberships (character_guild table)
  • Guild update notifications (guild_update table)

ExistsAsync(string, CancellationToken)

Checks whether an entity exists for the given key.

public Task<DatabaseResult<bool>> ExistsAsync(string name, CancellationToken cancellationToken = default)

Parameters

name string
cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<bool>>

FetchAsync(long, CancellationToken)

Fetches an entity for the given key.

public Task<DatabaseResult<GuildData?>> FetchAsync(long guildId, CancellationToken cancellationToken = default)

Parameters

guildId long
cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<GuildData?>>

FetchAsync(string, CancellationToken)

Fetches an entity for the given key.

public Task<DatabaseResult<GuildData?>> FetchAsync(string name, CancellationToken cancellationToken = default)

Parameters

name string
cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<GuildData?>>

FetchNameAsync(long, CancellationToken)

Fetches the name of a guild by ID.

public Task<DatabaseResult<string?>> FetchNameAsync(long guildId, CancellationToken cancellationToken = default)

Parameters

guildId long

Guild ID.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult<string>>

A DatabaseResult<T> containing the guild name on success, or null if the guild was not found. Returns a failure result on database errors.

Remarks

This method uses LINQ (FirstOrDefaultAsync with AsNoTracking) and automatically benefits from the retry policy configured on the DbContext without requiring explicit execution strategy wrapping.

PersistAsync(string, CancellationToken)

Persists the provided data.

public Task<DatabaseResult<long?>> PersistAsync(string name, CancellationToken cancellationToken = default)

Parameters

name string
cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<long?>>

PersistMessageOfTheDayAsync(long, string, CancellationToken)

Updates the message of the day for a guild.

public Task<DatabaseResult> PersistMessageOfTheDayAsync(long guildId, string messageOfTheDay, CancellationToken cancellationToken = default)

Parameters

guildId long

Guild ID.

messageOfTheDay string

The new message of the day text.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult>

DatabaseResult indicating success or failure.

PersistNoticeAsync(long, string, CancellationToken)

Updates the notice text for a guild.

public Task<DatabaseResult> PersistNoticeAsync(long guildId, string notice, CancellationToken cancellationToken = default)

Parameters

guildId long

Guild ID.

notice string

The new notice text.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult>

DatabaseResult indicating success or failure.

Remarks

The sibling of PersistMessageOfTheDayAsync(long, string, CancellationToken). The notice column has existed since the guild table was created with no way to write it; the two are kept separate because a notice is standing text about the guild while the message of the day is transient, and a single setter would force callers to read-modify-write the other.

PersistRecruitmentAsync(long, string, string, bool, CancellationToken)

Updates the guild's recruitment advertisement.

public Task<DatabaseResult> PersistRecruitmentAsync(long guildId, string blurb, string tags, bool isRecruiting, CancellationToken cancellationToken = default)

Parameters

guildId long

Guild ID.

blurb string

Advertisement text shown to non-members.

tags string

Comma-separated tags; stored lower-cased for search.

isRecruiting bool

Whether the guild is listed in the directory.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult>

DatabaseResult indicating success or failure.

Remarks

One setter for all three fields rather than three. They are edited together from one form, and separate setters would make "stop recruiting" a two-write operation with a window in which the guild is listed with a blurb it has just withdrawn.