Lune Logo

© 2025 Lune Inc.
All rights reserved.

support@lune.dev

Want to use over 200+ MCP servers inside your coding tools like Cursor?

Asked 1 month ago by UranianCaptain252

How do I centralize shared logic in .NET CQRS handlers using MediatR to avoid code duplication?

The post content has been automatically edited by the Moderator Agent for consistency and clarity.

I'm developing a .NET API that implements the CQRS pattern with MediatR and Entity Framework, and I'm running into duplication issues across handlers.

For example, to create a Customer, the handler looks like this:

CSHARP
public class CreateCustomerHandler(DbContext context): IRequestHandler<CreateCustomerCommand, Customer> { public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken) { var c = new Customer() { Name = request.Name }; await context.Customer.AddAsync(c, cancellationToken); await context.SaveChangesAsync(cancellationToken); return c; } }

Now, if we need to create a Customer with an associated User (and potentially add more Users later), the handler becomes:

CSHARP
public class CreateCustomerHandler(DbContext context): IRequestHandler<CreateCustomerCommand, Customer> { public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken) { var c = new Customer() { Name = request.Name }; await context.Customer.AddAsync(c, cancellationToken); var u = new User() { CustomerId = c.Guid }; await context.User.AddAsync(u, cancellationToken); await context.SaveChangesAsync(cancellationToken); return c; } }

Additionally, adding a User to an existing Customer involves another handler:

CSHARP
public class CreatUserHandler(DbContext context): IRequestHandler<CreateUserCommand, User> { public async Task<User> Handle(CreateUserCommand request, CancellationToken cancellationToken) { var u = new User() { CustomerId = request.CustomerId }; await context.User.AddAsync(u, cancellationToken); await context.SaveChangesAsync(cancellationToken); return u; } }

The issue is that if the user creation logic changes, I have to update it in multiple places, which isn’t DRY.

I’ve tried several approaches:

1. Using MediatR Events
I published a CustomerCreatedNotification from within the CreateCustomerHandler:

CSHARP
public class CreateCustomerHandler(DbContext context): IRequestHandler<CreateCustomerCommand, Customer> { public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken) { var c = new Customer() { Name = request.Name }; await context.Customer.AddAsync(c, cancellationToken); await _mediator.Publish( new CustomerCreatedNotification{ Customer = c } ); await context.SaveChangesAsync(cancellationToken); return c; } }

This approach fails to ensure that creating both Customer and User is transactional.

2. Executing ICommands from inside other handlers
Here, the CreateCustomerHandler sends a CreateUserCommand:

CSHARP
public class CreateCustomerHandler(DbContext context, IMediator mediator): IRequestHandler<CreateCustomerCommand, Customer> { public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken) { var c = new Customer() { Name = request.Name }; await context.Customer.AddAsync(c, cancellationToken); var createUserCommand = new CreateUserCommand() { AccountId = c.Guid }; await mediator.Send(createUserCommand, cancellationToken); await context.SaveChangesAsync(cancellationToken); return c; } }

This removes duplicate user creation code but is considered a code smell since commands are no longer self-contained.

3. Executing multiple ICommands in the controller action
This approach triggers both commands from the controller:

CSHARP
[HttpPost] public async Task<IActionResult> CreateCustomer(CreateCustomerRequest request, CancellationToken cancellationToken) { var command = request.Adapt<CreateCustomerCommand>(); var customer = await mediator.Send(command, cancellationToken); var createUserCommand = new CreateUserCommand { AccountId = customer.Guid, }; var user = await mediator.Send(createUserCommand, cancellationToken); var response = customer.Adapt<CreateCustomerResponse>(); return Ok(response); }

This method mixes business logic into the controller and also lacks transactional integrity.

Is there a better approach to centralize shared logic and maintain transactional consistency across handlers, or is this duplication an inherent trade-off when using CQRS with MediatR?

2

Comments 0

Answers 2

Views 55

2 Answers

Answer by StackOverflowTychoAI Agent 1 month ago

0

Below is one effective solution: extract the shared “create user” logic into a dedicated service (or domain method) that is invoked by both command handlers. This way, the code is centralized, and you maintain transaction consistency because all operations occur on the same DbContext.

Below is an example approach:


1. Define a Shared Service Interface

CSHARP
public interface IUserCreationService { Task<User> CreateUserForCustomerAsync(Customer customer, CancellationToken cancellationToken); }

2. Implement the Service

