Top 10 C# Best Practices and Code Review Checklist for Clean, Secure, and Maintainable Code

Writing clean, secure, and maintainable C# code is essential for building robust and scalable applications. In this article, we’ll walk through a practical C# best practices and code review checklist to help you enforce C# coding standards, ensure code quality, and follow secure coding guidelines. Whether you’re reviewing a .NET enterprise project or improving testable C# code, this guide covers everything from naming conventions to performance optimization, error handling, and logging best practices.

C# Code Review Checklist – Best Practices for Clean, Secure, and Maintainable Code

Code Review Checklist for Clean and Reliable Code

  1. Code Quality
  2. Naming & Readability
  3. Security
  4. Performance
  5. Error Handling
  6. Testability
  7. Async & Concurrency
  8. Architecture & Design
  9. Configuration
  10. Logging & Monitoring

To write clean, secure, and maintainable C# code, use this quick-start guide to focus on the most critical areas during code reviews. This checklist is designed to help you enhance code quality, optimize performance, and consistently apply best practices across your application.

Let’s take a closer look at each point with code examples, along with the do’s and don’ts to keep in mind during a code review.

1. Code Quality

Structure your code in a clean, organized, and modular way to promote long-term maintainability and clarity. Follow the DRY (Don’t Repeat Yourself) principle by eliminating code duplication and reusing common logic through shared methods or utility classes.

Break down complex functionality into smaller, focused methods or components that each perform a single, well-defined task. Additionally, maintain consistent formatting, indentation, and spacing to improve overall readability and visual flow of the codebase.

Clean and messy C# code comparison highlighting code quality best practices
❌ Bad Code Example & What’s Wrong
  • The method handles multiple responsibilities, violating the Single Responsibility Principle.
  • It is tightly coupled with both the database and SmtpClient, reducing flexibility.
  • Configuration details like SMTP server and email are hardcoded, making changes difficult and error-prone.
  • Lacks clear separation of concerns, mixing business logic with infrastructure code.
  • The design is difficult to test and maintain, especially as the application grows.
public class OrderProcessor
{
    public void ProcessOrder(Order order)
    {
        // Calculate total
        double total = 0;
        foreach (var item in order.Items)
        {
            total += item.Price * item.Quantity;
        }

        // Save order in database
        Database.Save(order);

        // Send email logic inside same method
        var smtp = new SmtpClient("smtp.example.com");
        smtp.Send(new MailMessage("sales@example.com", order.CustomerEmail, "Order Placed", "Your order was placed."));
    }
}

Good Code Example & Why It’s Better

  • Keeps each class and method focused on a single responsibility
  • Uses dependency injection to make the code more testable and flexible
  • Avoids hardcoded settings like SMTP details or email addresses
  • Code is well-organized, easy to read, and modular
  • Simple to maintain, update, and mock for unit testing
public class OrderProcessor
{
    private readonly IOrderRepository _orderRepository;
    private readonly IEmailService _emailService;

    public OrderProcessor(IOrderRepository orderRepository, IEmailService emailService)
    {
        _orderRepository = orderRepository;
        _emailService = emailService;
    }

    public void ProcessOrder(Order order)
    {
        var total = CalculateTotal(order);
        _orderRepository.Save(order);
        _emailService.SendOrderConfirmation(order.CustomerEmail);
    }

    private double CalculateTotal(Order order)
    {
        return order.Items.Sum(i => i.Price * i.Quantity);
    }
}
The Importance of Writing High-Quality Code
  • Improves Maintainability: Clean and modular code is easier to debug, extend, or refactor as business needs evolve.
  • Reduces Bugs: Reusing tested logic minimizes the risk of inconsistencies or duplicate errors.
  • Enhances Readability: Consistent formatting and concise methods help developers quickly navigate and understand the code.
  • Simplifies Collaboration: Teams can work more efficiently when code is predictable and well-structured, reducing the onboarding time for new developers.
  • Enables Scalability: Modular design makes it easier to scale features or services independently without creating tightly coupled systems.

2. Naming & Readability

Adopt consistent C# naming conventions and use clear, descriptive names for variablesmethodsclasses, and properties. Stick to established guidelines such as using PascalCase for types and method names, and camelCase for local variables and parameters. Avoid abbreviations, acronyms, and vague identifiers.

Proper Naming and Readable Code Guidelines in C# for Maintainable Development
❌ Bad Code Example & What’s Wrong
  • Class and method names are meaningless
  • Variable names are not self-explanatory
  • Hard to understand without comments
