AWS Compute Blog
Migrating a monolithic .NET REST API to AWS Lambda
This post is written by James Eastham, Cloud Infrastructure Architect.
There are many ways to deploy a .NET application to AWS. From a single process ASP.NET Core Web API hosted on an EC2 instance to a serverless API backed by AWS Lambda. This post explains key topics to simplify your move from monolith to serverless.
The .NET Framework launched in 2002. This means that there are years’ worth of existing .NET application code that can benefit from moving to a serverless architecture. With the release of the Porting Assistant for .NET and the AWS Microservices Extractor for .NET, AWS tooling can assist directly with this modernization.
These tools help modernization but don’t migrate the compute layer from traditional servers to serverless technology.
Hexagonal architecture
The hexagonal architecture pattern proposes the division of a system into loosely coupled and interchangeable components. The application and business logic sit at the core of the application.
The next layer up is a set of interfaces that handle bidirectional communication from the core business logic layer. Implementation details are moved to the outside. The inputs (API controllers, UI, consoles, test scripts) and outputs (database implementations, message bus interactions) are at the perimeter.
The chosen compute layer becomes an implementation detail, not a core part of the system. It allows a cleaner process for migrating any integrations, from the frontend, to the compute layer and underlying database engine.
Code examples
The GitHub repo contains the code examples from this post with instructions for deploying the migrated serverless application.
The repository contains a .NET Core REST API. It uses MySQL for its database engine and relies on an external API as part of its business logic. It also contains a migrated serverless version of the same application that you can deploy to your AWS account. This uses a combination of the AWS Cloud Development Kit (CDK) and the AWS Serverless Application Model (AWS SAM) CLI.
The architecture of the deployed monolithic application is:
After migrating the application to Lambda, the architecture is:
Integrations
Modern web applications rely on databases, file systems, and even other applications. With first class support for dependency injection in .NET Core, managing these integrations is simpler.
The following code snippet is taken from the BookingController.cs file. It shows how required interfaces are injected into the constructor of the controller. One of the controller methods uses the injected interface to list bookings from the BookingRepository.
[ApiController]
[Route("[controller]")]
public class BookingController : ControllerBase
{
private readonly ILogger<BookingController> _logger;
private readonly IBookingRepository _bookingRepository;
private readonly ICustomerService _customerService;
public BookingController(ILogger<BookingController> logger,
IBookingRepository bookingRepository,
ICustomerService customerService)
{
this._logger = logger;
this._bookingRepository = bookingRepository;
this._customerService = customerService;
}
/// <summary>
/// HTTP GET endpoint to list all bookings for a customer.
/// </summary>
/// <param name="customerId">The customer id to list for.</param>
/// <returns>All <see cref="Booking"/> for the given customer.</returns>
[HttpGet("customer/{customerId}")]
public async Task<IActionResult> ListForCustomer(string customerId)
{
this._logger.LogInformation($"Received request to list bookings for {customerId}");
return this.Ok(await this._bookingRepository.ListForCustomer(customerId));
}
}
The implementation of the IBookingRepository is configured at startup using dependency injection in the Startup.cs file.
services.AddTransient<IBookingRepository, BookingRepository>();
This works when using an ASP.NET Core Web API project, since the framework abstracts much of the complexity and configuration. But it’s possible to apply the same practices for .NET Core code running in Lambda.
Configuring dependency injection in AWS Lambda
The startup logic is moved to a standalone DotnetToLambda.Serverless.Config library. This allows you to share the dependency injection configuration between multiple Lambda functions. This library contains a single static class named ServerlessConfig.
There is little difference between this file and the Startup.cs file:
public void ConfigureServices(IServiceCollection services)
{
var databaseConnection =
new DatabaseConnection(this.Configuration.GetConnectionString("DatabaseConnection"));
services.AddSingleton<DatabaseConnection>(databaseConnection);
services.AddDbContext<BookingContext>(options =>
options.UseMySQL(databaseConnection.ToString()));
services.AddTransient<IBookingRepository, BookingRepository>();
services.AddHttpClient<ICustomerService, CustomerService>();
services.AddControllers();
}
And the configuration method in the ServerlessConfig class:
public static void ConfigureServices()
{
var client = new AmazonSecretsManagerClient();
var serviceCollection = new ServiceCollection();
var connectionDetails = LoadDatabaseSecret(client);
serviceCollection.AddDbContext<BookingContext>(options =>
options.UseMySQL(connectionDetails.ToString()));
serviceCollection.AddHttpClient<ICustomerService, CustomerService>();
serviceCollection.AddTransient<IBookingRepository, BookingRepository>();
serviceCollection.AddSingleton<DatabaseConnection>(connectionDetails);
serviceCollection.AddSingleton<IConfiguration>(LoadAppConfiguration());
serviceCollection.AddLogging(logging =>
{
logging.AddLambdaLogger();
logging.SetMinimumLevel(LogLevel.Debug);
});
Services = serviceCollection.BuildServiceProvider();
}
The key addition is the manual creation of the ServiceCollection object on line 27 and the call to BuildServiceProvider on line 45. In.NET Core the framework abstracts away this manual object initialization. The created ServiceProvider is then exposed as a read-only property of the ServerlessConfig class. All we have done is taken the boilerplate code that an ASP.NET Core Web API performs behind the scenes and brought it into the foreground.
This allows you to copy and paste large parts of the startup configuration directly from the web API and re-use it in your Lambda functions.
Lambda API controllers
For the function code, follow a similar process. For example, here is the ListForCustomer endpoint re-written for Lambda:
public class Function
{
private readonly IBookingRepository _bookingRepository;
private readonly ILogger<Function> _logger;
public Function()
{
ServerlessConfig.ConfigureServices();
this._bookingRepository = ServerlessConfig.Services.GetRequiredService<IBookingRepository>();
this._logger = ServerlessConfig.Services.GetRequiredService<ILogger<Function>>();
}
public async Task<APIGatewayProxyResponse> FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context)
{
if (!apigProxyEvent.PathParameters.ContainsKey("customerId"))
{
return new APIGatewayProxyResponse
{
StatusCode = 400,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
var customerId = apigProxyEvent.PathParameters["customerId"];
this._logger.LogInformation($"Received request to list bookings for: {customerId}");
var customerBookings = await this._bookingRepository.ListForCustomer(customerId);
return new APIGatewayProxyResponse
{
Body = JsonSerializer.Serialize(customerBookings),
StatusCode = 200,
Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
};
}
}
The function constructor calls the startup configuration. This allows the initial configuration to be re-used while the Lambda execution environment is still active. Once the services have been configured any required interfaces can be retrieved from the services property of the ServerlessConfig class.
The second key differences are the mapping of the inbound request and response back to API Gateway. The HTTP request arrives as an event and the contents must be manually parsed out of the raw HTTP data. The same applies to the HTTP response, which must be constructed manually. Other than these two differences, it’s a copy from the original BookingController.
Application configuration
An ASP.NET Core Web API contains an appsettings.json file, which contains runtime specific configuration. The framework handles loading the file and exposing it as an injectable IConfiguration interface. It’s also possible to load settings from environment variables.
This is still possible when using Lambda. You can package an appsettings.json file with the compiled code and load it manually at runtime. However, when using Lambda as the compute layer, there are AWS-specific options for managing configuration.
Environment variables
Lambda environment variables are used to add runtime configuration, as shown in the template.yaml file:
Environment:
Variables:
SERVICE: bookings
DATABASE_CONNECTION_SECRET_ID: !Ref SecretArn
This AWS SAM configuration adds an environment variable named DATABASE_CONNECTION_SECRET_ID
. You can access this in Lambda the same way an environment variable is accessed in any C# application:
var databaseConnectionSecret = client.GetSecretValueAsync(new GetSecretValueRequest()
{
SecretId = Environment.GetEnvironmentVariable("DATABASE_CONNECTION_SECRET_ID"),
}).Result;
This is the simplest way to add runtime configuration. The variables are stored in plaintext and any change requires a redeployment or manual interaction.
External configuration services
AWS has services that allow you to move application configuration outside of the function code. These include AWS Systems Manager Parameter Store, AWS AppConfig and AWS Secrets Manager.
You can use Parameter Store to store plaintext parameters that can also be encrypted using the AWS Key Management Service. The contents of the appsettings.json file from the ASP.NET Core API is directly copied into the parameter string and deployed using the AWS CDK.
var parameter = new StringParameter(this, "dev-configuration", new StringParameterProps()
{
ParameterName = "dotnet-to-lambda-dev",
StringValue = "{\"CustomerApiEndpoint\": \"https://jsonplaceholder.typicode.com/users\"}",
DataType = ParameterDataType.TEXT,
Tier = ParameterTier.STANDARD,
Type = ParameterType.STRING,
Description = "Dev configuration for dotnet to lambda",
});
This JSON data is loaded as part of the startup configuration. The IConfiguration implementation is then built manually using the parameter string.
private static IConfiguration LoadAppConfiguration()
{
var client = new AmazonSimpleSystemsManagementClient();
var param = client.GetParameterAsync(new GetParameterRequest()
{
Name = "dotnet-to-lambda-dev"
}).Result;
return new ConfigurationBuilder()
.AddJsonStream(new MemoryStream(Encoding.ASCII.GetBytes(param.Parameter.Value)))
.Build();
The second configuration mechanism is Secrets Manager. This helps protect secrets and provides easier rotation and management of database credentials.
Amazon RDS is integrated with Secrets Manager. When creating a new RDS instance, the database connection details can be automatically encrypted and persisted as a secret. The details for the MySQL instance are stored in Secrets Manager and are not exposed. These connection details can be accessed as part of the startup configuration using the Secrets Manager SDK.
private static DatabaseConnection LoadDatabaseSecret(AmazonSecretsManagerClient client)
{
var databaseConnectionSecret = client.GetSecretValueAsync(new GetSecretValueRequest()
{
SecretId = Environment.GetEnvironmentVariable("DATABASE_CONNECTION_SECRET_ID"),
}).Result;
return JsonSerializer
.Deserialize<DatabaseConnection>(databaseConnectionSecret.SecretString);
}
The Lambda functions require IAM permissions to access both Secrets Manager and Parameter Store. AWS SAM includes pre-defined policy templates that you can add to the template. Four lines of YAML apply the required Secrets Manager and SSM permissions:
Policies:
- AWSSecretsManagerGetSecretValuePolicy:
SecretArn: !Ref SecretArn
- SSMParameterReadPolicy:
ParameterName: dotnet-to-lambda-dev
For a full list, see the policy template list.
Networking
The final architectural component is the network. Lambda functions are deployed into a VPC owned by the service. The function can access anything available on the public internet such as other AWS services, HTTPS endpoints for APIs, or services and endpoints outside AWS. The function then has no way to connect to your private resources inside of your VPC.
When deploying an RDS instance into AWS, it’s best practice to place the database in a private subnet with external ingress. If Lambda uses RDS, you must create a connection between the Lambda service VPC and your VPC. The details of this networking component can be found in this blog article.
The AWS SAM template defines this networking configuration:
VpcConfig:
SubnetIds:
- !Ref PrivateSubnet1
- !Ref PrivateSubnet2
SecurityGroupIds:
- !Ref SecurityGroup
In this example, the networking configuration is applied globally. This means that the same configuration is applied to all Lambda functions in the template. The functions here are deployed across two subnets and one security group. Learn more about the steps for configuring the subnets and security groups for RDS access in this article.
The specific values for the subnets and security groups are taken from environment variables. When running locally, you can provide these variables manually. When deploying via CICD, these variables can be changed dynamically based on the stage of the pipeline.
PrivateSubnet1:
Description: 'Required. Private subnet 1. Output from cdk deploy'
Type: 'String'
PrivateSubnet2:
Description: 'Required. Private subnet 2. Output from cdk deploy'
Type: 'String'
SecurityGroup:
Description: 'Required. Security group. Output from cdk deploy'
Type: 'String'
Conclusion
This blog post shows the required considerations for migrating a .NET Core REST API to AWS Lambda. You can now start to look at your existing code base and make an informed decision whether Lambda is for you. With the right abstractions and configuration, you can migrate a .NET Core API to Lambda compute with copy and paste.
For more serverless learning resources, visit Serverless Land.