0

Suppose we are building a REST API with ASP.NET Core. Suppose there are 3 projects in our solution:

  1. The ASP.NET Core Web API project.
  2. A class library project for the businnes layer.
  3. A class library project for the data persistence, using EF Core.

The first one contains all the controllers and actions, dtos, etc. It will call methods from the second project so it would have a reference to it.

The second project, which implements all the business logic, would have a reference to the third project.

The third project has all the database code using EF Core.

The question now is, where does one write the code for registering everything with the DI?

Microsoft's convention is to

use a single Add{GROUP_NAME} extension method to register all of the services required by a framework feature.

So let's say we make an extension method inside our third project:

public static class DatabaseServiceCollectionExtensions
{
    public static IServiceCollection AddDbContext(this IServiceCollection services, string connectionString)
    {
        services.AddDbContext<AwesomeDbContext>((services, optionsBuilder) => 
        {
            optionsBuilder.UseNpgsql(connectionString);
        }
    }
}

You would then have another extension mehthod inside the second project

public static class BusinessCollectionExtensions
{
    public static IServiceCollection AddBusinessServices(this IServiceCollection services)
    {
        services.AddScoped<IUserService, UserService>();
        ...
    }
}

Based on the Microsoft docs, I would then call these in Program.cs

...
builder.Services.AddDbContext(someConnectionString)
                .AddBusinessServices()
                ...
...

But that would mean that the first project would have to have a reference to the third project and would have to provide a connection string. Is the API project supposed to be aware of the database layer?

What if I need to change the way data is persisted and instead of using a database we have to a third party API? That means I have to change my API project as well.

What if instead of calling AddDbContext in the Program.cs I call it in the second project's extension method like so

public static class BusinessCollectionExtensions
{
    public static IServiceCollection AddBusinessServices(this IServiceCollection services)
    {
        var connectionString = GetConnectionStringFromSomewhere();
        services.AddScoped<IUserService, UserService>()
                .AddDbContext(connectionString); // this line here
        ...
    }
}

But this also kind of feels wrong. Where do I get the connection string from? Do I load the configuration and, e.g., read the env vars at this stage in the businnes layer project? Am I overthinking this?

What is considered "best practice" in this case?

4
  • Best practices are not really answerable questions, because it assumes there is a generally accepted "best" way to do things. A "best practice" is just a substitute for a good honest conversation about the benefits and drawbacks to various solutions to a problem. Commented Nov 4, 2023 at 22:15
  • You have outlined some problems with the Microsoft recommendation, which is a good starting point. Commented Nov 4, 2023 at 22:16
  • Have you considered just writing code necessary to register dependencies in Program.cs and ignore the Microsoft recommendation? What's wrong with that? Commented Nov 4, 2023 at 22:20
  • That is why I put quotation marks around best practice. I want to see how people wiser than me approach this. As for the last point, that would imply that the programmer would know how to register the dependencies inside Program.cs. They would have to know that they need to add a database context in order for the business project to function. Is it the API's concern how the business layer gets the data? What if in the future the business layer stops using a database and instead consumes a third party API to retrieve the data? I'd have to change Program.cs to configure this accordingly. Commented Nov 5, 2023 at 7:54

2 Answers 2

1

But that would mean that the first project would have to have a reference to the third project and would have to provide a connection string. Is the API project supposed to be aware of the database layer?

You're pointing at a genuine discrepancy here, but the short answer is that this specific case (i.e. DI registration) is best just given a free pass to break a rule that other code must abide by.

I'm assuming you're working with a simple N-tier architecture here. The core problem applies to all kinds of architectures but I'm going to specifically write it for N-tier here.

Forgetting about DI registration, it's correct to say that layer 1 only depends on layer 2 and layer 2 only depends on layer 3. Layer 2, the business layer, is an intentional middle man in this process. If layer 1 talked to layer 3 directly, that would be against the design of our architecture.

If you didn't do IoC and you would only use concrete classes, this is how you could build it. However, when you introduce IoC, you very specifically give control over the concrete implementations to your consumer, which means that your API project is explicitly expected to tell the DI container which implementation of e.g. a repository interface to use.

You can delegate this DI registration using an extension method, but that extension method needs to live in a place that has access to the concrete repository class, and the API project still needs access to the extension method itself, so there's still some sort of connection between the API project and the specific repository class.

Personally, I just allow for an exception here for DI registration. The extra project reference isn't the end of the world in and of itself, since the compiled application eventually ties together all the projects anyway. The only difference is that you need the self-discipline to not rely on that additional project reference in your actual API logic (i.e. the code that doesn't related to DI registration).

