BetaByte
BetaByte

Reputation: 75

How can I keep my .NET API DRY when implementing the CQRS pattern with MediatR

I have recently been trying to create a .NET API that follows the CQRS pattern with MediatR & Entity Framework. I have come across some issues with keeping duplicate code out of my project.

Let's say that I have an entity of Customer that I want to be able to create, this would be simple enough using standard MediatR handling:

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 lets say that a requirement is added for a customer must be created with an User entity associated, with the ability to add additional users at a later date. The handler now looks like this:

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;
    }
}

And I would need another handler to add a User to an existing Customer which would look something like:

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;
    }
}

Now, given the above two handlers, lets say that the way we create a User requires a change, I would now have to replicate the change across BOTH handlers, which is not DRY. I appreciate that in this simple example that would not be a big issue, but surely there must be some way of removing this duplicate of code so I don't have to make changes in multiple handlers.

What I have tried:

1. Using MediatR Events

I have tried fixing this issue with MediatR events:

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;
    }
}

and then handling the creation of the User in a CustomerCreatedNotificationHandler. Ideally I want the creation of the Customer and User to be transactional, which is not achieved by this method.

2. Executing ICommands from inside other handlers

Which I have implemented as follows:

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 seems better from a DRY perspective, however I have read numerous articles describing this implementation as a code-smell as the ICommands would not longer be 'self-contained'.

3. Executing multiple ICommands in the controller action method

    [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);
    }

However this also seems wrong to me as I am now performing business logic inside of a Controller... (it is also not transactional)

Is there any way to avoid the duplication of code in the above case, is this the cost of using the CQRS pattern or am I simply approaching the whole problem wrong?

Upvotes: 2

Views: 44

Answers (1)

sobinesh S
sobinesh S

Reputation: 29

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 :

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 :

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 :

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.

Upvotes: 2

Related Questions