AWS Lambda and .NET: How to Structure Your Solution?

AWS Lambda and .NET: How to Structure Your Solution?

As beginners writing Lambda functions using .NET, we often face questions about solution structure and the choice of libraries or tools. In this article, we address these concerns and present two solutions for the same problem - one following clean architecture principles and the other emphasizing a pragmatic approach.

The Problem

We want to build an e-commerce application with the following requirements:

  • A client can submit requests to register for the application.

  • An administrator can approve the client's request.

  • An administrator can register products and enable them.

  • A client can add products to their shopping cart.

  • A client can place an order.

Tools

We are using a set of tools to improve the development process of our application:

Libraries

In addition to the common Amazon.Lambda.* packages and the essential Microsoft.Extensions.* packages, we will use:

Disclaimer: Powertools for AWS Lambda and Lambda Annotations are not entirely compatible; we are only using the logging feature.

The Solution

Both solutions consist of four projects:

  • _build: This is the project where all automated tasks reside.

  • MyECommerceApp: The main project. The structure will change based on the approach.

  • MyECommerceApp.Migrator: A project responsible for keeping the database up-to-date.

  • MyECommerceApp.Tests: This project contains all the acceptance tests.

The main driver in both approaches is building the application around features.

Another common aspect of both approaches is the Test project, which has the following structure:

├── ClientRequests
    ├── ApproveClientRequestTests.cs
    ├── ClientRequestsDsl.cs
    └── RegisterClientRequestTests.cs
├── Clients
├── Infrastructure
├── Orders
├── Products
├── ShoppingCart
└── AppDsl.cs

Every folder contains tests per each command/query associated with a feature and the Domain Specific Language (DSL) of it. We can run the test with the command Nuke Test. Learn more about Acceptance Testing here.

Clean Architecture

There are numerous pieces of literature written about clean architecture, often referred to as onion or hexagonal architecture, which share the same advantages and disadvantages.

Pros:

  • Loose coupling.

  • The separation of concerns.

  • The domain logic is agnostic and independent.

Cons:

  • A high degree of indirection.

  • The learning curve, the pattern requires an upfront investment of time.

  • In some cases, an over-engineered solution.

  • Context switching, working on the same feature often requires navigating through multiple folders.

The first level of folders in the main project is reserved for the features.

├── ClientRequests
├── Clients
├── Orders
├── Products
├── Shared
└── ShoppingCart

Within each feature folder, we can find the standard four layers of this architecture:

├── ClientRequests
    ├── Application
    ├── Domain
    ├── Host
    └── Infrastructure
├── Clients
├── Orders
├── Products
├── Shared
└── ShoppingCart
  • The Domain Layer contains the business logic of our application and should be independent of any other layers.

  • The Application Layer implements the use cases and orchestrates the flow of data between the Domain Layer and the Infrastructure Layer.

  • The Infrastructure Layer interacts with the outside world. This layer is responsible for tasks, such as database access, API calls, message handling, etc.

  • The Presentation Layer is responsible for displaying data to users and collecting input from them, as well as from other sources.

├── ClientRequests
    ├── Application  
        ├── ApproveClientRequest.cs
        └── RegisterClientRequest.cs
    ├── Domain
        ├── ClientRequest.cs
        ├── ClientRequestApproved.cs
        └── ClientRequestStatus.cs
    ├── Host
        └── Function.cs
    └── Infrastructure
        ├── AnyClientRequests.cs
        ├── EntityTypeConfiguration.cs
        ├── GetClientRequest.cs
        ├── ListClientRequests.cs
        └── ServiceCollectionExtensions.cs
├── Clients
├── Orders
├── Products
├── Shared
└── ShoppingCart

In the Application folder, all the commands are stored. Each file maintains a consistent structure, such as:

public static class RegisterClientRequest
{
    public class Command
    {
        public string Name { get; set; }
        public string Address { get; set; }
        public string PhoneNumber { get; set; }
        [JsonIgnore]
        public bool Any { get; set; }
    }

