.NET Configuration Architecture: Getting Started
Juggling AppSettings, Environment Variables, and ...
.NET apps nowadays come out of the box with a robust set of configuration sources. A newly scaffolded ASP .NET app reads configuration from JSON files (appsettings.json
), environment variables, and command-line arguments. Despite this and partially because of it, maintaining a clean configuration architecture becomes a challenge. Let's walk through one particularly common architectural puzzle and try to find a solution for it.
Database and AppSettings
Let's say we are developing an ASP .NET application. We've just scaffolded it via dotnet new web --name Confitecture
. Now we want to connect it to a database, so we need to put our connection string somewhere. Obviously, we can't put it straight in code, as we'll have different databases in development and production. A brief look at the folder structure hints that appsettings.json
and appsettings.Development.json
can easily handle the use case. Indeed, if we'll add in appsettings.json
{
"ConnectionStrings": {
"Db" : "ProductionDbConnectionString"
},
//
}
and in appsettings.Development.json
:
{
"ConnectionStrings": {
"Db" : "DevelopmentDbConnectionString"
},
//
}
and then use that connection string:
var dbConnectionString = app.Configuration.GetConnectionString("Db");
app.Logger.LogInformation("Db Connection string: {dbConnectionString}", dbConnectionString);
We'll be able to get the different connection strings depending on the environment we run in:
dotnet run --environment=Development # logs `Db Connection string: DevelopmentDbConnectionString`
dotnet run --environment=Production # logs `Db Connection string: ProductionDbConnectionString`
However, there are a few problems with this approach:
- It's not secure to store connection strings in the code, since any developer will have easy access to the production database password.
- It's entirely possible that there will be a ton of environments. In fact, in my practice, I've seen repositories having
appsettings.Local.json
,appsettings.Development.json
,appsettings.Local.json
,appsettings.QA1.json
,appsettings.QA2.json
,appsettings.Staging.json
,appsettings.json
files in every single project. Of course, that resulted in a lot of duplications and general maintenance mess. - Since
appsetting.json
serves as both production and default configuration source, forgetting to override something may result in connecting and potentially modifying something on production during debugging. - Blurs the list of configuration values that need to be specified when configuring a new environment. Sometimes resulting in false-positive configuration as in point #3.
So, how about we find something better?
Environment Variables for Connectivity
Well, we have two great configuration sources left: Environment Variable and Command Line arguments. In a sense, they both provide very similar experiences: they are externalized and are natively supported by virtually every CI system. Probably the main argument for environment variables is that they provide easier maintenance when their number grows. Let's prototype how the Environment Variable will be used in our app.
To "keep it real" let's now actually use our connection string to connect to a database. First, let's add EF Core with Postgres:
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
And update our Program.cs
to connect to the database:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<DbContext>(
o => o.UseNpgsql(builder.Configuration.GetConnectionString("Db"))
);
var app = builder.Build();
app.MapGet("/", async (DbContext context) => {
await context.Database.OpenConnectionAsync();
return "Connected!";
});
app.Run();
Perhaps, the first thing a nice repository must do is to provide a way to deploy it locally. In our case, it would imply providing a simple way to deploy the PostgreSQL database with our App connected to it. Nowadays, docker compose is the most popular and simple way to achieve that. First, we'll need a Dockerfile
:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
EXPOSE 8080
WORKDIR /app
COPY . .
RUN dotnet publish "Confitecture.csproj" -c Release -o /app/publish
WORKDIR /app/publish
ENTRYPOINT dotnet Confitecture.dll
And then the compose.yml
:
name: confitecture
services:
app:
image: confitecture
ports:
- "53593:8080"
build: .
profiles: [ "full" ]
environment:
- CONNECTIONSTRINGS__DB=Host=db;Port=5432;Username=postgres;Password=postgres
- ASPNETCORE_ENVIRONMENT=Development
db:
image: postgres
environment:
- POSTGRES_PASSWORD=postgres
ports:
- "5432:5432"
With the setup if we'll deploy our service, wait for one second and call our endpoint:
docker compose up -d && sleep 1 && curl localhost:53593
We'll get the Connected!
response! Notice the - CONNECTIONSTRINGS__DB=Host=db;Port=5432;Username=postgres;Password=postgres
. That's how we propagated environment variables from docker to the .NET app.
💪 The cool thing about it is that we don't leak any docker-specific detail (like the docker internal network domain name
db
).
Onboard Developers with LaunchSettings
One thing we've lost when we moved from AppSettings to Environment Variables is the ability of a developer to run the app for debugging, by just using dotnet run
. Let's get this back! The first thing we need to do is to provide developers with a simple way to deploy just the infrastructure services. This can be achieved by docker compose service profiles. If we add profile full
to the app service, like this:
name: confitecture
services:
app:
# ...
profiles: [ "full" ]
db:
# ...
The app
service will be run only if the profile is specified in the docker compose command. And profile-less db
will be run all the time. Let's check it by first killing all the services:
docker compose --profile full down
And then starting just the db
service.
docker compose up -d
With the PostgreSQL now deployed on the localhost
, all we need to do is to find a place where we can place the localhost
connection so that it is used only when a developer runs the app locally e.g. for debugging. launchSettings.json
serves exactly this purpose. So, if we update the content of the file to:
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"Local": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:53593",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"CONNECTIONSTRINGS__DB": "Host=localhost;Port=5432;Username=postgres;Password=postgres"
},
}
}
}
A developer will be able to just send dotnet run
command (which will use the only existing launch profile) to get up and running with our application. In most cases, you'll probably start development from the launchSettings
instead of the docker files. When you'll get the launchSettings
profile working it will also identify which environment variables you'll have to specify in the docker-compose file.
☝️ The main argument for using
environmentVariables
overcommandLineArgs
(whichlaunchSettings
also provides) is, in my taste, that environment variables have a few peculiarities. You may already notice the double underscore (__
) in the variable name in the docker compose. SolaunchSettings
provides the quickest way to catch possible configuration errors.
📚 I talk about the environment variable nuances in depth in the dedicated article.
What's Next?
If appsettings.json
bears so many problems and we have a valid alternative to it, is it even needed? It is! I'll describe why in the next article. For now, you can check out the example source code on the github.