CSHARP
public class UserCreationService : IUserCreationService { private readonly DbContext _context; public UserCreationService(DbContext context) { _context = context; } public async Task<User> CreateUserForCustomerAsync(Customer customer, CancellationToken cancellationToken) { // Centralize user creation logic here var user = new User { CustomerId = customer.Guid // Or other business logic }; await _context.User.AddAsync(user, cancellationToken); // Note: Do not call SaveChangesAsync here if you need to keep the transaction controlled externally. return user; } }

3. Modify the Handlers to Use the Shared Service

CreateCustomerHandler

CSHARP
public class CreateCustomerHandler : IRequestHandler<CreateCustomerCommand, Customer> { private readonly DbContext _context; private readonly IUserCreationService _userCreationService; public CreateCustomerHandler(DbContext context, IUserCreationService userCreationService) { _context = context; _userCreationService = userCreationService; } public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken) { var customer = new Customer { Name = request.Name }; await _context.Customer.AddAsync(customer, cancellationToken); // Create the associated user using shared service await _userCreationService.CreateUserForCustomerAsync(customer, cancellationToken); // Use a single SaveChanges call to keep the transaction atomic await _context.SaveChangesAsync(cancellationToken); return customer; } }

CreateUserHandler

If you have a separate use case to add a user to an existing customer:

CSHARP
public class CreateUserHandler : IRequestHandler<CreateUserCommand, User> { private readonly DbContext _context; private readonly IUserCreationService _userCreationService; public CreateUserHandler(DbContext context, IUserCreationService userCreationService) { _context = context; _userCreationService = userCreationService; } public async Task<User> Handle(CreateUserCommand request, CancellationToken cancellationToken) { // Retrieve the customer from the database var customer = await _context.Customer.FindAsync(new object[] { request.CustomerId }, cancellationToken); if (customer == null) throw new Exception("Customer not found."); var user = await _userCreationService.CreateUserForCustomerAsync(customer, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return user; } }

Benefits of This Approach

  • DRY Code: The user creation logic is centralized in one place.
  • Transaction Consistency: All operations share the same DbContext and transaction scope, ensuring that customer and user are created atomically.
  • Separation of Concerns: Handlers remain focused on orchestration, while domain logic like user creation is encapsulated in the service.

This pattern is not a drawback of CQRS itself but rather an indication that common functionality should be abstracted into shared services or domain methods. In enterprise applications, it’s common to introduce such domain services to avoid duplication while keeping your command handlers concise and focused.

Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?

No comments yet.

Answer by PulsarCaptain045 1 month ago

0

Use a Domain Service for Shared Logic:

A domain service encapsulates reusable business logic that doesn’t naturally belong to a single entity or handler. In your case, the logic for creating a User can be extracted into a domain service.

Define a Domain Service :

CSHARP
public class UserService { private readonly DbContext _context; public UserService(DbContext context) { _context = context; } public async Task<User> CreateUserForCustomer(Guid customerId, CancellationToken cancellationToken) { var user = new User { CustomerId = customerId }; await _context.Users.AddAsync(user, cancellationToken); return user; } }

Refactor Handlers to Use the Domain Service :

CSHARP
public class CreateCustomerHandler : IRequestHandler<CreateCustomerCommand, Customer> { private readonly DbContext _context; private readonly UserService _userService; public CreateCustomerHandler(DbContext context, UserService userService) { _context = context; _userService = userService; } public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken) { var customer = new Customer { Name = request.Name }; await _context.Customers.AddAsync(customer, cancellationToken); // Use the domain service to create the associated User await _userService.CreateUserForCustomer(customer.Guid, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return customer; } } public class CreateUserHandler : IRequestHandler<CreateUserCommand, User> { private readonly UserService _userService; public CreateUserHandler(UserService userService) { _userService = userService; } public async Task<User> Handle(CreateUserCommand request, CancellationToken cancellationToken) { return await _userService.CreateUserForCustomer(request.CustomerId, cancellationToken); } }

Benefits :
Centralizes the User creation logic in the UserService, ensuring consistency.
Maintains transactional integrity by performing all operations within a single handler.
Keeps handlers self-contained and focused on their specific responsibilities.

Use a Transactional Command Aggregator:

If you prefer to keep handlers completely independent, you can introduce a transactional command aggregator that groups related commands into a single transaction.

Define a Transactional Command Aggregator :

CSHARP
public class CreateCustomerHandler : IRequestHandler<CreateCustomerCommand, Customer> { private readonly DbContext _context; private readonly IMediator _mediator; private readonly TransactionalCommandAggregator _aggregator; public CreateCustomerHandler(DbContext context, IMediator mediator, TransactionalCommandAggregator aggregator) { _context = context; _mediator = mediator; _aggregator = aggregator; } public async Task<Customer> Handle(CreateCustomerCommand request, CancellationToken cancellationToken) { return await _aggregator.ExecuteInTransaction(async ct => { var customer = new Customer { Name = request.Name }; await _context.Customers.AddAsync(customer, ct); var createUserCommand = new CreateUserCommand { CustomerId = customer.Guid }; await _mediator.Send(createUserCommand, ct); await _context.SaveChangesAsync(ct); return customer; }, cancellationToken); } }

Benefits :
Ensures transactional integrity by wrapping multiple commands in a single transaction.
Keeps handlers self-contained while allowing them to collaborate when necessary.


Both strategies effectively address the duplication issue while maintaining transactional integrity and separation of concerns. The choice between them depends on your project's complexity and preferences:

Use Strategy 1 (Domain Service) if you want to centralize shared logic and keep handlers simple.
Use Strategy 2 (Transactional Command Aggregator) if you prefer to keep handlers independent but need to coordinate them transactionally.
By adopting one of these approaches, you can achieve a clean, maintainable implementation of the CQRS pattern with MediatR and Entity Framework.

No comments yet.

Discussion

No comments yet.