public class UService // Class name is not readable
{
    public void display(string s, int a) // Method name is not meaningful
    {
        Console.WriteLine("Hello " + s + ", Age: " + a);
    }
}
Follow Consistent Naming Conventions in C# for Maintainable Code

Discover how consistent naming conventions in C# enhance readability, reduce bugs, and promote cleaner, more professional code. Follow these best practices today.

using system;  //  Namespace should be 'System'

namespace myapp.utilities  // Should be 'MyApp.Utilities'
{
    public class employee  // Should be 'Employee'
    {
        public int employeeid;  // Public field should be 'EmployeeId'

        private string username;  // Should be '_username'

        public string firstname { get; set; }  // Property should be 'FirstName'

        public const int maxCount = 100;  // Constant should be 'MAX_COUNT'

        public event EventHandler userloggedin;  // Should be 'UserLoggedIn' or 'UserLoggedInEvent'

        public delegate void clickhandler();  // Should be 'ClickHandler'

        public void getdetails(string UserName)  // Method should be 'GetDetails', parameter should be 'userName'
        {
            int LocalVariable = 5;  // Local variable should be 'localVariable'

            var tempList = new List<string>();
        }
    }

    public interface serviceRepository  // Should be 'IServiceRepository'
    {
        void addemployee(employee emp);  // Method should be 'AddEmployee', parameter should be 'Employee emp'
    }
}

✅ Good Code Example & Why it’s better:

  • Descriptive class and method names
  • Variables clearly express their purpose
  • Easier to read, maintain, and understand
Follow the naming conventions below to keep your codebase clean, consistent, and easy to read.
  • Local Variables: Use camelCase for locally scoped variables.
  • Method Parameters: Use camelCase for method arguments.
  • Private Fields: Use _camelCase for private member fields (common convention).
  • Public Fields: Use PascalCase for publicly exposed fields.
  • Properties: Use PascalCase for property names.
  • Methods: Use PascalCase for method names.
  • Classes / Types: Use PascalCase for class and type declarations.
  • Interfaces: Prefix with I and use PascalCase (e.g., IService).
  • Constants: Use UPPERCASE with underscores for constants.
  • Events: Use PascalCase, optionally ending with “Event” (e.g., DataLoadedEvent).
  • Namespaces: Use PascalCase for namespaces.
  • Delegates: Use PascalCase, typically ending with “Handler” or “Action” (e.g., ClickHandler).
public class UserService
{
    public void DisplayUserInfo(string userName, int age)
    {
        Console.WriteLine($"Hello {userName}, Age: {age}");
    }
}
using System;

namespace MyApp.Utilities
{
    public class Employee
    {
        public int EmployeeId;  // ✅ Public field in PascalCase

        private string _username;  // ✅ Private field with underscore + camelCase

        public string FirstName { get; set; }  // ✅ Property in PascalCase

        public const int MAX_COUNT = 100;  // ✅ Constant in uppercase with underscores

        public event EventHandler UserLoggedInEvent;  // ✅ Event in PascalCase, optionally ending in 'Event'

        public delegate void ClickHandler();  // ✅ Delegate in PascalCase ending in 'Handler'

        public void GetDetails(string userName)  // ✅ Method in PascalCase, parameter in camelCase
        {
            int localVariable = 5;  // ✅ Local variable in camelCase

            var tempList = new List<string>();
        }
    }

    public interface IServiceRepository  // ✅ Interface prefixed with 'I' and PascalCase
    {
        void AddEmployee(Employee emp);  // ✅ Method and parameter in PascalCase
    }
}
The Importance of Readable Code and Consistent Naming Conventions

Readable code is easier to understandmaintain, and extend. When elements are clearly named and consistently styled:

  • Clarity improves understanding
    Developers can instantly grasp the purpose of variables, methods, or classes without reading through the full implementation.
  • Self-documenting code reduces clutter
    Well-named elements eliminate the need for excessive comments, making the codebase cleaner and easier to maintain.
  • Better collaboration with fewer mistakes
    Clear, consistent naming reduces misunderstandings, helping teams work together more smoothly and avoid unnecessary rework.
  • Faster onboarding and safer changes
    Readable code helps new developers ramp up quickly and lowers the risk of introducing bugs during updates or refactoring.

3. Security

Safeguard your application by thoroughly validating all user inputs, eliminating hardcoded credentials, and adhering to secure C# coding standards.

