Skip to main content

Command Palette

Search for a command to run...

The Hidden Architecture Inside Your Program.cs File

Updated
10 min read
The Hidden Architecture Inside Your Program.cs File
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

Program.cs looks harmless because it usually starts as a few lines of setup code. Create the builder. Register some services. Add authentication. Map the endpoints. Run the app. That makes it easy to treat the file as plumbing. In a small application, thats fine. In a serious .NET system, Program.cs is one of the first places where architecture becomes real. It decides how requests enter the system, which dependencies exist, which crosscutting rules apply, how modules are wired, how failures are exposed, and what the outside world is allowed to call. A lot of architecture diagrams skip this file. Production doesnt.

Startup code is where design meets runtime behaviour

Modern ASP.NET Core made startup code feel smaller. Minimal hosting removed a lot of ceremony and gave us a single, direct place to build the app. That was a good move. The problem is that less ceremony can make important decisions look less important.

This line is not just a setup detail.

builder.Services.AddAuthentication(); 

This line is not just a route registration.

app.MapGroup("/api/programs").MapProgramEndpoints();

This line is not just monitoring noise.

app.MapHealthChecks("/health");

Each one says something about the shape of the system. It says where identity is checked, where a module boundary starts, how the app reports its own health, and which behaviours sit outside the feature code. Thats architecture.

The request pipeline is a policy document

Middleware order is one of the easiest things to underestimate in ASP.NET Core. The code is short, but the behaviour isnt small. The order decides what happens first. Thats important in ways that only become obvious when something breaks. If forwarded headers run too late, the app may misunderstand the original scheme, host, or client IP behind a proxy. If authentication and authorisation are misplaced, endpoints can behave differently from what the team expects. If exception handling sits in the wrong place, some failures are captured cleanly while others leak out in strange ways. If rate limiting sits after expensive work, it protects the wrong thing.

A simplified pipeline.

The exact order depends on the app, but the point is the same. Program.cs defines the outer boundary of the request. The handler only receives the request after those decisions have already been made. Thats why reviewing only controllers, endpoints, handlers, and services can miss the real behaviour of the application.

Dependency injection registration tells you who owns what

The service collection is often treated as a long list of things the app needs.

builder.Services.AddScoped<IProgramService, ProgramService>();
builder.Services.AddScoped<IEmailParser, EmailParser>();
builder.Services.AddScoped<IClaimWorkflow, ClaimWorkflow>();
builder.Services.AddScoped<IClock, SystemClock>();
builder.Services.AddSingleton<IQueueWriter, QueueWriter>();
builder.Services.AddHostedService<DocumentWorker>();

At first, this looks like wiring. As the app grows, it becomes a map of ownership. A scoped service tells you something is request bound. A singleton tells you something lives for the lifetime of the app. A hosted service tells you the process does more than respond to HTTP. A typed HTTP client tells you the app depends on another system. A database context tells you where persistence enters the codebase. This is why DI lifetime mistakes are architectural mistakes, not just technical mistakes. Capturing a scoped dependency inside a singleton is not only a bug risk. It usually means the code has confused app level state with request level work. Registering every class behind an interface is not always good design either. Sometimes it just hides the real dependency graph behind a wall of names.

The registrations also reveal whether a modular monolith has actual module boundaries or just folders. If every module registers services into one shared soup, with generic helpers and cross module dependencies everywhere, the module structure is probably weaker than it looks.

A better Program.cs does not need to expose every class, but it should make the main boundaries visible.

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddApiDefaults(builder.Configuration)
    .AddObservability(builder.Configuration)
    .AddSecurity(builder.Configuration)
    .AddProgramModule(builder.Configuration)
    .AddDocumentIngressModule(builder.Configuration)
    .AddSubmissionModule(builder.Configuration);

var app = builder.Build();

app.UseApiDefaults();
app.UseSecurityBoundary();

app.MapHealthEndpoints();
app.MapProgramModule();
app.MapDocumentIngressModule();
app.MapSubmissionModule();

app.Run();

This is still startup code, but it tells a reader how the system is organised. There is a difference between hiding clutter and hiding design. Good extension methods reveal the shape of the system. Bad ones just move the mess into another file.

Endpoint mapping shows your real API surface

In a Minimal API application, route mapping is where your public contract becomes visible. That contract is bigger than URL paths. It includes versioning, tags, auth policies, filters, request models, response types, OpenAPI metadata, endpoint groups, and module ownership. If all of that lives in one giant Program.cs, the file becomes unreadable. If it disappears into vague methods like MapEndpoints(), the contract becomes hard to review.

There is a useful middle ground.

app.MapGroup("/api/programs")
    .RequireAuthorization("Programs.ReadWrite")
    .WithTags("Programs")
    .MapProgramEndpoints();

app.MapGroup("/api/submissions")
    .RequireAuthorization("Submissions.ReadWrite")
    .WithTags("Submissions")
    .MapSubmissionEndpoints();

