Project Structure
Last modified on Tue 28 Nov 2023

A good project structure makes finding their way around the code easier for developers, especially for the ones who are new to the project. This can be directly translated into quick onboarding times and effortless additions of new features or replacements/fixes of existing ones.

The implementation should be separated from the contract. For example, we can have multiple data sources but the services using them should only be aware of the contract and not the implementation. In case we have multiple data sources, each source should be implemented in a separate project

All the projects inside a solution are separated into two folders (both solution folder and in the file structure), Src and Test. The former contains all the source code for our app, while the latter contains test projects for each Src project.

This is an example of a project structure that is mostly used for standard .NET Core WebAPIs:

All the projects that are not used as a "presentation" layer should be implemented as .NET Standard projects. The rest depends on the needs, but in our example, we would use .NET (former .NET Core) for our API.

Test projects

Regardless of the test framework, we try to keep a simple test project structure. For each app project, we create additional test projects. We use simple naming strategies for the projects, only adding the suffix ".Test" for the test project. For example, Example.API would be Example.API.Test.

You can find more information about app testing in the Testing section of the handbook.

Configurations

Up until .NET 6, the entry point for adding all configurations and registrations was in the Startup class. With .NET 6 came a simplified version of application configuration - instead of having separate classes for configuring and running the API, all that code can now be found in Program.cs. Even though we could continue the trend by adding all our registrations and configurations there, we prefer placing them in extension methods for IServiceCollection and ApplicationBuilder.

namespace Example.Services.Configuration;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection ConfigureExampleServices(this IServiceCollection services)
    {
        services.AddScoped<IExampleService, ExampleService>();
        ...

        return services;
    }
}

After that, we can simply call the methods from our Program.cs:

using Example.Services.Configuration;

...

builder.Services.ConfigureExampleServices();

These extension methods should be placed in the projects which contain the code that the method configures. For example, methods for registering all our services to DI container should be placed in Example.Services project, while configuring database-related code should be placed inside Example.Data.Db.

If we have direct project dependencies within our solution, we can add the dependent configuration method call inside another configuration method. For example, if our services project depends on some interfaces from Example.Data.AzureStorage, then we can call that configuration method from the Services project's configuration method:

using Example.Data.AzureStorage.Configuration;

namespace Example.Services.Configuration;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection ConfigureExampleServices(this IServiceCollection services)
    {
        ...
        // Calling the configuration method for Example.Data.AzureStorage:
        services.AddAzureDataServices();
        ...

        return services;
    }
}

This way we ensure that all the required services are registered with just one method call.

If you want to know more about managing configurations, you can check out our Configuration section.