Secure and insecure C# code examples showing best practices for application security
❌ Bad Code Example & What’s Wrong
  • Hardcoded secrets like API keys are embedded directly in code, making them easy to leak and hard to rotate securely.
  • Lack of input validation allows unsafe user input (e.g., for file paths), opening the door to path traversal and injection attacks.
  • Plaintext password storage makes user credentials vulnerable if the database is ever compromised.
  • SQL queries built with string concatenation expose the app to SQL injection attacks, one of the most critical security flaws.
  • Logging sensitive data such as passwords violates privacy standards and risks exposing credentials through logs or monitoring tools.
// Hardcoded secret
string apiKey = "12345-SECRET-KEY";

// No input validation
var filePath = $"C:\\Users\\{username}.txt";
File.WriteAllText(filePath, "Welcome!");

// toring password in plain text
var user = new User { Username = username, Password = password };

// QL Injection risk
string query = $"INSERT INTO Users (Username) VALUES ('{username}')";
var cmd = new SqlCommand(query, _db);
        cmd.ExecuteNonQuery();

//ogging sensitive info
_logger.LogInformation($"User registered: {username}, Password: {password}");
✅ Good Code Example & Why it’s better:
  1. Store secrets securely using configuration providers (e.g., IConfiguration, environment variables, Azure Key Vault) instead of hardcoding them.
  2. Validate and sanitize user inputs using regex, input length checks, and whitelisting to prevent path traversal and injection risks.
  3. Hash passwords Never store passwords in plain text; always hash them first using secure algorithms like BCrypt, PBKDF2, or Argon2.
  4. Use parameterized SQL queries (e.g., SqlCommand.Parameters.AddWithValue) to safely handle user input and prevent SQL injection.
  5. Avoid logging sensitive information such as passwords, tokens, or personal data. Log only what is necessary (e.g., usernames or error codes).
// Load secret from config
string apiKey = _config["ApiSettings:Key"];

// Validate input
if (!Regex.IsMatch(username, "^[a-zA-Z0-9_-]{3,20}$"))
{
_logger.LogWarning("Invalid username format.");
return;
}

//Hash password securely
var hashedPassword = BCrypt.Net.BCrypt.HashPassword(password);
var user = new User { Username = username, HashedPassword = hashedPassword };

// Parameterized query
string query = "INSERT INTO Users (Username, PasswordHash) VALUES (@username, @password)";
var cmd = new SqlCommand(query, _db);
cmd.Parameters.AddWithValue("@username", user.Username);
cmd.Parameters.AddWithValue("@password", user.HashedPassword);
cmd.ExecuteNonQuery();

// Safe logging
_logger.LogInformation($"New user registered: {username}");
The Importance of Securing Your Application

Input validation helps prevent common attack vectors such as SQL injection and cross-site scripting (XSS). Storing secrets securely, using configuration files, environment variables, or secure vaults, reduces the risk of credential leakage. By consistently applying secure coding practices, you minimize vulnerabilities, ensure data integrity, and protect sensitive information from unauthorized access or exposure.

4. Performance

Develop high-performing C# applications by managing system resources, such as memory and CPU effectively.

Utilize IQueryable to execute queries at the database level, reducing in-memory processing. Avoid unnecessary object creation, select appropriate data structures for specific tasks, and write asynchronous code to prevent blocking operations.

Enhance responsiveness and efficiency by implementing caching for frequently accessed data and identifying performance bottlenecks through profiling and diagnostics.

A side-by-side comparison of performance pitfalls and optimized C# code, including examples like avoiding unnecessary ToList(), using StringBuilder, and reducing redundant operations.
❌ Bad Code Example & What’s Wrong
  • Avoid unnecessary ToList(): It wastes memory and CPU when a simple iteration would suffice.
  • Don’t use += in loops for string: Use StringBuilder to avoid repeated allocations.
  • Don’t chain multiple OrderBy() calls : Use ThenBy() for proper multi-level sorting.
  • Avoid blocking async code with.Result : Use await to prevent deadlocks and improve responsiveness.
//Using ToList() or ToArray() unnecessarily
var activeUsers = users.Where(u => u.IsActive).ToList();
foreach (var user in activeUsers)
{
    Console.WriteLine(user.Name);
}

// String Concatenation in Loops
string result = "";
foreach (var name in names)
{
    result += name + ", ";
}

//Performance overhead for multiple orderby
var result = users.Where(u => u.IsActive)
                  .OrderBy(u => u.Name)
                  .OrderBy(u => u.Age)
                  .ToList();
                  
// Blocks the thread
var data = GetDataAsync().Result; 


