Some Useful Patterns for C# AWS Lambda Functions

Lately I've been writing a lot of Lambda functions in C#, and I've found myself using a few patterns in particular over and over again with good results.

All code samples in this post come from my dotnet-core-aws-lambda-example project on github. Go check it out if you want a complete example you can deploy and run yourself.

For those of you who are unfamiliar, AWS Lambda is Amazon's serverless compute platform. It allows you to write, deploy, and run code without managing the servers the code runs on; Amazon does that for you (hence the term "serverless").

A C# Lambda function is just an assembly that has a public method that gets called by the Lambda runtime. In its simplest form, it looks like this:

using System;
using System.Threading.Tasks;
using Amazon.Lambda.Core;

namespace DotnetCoreLambdaExample
{
    public class Function
    {   
        [LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]
        public async Task<ExampleResult> Handler(ExampleEvent lambdaEvent)
        {
            /* ... Do stuff here ... */ 

            return new ExampleResult();
        }
    }
}

When you upload a C# Lambda function to AWS, you need to tell Lambda which method in the assembly the runtime should invoke by providing a signature in the form assemblyName::Namespace.Qualified.ClassName::Method. So, for example, the Lambda function above would have a signature of DotnetLambdaExample::DotnetLambdaExample.Function::Handler. When your Lambda function gets invoked, it will call this method with the JSON payload the function was invoked with. That's all there is to it.

Now that we have introductions out of the way - let's get to the patterns.

Patterns for bootstrapping

Lambda doesn't give us any initialization hooks we can use to read configuration files, build our object graph, set up dependency injection, and so on. Initially this creates a temptation not to use dependency injection at all and just create objects with field initializers or in code as-needed, but that makes it impossible to unit test all of our Lambda function's code. There's a better way.

It turns out that the class the Lambda entry point method belongs to, if you don't make it static, will be instantiated using its default constructor the first time your function is invoked. So, I like to build my object graph in the default constructor and pass the results to an overloaded injection constructor.

Yes, this is poor man's dependency injection. But it's okay to do here, since Lambda doesn't give us any initialization hooks to use. Just make sure you only do it in one place.

Suppose we have a simple service with an interface, like this:

using System.Threading.Tasks;

namespace DotnetCoreLambdaExample
{
    public interface IExampleService
    {
        Task GetMessageToReturn();
    }
}
using System.Threading.Tasks;

namespace DotnetCoreLambdaExample
{
    public class ExampleService : IExampleService
    {
        private readonly string messageToReturn;

        public ExampleService(string messageToReturn)
        {
            this.messageToReturn = messageToReturn;
        }

        // Just returns the message the service was configured with.
        public async Task GetMessageToReturn()
            => this.messageToReturn;
    }
}

… and a static bootstrapper helper class that creates, configures, and returns an instance of the service, like this:

using System;

namespace DotnetCoreLambdaExample
{
    public static class ExampleServiceBootstrapper
    {
        public static IExampleService CreateInstance()
        {
            var messageToReturn = Environment.GetEnvironmentVariable("MessageToReturn");

            return new ExampleService(messageToReturn);
        }
    }
}

Now, we can give our Function class an injection constructor and a default constructor that creates our service instance and passes it to the injection constructor.

using System;
using System.Threading.Tasks;
using Amazon.Lambda.Core;

namespace DotnetCoreLambdaExample
{
    public class Function
    {
        private readonly IExampleService exampleService;
        
        // Default ctor
        public Function()
            : this (ExampleServiceBootstrapper.CreateInstance()) {}
        
        // Injection ctor
        public Function(IExampleService exampleService)
        {
            this.exampleService = exampleService;
        }

        [LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]
        public async Task<ExampleResult> Handler(ExampleEvent lambdaEvent)
        {
            var message = await this.exampleService.GetMessageToReturn();

            return new ExampleResult 
            { 
                Message = message
            };
        }
    }
}

Doing things this way keeps our entire handler method testable. Now we can inject mocks directly into our Lambda function class in our unit tests:

using System;
using System.Threading.Tasks;
using Xunit;
using Moq;
using DotnetCoreLambdaExample;

namespace DotnetCoreLambdaExample.Tests
{
    public class FunctionTests
    {
        [Fact]
        public async Task Handler_Returns_Message_From_Example_Service()
        {
            var testMessage = "Hello, world!  Testing...";

            var exampleServiceMock = new Mock();

            exampleServiceMock
                .Setup(m => m.GetMessageToReturn())
                .Returns(Task.FromResult(testMessage));

            // here we use the overloaded ctor to inject mocks
            var function = new Function(exampleServiceMock.Object);

            var result = await function.Handler(new ExampleEvent());

            // assert that the message we told the service mock to return is equal to the one
            // the function returns in the result.
            Assert.Equal(testMessage, result.Message);
        }
    }
}

Patterns for configuration

We have a lot of different options for configuring our Lambda functions, and each one has benefits and drawbacks.

The easiest way of doing it is to just deploy a configuration file with your build and read it in your Lambda at runtime. Of course, this means that you'll need to redeploy your code every time your configuration needs to change.

Lambda also gives us environment variables, which can be changed without having to redeploy your code. However, changing them requires an update to the function resource itself, and this can be a pain depending on how you created it in the first place (e.g. you probably don't want to run your CloudFormation templates just to make a configuration change). Also be aware that your environment variables, collectively, can't be larger than four kilobytes.

Another option is to fetch a configuration file from S3, cache it in memory, and re-fetch it when the cache expires. This approach deserves its own blog post, though, so I won't discuss it here.

In a C# Lambda, we can read configuration from both the function's environment variables and a local configuration file with just a few lines of code using the .NET Core configuration APIs (Microsoft.Extensions.Configuration). I usually set up a singleton, lazy-initialized configuration object that combines configuration from both sources on initialization, as shown below.

using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
using System.IO;

namespace DotnetCoreLambdaExample
{
    public static class LambdaConfiguration
    {
        private static IConfigurationRoot instance = null;

        public static IConfigurationRoot Instance
        {
            get 
            {
                return instance ?? (instance = new ConfigurationBuilder()
                    .SetBasePath(Directory.GetCurrentDirectory())
                    .AddJsonFile("appsettings.json")  // local config file
                    .AddEnvironmentVariables()  // environment variables
                    .Build());
            }
        }
    }
}

IConfigurationRoot implements a dictionary interface. Using it in our bootstrapping code is easy:

namespace DotnetCoreLambdaExample
{
    public static class ExampleServiceBootstrapper
    {
        public static IExampleService CreateInstance()
        {
            var messageToReturn = LambdaConfiguration.Instance["MessageToReturn"];

            return new ExampleService(messageToReturn);
        }
    }
}

Be sure to only access the singleton in your bootstrapping code, since any code that accesses the static singleton property is untestable.