This tells you something important at the composition layer. Programs and submissions are separate route groups. They have separate policies. They are mapped as separate modules. You can still put the detailed endpoint definitions inside each module, but the application boundary remains readable.

That boundary deserves review. Adding a new route is rarely just adding a method. It may create a new public contract, a new permission surface, a new audit requirement, a new rate limit concern, or a new versioning problem.

Program.cs is where those concerns either become explicit or get forgotten.

Cross cutting logic needs a clear home

Every .NET application ends up with logic that does not belong cleanly inside one feature. You can put some of this in middleware. You can put some of it in endpoint filters. You can put some of it in base classes or helper methods, although that usually ages badly. The problem starts when the team has no rule for where these behaviours live.

One endpoint validates through a filter. Another validates inside the handler. Another uses FluentValidation manually. Another relies on database constraints. One endpoint maps domain errors to ProblemDetails. Another throws an exception. Another returns null and lets someone else deal with it. The result is a system that is technically working but difficult to reason about. Program.cs will not contain all of that logic, but it should show which layers exist. If theres an API-wide exception strategy, you should see it. If there is a security boundary, you should see it. If endpoint filters are part of the design, the mapping should make that obvious. If tenant resolution is mandatory, it should not depend on each handler remembering to call a helper.

Cross cutting logic becomes safer when the application boundary enforces it consistently.

Health checks are architecture too

A health endpoint can be one of the most misleading parts of a .NET app.

app.MapHealthChecks("/health");

That line can mean many different things.

It might mean the process is alive. It might mean the database is reachable. It might mean the queue is reachable. It might mean the app can serve real traffic. It might mean almost nothing. This becomes important when the app runs in containers, Kubernetes, Azure App Service, or behind a load balancer. A liveness check and a readiness check are different promises. One says the process is still running. The other says the app is ready to receive traffic. A worker-heavy app may need another view again, because HTTP can be healthy while the background queue is completely stuck.

The design should be visible.

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

The exact implementation can vary, but the intent should not be vague. Health checks are operational contracts. If they lie, the platform will make bad decisions on your behalf.

Background services change what the process is

A Web API that only handles HTTP requests is one kind of system. A Web API that also runs background workers is a different kind of system.

builder.Services.AddHostedService<SubmissionQueueWorker>();
builder.Services.AddHostedService<DocumentExtractionWorker>();

Those two lines change the meaning of the application. Now the process owns asynchronous work. It may hold queue leases. It may process retries. It may write to the database outside a request. It may need graceful shutdown. It may need scoped services created manually per work item. It may need separate health checks. It may need different scaling rules from the HTTP side of the app.

This doesnt mean background services are wrong. They are often a good choice. But they are not just another service registration. When a process hosts both API endpoints and workers, Program.cs becomes the place where that decision is visible. If the API can scale horizontally but the worker must be singleton like, thats an architectural tension. If the worker depends on the same database connection pool as the API, that is another one. If the app shuts down while work is half finished, that needs a deliberate design.

Hosted services are small lines of code with large operational meaning.

Configuration binding is where policy enters the app

Configuration.

builder.Services.Configure<PaymentOptions>(
    builder.Configuration.GetSection("Payments"));

But configuration is where runtime policy enters the system. Various thresholds all decide how the app behaves without changing code. Thats powerful. Its also risky. A codebase can look stable while configuration changes the real production behaviour. One environment has a 30-second timeout. Another has 2 minutes. One has retries enabled. Another does not. One uses a real provider. Another uses a stub. One has a feature flag permanently on because nobody knows who owns it.

Program.cs should make critical configuration explicit and validated.

builder.Services
    .AddOptions<DocumentIngressOptions>()
    .Bind(builder.Configuration.GetSection("DocumentIngress"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Failing fast at startup is usually better than discovering a broken setting halfway through a production workflow.

The file should be simple, but not invisible

A good Program.cs should not be clever. It should not contain business logic. It should not become a thousand line dumping ground. It should not hide the whole application behind one magical AddEverything() call either. The sweet spot is simple composition with visible boundaries. When I review a serious .NET app, I want to understand a few things quickly. How does the request enter the system? Where is the security boundary? How are modules mapped? What cross cutting behaviours are guaranteed? Which dependencies are app wide? Which background processes run in this host? What does healthy mean? Which configuration is validated at startup?

If those answers are hard to find, the system probably has hidden architecture. And hidden architecture is expensive. It makes code review weaker. It makes onboarding slower. It makes production incidents harder to diagnose. It lets important decisions drift because nobody sees them as decisions anymore.

Treat Program.cs as an architectural review point

The practical fix is simple, review Program.cs like you review database migrations, public API contracts, authentication changes, and deployment configuration. Everything deserves attention. This does not need a heavy process for every tiny edit. It needs the team to stop pretending startup code is neutral.

In ASP.NET Core, Program.cs is where the application is assembled. That means it is also where many of the real architectural choices are made. Keep it small. Keep it readable. Keep the boundaries visible. Because when production traffic arrives, it does not care about your architecture diagram. It runs through your pipeline.