Showing posts with label C#. Show all posts
Showing posts with label C#. Show all posts

SOLID Principles Implementation in C#.

Software development is not just about writing code; it’s about writing clean, maintainable, scalable, and testable code. That’s exactly what the SOLID principles help us achieve.

Whether you’re building a small API or an enterprise-level microservice, understanding SOLID principles makes your code more flexible, easier to extend, and less prone to bugs.

In this article, we’ll break down SOLID principles with simple explanations and practical C# implementations.

SOLID Principle Full Form

What is the SOLID Principle?

The SOLID Principles are a set of five core object-oriented design (OOD) guidelines used in software development to create systems that are more understandable, flexible, maintainable, and scalable. Coined by Michael Feathers and based on principles from Robert C. Martin ("Uncle Bob").

SOLID is an acronym for five object-oriented design principles: 

  • S: Single Responsibility Principle 
  • O: Open/Closed Principle 
  • L: Liskov Substitution Principle 
  • I: Interface Segregation Principle 
  • D: Dependency Inversion Principle 

Let’s go through each principle one by one and look at the problems that arise when we don’t follow the SOLID Principle. After that, we’ll explore how applying these principles can help us write cleaner, more maintainable code.

1. S - Single Responsibility Principle.

The 'S' in the SOLID principles stands for the Single Responsibility Principle (SRP). The Single Responsibility Principle states that a class, module, or function should have only one reason to change, meaning it should have a single, clearly defined purpose or job within the software system.

Bad Example: Imagine an online shopping system where:

  • An order is created
  • Order is saved to the database
  • Customer is emailed

If all of this is in ONE class, it violates SRP.

Example Code:

public class OrderService
{
    public void CreateOrder()
    {
        Console.WriteLine("Order created.");
    }

    public void SaveToDatabase()
    {
        Console.WriteLine("Order saved to DB.");
    }

    public void SendEmail()
    {
        Console.WriteLine("Email sent to customer.");
    }
}

Let's see how we can write better code using SRP.

Good Example: To follow the single responsibility principle, we have created classes in such a way that each class has its own responsibility and reason to change. In our example, we will split the responsibilities into three different classes.

Example Code:
namespace PracticeCode.SOLID
{
    public class OrderCreator
    {
        public void CreateOrder()
        {
            Console.WriteLine("Order created.");
        }
    }

    public class OrderRepository
    {
        public void Save()
        {
            Console.WriteLine("Order saved to DB.");
        }
    }

    public class EmailService
    {
        public void Send()
        {
            Console.WriteLine("Email sent to customer.");
        }
    }

    public class OrderService
    {
        private readonly OrderCreator _orderCreator;
        private readonly OrderRepository _orderRepository;
        private readonly EmailService _emailService;

        public OrderService()
        {
            _orderCreator = new OrderCreator();
            _orderRepository = new OrderRepository();
            _emailService = new EmailService();
        }

        public void ProcessOrder()
        {
            _orderCreator.CreateOrder();
            _orderRepository.Save();
            _emailService.Send();
        }
    }
}
Program.cs
OrderService processor = new OrderService();
processor.ProcessOrder();
Console.ReadLine();
Output:
Order created.
Order saved to DB.
Email sent to customer.

Now each class has only one reason to change. This is the correct example of SRP.

2. O - Open/Closed Principle (OCP)

A class should open for extension but close for modification. It means that you should be able to add new functionality without changing the existing code. This avoids breaking old logic when adding new features.

Bad Example: Multiple types of discount calculations are implemented in a single class. If you want to add a new discount type, you need to modify the same class repeatedly. This violates OCP because modification is required for every new discount.
public class DiscountService
{
    public decimal GetDiscount(string customerType, decimal amount)
    {
        if (customerType == "Regular")
            return amount * 0.05m;

        if (customerType == "Premium")
            return amount * 0.10m;

        return 0;
    }
}

Good Example: We can refactor the above code using Inheritance and Polymorphism. Let's see how we can use the open/close principle to write better code.

Step 1: Create a base class, also called an extension point.
public abstract class Discount
{
    public abstract decimal Calculate(decimal amount);
}