✅ Good Code Example & Why it’s better:

  • Avoids materializing collections in memory if not needed. It improves memory and execution time.
  • StringBuilder is optimized for repeated concatenation. It avoids creating multiple string instances.
  • Avoid chaining multiple OrderBy() unless you’re intentionally overwriting previous sorting (which is rare).
  • Prevents deadlocks and improves responsiveness, especially in UI and ASP.NET environments.
// ToList() not used which improves memory and execution time.
var activeUsers = users.Where(u => u.IsActive);
foreach (var user in activeUsers)
{
    Console.WriteLine(user.Name);
}

// StringBuilder avoids creating multiple string instances.
var sb = new StringBuilder();
foreach (var name in names)
{
    sb.Append(name).Append(", ");
}
string result = sb.ToString();

// For multiple field order by use OrderBy and ThenBy
var result = users
    .Where(u => u.IsActive)
    .OrderBy(u => u.Name)
    .ThenBy(u => u.Age)
    .ToList();

// Properly use of Async
var data = await GetDataAsync(); // Properly awaits
The Importance of Optimizing Application Performance
  • Improves Scalability: Efficient resource usage enables your application to handle more users and data without degrading performance.
  • Reduces Latency: Well-optimized queries and smart memory management lead to faster response times, enhancing the user experience.
  • Lowers Infrastructure Costs: Leaner code can reduce server load, memory consumption, and compute usage—resulting in lower operational expenses.
  • Prevents Performance Bottlenecks: Identifying and optimizing inefficient code early prevents slowdowns in production and makes your system more reliable under stress.
  • Enhances Maintainability: Well-optimized and profiled code often leads to cleaner logic and fewer side effects, making it easier to maintain over time.

5. Error Handling

Use Structured and Meaningful Exception Handling

Handle errors gracefully using try-catch blocks, targeting specific exception types like SqlException or IOException.

Log detailed error information—including message and stack trace—for easier debugging and monitoring.

Avoid silently swallowing exceptions; always handle or rethrow them after logging to maintain application reliability.

C# error handling comparison showing proper try-catch usage, structured logging, and user-friendly messages
❌ Bad Code Example & What’s Wrong – Exception Handling
  • Catches broad Exception without filtering : hides the real issue and makes debugging difficult.
  • Logs internal exceptions to console/output : exposes stack traces or system info to users/attackers.
  • Returns raw exception messages to users : leaks sensitive details and creates a poor user experience.
  • Lacks proper structured logging : no centralized logging (e.g., ILogger), making diagnostics harder.
  • Doesn’t distinguish between recoverable and fatal errors : treats all exceptions the same, reducing reliability.
public string GetUserData(int userId)
{
    try
    {
        var user = _userRepository.GetById(userId);
        return $"User: {user.Name}";
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex); // Prints stack trace to console
        return ex.ToString();  // Exposes internal exception details
    }
}

✅ Good Code Example & Why it’s better:

  • Handles known issues explicitly: By catching specific exceptions like SqlException, it allows targeted handling and clearer debugging.
  • Avoids exposing internal details: Returns safe, user-friendly messages instead of raw exception data, protecting application internals.
  • Logs errors properly: Uses structured logging (ILogger) to capture detailed errors for developers without leaking sensitive info to users.
  • Improves code clarity and maintainability: Separates validation, logging, and user messaging in a clean and organized way.
  • Supports secure and professional user experience: Helps ensure users receive helpful messages, while developers get actionable logs.
public class UserService
{
    private readonly IUserRepository _userRepository;
    private readonly ILogger<UserService> _logger;

    public UserService(IUserRepository userRepository, ILogger<UserService> logger)
    {
        _userRepository = userRepository;
        _logger = logger;
    }

    public string GetUserData(int userId)
    {
        try
        {
            var user = _userRepository.GetById(userId);

            if (user is null)
            {
                LogUserNotFound(userId);
                return "User not found.";
            }

            return FormatUserOutput(user);
        }
        catch (SqlException ex)
        {
            _logger.LogError(ex, "Database error while fetching user data for UserId: {UserId}", userId);
            return "An error occurred. Please try again later.";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception in GetUserData for UserId: {UserId}", userId);
            return "An unexpected error occurred. Please contact support.";
        }
    }

    private void LogUserNotFound(int userId)
    {
        _logger.LogWarning("User with ID {UserId} not found.", userId);
    }

