Command Query Separation for AWS Lambda ⚡ ✔️ Provides generic function support for commands and queries with Amazon API Gateway ✔️ Enables APIs based on HTTP POST and GET 📄 https://hlaueriksson.me/CommandQuery.AWSLambda/
$ dotnet add package CommandQuery.AWSLambdaCommand Query Separation for AWS Lambda
POST and GETCommandQuery.AWSLambda package from NuGet
PM> Install-Package CommandQuery.AWSLambdaCommand and QueryICommand and ICommandHandler<in TCommand>ICommand<TResult> and ICommandHandler<in TCommand, TResult>IQuery<TResult> and IQueryHandler<in TQuery, TResult>
Choose:

Choose:
using Amazon.Lambda.Annotations;
using Amazon.Lambda.Annotations.APIGateway;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using CommandQuery.AWSLambda;
namespace CommandQuery.Sample.AWSLambda;
public class Command(ICommandFunction commandFunction)
{
[LambdaFunction(Policies = "AWSLambdaBasicExecutionRole", MemorySize = 256, Timeout = 30)]
[RestApi(LambdaHttpMethod.Post, "/command/{commandName}")]
public async Task<APIGatewayProxyResponse> Post(
APIGatewayProxyRequest request,
ILambdaContext context,
string commandName) =>
await commandFunction.HandleAsync(commandName, request, context.Logger);
}POST with the Content-Type application/json in the header.200.400 or 500.Commands with result:
200.using Amazon.Lambda.Annotations;
using Amazon.Lambda.Annotations.APIGateway;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using CommandQuery.AWSLambda;
namespace CommandQuery.Sample.AWSLambda
{
public class Query(IQueryFunction queryFunction)
{
[LambdaFunction(Policies = "AWSLambdaBasicExecutionRole", MemorySize = 256, Timeout = 30)]
[RestApi(LambdaHttpMethod.Get, "/query/{queryName}")]
public async Task<APIGatewayProxyResponse> Get(
APIGatewayProxyRequest request,
ILambdaContext context,
string queryName) =>
await queryFunction.HandleAsync(queryName, request, context.Logger);
[LambdaFunction(Policies = "AWSLambdaBasicExecutionRole", MemorySize = 256, Timeout = 30)]
[RestApi(LambdaHttpMethod.Post, "/query/{queryName}")]
public async Task<APIGatewayProxyResponse> Post(
APIGatewayProxyRequest request,
ILambdaContext context,
string queryName) =>
await queryFunction.HandleAsync(queryName, request, context.Logger);
}
}POST with the Content-Type application/json in the header and the query itself as JSON in the bodyGET and the query itself as query string parameters in the URL200.400 or 500.Configuration in Startup.cs:
using Amazon.Lambda.Annotations;
using Amazon.Lambda.Core;
using CommandQuery.AWSLambda;
using CommandQuery.Sample.Contracts.Commands;
using CommandQuery.Sample.Contracts.Queries;
using CommandQuery.Sample.Handlers;
using CommandQuery.Sample.Handlers.Commands;
using CommandQuery.Sample.Handlers.Queries;
using Microsoft.Extensions.DependencyInjection;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace CommandQuery.Sample.AWSLambda;
[LambdaStartup]
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
//services.AddSingleton(new JsonSerializerOptions(JsonSerializerDefaults.Web));
// Add commands and queries
services.AddCommandFunction(typeof(FooCommandHandler).Assembly, typeof(FooCommand).Assembly);
services.AddQueryFunction(typeof(BarQueryHandler).Assembly, typeof(BarQuery).Assembly);
// Add handler dependencies
services.AddTransient<IDateTimeProxy, DateTimeProxy>();
services.AddTransient<ICultureService, CultureService>();
// Validation
var serviceProvider = services.BuildServiceProvider();
serviceProvider.GetService<ICommandProcessor>()!.AssertConfigurationIsValid();
serviceProvider.GetService<IQueryProcessor>()!.AssertConfigurationIsValid();
}
}The extension methods AddCommandFunction and AddQueryFunction will add functions and all command/query handlers in the given assemblies to the IoC container.
You can pass in a params array of Assembly arguments if your handlers are located in different projects.
If you only have one project you can use typeof(Program).Assembly as a single argument.
Configuration in serverless.template:
{
"AWSTemplateFormatVersion": "2010-09-09",
"Transform": "AWS::Serverless-2016-10-31",
"Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.5.0.0).",
"Resources": {
"CommandQuerySampleAWSLambdaCommandPostGenerated": {
"Type": "AWS::Serverless::Function",
"Metadata": {
"Tool": "Amazon.Lambda.Annotations",
"SyncedEvents": [
"RootPost"
],
"SyncedEventProperties": {
"RootPost": [
"Path",
"Method"
]
}
},
"Properties": {
"Architectures": [
"x86_64"
],
"Handler": "CommandQuery.Sample.AWSLambda::CommandQuery.Sample.AWSLambda.Command_Post_Generated::Post",
"Runtime": "dotnet8",
"CodeUri": ".",
"MemorySize": 256,
"Timeout": 30,
"Policies": [
"AWSLambdaBasicExecutionRole"
],
"PackageType": "Zip",
"Events": {
"RootPost": {
"Type": "Api",
"Properties": {
"Path": "/command/{commandName}",
"Method": "POST"
}
}
}
}
},
"CommandQuerySampleAWSLambdaQueryGetGenerated": {
"Type": "AWS::Serverless::Function",
"Metadata": {
"Tool": "Amazon.Lambda.Annotations",
"SyncedEvents": [
"RootGet"
],
"SyncedEventProperties": {
"RootGet": [
"Path",
"Method"
]
}
},
"Properties": {
"Architectures": [
"x86_64"
],
"Handler": "CommandQuery.Sample.AWSLambda::CommandQuery.Sample.AWSLambda.Query_Get_Generated::Get",
"Runtime": "dotnet8",
"CodeUri": ".",
"MemorySize": 256,
"Timeout": 30,
"Policies": [
"AWSLambdaBasicExecutionRole"
],
"PackageType": "Zip",
"Events": {
"RootGet": {
"Type": "Api",
"Properties": {
"Path": "/query/{queryName}",
"Method": "GET"
}
}
}
}
},
"CommandQuerySampleAWSLambdaQueryPostGenerated": {
"Type": "AWS::Serverless::Function",
"Metadata": {
"Tool": "Amazon.Lambda.Annotations",
"SyncedEvents": [
"RootPost"
],
"SyncedEventProperties": {
"RootPost": [
"Path",
"Method"
]
}
},
"Properties": {
"Architectures": [
"x86_64"
],
"Handler": "CommandQuery.Sample.AWSLambda::CommandQuery.Sample.AWSLambda.Query_Post_Generated::Post",
"Runtime": "dotnet8",
"CodeUri": ".",
"MemorySize": 256,
"Timeout": 30,
"Policies": [
"AWSLambdaBasicExecutionRole"
],
"PackageType": "Zip",
"Events": {
"RootPost": {
"Type": "Api",
"Properties": {
"Path": "/query/{queryName}",
"Method": "POST"
}
}
}
}
}
},
"Outputs": {
"ApiURL": {
"Description": "API endpoint URL for Prod environment",
"Value": {
"Fn::Sub": "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
}
}
}
}You can test your lambdas with the Amazon.Lambda.TestUtilities package.
using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.TestUtilities;
using CommandQuery.AWSLambda;
using CommandQuery.Sample.Contracts.Commands;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace CommandQuery.Sample.AWSLambda.Tests
{
public class CommandTests
{
[SetUp]
public void SetUp()
{
var serviceCollection = new ServiceCollection();
new Startup().ConfigureServices(serviceCollection);
var serviceProvider = serviceCollection.BuildServiceProvider();
Subject = new Command(serviceProvider.GetRequiredService<ICommandFunction>());
Context = new TestLambdaContext();
}
[Test]
public async Task should_handle_command()
{
var response = await Subject.Post(GetRequest(new FooCommand { Value = "Foo" }), Context, "FooCommand");
response.StatusCode.Should().Be(200);
}
[Test]
public async Task should_handle_errors()
{
var response = await Subject.Post(GetRequest(new FooCommand()), Context, "FooCommand");
response.ShouldBeError("Value cannot be null or empty");
}
static APIGatewayProxyRequest GetRequest(object body) => new() { Body = JsonSerializer.Serialize(body) };
Command Subject = null!;
TestLambdaContext Context = null!;
}
}