Step 2: Create separate classes for each discount type.
public class RegularDiscount : Discount
{
    public override decimal Calculate(decimal amount)
        => amount * 0.05m;
}

public class PremiumDiscount : Discount
{
    public override decimal Calculate(decimal amount)
        => amount * 0.10m;
}
You can add a new type like:
public class GoldDiscount : Discount
{
    public override decimal Calculate(decimal amount)
        => amount * 0.15m;
}

Step 3: Create a Factory Class to choose the correct discount type.
//----------------Discount Factory----------------//
public static class DiscountFactory
{
    public static Discount GetDiscount(string discountType)
    {
        return discountType.ToLower() switch
        {
            "regular" => new RegularDiscount(),
            "seasonal" => new SeasonalDiscount(),
            _ => throw new ArgumentException("Invalid discount type"),
        };
    }
}

public class Customer
{
    private readonly Discount _discount;

    public Customer(Discount discount)
    {
        _discount = discount;
    }

    public decimal GetFinalAmount(decimal amount)
    {
        return _discount.ApplyDiscount(amount);
    }
}

Step 4: Calling the Factory Method in the Program.cs
class Program
{
    static void Main(string[] args)
    {
        Console.Write("Enter discount type (regular / seasonal): ");
        string type = Console.ReadLine();

        Discount discount = DiscountFactory.CreateDiscount(type);

        Customer customer = new Customer(discount);

        decimal amount = 1000;
        decimal finalAmount = customer.GetFinalAmount(amount);

        Console.WriteLine($"Final amount for {type} discount: {finalAmount}");
    }
}
Output:
Enter discount type (regular / seasonal): seasonal
Final amount for seasonal discount: 800

Now our old code will never change, even if we want to implement any new discount type in the future. This is what the open/closed principle wants.

3. L - Liskov Substitution Principle (LSP)

A child class should be replaceable for its parent class without breaking the program. It means wherever you use a base class, you should be able to use its derived class without causing errors or unexpected behavior. If substituting a child class changes the behavior, it violates LSP.

Bad Example: If the Payment class has Refund(), a CashPayment class that throws an exception for Refund violates LSP.

Example Code: We have a base class with Pay and Refund options that can be overridden by derived classes.
public class Payment
{
    public virtual void Pay(decimal amount)
    {
        Console.WriteLine($"Payment of {amount} done.");
    }

    public virtual void Refund(decimal amount)
    {
        Console.WriteLine($"Refund of {amount} processed.");
    }
}

Now, when we create the CashPayment and CardPayment derived classes, we inherit from the Payment class. CardPayment works fine because it has both Pay and Refund options, but CashPayment does not have a refund option.
public class CashPayment : Payment
{
    public override void Refund(decimal amount)
    {
        throw new NotSupportedException("Cash cannot be refunded!");
    }
}

Here, it will throw a runtime error when you create a child object and assign it to the Parent, because the child class is not substitutable for the Parent class.
Payment payment = new CashPayment();
payment.Refund(100);   // 💥 Crash at runtime!

CashPayment violates LSP because it cannot safely replace Payment’s refund behavior, causing the program to break when substituted.

Good Example: We FIX this by splitting responsibilities using interfaces.
We are splitting the Payment system into two interfaces, IPayment and IRfundable, and different methods inherit and implement them based on their functionality.
namespace PracticeCode.SOLID
{
    public interface IPayment
    {
        void Pay(decimal amount);
    }
    public interface IRfundable
    {
        void Refund(decimal amount);
    }

    public class CardPayment: IPayment, IRfundable
    {
        public void Pay(decimal amount)
            => Console.WriteLine($"Card Amount Paid: {amount}");

        public void Refund(decimal amount)
            => Console.WriteLine($"Card Amount Refund: {amount}");
    }

    public class CashPayment: IPayment
    {
        public void Pay(decimal amount)
            => Console.WriteLine($"Cash Amount Paid: {amount}");
    }

    public class PaymentProcess
    {
        public void ProcessPayment(IPayment payment, decimal amount)
        {
            payment.Pay(amount);
        }
        public void RefundPayment(IRfundable refund, decimal amount)
        {
            refund.Refund(amount);
        }
    }
}

