How do you implement async/await in a custom class or library?

6 minadvanced.NETasync-awaitlibrary-designbest-practices

Quick Answer

To implement async in a library, return `Task`/`Task<T>` (or `ValueTask` on hot paths), call genuinely async I/O with `await`, flow a `CancellationToken`, and avoid `async void` and fake-async `Task.Run` wrappers. Don't expose sync-over-async or async-over-sync; provide a real async API and let callers compose it. Name methods with the `Async` suffix and consider `ConfigureAwait(false)` on internal awaits.

Detailed Answer

Implementing async/await in custom classes requires following specific patterns to ensure proper async behavior, exception handling, and resource management. The key is to implement the async pattern correctly and provide both sync and async versions when appropriate.

Basic Async Implementation:

public class DataService
{
    private readonly HttpClient httpClient;
    
    public DataService(HttpClient httpClient)
    {
        this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }
    
    // Async method with proper naming convention
    public async Task<string> GetDataAsync(string url, CancellationToken cancellationToken = default)
    {
        try
        {
            // Use ConfigureAwait(false) in library code
            var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
            response.EnsureSuccessStatusCode();
            
            var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
            return content;
        }
        catch (HttpRequestException ex)
        {
            // Wrap in more specific exception
            throw new DataServiceException($"Failed to retrieve data from {url}", ex);
        }
    }
    
    // Async method with return value
    public async Task<T> GetDataAsync<T>(string url, CancellationToken cancellationToken = default)
    {
        var json = await GetDataAsync(url, cancellationToken).ConfigureAwait(false);
        return JsonSerializer.Deserialize<T>(json);
    }
    
    // Async method with multiple operations
    public async Task<ProcessedData> ProcessDataAsync(string input, CancellationToken cancellationToken = default)
    {
        // Validate input
        if (string.IsNullOrEmpty(input))
            throw new ArgumentException("Input cannot be null or empty", nameof(input));
        
        // Step 1: Fetch data
        var rawData = await GetDataAsync("https://api.example.com/data", cancellationToken).ConfigureAwait(false);
        
        // Step 2: Process data (CPU-bound work)
        var processedData = await Task.Run(() => ProcessRawData(rawData), cancellationToken).ConfigureAwait(false);
        
        // Step 3: Save result
        await SaveDataAsync(processedData, cancellationToken).ConfigureAwait(false);
        
        return processedData;
    }
    
    private ProcessedData ProcessRawData(string rawData)
    {
        // CPU-intensive processing
        Thread.Sleep(1000); // Simulate processing
        return new ProcessedData { Content = rawData.ToUpper(), ProcessedAt = DateTime.UtcNow };
    }
    
    private async Task SaveDataAsync(ProcessedData data, CancellationToken cancellationToken)
    {
        // Simulate saving to database
        await Task.Delay(100, cancellationToken).ConfigureAwait(false);
    }
}

public class ProcessedData
{
    public string Content { get; set; }
    public DateTime ProcessedAt { get; set; }
}

public class DataServiceException : Exception
{
    public DataServiceException(string message) : base(message) { }
    public DataServiceException(string message, Exception innerException) : base(message, innerException) { }
}

Advanced Async Patterns:

public class FileProcessor
{
    private readonly SemaphoreSlim semaphore;
    private readonly ILogger logger;
    
    public FileProcessor(int maxConcurrency = 4, ILogger logger = null)
    {
        this.semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
        this.logger = logger;
    }
    
    // Async method with concurrency control
    public async Task<ProcessingResult> ProcessFileAsync(string filePath, CancellationToken cancellationToken = default)
    {
        await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
        
        try
        {
            logger?.LogInformation($"Starting to process file: {filePath}");
            
            // Validate file exists
            if (!File.Exists(filePath))
                throw new FileNotFoundException($"File not found: {filePath}");
            
            // Read file asynchronously
            var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
            
            // Process content (CPU-bound)
            var processedContent = await Task.Run(() => ProcessContent(content), cancellationToken).ConfigureAwait(false);
            
            // Write result asynchronously
            var outputPath = GetOutputPath(filePath);
            await File.WriteAllTextAsync(outputPath, processedContent, cancellationToken).ConfigureAwait(false);
            
            logger?.LogInformation($"Successfully processed file: {filePath}");
            
            return new ProcessingResult
            {
                InputPath = filePath,
                OutputPath = outputPath,
                ProcessedAt = DateTime.UtcNow,
                Success = true
            };
        }
        catch (OperationCanceledException)
        {
            logger?.LogWarning($"Processing cancelled for file: {filePath}");
            throw;
        }
        catch (Exception ex)
        {
            logger?.LogError(ex, $"Error processing file: {filePath}");
            return new ProcessingResult
            {
                InputPath = filePath,
                ProcessedAt = DateTime.UtcNow,
                Success = false,
                Error = ex.Message
            };
        }
        finally
        {
            semaphore.Release();
        }
    }
    
