DEV Community

Cover image for Understanding the Mediator Pattern in .NET
mohamed Tayel
mohamed Tayel

Posted on • Updated on

Understanding the Mediator Pattern in .NET

Explore the Mediator Pattern in .NET for decoupling components and improving system maintainability. Learn through examples and step-by-step implementation.

Introduction

In complex applications, components often need to communicate with each other. Traditionally, this can be achieved by having components directly interact with one another. However, this approach can lead to a tightly coupled system where changes in one component might necessitate changes in others, leading to a maintenance nightmare. This is where the Mediator pattern comes into play.

The Mediator pattern promotes loose coupling by ensuring that instead of components communicating directly, they communicate through a mediator. This mediator handles the communication logic, resulting in a more decoupled and maintainable system.

Image description

Example Without Using MediatR

Let's start with an example without using MediatR to illustrate the complexity and tight coupling.

Original Implementation

ComponentB.cs

public class ComponentB
{
    public void SomeOperation()
    {
        // Original operation
        Console.WriteLine("ComponentB: Performing some operation.");
    }
}
Enter fullscreen mode Exit fullscreen mode

ComponentC.cs

public class ComponentC
{
    public void AnotherOperation()
    {
        // Original operation
        Console.WriteLine("ComponentC: Performing another operation.");
    }
}
Enter fullscreen mode Exit fullscreen mode

ComponentA.cs

public class ComponentA
{
    private readonly ComponentB _componentB;
    private readonly ComponentC _componentC;

    public ComponentA(ComponentB componentB, ComponentC componentC)
    {
        _componentB = componentB;
        _componentC = componentC;
    }

    public void Operation()
    {
        // ComponentA communicates directly with ComponentB and ComponentC
        _componentB.SomeOperation();
        _componentC.AnotherOperation();
    }
}
Enter fullscreen mode Exit fullscreen mode

Program.cs

public class Program
{
    public static void Main(string[] args)
    {
        var componentB = new ComponentB();
        var componentC = new ComponentC();
        var componentA = new ComponentA(componentB, componentC);

        componentA.Operation();
    }
}
Enter fullscreen mode Exit fullscreen mode

When you run this code, the output will be:

ComponentB: Performing some operation.
ComponentC: Performing another operation.
Enter fullscreen mode Exit fullscreen mode

Changes to Components

Now, let's say we need to update ComponentB and ComponentC:

  1. ComponentB: Change SomeOperation to ExecuteOperation and modify its implementation.
  2. ComponentC: Add a parameter to AnotherOperation.

Updated ComponentB.cs

