Code example of a microservice following the proposed structure can be found on GitHub.

Disclaimer: This blog post is opinionated. This is one way of doing it. There is no single truth to this, and it relies on personal preference and experience. The structure described in this blog post is what I like best at the current point in time. But it's always subject to change and improvement, which it should be.

Disclaimer aside, I've witnessed a lot of different ways of structuring a project, and also how this can get messy over time. I firmly believe that some ways of structuring a project is better that others. You might not agree to my proposed structure, but at least I hope we agree on that a project needs a well-defined structure.

Why do we need a structure

  • Each developer working on the service needs to quickly understand where to find code, and where to put code when adding features.
  • Easier for new developers to get their head around the code, even after you've left the project.
  • Cleaner code, less errors, easier to understand the flow through the application.
  • Less documentation needed.

A project will typically consist of multiple microservices, and I would advocate that each service should share the same structure. This will enable developers to easily move between services and spend a minimum of time getting familiar with each service, and make the cross-team communication easier.

The microservice

I've created a microservice named WeatherForecast for this blog post, which will act as a demo and reference. This service will expose an API and also have messaging functionality, reading messages from Azure Service Bus.

Implementation:

  • Minimal API for API and Azure Function for retrieving messages. Two separate hosts, because they might need different scaling.
  • Focus on a bounded domain (DDD)
  • Entity Framework as ORM (not important)

The code part is not the important thing here, but I needed some code to show how the code is structured.

Naming the microservice

Try to avoid naming the service *Service. Rather give it a functional name. Deciding on a good, functional name for your service should make you think about what the service is supposed to do, and equally important - what it's not suppose to do. A good name helps to understand and preserve the scope of the service.

For instance, you create a service that sends emails using SendGrid or another third party vendor. Instead of naming it EmailService you could name it EmailSender, and everyone will understand what it does.

The top-level structure

In the source control (i.e. GitHub), I would name the folder containing the microservice Service.WeatherForecast. The prefix Service is used to distinguish the different types of projects, but this is a long story, and maybe food for another blog post in the future.

Within the abovementioned folder, the top-level project structure is based on David Fowlers proposed structure for a .NET app:

Image Description

  • src - Main projects (the product code)
  • tests - Test projects
  • scripts - IaC (bicep)++

This has become the de facto way of top-level structuring, and should be familiar for most. The reasoning behind it can be found following the link above. I will focus on what lies inside the src-folder.

Number of projects in a solution

A lot of solutions have a large amount of projects. This over-complicates dependencies, increases build time and makes it harder to keep NuGet packages in sync.

Keep the number of projects to an absolute minimum. Do not create a new project if you can manage without.

Instead of having a bunch of projects, use folders for structure and use transient dependencies to keep the .csproj-files clean.

For my demo microservice I have the following projects:

WeatherForecastFeatures, domain, database, gateways
WeatherForecast.ApiEndpoints *
WeatherForecast.MessagingMessage handlers *

* The Endpoints and MessageHandlers folders could be called Features as well, but I've given them other names to avoid confusion with the features provided by the main project.

API and Messaging are separate projects because I have two hosts. The API will be deployed as an app service and messaging as an Azure function. If I only had one host, for instance an API, I would only have one project, where the API endpoints would reside in the relevant feature folder.

The API and Messaging projects are the entry points to the service. They contain validation and mapping, but no business logic. They should be kept thin, only registering the features they need. If wanted, the API could have a separate project for contracts, which can be packaged as a NuGet and used by others for a strongly typed interface.

The flow between the application layers looks like this for the example microservice:

The API project

The API project consists of Endpoints, which are the available routes in the API.

Notice the deliberate omission of the word "controller". I've always disliked the name controller. What does controller mean functionally? It's a remnant of MVC and have no place in an API. The introduction of Minimal APIs removes the use of the word "controller", and finally we can talk about the endpoints the API exposes, which is much better.

The endpoint configuration is basically a centralized register of the API routes:

using WeatherForecast.Api.Endpoints.GetWeatherForecast;
namespace WeatherForecast.Api;

public static class EndpointConfiguration
{
    public static void RegisterEndpoints(this WebApplication app)
    {
        app.MapGet("/weatherforecast", GetWeatherForecastEndpoint.Execute);
    }
}

The endpoint injects the relevant feature, executes it, and handles the result:

using WeatherForecast.Features.GetWeatherForecast;
namespace WeatherForecast.Api.Endpoints.GetWeatherForecast;