We will create a PaymentProcess object and call the methods defined inside with a child class object. As there is no Refund for Cash payment, we will get a compile-time error when we try to call the refund method (Program.cs).
//LSP
var processPayment = new PaymentProcess();
processPayment.ProcessPayment(new CardPayment(), 100);
processPayment.ProcessPayment(new CashPayment(), 100);
processPayment.RefundPayment(new CardPayment(), 100);
//processPayment.RefundPayment(new CashPayment(), 100); //Compile time error

This is how we can easily replace a child class object with a parent class without breaking the application by following LSP.

4. I - Interface Segregation Principle (ISP)

A class should not be forced to implement methods it does not use. Instead of one large interface, create many smaller interfaces. This keeps code clean, avoids empty methods, and removes unnecessary dependencies.

Bad Example: Having a big IWorker interface with Work() and Eat() forces robots to implement Eat(), which they don’t need.
public interface IWorker
{
    void Work();
    void Eat();
}

public class Robot : IWorker
{
    public void Work() 
    {
        Console.WriteLine("Robot working.");
    }

    public void Eat() 
    {
        throw new NotImplementedException("Robots don't eat!");
    }
}

Robots do NOT eat, but are forced to implement Eat(). This violates ISP because the interface forces unnecessary methods.

Good Example: Instead, splitting interfaces into IWorkable and IFeedable keeps everything clean and avoids unnecessary code.
public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

// Now classes implement only what they need
public class Human : IWorkable, IFeedable
{
    public void Work() => Console.WriteLine("Human working.");
    public void Eat()  => Console.WriteLine("Human eating.");
}

public class Robot : IWorkable
{
    public void Work() => Console.WriteLine("Robot working.");
}

Now use them in the Program.cs
namespace PracticeCode.SOLID
{
    class Program
    {
        static void Main(string[] args)
        {
            // Using IWorkable interface
            IWorkable humanWorker = new Human();
            IWorkable robotWorker = new Robot();

            humanWorker.Work();   // Output: Human working.
            robotWorker.Work();   // Output: Robot working.

            Console.WriteLine();

            // Using IFeedable interface
            IFeedable humanEater = new Human();
            humanEater.Eat();     // Output: Human eating.

            // Robot does NOT implement IFeedable
            // So you cannot do this:
            // IFeedable robotEater = new Robot();  // ❌ Compile-time error (Good!)

            Console.ReadLine();
        }
    }
}

Human implements both interfaces; Robot only implements IWorkable.

5. D - Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions. This makes your code flexible, testable, maintainable, and loosely coupled.

Bad Example: NotificationService depends directly on EmailService

public class EmailService
{
    public void Send(string message)
    {
        Console.WriteLine("EMAIL: " + message);
    }
}

public class NotificationService
{
    private readonly EmailService _emailService = new EmailService(); // ❌ Hard-coded dependency

    public void Notify(string message)
    {
        _emailService.Send(message);
    }
}

Because NotificationService directly depends on EmailService, it becomes tightly coupled, hard to extend, hard to test, and breaks when EmailService changes, clearly violating DIP.

Good Example: Instead of NotificationService calling EmailService directly,
We depend on IMessageService so we can switch between Email, SMS, or WhatsApp without modifying NotificationService.

Step 1: Create an abstraction (Interface) and implement Email & SMS services.
public interface IMessageService
{
    void Send(string message);
}

public class EmailService : IMessageService
{
    public void Send(string message)
    {
        Console.WriteLine("EMAIL: " + message);
    }
}

public class SmsService : IMessageService
{
    public void Send(string message)
    {
        Console.WriteLine("SMS: " + message);
    }
}

Step 2: High-level class depends on abstraction.
public class NotificationService
{
    private readonly IMessageService _messageService;

    public NotificationService(IMessageService messageService)
    {
        _messageService = messageService;
    }

    public void Notify(string message)
    {
        _messageService.Send(message);
    }
}

Step 3: Use it in the Program.cs
class Program
{
    static void Main(string[] args)
    {
        IMessageService service = new SmsService();     // choose service
        NotificationService notifier = new NotificationService(service);

        notifier.Notify("Order shipped!");               // SMS: Order shipped!

        Console.ReadLine();
    }
}

