Project Structure and Startup
By the end of this lesson
Read a project's startup code and know where each concern is configured.
A new web API project holds fewer files than most people expect. There is a project file, a startup file, two settings files, and whatever code you add.
Read the startup file properly once and the rest of the framework stops feeling arbitrary. It is the only place that decides what your application is made of and what every request passes through.
# A controller-based Web API project
dotnet new webapi -n EmployeeDirectory.Api --use-controllers
cd EmployeeDirectory.Api
# Restore packages, compile, and start Kestrel
dotnet run
# What the project depends on, resolved versions included
dotnet list package- The template defaults to minimal APIs, so --use-controllers is what asks for a Controllers folder. Both styles are covered later in this course.
- dotnet run restores, builds and starts the application, printing the URLs it is listening on. Stop it with Ctrl+C.
- dotnet list package is the honest answer to what this project depends on, including versions brought in indirectly.
What each file in the project is for:
- EmployeeDirectory.Api.csproj
- The project file: target framework, language settings and package references. Installing a package writes a line here, so this file is the dependency list.
- Program.cs
- Startup. Service registrations and the request pipeline. Everything else in the project is reached from here, directly or indirectly.
- appsettings.json
- Settings that are the same in every environment. Committed to source control.
- appsettings.Development.json
- Settings that apply only when the environment is Development. Overrides the file above, key by key, rather than replacing it.
- Properties/launchSettings.json
- Local run profiles: URLs and environment variables used when you start the app from an editor or with dotnet run. A developer convenience, and ignored in production.
- Controllers/
- Convention, not a rule. The framework finds controllers by inspecting types, not by reading folders. The folder exists so people can find them.
- bin/ and obj/
- Build output and intermediate files. Generated, never edited by hand, and kept out of source control.
using EmployeeDirectory.Api.Data;
using EmployeeDirectory.Api.Options;
var builder = WebApplication.CreateBuilder(args);
// ----- Builder phase: register what the application is made of -----
builder.Services.AddControllers();
builder.Services.AddScoped<IEmployeeRepository, SqlEmployeeRepository>();
builder.Services.Configure<DirectoryOptions>(
builder.Configuration.GetSection("EmployeeDirectory"));
var app = builder.Build();
// ----- App phase: build the pipeline requests travel through -----
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();- builder exposes three things you will reach for constantly: Services, the registration list; Configuration, the merged settings; and Environment, which environment this is.
- Everything in the builder phase answers one question: what can this application build? AddScoped records that a request for IEmployeeRepository should produce a SqlEmployeeRepository. No instance is created by that line.
- Configure binds a section of configuration to a class so settings arrive as a typed object instead of string lookups. The configuration lesson goes into the detail.
- Build draws the line between the phases. The service list becomes read-only, and the app it returns is what you add middleware to.
- The app phase answers a different question: what happens to a request? Each Use call appends a component to the pipeline, and MapControllers puts your endpoints at the end of it.
- The environment check shows why builder.Environment and app.Environment both exist. You often want different behaviour locally, and you can branch in either phase.
- Run starts Kestrel and blocks until shutdown.
The two-phase split is the thing most people miss when they first read this file, and nearly every confusing startup error traces back to it.
| Before Build — the builder phase | After Build — the app phase | |
|---|---|---|
| What you are describing | What the application is made of | What happens to each request |
| What you work with | builder.Services, builder.Configuration, builder.Environment | app, and app.Environment |
| Typical calls | AddControllers, AddScoped, AddDbContext, Configure of an options class | UseExceptionHandler, UseAuthentication, MapControllers |
| Does order matter? | Rarely. It is a list the container reads later, though registering the same service twice means the last one wins. | Always. Order is the order requests travel, so changing it changes behaviour. |
| How often does it run? | Once, at startup | Once to assemble the pipeline, then every request passes through what it assembled |
Summary
- A web API project is a project file, Program.cs, settings files and your own code
- Program.cs has two phases: registrations before Build, and the request pipeline after it
- builder gives you Services, Configuration and Environment; app gives you the pipeline
- The service list is frozen at Build, which is why registrations cannot be added afterwards
- Folders such as Controllers are convention; the framework discovers types, not directories
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Break the phase boundary on purpose
In a working project, move one AddScoped registration to below the Build line and run the application.
Read the exception carefully, then move the line back.
Show solution
The application fails at startup, not on the first request, and the message says the service collection cannot be modified after the application is built.
Failing at startup is the point worth noticing. A misconfiguration that surfaces immediately costs you one restart. The same problem surfacing on the first request that happens to need the service costs you a production incident and a confusing stack trace.
It also explains why registration and pipeline code cannot be interleaved for readability. The framework closes the door deliberately.
Think about it
Where does an environment decision belong?
You want to register a fake email sender when running locally and the real one everywhere else.
Which phase does that decision belong in, and what does your answer tell you about why builder.Environment exists?
Show solution
It belongs in the builder phase, because you are choosing what the application is made of. builder.Environment exists precisely so registration can branch on it before the container is sealed.
The deeper point is that this decision is made once. There is no way to choose a different email sender per request by branching here, because this code runs at startup. If a choice really does depend on the caller, it has to happen inside a request, usually behind an interface with one implementation that decides.
A practical warning: branching registrations by environment means your local wiring is not the wiring you deploy. Keep the differences few and obvious, or a bug will only exist in one of them.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.