Refactoring the E-Commerce Microservice Project — Introducing a Shared Kernel and Enhanced Error Handling

As I continued refining my E-Commerce Microservice project, I implemented significant improvements, including the introduction of a Shared Kernel and updates to the error-handling mechanism. This article walks through the thought process, changes made, and the benefits achieved.

Microservice Shared Kernel

Shared Kernel: What and Why

In Domain-Driven Design (DDD), a Shared Kernel is a distinct type of Bounded Context that holds shared code and data used by multiple bounded contexts, such as Catalog, Ordering, and Identity, within the same domain (eCommerce). It acts as a centralized repository for ubiquitous language elements, common domain logic, and shared data structures relevant to all or specific bounded contexts.

Why Did I Introduce a Shared Kernel?

In the original implementation, abstractions like Result and Error were defined independently across services. This approach posed a problem: any changes to these abstractions required modifying each service individually, which becomes cumbersome as the number of services grows. To address this, I centralized these common elements in a Shared Kernel, ensuring consistency and simplifying updates across the project.

Enhancing Error Handling in REST APIs

In the current implementation of controllers, I was returning only two types of status codes:

However, REST APIs often require more granular status codes to represent different scenarios. For example, when editing a product, if the product ID does not exist in the database, the response should return a 404 Not Found status instead of 400 Bad Request.

Initial Implementation

Handler method:

public async Task<Result<long>> Handle(EditProductCommand request, CancellationToken cancellationToken)
{
    var product = await _productRepository.GetByIdAsync(request.id);

    if (product is null)
    {
        return Result.Failure<long>(ProductErrors.NotFound());
    }
    product = Product.Update(product, request.name, request.description,
        new Money(request.priceAmount, Currency.Create(request.priceCurrency)),
        request.quantity, request.categoryId, _dateTimeProvider.UtcNow);
    _productRepository.Update(product);
    await _unitOfWork.SaveChangesAsync();
    return product.Id;
}

Controller method:

[HttpPut("{id}")]
public async Task<IActionResult> EditProduct(long id, EditProductRequest request, CancellationToken cancellationToken)
{
    var command = new EditProductCommand(id, request.name, request.description,
        request.priceCurrency, request.priceAmount, request.quantity, request.categoryId);

    var result = await _sender.Send(command, cancellationToken);
    if (result.IsFailure)
    {
        return BadRequest(result.Error);
    }
    return Ok(new { id = result.Value });
}

In this approach, every error, regardless of type, returned a 400 Bad Request status code with error details.

Introducing an Updated Error Object

To improve error handling and align with REST principles, I modified the Error record to include an additional property, HttpResponseStatusCodes, which represents the HTTP status code corresponding to the error.

Original Implementation of Error

public record Error(string Code, string Name)
{
    public static Error None = new(string.Empty, string.Empty);
    public static Error NullValue = new("Error.NullValue", "Null value was provided");
    public static Error NotFound = new("Error.NotFound", "Not Found");
}

Revised Implementation of Error

public record Error(string Name, string Description, HttpResponseStatusCodes Code)
{
    public static Error None = new(string.Empty, string.Empty, HttpResponseStatusCodes.InternalServerError);

    public static Error NullValue = new("Error.NullValue", "Null value was provided", HttpResponseStatusCodes.BadRequest);

    public static Error InternalServerError(string name, string description) =>
       new(name, description, HttpResponseStatusCodes.InternalServerError);

    public static Error NotFound(string name, string description) =>
        new(name, description, HttpResponseStatusCodes.NotFound);

    public static Error BadRequest(string name, string description) =>
        new(name, description, HttpResponseStatusCodes.BadRequest);

    public static Error Conflict(string name, string description) =>
        new(name, description, HttpResponseStatusCodes.Conflict);
}

I introduced an HttpResponseStatusCodes enum in the domain layer:

public enum HttpResponseStatusCodes
{
    BadRequest = 400,
    NotFound = 404,
    Conflict = 409,
    InternalServerError = 500,
}

Updated Controller Logic

With the revised Error object, I updated the controller logic to return the appropriate HTTP status code based on the error type:

[HttpPut("{id}")]
public async Task<IActionResult> EditProduct(long id, EditProductRequest request, CancellationToken cancellationToken)
{
    var command = new EditProductCommand(id, request.name, request.description,
        request.priceCurrency, request.priceAmount, request.quantity, request.categoryId);

    var result = await _sender.Send(command, cancellationToken);

    if (result.IsFailure)
    {
        if (result.Error.Code == HttpResponseStatusCodes.NotFound)
        {
            return NotFound(result.Error);
        }
        return BadRequest(result.Error);
    }
    return Ok(new { id = result.Value });
}

Streamlining with Error Mapping Extensions

To make the controller method cleaner, I introduced an ErrorMappingExtensions class to map errors to appropriate IActionResult objects:

public static class ErrorMappingExtensions
{
    public static IActionResult ToActionResult(this Error error)
    {
        return error.Code switch
        {
            HttpResponseStatusCodes.NotFound => new NotFoundObjectResult(error),
            HttpResponseStatusCodes.BadRequest => new BadRequestObjectResult(error),
            HttpResponseStatusCodes.Conflict => new ConflictObjectResult(error),
            HttpResponseStatusCodes.InternalServerError => new ObjectResult(error)
            {
                StatusCode = StatusCodes.Status500InternalServerError
            },
            _ => new ObjectResult(error) { StatusCode = StatusCodes.Status500InternalServerError }
        };
    }
}

Using this extension, the controller becomes much cleaner:

[HttpPut("{id}")]
public async Task<IActionResult> EditProduct(long id, EditProductRequest request, CancellationToken cancellationToken)
{
    var command = new EditProductCommand(id, request.name, request.description,
        request.priceCurrency, request.priceAmount, request.quantity, request.categoryId);

    var result = await _sender.Send(command, cancellationToken);

    if (result.IsFailure)
    {
        return result.Error.ToActionResult();
    }

    return Ok(new { id = result.Value });
}

With these modifications, I improved both maintainability and adherence to REST principles in my E-Commerce Microservice project. The introduction of a Shared Kernel ensures consistency across services, while the enhanced error-handling mechanism provides more meaningful and precise responses.

These changes not only simplify future development but also make the application more robust and scalable. I plan to apply these improvements across all controllers in all services, further streamlining the entire project.