public class ComponentB
{
    public void ExecuteOperation()
    {
        // Updated operation
        Console.WriteLine("ComponentB: Executing updated operation.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Updated ComponentC.cs

public class ComponentC
{
    public void AnotherOperation(string message)
    {
        // Updated operation with a parameter
        Console.WriteLine($"ComponentC: Performing another operation with message: {message}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Impact on ComponentA

Due to the changes in ComponentB and ComponentC, ComponentA must also be updated to accommodate these changes:

Updated ComponentA.cs

public class ComponentA
{
    private readonly ComponentB _componentB;
    private readonly ComponentC _componentC;

    public ComponentA(ComponentB componentB, ComponentC componentC)
    {
        _componentB = componentB;
        _componentC = componentC;
    }

    public void Operation()
    {
        // ComponentA must be updated to use the new methods and parameters
        _componentB.ExecuteOperation();
        _componentC.AnotherOperation("Hello from ComponentA");
    }
}
Enter fullscreen mode Exit fullscreen mode

Updated Program.cs

public class Program
{
    public static void Main(string[] args)
    {
        var componentB = new ComponentB();
        var componentC = new ComponentC();
        var componentA = new ComponentA(componentB, componentC);

        componentA.Operation();
    }
}
Enter fullscreen mode Exit fullscreen mode

Running this updated code will produce the following output:

ComponentB: Executing updated operation.
ComponentC: Performing another operation with message: Hello from ComponentA.
Enter fullscreen mode Exit fullscreen mode




Pros and Cons of Tight Coupling

Pros:

  1. Simplicity: Direct communication between components can be straightforward to implement initially.
  2. Performance: There is no intermediary, which can sometimes result in faster execution.

Cons:

  1. Maintenance: Any change in one component necessitates changes in all dependent components, leading to a fragile system.
  2. Scalability: As the system grows, the interdependencies become more complex, making it hard to manage.
  3. Testing: Unit testing becomes more difficult as components are tightly coupled.

Solving the Problem Using MediatR

Now, let's see how we can solve this problem using MediatR in a .NET 8 console application.

Step-by-Step Implementation

  1. Setup the Console Application

    Create a new .NET console application:

    dotnet new console -n MediatorExample
    cd MediatorExample
    
  2. Install MediatR Packages

    Add the necessary MediatR packages:

    dotnet add package MediatR
    dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
    
  3. Create Request and Handler Classes

    CreateUserRequest.cs

    using MediatR;
    
    public class CreateUserRequest : IRequest
    {
        public string UserName { get; set; }
        public string Email { get; set; }
    }
    

    CreateUserHandler.cs

    using MediatR;
    using System.Threading;
    using System.Threading.Tasks;
    
    public class CreateUserHandler : IRequestHandler<CreateUserRequest>
    {
        public Task<Unit> Handle(CreateUserRequest request, CancellationToken cancellationToken)
        {
            // Simulate user creation logic
            Console.WriteLine($"User created: {request.UserName} with email: {request.Email}");
            return Unit.Task;
        }
    }
    

    SendWelcomeEmailRequest.cs

    using MediatR;
    
    public class SendWelcomeEmailRequest : IRequest
    {
        public string Email { get; set; }
    }
    

    SendWelcomeEmailHandler.cs

    using MediatR;
    using System.Threading;
    using System.Threading.Tasks;
    
    public class SendWelcomeEmailHandler : IRequestHandler<SendWelcomeEmailRequest>
    {
        public Task<Unit> Handle(SendWelcomeEmailRequest request, CancellationToken cancellationToken)
        {
            // Simulate sending a welcome email
            Console.WriteLine($"Welcome email sent to: {request.Email}");
            return Unit.Task;
        }
    }
    
  4. Register MediatR in the Dependency Injection Container

    Program.cs

    using MediatR;
    using Microsoft.Extensions.DependencyInjection;
    using System;
    using System.Threading.Tasks;
    
    class Program
    {
        static async Task Main(string[] args)
        {
            var services = new ServiceCollection();
    
            services.AddMediatR(typeof(Program));
            services.AddTransient<UserService>();
    
            var serviceProvider = services.BuildServiceProvider();
            var userService = serviceProvider.GetService<UserService>();
    
            await userService.RegisterUser("JohnDoe", "johndoe@example.com");
        }
    }
    
  5. Define the UserService

    UserService.cs

    using MediatR;
    using System.Threading.Tasks;
    
    public class UserService
    {
        private readonly IMediator _mediator;
    
        public UserService(IMediator mediator)
        {
            _mediator = mediator;
        }
    
        public async Task RegisterUser(string userName, string email)
        {
            // Create user
            await _mediator.Send(new CreateUserRequest { UserName = userName, Email = email });
    
            // Send welcome email
            await _mediator.Send(new SendWelcomeEmailRequest { Email = email });
        }
    }
    

Pros and Cons of Using MediatR

Pros:

  1. Decoupling: Components do not need to know about each other, reducing dependencies and making the system more maintainable.
  2. Scalability: Easier to add new features and modify existing ones without affecting other parts of the system.
  3. Testing: Simplifies unit testing by isolating each component's behavior.

Cons:

  1. Complexity: Introduces additional layers, which can make the initial setup more complex.
  2. Performance: Potentially slower due to the overhead of the mediator, although this is usually negligible.

Conclusion

Using the Mediator pattern with MediatR helps to decouple components, leading to a more flexible and maintainable system. While the initial setup might be more complex, the long-term benefits of reduced dependencies and easier testing outweigh the cons. By applying the Mediator pattern, you can avoid tightly coupled dependencies, leading to a cleaner and more maintainable architecture.

Top comments (0)