public static class GetWeatherForecastEndpoint
{
    public static async Task<IResult> Execute(GetWeatherForecastFeature feature)
    {
        var (result, forecasts) = await feature.GetWeatherForecast();

        return result switch
        {
            FeatureResult.Success => Results.Ok(forecasts.ToContract()),
            FeatureResult.InvalidRequest => Results.Problem(nameof(FeatureResult.InvalidRequest), statusCode: StatusCodes.Status400BadRequest),

            _ => throw new NotImplementedException()
        };          
    }
}

The Messaging project

I won't go into details here. The point of this project is to show that a solution can have multiple hosts/entry points, and that these entry points uses the features provided by the main project.

The WeatherForecast project

This is the main project, where the application logic resides.

The main project consists of four main folders:
Features, Domain, Database/Storage and Gateways.

The main project should not have a dependency to the API and Messaging projects, or the external contracts exposed in those projects.

Features

The feature is a piece of functionality offered by the service. This is the application logic. The feature might do something with the database, call an external API or what not.

An example of an feature can be GetWeatherForecast and UpdateWheaterForecast. Ideally, you can delete the folder with a given feature to erase the feature from the project. (Not like MVC where models, views, controllers, view models and what not are scattered around).

The feature can have dependencies on the domain, the database and gateways, but not the other way around.

A feature cannot inject or call another feature.

It can be a good policy that the feature returns a feature result, often bundled with a resulting object. The OneOf-pattern is a great way to achieve this in a clean and understandable fashion. I haven't used this pattern in my example, but I do recommend checking it out.

Example of a feature:

using Microsoft.EntityFrameworkCore;
using WeatherForecast.Database;
namespace WeatherForecast.Features.GetWeatherForecast;

public class GetWeatherForecastFeature
{
    private readonly AppDbContext _db;

    public GetWeatherForecastFeature(AppDbContext db) => _db = db;

    public async Task<(FeatureResult, IList<Domain.WeatherForecast>)> GetWeatherForecast()
    {
        var forecasts = await _db.WeatherForecasts.ToListAsync();

        return (FeatureResult.Success, forecasts);
    }
}

Domain

The domain class is the heart and soul of your application. Deciding on which domain classes to use, and naming them and their members, is a crucial part of any solution.

The setters for the members of the class should always be private. This will ensure that all changes to a domain class will happen inside the class, by exposing methods like ChangeTemperature, and will enable that domain behavior resides inside the domain class. This will create a boundary for the domain.

using WeatherForecast.Domain.ValueObjects;
namespace WeatherForecast.Domain;

public class WeatherForecast
{
    public WeatherForecastId Id { get; private init; } = null!;

    public DateTime Date { get; private init; }

    public Temperature Temperature { get; private set; } = null!;

    public Summary? Summary { get; private set; }

    public WeatherForecast(DateTime date, Temperature temperature, Summary? summary)
    {
        Id = WeatherForecastId.NewId();
        Date = date;
        Temperature = temperature;
        Summary = summary;
    }

    public void ChangeTemperature(Temperature temperature) => Temperature = temperature;  
}

Notice the use of strongly typed objects, which enforces a certain consistency and prevents runtime errors.

namespace WeatherForecast.Domain.ValueObjects;

public record WeatherForecastId(Guid Value)
{
    public static WeatherForecastId NewId() => new(Guid.NewGuid());
};

The domain folder might also contain domain service, enums, extensions etc.

Be careful with boundaries. Make sure that outside objects don't leak into your domain.

Database

The database folder will contain:

  • ORM-specific code: Migrations, converters, configuration etc.
  • Database logic: Queries, persisters, repositories (if applicable)
  • My personal preference is to persist the domain models to the database directly, but if you use specific models instead, they should be kept here.

NB! Depending on the migration tool, the migrations might need to reside in a separate project.

Gateways

Gateways are external dependencies. It might be other Azure resources, like Azure Key Vault or Storage. Or it can be external APIs.

It can be beneficial to create façade classes to avoid external contracts to leak into your domain.

Sidenote: I recommend using the Refit library for REST, it really makes your code clean by removing a lot of the plumbing regarding the HTTP client.

TLDR

  1. Keep the number of projects to an absolute minimum. Do not create a new project if you can manage without.
  2. If you only have one host, one project might suffice.
  3. Use folders for grouping.
  4. The main project contains features, domain, database and gateways.
  5. The domain classes should always have private setters.
  6. Be strict about boundaries. Guard your domain from outside objects leaking in.
  7. API and Messaging projects are thin entry points to the main project, which injects a feature and executes it.
  8. The API and Messaging projects only executes features, they don't have a dependency on the database or gateways.
  9. A feature cannot inject or call another feature.
  10. Keep it simple