    // Batch processing with progress reporting
    public async Task<BatchProcessingResult> ProcessFilesAsync(
        IEnumerable<string> filePaths, 
        IProgress<ProcessingProgress> progress = null,
        CancellationToken cancellationToken = default)
    {
        var tasks = filePaths.Select(async filePath =>
        {
            var result = await ProcessFileAsync(filePath, cancellationToken).ConfigureAwait(false);
            progress?.Report(new ProcessingProgress { FilePath = filePath, Completed = true });
            return result;
        });
        
        var results = await Task.WhenAll(tasks).ConfigureAwait(false);
        
        return new BatchProcessingResult
        {
            TotalFiles = results.Length,
            SuccessfulFiles = results.Count(r => r.Success),
            FailedFiles = results.Count(r => !r.Success),
            Results = results
        };
    }
    
    private string ProcessContent(string content)
    {
        // Simulate CPU-intensive processing
        Thread.Sleep(500);
        return content.ToUpper();
    }
    
    private string GetOutputPath(string inputPath)
    {
        return Path.ChangeExtension(inputPath, ".processed");
    }
    
    public void Dispose()
    {
        semaphore?.Dispose();
    }
}

public class ProcessingResult
{
    public string InputPath { get; set; }
    public string OutputPath { get; set; }
    public DateTime ProcessedAt { get; set; }
    public bool Success { get; set; }
    public string Error { get; set; }
}

public class BatchProcessingResult
{
    public int TotalFiles { get; set; }
    public int SuccessfulFiles { get; set; }
    public int FailedFiles { get; set; }
    public ProcessingResult[] Results { get; set; }
}

public class ProcessingProgress
{
    public string FilePath { get; set; }
    public bool Completed { get; set; }
}

Async Stream Implementation:

public class DataStreamer
{
    private readonly HttpClient httpClient;
    
    public DataStreamer(HttpClient httpClient)
    {
        this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }
    
    // Async enumerable for streaming data
    public async IAsyncEnumerable<DataItem> StreamDataAsync(
        string endpoint,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var response = await httpClient.GetAsync(endpoint, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
            .ConfigureAwait(false);
        
        response.EnsureSuccessStatusCode();
        
        using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
        using var reader = new StreamReader(stream);
        
        string line;
        while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
        {
            cancellationToken.ThrowIfCancellationRequested();
            
            if (!string.IsNullOrWhiteSpace(line))
            {
                var item = ParseDataItem(line);
                yield return item;
            }
        }
    }
    
    // Async method with timeout
    public async Task<string> GetDataWithTimeoutAsync(string url, TimeSpan timeout)
    {
        using var cts = new CancellationTokenSource(timeout);
        
        try
        {
            var response = await httpClient.GetAsync(url, cts.Token).ConfigureAwait(false);
            return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cts.Token.IsCancellationRequested)
        {
            throw new TimeoutException($"Request timed out after {timeout.TotalSeconds} seconds");
        }
    }
    
    private DataItem ParseDataItem(string line)
    {
        // Simple parsing logic
        var parts = line.Split(',');
        return new DataItem
        {
            Id = parts[0],
            Value = parts[1],
            Timestamp = DateTime.Parse(parts[2])
        };
    }
}

public class DataItem
{
    public string Id { get; set; }
    public string Value { get; set; }
    public DateTime Timestamp { get; set; }
}

Async Factory Pattern:

public class DatabaseConnection
{
    private readonly string connectionString;
    
    private DatabaseConnection(string connectionString)
    {
        this.connectionString = connectionString;
    }
    
    // Async factory method
    public static async Task<DatabaseConnection> CreateAsync(string connectionString)
    {
        if (string.IsNullOrEmpty(connectionString))
            throw new ArgumentException("Connection string cannot be null or empty", nameof(connectionString));
        
        var connection = new DatabaseConnection(connectionString);
        
        // Test the connection asynchronously
        await connection.TestConnectionAsync().ConfigureAwait(false);
        
        return connection;
    }
    
    private async Task TestConnectionAsync()
    {
        // Simulate connection test
        await Task.Delay(100).ConfigureAwait(false);
        
        // In real implementation, you would test the actual database connection
        if (connectionString.Contains("invalid"))
            throw new InvalidOperationException("Invalid connection string");
    }
    
    public async Task<T> QueryAsync<T>(string sql, CancellationToken cancellationToken = default)
    {
        // Simulate database query
        await Task.Delay(50, cancellationToken).ConfigureAwait(false);
        
        // Return mock data
        return default(T);
    }
}

Best Practices for Async Implementation:

  1. Naming Convention:

    • Always suffix async methods with Async
    • Use descriptive names that indicate the operation
  2. Return Types:

    • Use Task for void operations
    • Use Task<T> for operations that return values
    • Use IAsyncEnumerable<T> for streaming data
  3. Cancellation Support:

    • Always accept CancellationToken parameters
    • Pass cancellation tokens to all async operations
    • Check cancellationToken.IsCancellationRequested in loops
  4. Exception Handling:

    • Let exceptions bubble up naturally
    • Wrap low-level exceptions in domain-specific exceptions
    • Use ConfigureAwait(false) in library code
  5. Resource Management:

    • Use using statements for disposable resources
    • Implement IAsyncDisposable when needed
    • Clean up resources in finally blocks
  6. Performance:

    • Use ConfigureAwait(false) in library code
    • Avoid blocking async methods with .Result or .Wait()
    • Use Task.Run() only for CPU-bound work