Learn / .NET API Engineering Path
Project Structure and Dependency Injection
Split API, application, and infrastructure responsibilities so dependency direction stays reviewable from the first feature.
Course: .NET API Engineering Path. Level: Advanced. Topic: Typed backend.
Stage: basic - Basic foundation - Project structure and dependency direction. Separate API, application, and infrastructure projects so composition stays explicit from the first feature.
Outcomes
- Separate endpoint, application, and infrastructure projects.
- Register services at the composition root.
- Keep framework types out of core use cases.
- Create projects and solutions with dotnet new.
- Read the most important parts of an SDK-style csproj file.
- Understand restore, build, run, test, and publish.
- Register services with appropriate lifetimes.
- Explain middleware order in the request pipeline.
- Bind configuration to options and validate settings.
- Compare layered architecture and vertical slice architecture.
- Place business rules, persistence, and HTTP concerns in appropriate layers.
- Use architecture patterns without turning them into ceremony.
Concepts
- composition root
- service lifetime
- project reference
- interface boundary
- SDK, CLI, and Project Structure
- The project file is the build contract
- The CLI development loop
- Modern C# and .NET foundations
- Guided practice
- Dependency Injection, Middleware, and Configuration
- Dependency injection lifetimes
- Middleware order matters
- Options for configuration
- Architecture Patterns
- Layered architecture
- Vertical slices
Concept flow
Show how project structure and dependency direction moves from trigger to implementation outcome in .NET APIs.
- API project
- Application use case
- Infrastructure adapter
- Options
- Dependency container
Session flow
- Model composition root (concept, 10 min) — Name the decisions behind composition root before writing code.
- Separate endpoint, application, and infrastructure projects.
- Explain where composition root belongs in order management API.
- Build the vertical slice (walkthrough, 17 min) — Implement the smallest useful slice in Orders.Application/CreateOrderHandler.cs.
- Register services at the composition root.
- Connect service lifetime to a working example.
- Verify and harden (exercise, 11 min) — Document which project owns each dependency.
- Keep framework types out of core use cases.
- Record one risk or follow-up before moving on.
- Projects, Solutions, and the CLI: The project file is the build contract (concept, 35 min) — SDK-style project files are intentionally small. The SDK supplies defaults for source file inclusion, language version, target framework, analyzers, and output paths. You add only the differences: target frameworks, nullable settings, package references, project references, and publish options.
- Retained source example: Typical console project file
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
The target framework controls the APIs and runtime you build for. Nullable enables compiler analysis for reference types. Implicit usings reduce repeated using directives in common project types.
- Projects, Solutions, and the CLI: The CLI development loop (walkthrough, 35 min) — The dotnet CLI wraps common MSBuild and test operations. Restore downloads packages. Build compiles. Run builds and starts an executable project. Test runs test projects. Publish prepares output for deployment.
- Retained source example: Create a small solution
dotnet new sln -n Catalog
dotnet new webapi -n Catalog.Api
dotnet new xunit -n Catalog.Tests
dotnet sln add Catalog.Api/Catalog.Api.csproj
dotnet sln add Catalog.Tests/Catalog.Tests.csproj
dotnet add Catalog.Tests/Catalog.Tests.csproj reference Catalog.Api/Catalog.Api.csproj
dotnet test
This creates a deployable API project and a separate test project that references it. Keeping tests separate avoids shipping test-only packages with production code.
- Projects, Solutions, and the CLI: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Keep project files small and explicit; avoid copying generated build output into source control.
- Practice: Use separate projects for application, domain, infrastructure, and tests when boundaries are useful.
- Practice: Pin package versions deliberately and review transitive dependencies during upgrades.
- Avoid: Editing generated obj or bin files instead of source and project files.
- Avoid: Adding circular project references, which usually signals unclear architecture.
- Avoid: Using publish as the first build check instead of running tests before packaging.
- Projects, Solutions, and the CLI: references (review, 2 min) — Original references retained from the legacy library.
- .NET CLI overview: https://learn.microsoft.com/dotnet/core/tools/
- MSBuild project SDKs: https://learn.microsoft.com/visualstudio/msbuild/how-to-use-project-sdk
- Composition Root, Middleware Pipeline, and Options: Dependency injection lifetimes (concept, 30 min) — ASP.NET Core has a built-in DI container. Singleton services live for the application lifetime, scoped services live for one request scope, and transient services are created each time they are requested. Most application services are scoped or transient; expensive shared infrastructure may be singleton if it is thread-safe.
- Retained source example: Service registration
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddHttpClient<PaymentsClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!);
});
The typed HttpClient registration uses IHttpClientFactory, which manages handlers and avoids socket exhaustion.
- Composition Root, Middleware Pipeline, and Options: Middleware order matters (walkthrough, 30 min) — Middleware forms a chain. Each component can inspect the request, call the next component, and inspect the response. Routing, authentication, authorization, exception handling, static files, and endpoint execution depend on order.
- Retained source example: Common pipeline shape
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapProductEndpoints();
Authorization must run after authentication. Endpoint mapping usually appears after shared middleware.
- Composition Root, Middleware Pipeline, and Options: Options for configuration (walkthrough, 30 min) — Configuration can come from JSON files, environment variables, command-line arguments, secret stores, and cloud providers. Bind related settings into options classes and validate them at startup.
- Retained source example: Validated options
builder.Services
.AddOptions<PaymentsOptions>()
.Bind(builder.Configuration.GetSection("Payments"))
.Validate(options => Uri.IsWellFormedUriString(options.BaseUrl, UriKind.Absolute), "BaseUrl must be absolute")
.ValidateOnStart();
public sealed class PaymentsOptions
{
public string BaseUrl { get; init; } = "";
}
ValidateOnStart fails fast instead of letting invalid configuration surface during the first request.
- Composition Root, Middleware Pipeline, and Options: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Avoid resolving scoped services from singletons.
- Practice: Prefer typed HttpClient registrations for outbound HTTP dependencies.
- Practice: Validate required configuration at startup.
- Practice: Log structured values rather than concatenated strings.
- Avoid: Registering stateful, non-thread-safe services as singleton.
- Avoid: Putting middleware in an order that bypasses authentication or exception handling.
- Avoid: Reading configuration directly throughout the codebase instead of using options.
- Composition Root, Middleware Pipeline, and Options: references (review, 2 min) — Original references retained from the legacy library.
- Dependency injection in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/dependency-injection
- ASP.NET Core middleware: https://learn.microsoft.com/aspnet/core/fundamentals/middleware/
- Options pattern: https://learn.microsoft.com/aspnet/core/fundamentals/configuration/options
- Boundaries, Patterns, and Tradeoffs: Layered architecture (concept, 48 min) — A common .NET backend solution has an API layer, application layer, domain layer, infrastructure layer, and tests. The API handles HTTP. The application layer coordinates use cases. The domain layer owns business rules. Infrastructure talks to databases, queues, files, and external services.
- Retained source example: Solution layout
Catalog.Api/
Catalog.Application/
Catalog.Domain/
Catalog.Infrastructure/
Catalog.Tests/
Project references should point inward toward stable business rules, not outward toward web or database details.
- Boundaries, Patterns, and Tradeoffs: Vertical slices (walkthrough, 48 min) — Vertical slice architecture groups code by feature instead of technical layer. A Products feature might contain its endpoint, request DTO, validation, handler, and query in one folder. This can reduce jumping across layers for small to medium services.
- Retained source example: Feature handler shape
public sealed record CreateProductCommand(string Name, decimal Price);
public sealed class CreateProductHandler(CatalogDbContext db)
{
public async Task<Guid> HandleAsync(CreateProductCommand command, CancellationToken cancellationToken)
{
var product = new Product { Id = Guid.NewGuid(), Name = command.Name, Price = command.Price };
db.Products.Add(product);
await db.SaveChangesAsync(cancellationToken);
return product.Id;
}
}
A handler can represent one use case. Keep it small and avoid hiding simple code behind unnecessary indirection.
- Boundaries, Patterns, and Tradeoffs: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Start simple and add layers or patterns when they solve real coordination problems.
- Practice: Keep business rules independent from HTTP and database frameworks where practical.
- Practice: Use domain events, outbox patterns, or queues for reliable cross-boundary side effects when needed.
- Practice: Make dependency direction intentional and enforce it with project references.
- Avoid: Copying enterprise patterns into a small CRUD service without a payoff.
- Avoid: Letting the API layer become the place where all business rules accumulate.
- Avoid: Using MediatR or CQRS terminology while still sharing one large mutable model everywhere.
- Boundaries, Patterns, and Tradeoffs: references (review, 2 min) — Original references retained from the legacy library.
- ASP.NET Core architecture guidance: https://learn.microsoft.com/dotnet/architecture/modern-web-apps-azure/
- .NET microservices architecture: https://learn.microsoft.com/dotnet/architecture/microservices/
Code example
C# in Orders.Application/CreateOrderHandler.cs.
public sealed class CreateOrderHandler(IOrderRepository orders, TimeProvider clock)
{
public async Task<Guid> HandleAsync(CreateOrder command, CancellationToken ct)
{
var order = Order.Create(command.CustomerId, command.TotalCents, clock.GetUtcNow());
await orders.SaveAsync(order, ct);
return order.Id;
}
}
Walkthrough examples
- Project Structure and Dependency Injection in a order management API — A team is extending a production-grade ASP.NET Core order API and needs this lesson's pattern to be clear enough for review, testing, and future maintenance.
- File: Orders.Application/CreateOrderHandler.cs
- File: src/Orders.Application/project-structure-and-dependency-injection.cs
- File: tests/Orders.Api.Tests/project-structure-and-dependency-injection.Tests.cs
- File: docs/dotnet-advanced/project-structure-and-dependency-injection.md
- Start from the provided C# snippet and make the intent visible in names and boundaries.
- Apply the checklist item "API project references application" before adding extra behavior.
- Write down how the implementation changes when service lifetime fails or becomes slow.
- Typical console project file — The target framework controls the APIs and runtime you build for. Nullable enables compiler analysis for reference types. Implicit usings reduce repeated using directives in common project types.
- Retained source code:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Create a small solution — This creates a deployable API project and a separate test project that references it. Keeping tests separate avoids shipping test-only packages with production code.
- Retained source code:
dotnet new sln -n Catalog
dotnet new webapi -n Catalog.Api
dotnet new xunit -n Catalog.Tests
dotnet sln add Catalog.Api/Catalog.Api.csproj
dotnet sln add Catalog.Tests/Catalog.Tests.csproj
dotnet add Catalog.Tests/Catalog.Tests.csproj reference Catalog.Api/Catalog.Api.csproj
dotnet test
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Package and project references — Package references pull code from NuGet. Project references connect projects in the same solution and allow the compiler to enforce boundaries.
- Retained source code:
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<ProjectReference Include="..\Catalog.Core\Catalog.Core.csproj" />
</ItemGroup>
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Service registration — The typed HttpClient registration uses IHttpClientFactory, which manages handlers and avoids socket exhaustion.
- Retained source code:
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddHttpClient<PaymentsClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!);
});
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Common pipeline shape — Authorization must run after authentication. Endpoint mapping usually appears after shared middleware.
- Retained source code:
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapProductEndpoints();
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Validated options — ValidateOnStart fails fast instead of letting invalid configuration surface during the first request.
- Retained source code:
builder.Services
.AddOptions<PaymentsOptions>()
.Bind(builder.Configuration.GetSection("Payments"))
.Validate(options => Uri.IsWellFormedUriString(options.BaseUrl, UriKind.Absolute), "BaseUrl must be absolute")
.ValidateOnStart();
public sealed class PaymentsOptions
{
public string BaseUrl { get; init; } = "";
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- appsettings.json fragment — Environment-specific files and environment variables can override these values.
- Retained source code:
{
"Payments": {
"BaseUrl": "https://payments.example.test"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Solution layout — Project references should point inward toward stable business rules, not outward toward web or database details.
- Retained source code:
Catalog.Api/
Catalog.Application/
Catalog.Domain/
Catalog.Infrastructure/
Catalog.Tests/
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Feature handler shape — A handler can represent one use case. Keep it small and avoid hiding simple code behind unnecessary indirection.
- Retained source code:
public sealed record CreateProductCommand(string Name, decimal Price);
public sealed class CreateProductHandler(CatalogDbContext db)
{
public async Task<Guid> HandleAsync(CreateProductCommand command, CancellationToken cancellationToken)
{
var product = new Product { Id = Guid.NewGuid(), Name = command.Name, Price = command.Price };
db.Products.Add(product);
await db.SaveChangesAsync(cancellationToken);
return product.Id;
}
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Background service skeleton — Background services need scopes for scoped dependencies and must honor cancellation during shutdown.
- Retained source code:
public sealed class OutboxDispatcher(IServiceScopeFactory scopeFactory, ILogger<OutboxDispatcher> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = scopeFactory.CreateScope();
logger.LogInformation("Dispatching outbox messages");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
Practice
- Move one use case out of Program.cs.
- Register one interface and implementation with a deliberate lifetime.
- Document which project owns each dependency.
- Order the CLI workflow: Place restore, build, test, and publish in the normal verification order for a release candidate.
- Starter code: 1. ?
2. ?
3. ?
4. ?
- Expected output: restore, build, test, publish
- Hint: Publishing should happen after compilation and tests have passed.
- Reference solution: A typical order is restore, build, test, then publish.
- Accepted answers: restore build test publish | restore, build, test, publish
- Pick a lifetime: An EF Core DbContext used per HTTP request should normally be registered with which lifetime?
- Starter code: lifetime = ____
- Expected output: scoped
- Hint: It should be shared inside one request but not across all requests.
- Reference solution: Use scoped lifetime, which is the default for AddDbContext.
- Accepted answers: scoped | AddScoped
- Place the rule: A rule says an order cannot ship before payment is captured. Should this live primarily in the API endpoint, domain/application logic, or EF migration?
- Starter code: location = ____
- Expected output: domain/application logic
- Hint: It is a business rule, not an HTTP or schema concern.
- Reference solution: Put the rule in domain/application logic so every entry point observes it.
- Accepted answers: domain | application | domain/application logic
Checklist
- API project references application
- Application avoids ASP.NET Core types
- Infrastructure implements ports
- Service lifetimes are named in review
Quiz prompts
- Where should framework-specific ASP.NET Core endpoint types usually live? — Keeping framework types at the edge protects application logic from HTTP and hosting churn.
- A teammate wants to hide composition root inside a convenient helper. What should you check first? — Place composition root at the boundary that keeps order management API behavior explicit, testable, and reviewable.
- Which artifact best proves this .NET APIs lesson is ready for review? — Production-ready learning needs evidence: a test, trace, command, screenshot, or log that catches the risk again.
- Basic foundation: a teammate says the happy path works, but "Composition root" is still implicit. What should you ask for before merging? — Composition root belongs in the basic stage only when the decision is visible, testable, and tied to a realistic failure mode.
- A reviewer has five minutes to evaluate this basic .NET APIs slice. Which evidence is strongest? — Build a small CreateOrder slice with API registration, an application handler, an infrastructure repository, and a test proving the use case runs without ASP.NET Core.
- What does <Nullable>enable</Nullable> do? — Nullable reference types are a compile-time analysis feature for reference values that might be null.
- What is a composition root? — In ASP.NET Core, Program.cs and extension methods called from it commonly form the composition root.
- What is a key architecture smell? — Business rules should not depend on transport-specific web details.
Flashcards
- Basic foundation: what decision does "Composition root" force you to make? Register concrete infrastructure at the API edge while application use cases depend on interfaces. Evidence prompt: Move one service registration into the API composition root and name the chosen lifetime.
- Basic foundation: what decision does "Application boundary" force you to make? Keep framework types out of core use cases so the business flow can be tested without ASP.NET hosting. Evidence prompt: Refactor one handler to accept a command and repository port instead of HttpContext.
- Basic foundation: what decision does "Dependency direction" force you to make? Make project references point inward from API and infrastructure toward the application contracts. Evidence prompt: Draw the project references and remove one dependency that points back toward the API project.
- In .NET APIs, what should you remember about composition root? composition root matters here because it supports "Separate endpoint, application, and infrastructure projects.".
- In .NET APIs, what should you remember about service lifetime? service lifetime matters here because it supports "Register services at the composition root.".
- In .NET APIs, what should you remember about project reference? project reference matters here because it supports "Keep framework types out of core use cases.".
- In .NET APIs, what should you remember about interface boundary? interface boundary matters here because it supports "Separate endpoint, application, and infrastructure projects.".
Labs
- Ship a project structure and dependency injection slice — Extend a production-grade ASP.NET Core order API with a small but reviewable feature that proves the lesson's architecture in code.
- Build a small CreateOrder slice with API registration, an application handler, an infrastructure repository, and a test proving the use case runs without ASP.NET Core.
- Move one service registration into the API composition root and name the chosen lifetime.
- Refactor one handler to accept a command and repository port instead of HttpContext.
- Draw the project references and remove one dependency that points back toward the API project.
- Move one use case out of Program.cs.
- Register one interface and implementation with a deliberate lifetime.
- The lab demonstrates the basic foundation outcome without skipping earlier contract evidence.
- Each subtopic has a concrete artifact: Composition root, Application boundary, Dependency direction.
- The review notes explain how this level changes ownership, verification, or operational risk.
Challenge
- Review-ready project structure and dependency injection (Core) — Build a small CreateOrder slice with API registration, an application handler, an infrastructure repository, and a test proving the use case runs without ASP.NET Core.
- All checklist items are either implemented or documented with a reason.
- The change can be understood from Orders.Application/CreateOrderHandler.cs plus one short note.
- The concept diagram names ownership, failure handling, and verification points.
- A teammate could run the verification steps without asking for hidden context.
Canonical lesson URL