I hope you are now clear about the real-life use case of SOLID principles. By applying SOLID correctly in your C# projects, you make your architecture:

  • Scalable
  • Maintainable
  • Testable
  • Professional

If you're preparing for interviews, mastering SOLID is one of the biggest advantages you can have.

Difference Between Middleware and Filter in C#.

Middleware Vs Filter

In ASP.NET Core, both Middleware and Filters are used to handle cross-cutting concerns such as logging, authentication, authorization, and exception handling. Although they may appear similar, they operate at different levels of the request pipeline and serve different purposes.

Understanding this difference is very important for real-world projects and is a frequently asked interview topic.

What is Middleware?

Middleware is a component that sits in the ASP.NET Core request processing pipeline and handles HTTP requests and responses.

Every incoming request passes through a series of middleware components, and each middleware can:

  • Inspect the request
  • Modify the request or response
  • Decide whether to pass control to the next middleware

Middleware is mainly used for cross-cutting concerns that apply to many or all requests.

When a request comes to an ASP.NET Core application, it flows through the middleware pipeline in the order in which they are registered.

Request Flow:

Request
Middleware 1
Middleware 2
Controller / Endpoint
Middleware 2
Middleware 1
Response

Each middleware:
  1. Executes some logic before calling the next middleware
  2. Calls the next middleware using await _next(context)
  3. Executes logic after the next middleware completes (optional)
Example of Built-in Middleware:
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Each of these is a middleware component registered in the pipeline.

Why do we need a Middleware?

Middleware helps to:
  • Handle requests globally.
  • Keep controllers clean.
  • Centralize logic like logging, security, and error handling.
  • Improve maintainability.

How To Create Custom Middleware?

There are multiple conditions where we need to create our own custom middleware to handle incoming requests and out going response. Let's learn how to create a custom middleware and register it in the existing pipeline.

Step 1: Create a Custom Middleware Class.

In this step, we create a middleware class that contains the actual logic to handle HTTP requests and responses.
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation(
            "Request Started: {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        await _next(context);

        _logger.LogInformation(
            "Response Finished: {StatusCode}",
            context.Response.StatusCode);
    }
}

Key Terms Explained
  • RequestDelegate: A delegate representing the next middleware in the pipeline. It is calling _next(context) passes control forward.
  • HttpContext: Represents the current HTTP request and response. It contains headers, body, method, status code, user info, etc.
  • InvokeAsync Method: The method ASP.NET Core automatically calls for each request. This is where middleware logic lives.
  • ILogger: Built-in logging abstraction used instead of Console.WriteLine. Supports logging levels and providers.

Step 2: Creating an Extension Method to Register Middleware.

This step makes your middleware look and behave like a built-in middleware, allowing clean and readable registration in Program.cs.
public static class RequestLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestLogging(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestLoggingMiddleware>();
    }
}
  • Extension Method: A static method that adds new functionality to an existing type without modifying it.
  • IApplicationBuilder: Used to build the HTTP request pipeline. It provides methods like Use, Run, and Map.
  • UseMiddleware<T>(): Registers a middleware type into the pipeline and lets the framework manage its lifetime and dependencies.
  • Fluent API: Returning app allows chaining multiple middleware registrations.

Step 3: Register the Middleware in the Program.cs

Here, the middleware is added to the request pipeline, and its position determines when it runs.
var app = builder.Build();

app.UseRequestLogging(); // Custom middleware

app.MapControllers();

app.Run();

Now our custom Middleware is registered successfully and ready to execute for each http request and response.

What is the use of the Run, Use, and Map keywords in the Middleware pipeline?

In ASP.NET Core, Use, Run, and Map are used to configure the middleware pipeline. Each keyword has a specific purpose and behavior.

1. Use: Use is used to add middleware that can pass control to the next middleware.
app.Use(async (context, next) =>
{
    Console.WriteLine("Before next middleware");
    await next();
    Console.WriteLine("After next middleware");
});

2. Run: Run adds a terminal middleware that does not call the next middleware.
app.Run(async context =>
{
    await context.Response.WriteAsync("Request handled here");
});

3. Map: A map is used to branch the request pipeline based on URL path.
app.Map("/admin", adminApp =>
{
    adminApp.Run(async context =>
    {
        await context.Response.WriteAsync("Admin area");
    });
});
Only requests starting with /admin go through this branch.