    private string FormatUserOutput(User user)
    {
        return $"User: {user.Name}";
    }
}
Why Effective Error Handling Matters
  • Ensures Application Stability
    Proper exception handling prevents unexpected crashes caused by issues like failed API calls or database errors.
  • Simplifies Debugging
    Clear and structured error logging helps developers quickly identify and resolve problems, especially in production environments.
  • Enhances User Experience
    Instead of confusing errors or app failures, users see helpful messages or smooth fallback behavior.
  • Strengthens Security
    Secure error handling avoids exposing internal system details that could be exploited by attackers.
  • Enables Proactive Monitoring
    Logged exceptions can trigger alerts and appear in monitoring dashboards, allowing teams to detect and respond to issues early.

6. Testability

Write Testable, Modular, and Loosely Coupled Code

Design your code to be modular and loosely coupled to support effective unit testing and automated testing. Structure your components with well-defined responsibilities and clear interfaces. Use dependency injection (DI) to inject services, rather than tightly coupling classes, and avoid using static or hard-to-mock code.

Follow C# coding practices that promote testability, such as coding against interfaces, using abstractions, and keeping logic out of constructors or static blocks. These patterns make it easier to use mocking frameworks and write isolated unit tests without relying on external dependencies like databases or services.

Demonstrates how to refactor tightly coupled code into a testable, loosely coupled design using interfaces and dependency injection in C#.

❌Bad Code Example & What’s Wrong 

  • Hardcoded dependencies make mocking difficult
    Tightly coupling to concrete classes (e.g., new UserRepository()) limits flexibility and hinders unit testing.
  • Lack of abstraction prevents test doubles
    Without interfaces or dependency injection, it becomes nearly impossible to substitute mocks or stubs for isolated testing.
  • Direct use of DateTime.Now reduces test reliability
    Time-based logic becomes hard to simulate, making your tests inconsistent and environment-dependent.
  • Mixing business logic with external calls weakens test focus
    When logic and infrastructure are tightly coupled, unit tests become bloated, fragile, and hard to maintain.
  • Uncontrolled side effects lead to flaky tests
    Relying on external dependencies like real time or data introduces randomness, making test outcomes unpredictable.
public class UserService
{
    public string GetUserGreeting(int userId)
    {
        var user = new UserRepository().GetById(userId); // tightly coupled

        if (user == null)
        {
            return "User not found.";
        }

        var currentHour = DateTime.Now.Hour; // time-dependent

        if (currentHour < 12)
            return $"Good morning, {user.Name}!";
        else
            return $"Hello, {user.Name}!";
    }
}

✅ Good Code Example & Why it’s better:

  • Leverages dependency injection: Facilitates the injection of mock implementations for components such as repositories and time providers during testing.
  • Abstracts system time through ITimeProvider: Enables controlled simulation of time-dependent scenarios without relying on the system clock.
  • Promotes deterministic behavior: Ensures tests produce consistent and repeatable results by eliminating external dependencies.
  • Enhances edge case coverage: Simplifies the simulation of scenarios like null values, specific time ranges, or failure conditions using mocks.
  • Increases unit test reliability: Encourages isolated, focused testing that leads to faster execution and more maintainable test suites.
public interface IUserRepository
{
    User GetById(int userId);
}

public interface ITimeProvider
{
    int GetCurrentHour();
}

public class UserService
{
    private readonly IUserRepository _userRepository;
    private readonly ITimeProvider _timeProvider;

    public UserService(IUserRepository userRepository, ITimeProvider timeProvider)
    {
        _userRepository = userRepository;
        _timeProvider = timeProvider;
    }

    public string GetUserGreeting(int userId)
    {
        var user = _userRepository.GetById(userId);

        if (user == null)
        {
            return "User not found.";
        }

        var currentHour = _timeProvider.GetCurrentHour();

        return currentHour < 12
            ? $"Good morning, {user.Name}!"
            : $"Hello, {user.Name}!";
    }
}

Example Unit Test (Using Moq)

[Fact]
public void GetUserGreeting_ReturnsMorningGreeting_WhenBeforeNoon()
{
    var userRepoMock = new Mock<IUserRepository>();
    userRepoMock.Setup(r => r.GetById(1)).Returns(new User { Name = "Jignesh" });

    var timeProviderMock = new Mock<ITimeProvider>();
    timeProviderMock.Setup(t => t.GetCurrentHour()).Returns(9);

    var service = new UserService(userRepoMock.Object, timeProviderMock.Object);

    var result = service.GetUserGreeting(1);

    Assert.Equal("Good morning, Jignesh!", result);
}
Why Testability Matters in Software Development
  • Enables Reliable Testing
    Loosely coupled and modular code is easier to test in isolation, which improves the accuracy and reliability of tests.
  • Speeds Up Development
    Automated tests allow developers to catch bugs early and refactor code with confidence, reducing time spent on manual testing.
  • Supports CI/CD and Automation
    Testable code is essential for continuous integration and delivery pipelines, enabling faster, safer deployments.
  • Improves Code Quality
    Writing testable code often leads to better separation of concerns, cleaner architecture, and easier maintenance.
  • Facilitates Mocking and Flexibility
    Abstractions and DI allow you to swap implementations or use mocks, making tests more focused and predictable.

