DEV Community

Cover image for Design Patterns in .NET Core
Ravi Vishwakarma
Ravi Vishwakarma

Posted on

Design Patterns in .NET Core

If you are learning .NET Core / ASP.NET Core, you will eventually hear a term called Design Pattern.

At first, it may sound complicated:

"What exactly is a design pattern? Why do we need it? Is it some kind of framework?"

Don't worry. It is actually much simpler than it sounds.

In this article, we will learn Design Patterns in .NET Core from the beginning, assuming you are a beginner in programming architecture.


1. What is a Design Pattern?

A Design Pattern is a commonly used solution to a common programming problem.

Think about building a house.

You don't invent everything from zero every time you build a house. There are already common ways to design:

  • Doors
  • Windows
  • Rooms
  • Electrical wiring
  • Plumbing

You can change the details, but the basic approaches are already known.

Design patterns work similarly in software development.

They give us a proven approach for solving common software design problems.

Simple Definition

A Design Pattern is a reusable solution or approach for solving a commonly occurring software design problem.

It is important to understand that a design pattern is not ready-made code.

It is more like a blueprint or idea that tells us how different classes and objects should work together.


2. Why Do We Need Design Patterns?

Imagine you are creating a small application.

Initially, you may write everything in one class:

public class OrderService
{
    public void CreateOrder()
    {
        // Create order

        // Save order

        // Send email

        // Send SMS

        // Process payment

        // Generate invoice
    }
}
Enter fullscreen mode Exit fullscreen mode

It works.

But what happens when your application becomes large?

You might have:

Order
Payment
Email
SMS
Invoice
Product
User
Notification
Database
Reports
Enter fullscreen mode Exit fullscreen mode

If everything is inside a few large classes, the application becomes difficult to:

  • Understand
  • Test
  • Maintain
  • Modify
  • Debug
  • Extend

This is where design patterns become useful.


3. Benefits of Design Patterns

Design patterns can help us write code that is:

1. Maintainable

It becomes easier to modify existing code.

2. Reusable

The same design approach can be used in different parts of an application.

3. Testable

Dependencies can be replaced with mocks or fake implementations.

4. Scalable

The architecture can handle increasing application complexity.

5. Understandable

Developers who know common patterns can understand the architecture more quickly.


4. Types of Design Patterns

Design patterns are commonly divided into three major categories.

Design Patterns
│
├── Creational Patterns
│
├── Structural Patterns
│
└── Behavioral Patterns
Enter fullscreen mode Exit fullscreen mode

Let's understand each category.


5. Creational Design Patterns

Creational patterns deal with object creation.

Instead of creating objects everywhere in your application, these patterns provide better ways to create and manage objects.

Common examples include:

  • Singleton
  • Factory
  • Abstract Factory
  • Builder
  • Prototype

6. Singleton Pattern

The Singleton Pattern ensures that a class has only one instance, or one shared instance, during the intended lifetime.

Simple Example

Suppose your application has a configuration manager.

You don't necessarily want every part of the application to create a completely separate configuration object.

Conceptually:

Application
     |
     v
Configuration
     |
     +---- Controller
     +---- Service
     +---- Repository
Enter fullscreen mode Exit fullscreen mode

All can use the same configured instance.


Singleton in .NET Core

ASP.NET Core has built-in Dependency Injection.

We can register a service as Singleton:

builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
Enter fullscreen mode Exit fullscreen mode

Now .NET manages the instance for us.

Example:

public interface IConfigurationService
{
    string GetValue(string key);
}
Enter fullscreen mode Exit fullscreen mode

Implementation:

public class ConfigurationService : IConfigurationService
{
    public string GetValue(string key)
    {
        return $"Value for {key}";
    }
}
Enter fullscreen mode Exit fullscreen mode

Registration:

builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
Enter fullscreen mode Exit fullscreen mode

Then we can inject it:

public class HomeController : Controller
{
    private readonly IConfigurationService _configurationService;

