Mastering .NET Architecture: Exceptions, Reflection, and Extensibility Patterns

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: ArgumentException indicates invalid parameters, ArgumentNullException signals null inputs, and FormatException handles malformed data structures.
  • Collection Errors: IndexOutOfRangeException occurs when accessing invalid indices, while ArrayTypeMismatchException arises from storing incompatible types.
  • IO Errors: IOException covers file and stream operation failures.
  • Memory & Arithmetic: OverflowException handles computational overflows, and ArithmeticException (including DivideByZeroException) manages math errors.
  • Data Access: DbException serves as a base for data source errors, with SqlException specific 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:

  1. Presentation Layer (UI): Handles user interaction.
  2. Busines Logic Layer (BLL): Encapsulates rules and workflows.
  3. 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].Web or [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, require override.
  • 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.

Tags: C# .NET software-architecture reflection design-patterns

Posted on Sat, 16 May 2026 03:36:35 +0000 by madspoihur