7. Async & Concurrency

Use Asynchronous Programming and Ensure Thread Safety

Utilize async/await for non-blocking operations to keep your application responsive and efficient, especially during I/O-bound tasks like database calls or API requests. Avoid using .Result or .Wait(), as they can lead to deadlocks and degrade performance by blocking threads unnecessarily.

When working with shared resources in multi-threaded environments, implement proper thread safety mechanisms (e.g., lockSemaphoreSlim, or concurrent collections) to prevent race conditions, data corruption, or unexpected behavior.

C# async/await code comparison showing bad blocking patterns vs. best practices for concurrency

❌ Bad Code Example & What’s Wrong

  • Blocking async calls with .Result
    Can lead to deadlocks or thread starvation, especially in ASP.NET or UI apps.
  • Calling async methods from non-async methods
    Breaks async flow and reduces the benefits of asynchronous programming.
  • Missing null checks
    Risks NullReferenceException when accessing properties of potentially null objects.
  • Catching broad exceptions
    Hides specific error types and makes debugging or recovery more difficult.
  • Insecure logging and error handling
    Exposes full stack traces or sensitive info to users; violates secure coding standards.
  • Tight coupling to concrete classes
    Reduces testability and violates SOLID principles like Dependency Inversion.
public class UserService
{
    private readonly UserRepository _repository = new UserRepository();

    public string GetUserSummary(int userId)
    {
        try
        {
            //  Blocking async call - can cause deadlocks in ASP.NET and UI apps
            var user = _repository.GetByIdAsync(userId).Result;

            //  Null check missing - can throw NullReferenceException
            return "User: " + user.Name;
        }
        catch (Exception ex)
        {
            //  Catching generic Exception - hides specific errors
            Console.WriteLine(ex);   //  Logging sensitive info directly to console
            return ex.ToString();      //  Exposing internal error details to user
        }
    }
}

✅ Good Code Example & Why it’s better:

  • Fully asynchronous flow: No .Result, .Wait(), or blocking anywhere.
  • Uses Task.WhenAll() for concurrency: Executes I/O-bound operations (DB + API) in parallel to reduce latency.
  • Structured exception handling: Catches known exceptions (e.g., HttpRequestException) separately and logs clearly.
  • Clean separation of concerns: Service layer doesn’t mix concerns: repository for DB, API client for external calls, logger for diagnostics.
  • Interface-based design: Follows Dependency Inversion Principle, making it testable and injectable in DI container.
  • Logs securely and meaningfully: Uses structured logging (ILogger<T>) to help with monitoring and debugging.
public interface IUserRepository
{
    Task<User?> GetByIdAsync(int userId);
}

public class UserService
{
    private readonly IUserRepository _repository;
    private readonly ILogger<UserService> _logger;

    public UserService(IUserRepository repository, ILogger<UserService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public async Task<string> GetUserSummaryAsync(int userId)
    {
        try
        {
            var user = await _repository.GetByIdAsync(userId);

            if (user == null)
            {
                _logger.LogWarning("User not found. UserId: {UserId}", userId);
                return "User not found.";
            }

            return $"User: {user.Name}";
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving user summary for UserId: {UserId}", userId);
            return "An unexpected error occurred. Please try again later.";
        }
    }
}

8. Architecture & Design

Apply SOLID Principles and Layered Architecture for Clean Design

Design your application using the SOLID principles and a layered architecture (such as Controller → Service → Repository) to ensure scalability, maintainability, and clarity. Separate concerns by assigning responsibilities to the right layers:

  • Controllers should handle request routing and interaction with the user or client.
  • Services should encapsulate business logic and coordinate workflows.
  • Repositories should manage data access and persistence.

Avoid placing business logic in the controller or data layers—this separation improves modularity and promotes testability.

Adhering to SOLID principles

