Exception Handling Strategies
Effective error management requires a clear boundary between infrastructure logic and user interaction. Exceptions should generally be caught at the upper layers to ensure appropriate user feedback, while lower layers should propagate errors rather than suppressing them. Swallowing exceptions hides critical failures, and catching only to rethrow without additional context is redundant unless specific local handling or logging is required.
Common Exception Categoreis
Exceptions in .NET typically derive from SystemException. Key categories include:
- Argument Errors:
ArgumentExceptionindicates invalid parameters,ArgumentNullExceptionsignals null inputs, andFormatExceptionhandles malformed data structures. - Collection Errors:
IndexOutOfRangeExceptionoccurs when accessing invalid indices, whileArrayTypeMismatchExceptionarises from storing incompatible types. - IO Errors:
IOExceptioncovers file and stream operation failures. - Memory & Arithmetic:
OverflowExceptionhandles computational overflows, andArithmeticException(includingDivideByZeroException) manages math errors. - Data Access:
DbExceptionserves as a base for data source errors, withSqlExceptionspecific to database connectivity and query issues.
Propagation and Logging
Lower-level methods should capture initial errors for logging purposes before propagating them upward. This allows higher-level callers to decide on user-facing messages or recovery strategies.
public static int ExecuteCommand(string query)
{
using (var dbConnection = new SqlConnection(connectionString))
{
var command = new SqlCommand(query, dbConnection);
try
{
dbConnection.Open();
return command.ExecuteNonQuery();
}
catch (Exception ex)
{
// Log error details internally
Logger.Log(ex);
throw; // Propagate without wrapping unless adding context
}
}
}
Mid-level components may employ multiple catch blocks to handle specific error codes or transform exceptions into domain-specific errors. However, generic Exception catches should always remain last. UI layers are the appropriate place to finalize exception handling for display purposes, while business logic should remain agnostic of presentation concerns.
Architectural Layers and Naming Conventions
Complex systems benefit from a three-tier architecture:
- Presentation Layer (UI): Handles user interaction.
- Busines Logic Layer (BLL): Encapsulates rules and workflows.
- Data Access Layer (DAL): Manages database connectivity and CRUD operations.
All layers interact with a shared Model/Entity Layer.
Standard naming conventions improve maintainability:
- Solution:
[ProjectName].sln - UI Project:
[ProjectName].Webor[ProjectName].WinForm - Logic Project:
[ProjectName].BLL(Classes:[Entity]Manager) - Data Project:
[ProjectName].DAL(Classes:[Entity]Service) - Models:
[ProjectName].Models
Serialization and Data Transfer
Entities often traverse layer boundaries. Serialization converts object state into a byte stream for storage or transmission, while deserialization reconstructs the object. This ensures data integrity across application domains.
[Serializable]
public class UserAccount
{
public int UserId { get; set; }
public string Username { get; set; }
}
Interfaces and Polymorphism
Interfaces define contracts without implementation. They enforce consistency across different classes and enable polymorphism. A class can implement multiple interfaces while inheriting from a single base class.
public interface INotificationChannel
{
void Send(string message);
bool ValidateRecipient(string address);
}
public class EmailService : INotificationChannel
{
public void Send(string message)
{
Console.WriteLine($"Email sent: {message}");
}
public bool ValidateRecipient(string address)
{
return address.Contains("@");
}
}
public class SmsService : INotificationChannel
{
public void Send(string message)
{
Console.WriteLine($"SMS sent: {message}");
}
public bool ValidateRecipient(string address)
{
return address.Length == 11;
}
}
Polymorphism allows methods to accept interface types, enabling dynamic behavior based on the concrete implementation provided at runtime.
public class NotificationManager
{
public void Broadcast(INotificationChannel channel, string content)
{
if (channel.ValidateRecipient("target"))
{
channel.Send(content);
}
}
}
Interfaces vs. Abstract Classes
- Abstract Classes: Defined with
abstract, support single inheritance, can contain implemented methods, requireoverride. - Interfaces: Defined with
interface, support multiple implementation, contain only declarations (typically), implemented directly.
Both cannot be instantiated directly and require derived types to fulfill contracts.
Design Patterns: Simple Factory
The Simple Factory pattern decouples object creation from usage. It often utilizes configuration files and reflection to determine which concrete class to instantiate.
public interface IDocumentGenerator
{
void Generate();
}
public class PdfGenerator : IDocumentGenerator
{
public void Generate() => Console.WriteLine("Generating PDF...");
}
public class WordGenerator : IDocumentGenerator
{
public void Generate() => Console.WriteLine("Generating Word Doc...");
}
public static class ProviderFactory
{
public static IDocumentGenerator Create(string type)
{
return type switch
{
"PDF" => new PdfGenerator(),
"WORD" => new WordGenerator(),
_ => throw new ArgumentException("Unknown type")
};
}
}
Reflection and Dynamic Loading
Reflection allows runtime inspection of metadata, enabling dynamic object creation, method invocation, and property manipulation. This is essantial for plugin architectures and frameworks.
var assembly = Assembly.LoadFrom("Plugin.dll");
var type = assembly.GetType("Plugins.CalculationEngine");
var instance = Activator.CreateInstance(type);
var method = type.GetMethod("Compute");
var result = method.Invoke(instance, new object[] { 10, 5 });
Reflection supports loading assemblies via Assembly.Load, LoadFrom, or LoadFile. It can handle generics, constructors, and private members using BindingFlags.
var genericType = type.MakeGenericType(typeof(int), typeof(string));
var genericInstance = Activator.CreateInstance(genericType);
Custom Attributes and Validation
Attributes are classes inheriting from System.Attribute. They embed metadata into code elements, accessible via reflection.
[AttributeUsage(AttributeTargets.Property)]
public class RangeAttribute : Attribute
{
public int Min { get; }
public int Max { get; }
public RangeAttribute(int min, int max)
{
Min = min;
Max = max;
}
}
public class Product
{
[Range(1, 100)]
public int Quantity { get; set; }
}
Validation logic can be encapsulated within attribute classes and executed via reflection.
public static class Validator
{
public static bool IsValid(object obj)
{
var type = obj.GetType();
foreach (var prop in type.GetProperties())
{
var attr = prop.GetCustomAttribute<RangeAttribute>();
if (attr != null)
{
var value = (int)prop.GetValue(obj);
if (value < attr.Min || value > attr.Max) return false;
}
}
return true;
}
}
Integrated Implementation: Generic Repository
Combining generics, reflection, attributes, and factory patterns creates a flexible data access layer. Entities inherit from a base model, and the repository handles CRUD operations dynamically.
public class BaseEntity
{
public int Id { get; set; }
}
public interface IRepository<T> where T : BaseEntity
{
T GetById(int id);
void Insert(T entity);
void Update(T entity);
void Delete(int id);
}
public class SqlRepository<T> : IRepository<T> where T : BaseEntity
{
private readonly string _connectionString;
public SqlRepository(string connString)
{
_connectionString = connString;
}
public void Insert(T entity)
{
var type = typeof(T);
var properties = type.GetProperties().Where(p => p.Name != "Id");
// Dynamic SQL construction based on properties
var columns = string.Join(",", properties.Select(p => $"[{p.Name}]"));
var values = string.Join(",", properties.Select(p => $"@{p.Name}"));
var sql = $"INSERT INTO [{type.Name}] ({columns}) VALUES ({values})";
using (var conn = new SqlConnection(_connectionString))
using (var cmd = new SqlCommand(sql, conn))
{
foreach (var prop in properties)
{
cmd.Parameters.AddWithValue($"@{prop.Name}", prop.GetValue(entity) ?? DBNull.Value);
}
conn.Open();
cmd.ExecuteNonQuery();
}
}
public T GetById(int id)
{
var type = typeof(T);
var sql = $"SELECT * FROM [{type.Name}] WHERE Id = @Id";
using (var conn = new SqlConnection(_connectionString))
using (var cmd = new SqlCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@Id", id);
conn.Open();
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
var instance = Activator.CreateInstance<T>();
foreach (var prop in type.GetProperties())
{
if (!reader.IsDBNull(reader.GetOrdinal(prop.Name)))
{
prop.SetValue(instance, reader[prop.Name]);
}
}
return instance;
}
}
}
return null;
}
// Update and Delete methods follow similar reflection-based logic
public void Update(T entity) { /* Implementation */ }
public void Delete(int id) { /* Implementation */ }
}
Configuration drives the specific implementation via a factory.
public static class DalResolver
{
private static readonly Type _dalType;
static DalResolver()
{
var assemblyName = ConfigurationManager.AppSettings["DalAssembly"];
var typeName = ConfigurationManager.AppSettings["DalType"];
var assembly = Assembly.Load(assemblyName);
_dalType = assembly.GetType(typeName);
}
public static IRepository<T> Resolve<T>() where T : BaseEntity
{
return (IRepository<T>)Activator.CreateInstance(_dalType);
}
}
This approach minimizes repetitive code, enforces validation through attributes, and allows database providers to be swapped via configuration without recompiling the core logic.