Common Use Cases of Middleware
  • Global exception handling
  • Authentication & authorization
  • Logging and auditing
  • Request/response modification
  • CORS
  • Rate limiting

What is a Filter?

A Filter is a component in ASP.NET Core that allows you to run custom logic before or after a controller action executes.
Filters are part of the MVC/Web API pipeline, not the global middleware pipeline.

Filters are mainly used for action-level cross-cutting concerns, such as:
  • Authorization
  • Validation
  • Logging
  • Exception handling
  • Modifying responses

Types of Filters.

ASP.NET Core provides five types of filters, each executing at a specific stage of the MVC request pipeline. Filters help handle cross-cutting concerns such as authorization, validation, logging, caching, and exception handling.

1. Authorization Filters

Authorization filters run first in the filter pipeline. They determine whether a user is allowed to access a controller or action.

If authorization fails, the request is short-circuited, and the action never executes.

2. Resource Filters

Resource filters run before model binding and after action execution. They are useful for caching, short-circuiting, or resource initialization.

3. Action Filters (Most Common)

Action filters run before and after controller action methods. They are commonly used for logging, validation, and auditing.

4. Result Filters

Result filters run before and after the action result executes (like Ok(), View()). They are useful for modifying responses.

5. Exception Filters

Exception filters handle exceptions thrown by controller actions.

How do filters work?

In ASP.NET Core, filters execute around a controller action and allow you to run logic before and after specific stages of request processing. Unlike middleware, filters are MVC-specific and are tightly coupled with controllers and actions.

Let’s understand the execution flow step by step using a real example.

Filter Execution Flow:
When a request reaches an MVC controller, it passes through filters in the following order:
Request
Authorization Filter
Resource Filter
Action Filter (Before)
Controller Action
Action Filter (After)
Result Filter
Response
If an exception occurs, Exception Filters are invoked.

Example: Logging Action Filter.

Step 1: Create a Custom Action Filter.
using Microsoft.AspNetCore.Mvc.Filters;

public class LogActionFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        Console.WriteLine("Before Action Method");
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        Console.WriteLine("After Action Method");
    }
}

Step 2: Apply Filter to Controller
[LogActionFilter]
public class ProductsController : Controller
{
    public IActionResult Get()
    {
        Console.WriteLine("Inside Controller Action");
        return Ok("Products");
    }
}

Step-by-Step Execution (What Happens Internally)

1. Request arrives at MVC: The request has already passed through middleware and is now handled by MVC.
2. Authorization Filters run: ASP.NET Core checks whether the request is authorized to access the action.
3. Resource Filters run: These execute before model binding and can short-circuit the request.
4. Action Filter – OnActionExecuting
Before Action Method
This runs just before the controller action executes.

5. Controller Action Executes
Inside Controller Action

6. Action Filter – OnActionExecuted
After Action Method
This runs immediately after the action method completes.

7. Result Filters execute: These run before and after the action result (e.g., Ok()).
8. Response is returned to the client.

Use Filters when:
  • Logic is tied to MVC actions
  • You need access to model/action parameters
  • Behavior is controller/action specific

Middleware Vs Filter.

Feature Middleware Filter
Scope Global (entire application) MVC / Controller / Action
Execution Level Request pipeline (before MVC) Inside the MVC pipeline
Access to Action Context No Yes
Order Control Registration order in the Program.cs Filter type and order attribute
Can Short-Circuit Request Yes Yes
Dependency on MVC No Yes
Runs for Static Files Yes No
Common Use Cases Logging, Auth, Exception handling Validation, Authorization, Action logging
Performance Faster (framework-level) Slightly slower (MVC-specific)

Use Middleware when you need to handle cross-cutting concerns globally for every HTTP request, such as authentication, logging, CORS, or exception handling, and when the logic is not tied to MVC controllers. Use Filters when the logic is specific to MVC actions or controllers, requires access to action parameters, model state, or results, and should run before or after controller execution, such as validation, authorization at the action level, or action-specific logging.

DON'T MISS

Tech News
© all rights reserved
made with by AlgoLesson