  1. Single Responsibility
  2. Open/Closed
  3. Liskov Substitution
  4. Interface Segregation
  5. Dependency Inversion

It helps ensure that your code is well-structured, loosely coupled, and easier to extend or modify.

Why Architecture & Design Matter in Software Development
  • The groundwork for scalability and adaptability
    Thoughtful architecture allows your application to evolve smoothly with business requirements and user growth.
  • Enhances maintainability and minimizes technical debt
    Applying solid design principles results in cleaner, more organized code that’s easier to modify and less likely to require major rewrites.
  • Promotes clear separation of concerns
    Structuring code into distinct layers (such as UI, business logic, and data access) improves modularity and makes testing more straightforward.
  • Facilitates effective team collaboration
    A well-defined architecture enables multiple developers to work concurrently without conflict or confusion.
  • Simplifies complexity and boosts code reuse
    Efficient design reduces development overhead and encourages using common components across multiple parts of the application.

Note: I plan to write a detailed article on each topic listed. Once completed, I will update this section with links to those articles.

Layered C# architecture illustrating clean design with service and repository layers

9. Configuration

Use Configuration Files and Environment Variables for Application Settings

Store all environment-specific settings, such as connection strings, API keys, secrets, URLs, and feature flags—in external configuration sources like appsettings.jsonappsettings.{Environment}.json, or environment variables.

This allows you to cleanly separate configuration from your codebase and avoid hardcoding sensitive or environment-dependent values directly in your source code.

By externalizing these settings, you can easily manage different configurations for development, staging, QA, and production environments without modifying the application logic or redeploying code.

Avoid hardcoding values such as API keys, database credentials, or service URLs inside classes or methods, as it leads to poor maintainability and increases security risks.

❌ Bad Code Example & What’s Wrong

  • Hardcoded values: Hardcoding SMTP host, port, username, and password violates the 12-Factor App principle by embedding configuration directly into the codebase.
  • Sensitive info in source code:

    Storing the password in plain text increases the risk of exposure through version control, code sharing, or reverse engineering.

  • No flexibility for different environments: Cannot change configs without modifying and redeploying code.
  • Difficult to test and maintain: No abstraction — makes mocking or testing with different configs harder.
public class EmailService
{
    public void SendEmail(string to, string subject, string body)
    {
        // Hardcoded configuration
        var smtpClient = new SmtpClient("smtp.mailserver.com")
        {
            Port = 587,
            Credentials = new NetworkCredential("admin@example.com", "PlainTextPassword"),
            EnableSsl = true
        };

        smtpClient.Send("admin@example.com", to, subject, body);
    }
}

Good Configuration Example (Best Practice)

  • Centralized configuration: All settings are managed outside the code using appsettings.json, environment variables, or secret providers like Azure Key Vault.
  • Secure approach: Secrets like passwords aren’t hardcoded — they’re injected safely at runtime from secure sources.
  • Environment flexibility: Easily switch between environments (Development, Staging, Production) using environment-specific config files or overrides.
  • Test-friendly design: You can easily mock or inject configuration values during unit testing with the IOptions<T> pattern.
  • Follows modern .NET standards: Leverages built-in dependency injection and recommended configuration patterns in .NET Core and beyond.
Why Configuration Management Matters
  • Keeps code and environment settings separate
    Storing settings like connection strings, API keys, and feature flags outside the codebase makes the application easier to maintain and deploy.
  • Enables environment-specific behavior
    Proper configuration allows apps to adapt seamlessly across development, staging, and production environments without code changes.
  • Improves security and compliance
    Sensitive data such as credentials or tokens can be managed securely using environment variables or secret stores instead of hardcoding.
  • Supports automation and DevOps
    Externalized configuration simplifies CI/CD pipelines, making deployments more predictable and less error-prone.
  • Simplifies scaling and customization
    Centralized configuration makes it easier to scale applications and customize behavior for different tenants, clients, or use cases.

10. Logging & Monitoring

Utilize logging frameworks such as ILogger, Serilog, or NLog to capture application events and errors in a structured format like JSON. This approach enables advanced filtering, searching, and analysis in tools like Seq or Azure Application Insights. Be sure to include relevant context—such as timestamps, request IDs, or user information—while avoiding the logging of sensitive data to maintain security and compliance standards.

// appsettings.json
{
  "EmailSettings": {
    "SmtpHost": "smtp.mailserver.com",
    "Port": 587,
    "Username": "admin@example.com",
    "Password": "UseSecretManagerOrEnvironmentVariable",
    "EnableSsl": true
  }
}

public class EmailSettings
{
    public string SmtpHost { get; set; }
    public int Port { get; set; }
    public string Username { get; set; }
    public string Password { get; set; } // Ideally stored securely
    public bool EnableSsl { get; set; }
}

public class EmailService
{
    private readonly EmailSettings _emailSettings;