    public HomeController(
        IConfigurationService configurationService)
    {
        _configurationService = configurationService;
    }

    public IActionResult Index()
    {
        var value = _configurationService.GetValue("AppName");

        return Ok(value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Important

In modern .NET applications, you generally should not manually implement Singleton with static variables when the built-in Dependency Injection container can manage the lifetime.


7. Dependency Injection and Design Patterns

One of the most important concepts in ASP.NET Core is Dependency Injection (DI).

Suppose we have:

public class OrderService
{
    private PaymentService _paymentService;

    public OrderService()
    {
        _paymentService = new PaymentService();
    }
}
Enter fullscreen mode Exit fullscreen mode

There is a problem.

OrderService is directly creating PaymentService.

This creates tight coupling.

Instead:

public class OrderService
{
    private readonly IPaymentService _paymentService;

    public OrderService(IPaymentService paymentService)
    {
        _paymentService = paymentService;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now OrderService doesn't care how the payment service is created.

ASP.NET Core provides it through DI.

Registration:

builder.Services.AddScoped<IPaymentService, PaymentService>();
Enter fullscreen mode Exit fullscreen mode

This makes our application easier to test and maintain.


8. Factory Pattern

The Factory Pattern is used when we want to create objects without exposing the exact object creation logic to the calling code.

Imagine we support multiple payment methods:

Payment
│
├── Credit Card
├── UPI
├── PayPal
└── Wallet
Enter fullscreen mode Exit fullscreen mode

Without a Factory:

if (paymentType == "UPI")
{
    // Create UPI payment
}
else if (paymentType == "Card")
{
    // Create card payment
}
else if (paymentType == "PayPal")
{
    // Create PayPal payment
}
Enter fullscreen mode Exit fullscreen mode

As payment methods increase, this code becomes messy.


Factory Example

Create an interface:

public interface IPayment
{
    void Pay(decimal amount);
}
Enter fullscreen mode Exit fullscreen mode

UPI:

public class UpiPayment : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid ₹{amount} using UPI");
    }
}
Enter fullscreen mode Exit fullscreen mode

Card:

public class CardPayment : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid ₹{amount} using Card");
    }
}
Enter fullscreen mode Exit fullscreen mode

Factory:

public class PaymentFactory
{
    public IPayment CreatePayment(string type)
    {
        return type.ToLower() switch
        {
            "upi" => new UpiPayment(),
            "card" => new CardPayment(),
            _ => throw new ArgumentException("Invalid payment type")
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

var factory = new PaymentFactory();

IPayment payment = factory.CreatePayment("upi");

payment.Pay(1000);
Enter fullscreen mode Exit fullscreen mode

Output:

Paid ₹1000 using UPI
Enter fullscreen mode Exit fullscreen mode

The calling code doesn't need to know how UpiPayment is created.


9. Builder Pattern

The Builder Pattern is useful when creating a complex object with many optional properties.

For example:

var user = new User
{
    Name = "Rahul",
    Email = "rahul@example.com",
    Phone = "9999999999",
    Address = "Delhi",
    Age = 25
};
Enter fullscreen mode Exit fullscreen mode

This may become difficult when an object has many configuration options.

A Builder can make the creation process clearer.

Example:

public class UserBuilder
{
    private readonly User _user = new();

    public UserBuilder SetName(string name)
    {
        _user.Name = name;
        return this;
    }

    public UserBuilder SetEmail(string email)
    {
        _user.Email = email;
        return this;
    }

    public UserBuilder SetAge(int age)
    {
        _user.Age = age;
        return this;
    }

    public User Build()
    {
        return _user;
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

var user = new UserBuilder()
    .SetName("Rahul")
    .SetEmail("rahul@example.com")
    .SetAge(25)
    .Build();
Enter fullscreen mode Exit fullscreen mode

This is especially useful when object construction has multiple steps or optional configuration.


10. Structural Design Patterns

Structural patterns deal with how classes and objects are connected.

Common examples include:

  • Adapter
  • Decorator
  • Facade
  • Proxy
  • Composite

Let's understand the most useful ones.


11. Adapter Pattern

Suppose your application expects:

public interface IPayment
{
    void Pay(decimal amount);
}
Enter fullscreen mode Exit fullscreen mode

But you have an external payment SDK that provides:

public class ExternalPayment
{
    public void MakePayment(decimal amount)
    {
        Console.WriteLine($"Payment completed: {amount}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Our application expects:

Pay()
Enter fullscreen mode Exit fullscreen mode

but the external library provides:

MakePayment()
Enter fullscreen mode Exit fullscreen mode

We can use an Adapter.

public class PaymentAdapter : IPayment
{
    private readonly ExternalPayment _externalPayment;

    public PaymentAdapter(ExternalPayment externalPayment)
    {
        _externalPayment = externalPayment;
    }

    public void Pay(decimal amount)
    {
        _externalPayment.MakePayment(amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now our application can use:

IPayment payment =
    new PaymentAdapter(new ExternalPayment());

payment.Pay(500);
Enter fullscreen mode Exit fullscreen mode

The Adapter converts one interface into another interface that our application expects.


12. Decorator Pattern

The Decorator Pattern allows us to add additional behavior to an existing object without changing its original class.

Suppose we have:

public interface IOrderService
{
    void CreateOrder();
}
Enter fullscreen mode Exit fullscreen mode

Basic implementation:

public class OrderService : IOrderService
{
    public void CreateOrder()
    {
        Console.WriteLine("Order created");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we want logging.

Instead of modifying OrderService, we can create a decorator:

public class LoggingOrderService : IOrderService
{
    private readonly IOrderService _orderService;

    public LoggingOrderService(IOrderService orderService)
    {
        _orderService = orderService;
    }

    public void CreateOrder()
    {
        Console.WriteLine("Creating order...");

        _orderService.CreateOrder();

        Console.WriteLine("Order creation completed");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now:

LoggingOrderService
        |
        v
   OrderService
Enter fullscreen mode Exit fullscreen mode

The original service remains unchanged.


13. Facade Pattern

A Facade provides a simple interface to a complicated system.

Imagine placing an order requires:

Order Service
      |
      +-- Payment Service
      |
      +-- Inventory Service
      |
      +-- Notification Service
      |
      +-- Invoice Service
Enter fullscreen mode Exit fullscreen mode

Instead of the controller calling all of these services individually, we can create:

public class OrderFacade
{
    public void PlaceOrder()
    {
        // Payment
        // Inventory
        // Invoice
        // Notification
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller simply does:

_orderFacade.PlaceOrder();
Enter fullscreen mode Exit fullscreen mode

The complexity is hidden behind the Facade.


14. Behavioral Design Patterns

Behavioral patterns focus on how objects communicate and how responsibilities are distributed.

Common examples include:

  • Strategy
  • Observer
  • Command
  • Chain of Responsibility
  • Mediator
  • State

15. Strategy Pattern

The Strategy Pattern is very useful in real-world applications.

Suppose an e-commerce application has different discount strategies:

Discount
│
├── Festival Discount
├── VIP Discount
├── Coupon Discount
└── No Discount
Enter fullscreen mode Exit fullscreen mode

Instead of writing:

if (type == "festival")
{
}
else if (type == "vip")
{
}
else if (type == "coupon")
{
}
Enter fullscreen mode Exit fullscreen mode

we can create different strategies.

Interface:

public interface IDiscountStrategy
{
    decimal CalculateDiscount(decimal amount);
}
Enter fullscreen mode Exit fullscreen mode

Festival:

public class FestivalDiscount : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal amount)
    {
        return amount * 0.20m;
    }
}
Enter fullscreen mode Exit fullscreen mode

VIP:

public class VipDiscount : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal amount)
    {
        return amount * 0.30m;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now our application can select the required strategy.

public class DiscountService
{
    private readonly IDiscountStrategy _strategy;

    public DiscountService(IDiscountStrategy strategy)
    {
        _strategy = strategy;
    }

    public decimal GetDiscount(decimal amount)
    {
        return _strategy.CalculateDiscount(amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

The important idea is:

We can change the algorithm without changing the main business logic.


16. Observer Pattern

The Observer Pattern is useful when one event needs to notify multiple objects.

Imagine an order is created.

We may need to:

Order Created
     |
     +---- Send Email
     |
     +---- Send SMS
     |
     +---- Send Push Notification
     |
     +---- Update Analytics
Enter fullscreen mode Exit fullscreen mode

Instead of tightly connecting everything to the Order class, we can use an event/observer approach.

In .NET, this concept appears in many forms, including:

  • Events
  • Event handlers
  • Messaging systems
  • Domain events
  • Pub/Sub systems

For example:

public class OrderService
{
    public event EventHandler? OrderCreated;

    public void CreateOrder()
    {
        Console.WriteLine("Order Created");

        OrderCreated?.Invoke(this, EventArgs.Empty);
    }
}
Enter fullscreen mode Exit fullscreen mode

Another component can subscribe:

orderService.OrderCreated += SendEmail;
Enter fullscreen mode Exit fullscreen mode

This allows one event to notify multiple listeners.


17. Repository Pattern

If you work with ASP.NET Core and Entity Framework Core, you will often hear about the Repository Pattern.

The idea is to separate database access from business logic.

Without separation:

public class ProductService
{
    public Product GetProduct(int id)
    {
        // Direct database code
    }
}
Enter fullscreen mode Exit fullscreen mode

With a repository:

Controller
    |
    v
Service
    |
    v
Repository
    |
    v
Database
Enter fullscreen mode Exit fullscreen mode

Interface:

public interface IProductRepository
{
    Product? GetById(int id);

    IEnumerable<Product> GetAll();
}
Enter fullscreen mode Exit fullscreen mode

Implementation:

public class ProductRepository : IProductRepository
{
    private readonly AppDbContext _context;

    public ProductRepository(AppDbContext context)
    {
        _context = context;
    }

    public Product? GetById(int id)
    {
        return _context.Products
            .FirstOrDefault(x => x.Id == id);
    }

    public IEnumerable<Product> GetAll()
    {
        return _context.Products.ToList();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then the service uses the abstraction:

public class ProductService
{
    private readonly IProductRepository _repository;

    public ProductService(IProductRepository repository)
    {
        _repository = repository;
    }

    public Product? GetProduct(int id)
    {
        return _repository.GetById(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

18. Important Note About Repository Pattern in EF Core

There is an important architectural discussion here.

Entity Framework Core already provides abstractions such as:

DbContext
DbSet<T>
Enter fullscreen mode Exit fullscreen mode

So adding a generic repository on top of EF Core is not automatically a best practice.

For simple applications, directly using DbContext inside application services can be perfectly reasonable.

Repositories become more useful when they provide meaningful domain-specific data-access behavior or when the architecture has a clear reason for the abstraction.

Don't create a pattern just because someone says:

"Every .NET project must have Repository Pattern."

Patterns should solve problems, not create extra code.


19. Unit of Work Pattern

The Unit of Work Pattern groups multiple database operations into one logical transaction.

Imagine creating an order:

Create Order
     |
     +-- Save Order
     |
     +-- Update Inventory
     |
     +-- Create Payment Record
Enter fullscreen mode Exit fullscreen mode

We generally want these changes to succeed together.

Conceptually:

BEGIN TRANSACTION

Create Order
Update Inventory
Create Payment

COMMIT
Enter fullscreen mode Exit fullscreen mode

If something fails:

ROLLBACK
Enter fullscreen mode Exit fullscreen mode

EF Core's DbContext already provides behavior that can cover Unit-of-Work responsibilities in many applications.

Therefore, just like Repository Pattern, don't add a separate Unit of Work abstraction automatically.


20. Dependency Injection Lifetimes in ASP.NET Core

ASP.NET Core provides three common DI lifetimes.

Singleton

One instance for the application's service container lifetime.

builder.Services.AddSingleton<IMyService, MyService>();
Enter fullscreen mode Exit fullscreen mode

Use it for services that are:

  • Stateless
  • Thread-safe
  • Safe to share

Scoped

One instance per scope.

In a typical ASP.NET Core web request, that usually means one instance per HTTP request.

builder.Services.AddScoped<IMyService, MyService>();
Enter fullscreen mode Exit fullscreen mode

This is commonly used for services working with:

DbContext
Business Services
Repositories
Enter fullscreen mode Exit fullscreen mode

Transient

A new instance is created each time it is requested.

builder.Services.AddTransient<IMyService, MyService>();
Enter fullscreen mode Exit fullscreen mode

Useful for lightweight, stateless services where sharing an instance is unnecessary.


21. Design Patterns and SOLID Principles

Design patterns work very closely with SOLID principles.

SOLID stands for:

S → Single Responsibility Principle

O → Open/Closed Principle

L → Liskov Substitution Principle

I → Interface Segregation Principle

D → Dependency Inversion Principle
Enter fullscreen mode Exit fullscreen mode

For example, consider:

public class OrderService
{
    public void CreateOrder()
    {
        // Order logic
        // Payment logic
        // Email logic
        // Invoice logic
    }
}
Enter fullscreen mode Exit fullscreen mode

This class has too many responsibilities.

The Single Responsibility Principle suggests separating these concerns.

We might have:

OrderService
PaymentService
EmailService
InvoiceService
Enter fullscreen mode Exit fullscreen mode

Then Dependency Injection connects them.

This produces a cleaner architecture.


22. How Patterns Fit Together in a Real ASP.NET Core Application

A production application might look something like this:

                 Client
                   |
                   v
              Controller
                   |
                   v
             Application
               Service
                   |
          +--------+--------+
          |        |        |
          v        v        v
      Repository Payment  Notification
          |        |        |
          v        v        v
       Database   Gateway   Email/SMS
Enter fullscreen mode Exit fullscreen mode

Different patterns may be used at different levels.

For example:

Dependency Injection
        ↓
Service Layer
        ↓
Repository
        ↓
Database
Enter fullscreen mode Exit fullscreen mode

And inside the service:

Strategy
Factory
Facade
Decorator
Enter fullscreen mode Exit fullscreen mode

The exact architecture depends on the application.


23. Example: E-Commerce Application

Let's take a practical example.

Suppose we are building an e-commerce application.

A customer places an order.

The flow could be:

Customer
   |
   v
OrderController
   |
   v
OrderService
   |
   +---- Discount Strategy
   |
   +---- Payment Factory
   |
   +---- Inventory Repository
   |
   +---- Notification Service
   |
   v
Database
Enter fullscreen mode Exit fullscreen mode

Step 1 — Controller

Receives the HTTP request.

[HttpPost]
public IActionResult CreateOrder(CreateOrderRequest request)
{
    _orderService.CreateOrder(request);

    return Ok();
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Service

Handles business logic.

public void CreateOrder(CreateOrderRequest request)
{
    // Validate order

    // Calculate discount

    // Process payment

    // Update inventory

    // Save order

    // Notify customer
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Repository

Handles database-related operations.

_repository.Save(order);
Enter fullscreen mode Exit fullscreen mode

Step 4 — Strategy

Calculates the appropriate discount.

var discount = strategy.CalculateDiscount(amount);
Enter fullscreen mode Exit fullscreen mode

Step 5 — Factory

Creates the appropriate payment implementation.

var payment = paymentFactory.CreatePayment("upi");
Enter fullscreen mode Exit fullscreen mode

Now every component has a clear responsibility.


24. Which Design Patterns Should a Beginner Learn First?

Don't try to memorize 20+ patterns at once.

Start with these:

Level 1 — Must Know

1. Dependency Injection
2. Factory
3. Strategy
4. Repository
5. Singleton
Enter fullscreen mode Exit fullscreen mode

Level 2 — Learn Next

6. Decorator
7. Adapter
8. Facade
9. Builder
10. Unit of Work
Enter fullscreen mode Exit fullscreen mode

Level 3 — Advanced

11. Mediator
12. Command
13. Observer
14. Chain of Responsibility
15. State
Enter fullscreen mode Exit fullscreen mode

The exact order can vary depending on the type of project you are building.


25. Don't Overuse Design Patterns

This is one of the most important lessons.

A beginner may learn design patterns and then think:

"I need to use a pattern everywhere."

That's a mistake.

For example, if your application has:

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}
Enter fullscreen mode Exit fullscreen mode

You probably don't need:

CalculatorFactory
CalculatorRepository
CalculatorStrategy
CalculatorFacade
CalculatorBuilder
CalculatorManager
CalculatorProvider
Enter fullscreen mode Exit fullscreen mode

That would make simple code unnecessarily complicated.

A good developer asks:

"What problem am I solving?"

Then chooses a pattern if it actually helps.


26. Design Pattern vs Framework Feature

Another important point:

A framework feature and a design pattern are not always the same thing.

For example, ASP.NET Core provides:

Dependency Injection
Middleware
Configuration
Logging
Routing
Authentication
Authorization
Enter fullscreen mode Exit fullscreen mode

Some of these features are implemented using established software design concepts and patterns internally.

As a developer, you don't always need to manually implement the underlying pattern.

For example, instead of manually implementing Singleton, ASP.NET Core DI can manage service lifetimes:

builder.Services.AddSingleton<IMyService, MyService>();
Enter fullscreen mode Exit fullscreen mode

Use the framework's capabilities when they already solve the problem well.


27. A Simple Rule to Remember

You can remember design patterns like this:

CREATIONAL
"How should I create objects?"

        ↓

Factory
Builder
Singleton


STRUCTURAL
"How should objects/classes be connected?"

        ↓

Adapter
Decorator
Facade


BEHAVIORAL
"How should objects communicate or behave?"

        ↓

Strategy
Observer
Command
Mediator
Enter fullscreen mode Exit fullscreen mode

This simple classification makes it much easier to understand them.


28. Final Example

Let's imagine we are building a food delivery application.

We might have:

                API
                 |
                 v
             Controller
                 |
                 v
             OrderService
                 |
       +---------+---------+
       |         |         |
       v         v         v
   Strategy    Factory   Repository
       |         |         |
       v         v         v
  Discount    Payment    Database
              Gateway
Enter fullscreen mode Exit fullscreen mode

Here:

Dependency Injection
connects our components.

Strategy Pattern
handles different discount algorithms.

Factory Pattern
creates different payment implementations.

Repository Pattern
separates data access.

Decorator Pattern
can add logging, caching, etc.

Facade Pattern
can simplify complicated workflows.

This is how patterns can work together in a real application.


Conclusion

Design Patterns are not magic code and they are not rules that every .NET Core application must follow.

They are proven approaches for solving recurring software design problems.

If you are a beginner in .NET Core, don't try to memorize every pattern.

First understand:

Problem
   ↓
Why is the current code difficult?
   ↓
What responsibility should be separated?
   ↓
Which pattern can help?
   ↓
Implement only what is necessary
Enter fullscreen mode Exit fullscreen mode

Start with Dependency Injection, Factory, Strategy, Repository, and Singleton, then gradually learn more advanced patterns such as Decorator, Adapter, Facade, Mediator, and Command.

The real goal is not to say:

"I know 20 design patterns."

The real goal is to be able to say:

"I understand the problem, and I know which design approach will make my code cleaner, maintainable, testable, and easier to extend."

That is what good software design is about.

Top comments (0)