    public class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(command => command.Name).MaximumLength(100).NotEmpty();
            RuleFor(command => command.Address).MaximumLength(500).NotEmpty();
            RuleFor(command => command.PhoneNumber).MaximumLength(20).NotEmpty();
        }
    }

    public class Result
    {
        public Guid ClientRequestId { get; set; }
    }

    public class Handler
    {
        private readonly IRepository<ClientRequest> _clientRequestRepository;

        public Handler(IRepository<ClientRequest> clientRequestRepository)
        {
            _clientRequestRepository = clientRequestRepository;
        }

        public Task<Result> Handle(Command command)
        {
            var clientRequest = new ClientRequest (
                NewId.Next().ToSequentialGuid(), 
                command.Name, 
                command.Address, 
                command.PhoneNumber, 
                command.Any);

            _clientRequestRepository.Add(clientRequest);

            return Task.FromResult(new Result()
            {
                ClientRequestId = clientRequest.ClientRequestId
            });
        }
    }
}

In the Infrastructure folder, everything related to a specific technology is stored: the queries, the Entity Framework configuration, and the dependency injection setup. The queries follow a structure like this:

public class GetClientRequest
{
    public class Query
    {
        public Guid ClientRequestId { get; set; }
    }

    public class Result
    {
        public Guid ClientRequestId { get; set; }
        public string Name { get; set; }
        public string Address { get; set; }
        public string PhoneNumber { get; set; }
        public string Status { get; set; }
        public DateTimeOffset RegisteredAt { get; set; }
        public DateTimeOffset? ApprovedAt { get; set; }
        public DateTimeOffset? RejectedAt { get; set; }
    }

    public class Runner : BaseRunner
    {
        public Runner(SqlKataQueryRunner queryRunner) : base(queryRunner) { }

        public Task<Result> Run(Query query)
        {
            return _queryRunner.Get<Result>((qf) => qf
                .Query(Tables.ClientRequests)
                .Where(Tables.ClientRequests.Field(nameof(Query.ClientRequestId)), query.ClientRequestId));
        }
    }
}

The entry points for our Lambda functions are in the Host folder, a single file like this:

public class Function : BaseFunction
{
    [LambdaFunction]
    [RestApi(LambdaHttpMethod.Post, "/client-requests")]
    public Task<IHttpResult> RegisterClientRequest(
        [FromServices] AnyClientRequests.Runner runner,
        [FromServices] TransactionBehavior behavior, 
        [FromServices] RegisterClientRequest.Handler handler,
        [FromBody] RegisterClientRequest.Command command)
    {
        return Handle(async ()=>
        {
            new RegisterClientRequest.Validator().ValidateAndThrow(command);
            command.Any = await runner.Run(new AnyClientRequests.Query() { Name = command.Name });
            var result = await behavior.Handle(() => handler.Handle(command));
            return result;
        });
    }

    [LambdaFunction]
    [RestApi(LambdaHttpMethod.Post, "/client-requests/{clientRequestId}/approve")]
    public Task<IHttpResult> ApproveClientRequest(
    [FromServices] TransactionBehavior behavior,
    [FromServices] ApproveClientRequest.Handler handler,
    [FromServices] EventPublisher publisher,
    string clientRequestId)
    {
        return Handle(async () =>
        {
            var command = new ApproveClientRequest.Command() { ClientRequestId = Guid.Parse(clientRequestId) };
            await behavior.Handle(() => handler.Handle(command));
            await publisher.Publish(new ClientRequestApproved(command.ClientRequestId));
        });
    }

    [LambdaFunction]
    [RestApi(LambdaHttpMethod.Get, "/client-requests")]
    public Task<IHttpResult> ListClientRequests(
        [FromServices] ListClientRequests.Runner runner, 
        [FromQuery] string name,
        [FromQuery] int page, 
        [FromQuery] int pageSize)
    {
        return Handle(()=>runner.Run(new ListClientRequests.Query() { Name = name, Page = page, PageSize = pageSize }));
    }
}

Remember, we are using Lambda Annotations, so the library generates all the boilerplate code for us. The Domain folder contains the classes related to the core of the application. Finally, there is a Shared folder containing concerns from every layer that can be shared among various components:

├── Shared
    ├── Domain
        ├── Exceptions.cs
        └── IRepository.cs
    ├── Host
        └── BaseFunction.cs
    └── Infrastructure
        ├── EntityFramework
        ├── ExceptionHandling
        ├── Messaging
        ├── SqlKata
        └── StringExtensions.cs

The code for this solution can be found here.

Pragmatic

The second approach involves removing the concept of layers from the solution to keep all the classes directly under the same feature folder:

├── ClientRequests
    ├── AnyClientRequests.cs
    ├── ApproveClientRequest.cs
    ├── ClientRequest.cs
    ├── EntityTypeConfiguration.cs
    ├── GetClientRequest.cs
    ├── ListClientRequests.cs
    ├── RegisterClientRequest.cs
    └── ServiceCollectionExtensions.cs
├── Clients
├── Infrastructure
├── Orders
├── Products
└── ShoppingCart

Now, the commands and queries can incorporate the Lambda function as part of their respective files:

public class RegisterClientRequest : BaseFunction
{
    public class Command
    {
        public string Name { get; set; }
        public string Address { get; set; }
        public string PhoneNumber { get; set; }
        [JsonIgnore]
        public bool Any { get; set; }
    }

    public class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(command => command.Name).MaximumLength(100).NotEmpty();
            RuleFor(command => command.Address).MaximumLength(500).NotEmpty();
            RuleFor(command => command.PhoneNumber).MaximumLength(20).NotEmpty();
        }
    }

    public class Result
    {
        public Guid ClientRequestId { get; set; }
    }

    public class Handler
    {
        private readonly ApplicationDbContext _context;

        public Handler(ApplicationDbContext context)
        {
            _context = context;
        }

        public Task<Result> Handle(Command command)
        {
            var clientRequest = new ClientRequest(
                NewId.Next().ToSequentialGuid(),
                command.Name,
                command.Address,
                command.PhoneNumber,
                command.Any);

            _context.Set<ClientRequest>().Add(clientRequest);

            return Task.FromResult(new Result()
            {
                ClientRequestId = clientRequest.ClientRequestId
            });
        }
    }

    [LambdaFunction]
    [RestApi(LambdaHttpMethod.Post, "/client-requests")]
    public Task<IHttpResult> Handle(
        [FromServices] AnyClientRequests.Runner runner,
        [FromServices] TransactionBehavior behavior,
        [FromServices] Handler handler,
        [FromBody] Command command)
    {
        return Handle(async () =>
        {
            new Validator().ValidateAndThrow(command);
            command.Any = await runner.Run(new AnyClientRequests.Query() { Name = command.Name });
            var result = await behavior.Handle(() => handler.Handle(command));
            return result;
        });
    }
}
public class ListClientRequests : BaseFunction
{
    public class Query : ListQuery
    {
        public string Name { get; set; }
    }

    public class Result
    {
        public Guid ClientRequestId { get; set; }
        public string Name { get; set; }
        public string Address { get; set; }
        public string PhoneNumber { get; set; }
        public string Status { get; set; }
        public DateTimeOffset RegisteredAt { get; set; }
        public DateTimeOffset? ApprovedAt { get; set; }
        public DateTimeOffset? RejectedAt { get; set; }
    }

    public class Runner : BaseRunner
    {
        public Runner(SqlKataQueryRunner queryRunner): base(queryRunner) { }

        public Task<ListResults<Result>> Run(Query query)
        {
            return _queryRunner.List<Query, Result>((qf)=> {
                var statement = qf.Query(Tables.ClientRequests);

                if (!string.IsNullOrEmpty(query.Name))
                {
                    statement = statement.WhereLike(Tables.ClientRequests.Field(nameof(Query.Name)), $"%{query.Name}%");
                }
                return statement;
            }, query);
        }
    }

    [LambdaFunction]
    [RestApi(LambdaHttpMethod.Get, "/client-requests")]
    public Task<IHttpResult> Handle(
        [FromServices] Runner runner,
        [FromQuery] string name,
        [FromQuery] int page,
        [FromQuery] int pageSize)
    {
        return Handle(() => runner.Run(new Query() { Name = name, Page = page, PageSize = pageSize }));
    }
}

The Shared folder on the first approach was replaced with an Infrastructure folder with:

├── Infrastructure
    ├── EntityFramework
    ├── ExceptionHandling
    ├── Host
    ├── Messaging
    ├── SqlKata
    └── StringExtensions.cs

With this approach, we keep all the elements that could potentially be impacted by a change in a feature together. The code for this solution can be found here.

In conclusion, structuring an AWS Lambda and .NET solution can be approached using either clean architecture or a more pragmatic method. Both have their advantages and drawbacks, with clean architecture promoting loose coupling and separation of concerns, while the pragmatic approach emphasizes simplicity and cohesiveness. Ultimately, the choice depends on your specific needs, development style, and project requirements. Thanks, and happy coding.