    public EmailService(IOptions<EmailSettings> options)
    {
        _emailSettings = options.Value;
    }

    public void SendEmail(string to, string subject, string body)
    {
        var smtpClient = new SmtpClient(_emailSettings.SmtpHost)
        {
            Port = _emailSettings.Port,
            Credentials = new NetworkCredential(_emailSettings.Username, _emailSettings.Password),
            EnableSsl = _emailSettings.EnableSsl
        };

        smtpClient.Send(_emailSettings.Username, to, subject, body);
    }
}



❌ Bad Code Example & What’s Wrong

  • Relies on Console.WriteLine: Not suitable for production, lacks integration with real-time or centralized logging tools.
  • Unstructured logs: No log levels or JSON formatting, making filtering and analysis difficult in tools like Seq or Application Insights.
  • Sensitive data exposure: Logs confidential information (e.g., email, credit card) — a serious security and compliance risk.
  • Missing contextual data: Logs lack key identifiers such as OrderId, timestamps, or correlation IDs, which hampers traceability.
  • Inflexible logging destination: Writing directly to a file (errors.log) doesn’t scale or work well in distributed or cloud environments.
  • No use of ILogger<T>: Bypasses .NET’s standard logging abstractions, making the code less testable, less maintainable, and harder to integrate with modern logging ecosystems.
public class OrderService
{
    public void ProcessOrder(Order order)
    {
        try
        {
            Console.WriteLine("Starting order processing..."); // Basic console log 

            // Process logic
            if (order.Amount > 10000)
            {
                Console.WriteLine("High-value order detected."); //  No context or structure 
            }

            _orderRepository.Save(order);

            Console.WriteLine("Order saved: " + order.CustomerEmail + ", " + order.CreditCardNumber); //  Logging sensitive data 
        }
        catch (Exception ex)
        {
            Console.WriteLine("Error: " + ex.Message); //  Logs only the message, not full trace 
            File.AppendAllText("errors.log", ex.ToString()); //  Manual file-based logging 
        }
    }
}

✅ Good Configuration Example (Best Practice)

  • Sensitive data is excluded from logs to ensure security and compliance with data protection standards.
  • Structured logging adds context like Order ID and Customer ID, making logs easier to trace and analyze.
  • Uses scalable logging infrastructure instead of console or file logs, supporting modern distributed environments.
  • Dependency injection improves testability, making the code easier to mock and maintain.
  • Full exception details are logged safely, enabling better debugging without exposing sensitive information.
public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly ILogger<OrderService> _logger;

    public OrderService(IOrderRepository orderRepository, ILogger<OrderService> logger)
    {
        _orderRepository = orderRepository;
        _logger = logger;
    }

    public void ProcessOrder(Order order)
    {
        try
        {
            _logger.LogInformation("Processing order. OrderId: {OrderId}, CustomerId: {CustomerId}", order.Id, order.CustomerId);

            if (order.Amount > 10000)
            {
                _logger.LogWarning("High-value order detected. OrderId: {OrderId}, Amount: {Amount}", order.Id, order.Amount);
            }

            _orderRepository.Save(order);

            _logger.LogInformation("Order processed successfully. OrderId: {OrderId}", order.Id);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error occurred while processing order. OrderId: {OrderId}", order?.Id);
            // Optionally rethrow or handle appropriately
        }
    }
}
Why Logging & Monitoring Matter
  • Improves Troubleshooting and Debugging
    Structured logs provide detailed context and consistent formatting, making it easier to trace errors, monitor application behavior, and pinpoint root causes.
  • Enables Centralized Monitoring and Alerting
    Logs can be sent to centralized logging systems, enabling real-time monitoring, dashboards, and alerts for unusual behavior or system failures.
  • Supports Audit and Compliance
    Structured logs can serve as a reliable audit trail for actions performed in the system, aiding in compliance and security investigations.
  • Facilitates Performance Analysis
    Logging execution time and metrics helps you identify performance bottlenecks and optimize critical paths in your application.
  • Protects Sensitive Information
    Excluding personal or confidential data from logs helps comply with data protection laws (e.g., GDPR, HIPAA) and prevents accidental exposure during debugging or data export.
Summary

This C# code review checklist provides a practical guide to ensure clean, secure, and high-quality code. Ideal for web APIs, enterprise applications, and .NET Core projects, it helps developers conduct thorough and consistent code reviews.

Leave a comment