For N-tier architectures, there is a second possible solution, and that is to perform the AddDbContext call inside the AddBusinessServices method. This means that the API project does not need to depend on the persistence project.
This achieves the goal, but personally I don't like this. It creates a needless extra passthrough step. I prefer to give API full control over the DI registration logic and I accept the project reference from API to Persistence as a technical necessity to make that a reality.

I want to take a brief pause here and point out that .NET Core specifically enables transitive project references by default. What this means is that even if API only references Business and only Business references Persistence, that API code will still have access to the content of the Persistence projects (because access is being transitively provided across the chain of project references).
I vehemently disagree with this approach. However, this does suggest that Microsoft is not at all concerned with the ability for one layer (API) being able to reach through another layer (Business) to access a hidden layer (Persistence) that (in my opinion) it should never have direct access to.

So even if you structure your project references correctly, transitive behavior will still allow access anyway, which defeats the purpose of being very strict about your project references. Unless you manually override the transitive project reference behavior, which requires manually editing your csproj file for every individual project reference, which is a pain in the ***.

It really is your choice here how you tackle it. At the end of the day, my advice would be that it's usually counterproductive to try and wrangle your code in complex shapes just to adhere to a rule, when the alternative is still following the spirit of the rule, but you're simply allowing an exception to the rule only for DI registration purposes (and nothing else!). But I am a very pragmatic person and this is why I approach it this way.

3

I think we have to let Microsoft be the authority on "Best practice" here, so yeah, write an extension method, assume the user knows the namespace for it, read the config by magically knowing the convention, hide the constructor by making it internal and only visible to your extension method etc etc

The upside of this is you can produce a complex library with a sample config and it's just one line of code for people to add to their projects.

However, as you point out there are lots of obvious downsides if you are writing multiple simple libraries which are only going to be used in your own projects.

MyRepo
MyApiClient
MyBusinessLogicService
etc

I would suggest "Best Practice" for these is to have normal public constructors and just do the config reading and service registry in program.cs

If its getting very long, you can separate out sections into their own classes or methods like in the old MVC framework. eg:

SetupAuth();
SetupSwagger();
SetupLogging();
SetupServices();
etc

The benefit to this approach over Microsoft's suggestion is that is makes the setup super clear to anyone reading the code. You can tell whether something is a singleton or scoped, see the config strings, see the order in which things are setup, see all the options used etc.

If you are in the "development in progress" stage of your project, (which I think for most commercial projects is forever), then you will be fiddling with this setup all the time; when you add new libraries, refactor old ones, change the config for tests etc etc.

If you are in the "feature complete, distribute my framework to the world" stage of your project, then you can see the advantages of encapsulating the default setup in a static extension method.

2
  • Definately the "in progress" stage but it is planned to have some of the libraries distributed internally as nuget packages in the near future. Let's assume I would encapsulate the default setup in an extension method. How would one handle configuration? Pass the necessary info as parameters of the extension method like the connection string in my example above or handle it completely inside the extension method and just hope that there is an environment variable set up for the connection stirng and throw an exception if not? Commented Nov 5, 2023 at 20:05
  • I think the MS way of handling the config is just to pass IConfiguration into the ext method and read the locations by convention.
    – Ewan
    Commented Nov 6, 2023 at 15:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.