Why Do We Need the Mediator Pattern?
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.
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."); }
}
ComponentC.cs
public class ComponentC
{ public void AnotherOperation() { // Original operation Console.WriteLine("ComponentC: Performing another operation."); }
}
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(); }
}
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(); }
}
When you run this code, the output will be:
ComponentB: Performing some operation.
ComponentC: Performing another operation.
Changes to Components
Now, let’s say we need to update ComponentB
and ComponentC
:
-
ComponentB: Change
SomeOperation
toExecuteOperation
and modify its implementation. -
ComponentC: Add a parameter to
AnotherOperation
.
Updated ComponentB.cs
public class ComponentB
{ public void ExecuteOperation() { // Updated operation Console.WriteLine("ComponentB: Executing updated operation."); }
}
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}"); }
}
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"); }
}
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(); }
}
Running this updated code will produce the following output:
ComponentB: Executing updated operation.
ComponentC: Performing another operation with message: Hello from ComponentA.
Pros and Cons of Tight Coupling
Pros:
- Simplicity: Direct communication between components can be straightforward to implement initially.
- Performance: There is no intermediary, which can sometimes result in faster execution.
Cons:
- Maintenance: Any change in one component necessitates changes in all dependent components, leading to a fragile system.
- Scalability: As the system grows, the interdependencies become more complex, making it hard to manage.
- 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
Setup the Console Application
Create a new .NET console application:
dotnet new console -n MediatorExample cd MediatorExample
Install MediatR Packages
Add the necessary MediatR packages:
dotnet add package MediatR dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
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; } }
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", "[email protected]"); } }
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:
- Decoupling: Components do not need to know about each other, reducing dependencies and making the system more maintainable.
- Scalability: Easier to add new features and modify existing ones without affecting other parts of the system.
- Testing: Simplifies unit testing by isolating each component’s behavior.
Cons:
- Complexity: Introduces additional layers, which can make the initial setup more complex.
- 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.