Monolithic modular web application in .NET Core 7

1.2k Views Asked by At

I want to develop a web application separated in modules. Suppose, for example, one module for authentication, one for products catalog, one for products and one for customers.

When using Visual Studio 2022, I created some Web Api projects. The problem I have is that all projects contain a Program.cs file and all projects have a diferent web port assigned by Visual Studio project template.

What should I do? should I create an "entry point" project which will have a Program.cs file and delete Program.cs from the other projects? If that is the case, how can I load the assemblies belonging to the other projects so that I can access controllers and views from the other assemblies?

EDIT:

I have used examples in this page to create my application: https://www.thinktecture.com/en/asp-net-core/modular-monolith/

I created a project called EntryPoint.Core which has this Program.cs file:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers().ConfigureApplicationPartManager(manager =>
{
    // Clear all auto detected controllers.
    manager.ApplicationParts.Clear();

    // Add feature provider to allow "internal" controller
    manager.FeatureProviders.Add(new InternalControllerFeatureProvider());
});

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Register a convention allowing to us to prefix routes to modules.
builder.Services.AddTransient<IPostConfigureOptions<MvcOptions>, ModuleRoutingMvcOptionsPostConfigure>();

builder.Services.AddModule<Modules.Authenticate.Core.Startup>("/Api/Authenticate", builder.Configuration);

var app = builder.Build();

app.UseRouting();

var modules = app.Services.GetRequiredService<IEnumerable<Module>>();
foreach (var module in modules)
{
    app.Map($"/{module.RoutePrefix}", ab =>
    {
        ab.UseRouting();
        module.Startup.Configure(ab, builder.Configuration);
    });
}

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapControllers();

app.Run();

On the other hand, I have the Modules.Authenticate.Core project which has this Startup.cs file:

    public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
    {
        services.AddDbContext<SecuWebModulesAuthenticateContext>(options =>
        {
            options
                .UseSqlServer(configuration.GetConnectionString("DefaultConnection"));
   #if DEBUG
            options.LogTo(Console.WriteLine);
   #endif
        });

        // Agrega autenticación
        services.AddAuthentication()
            .AddCookie("Cookies", options =>
            {
                options.LoginPath = "/Account/Login";
                options.LogoutPath = "/Account/Logout";
                options.AccessDeniedPath = "/Account/AccessDenied";
                options.ReturnUrlParameter = "ReturnUrl";
            })
            .AddJwtBearer(x =>
            {
                x.RequireHttpsMetadata = true;
                x.SaveToken = true;
                x.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = configuration["AuthJwt:Issuer"],
                    ValidateAudience = true,
                    ValidAudience = configuration["AuthJwt:Audience"],
                    ValidateIssuerSigningKey = true,
                    RequireExpirationTime = false,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.Zero,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["AuthJwt:Key"] ?? string.Empty))
                };
            });

        services.AddAuthorization();
    }

    public void Configure(IApplicationBuilder app, IConfiguration configuration)
    {
        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
            endpoints.MapPost("/Api/Authenticate/CreateToken", [AllowAnonymous] async (string usuario, string clave) =>
            {
                var optionsBuilder = new DbContextOptionsBuilder<SecuWebModulesAuthenticateContext>();
                optionsBuilder = optionsBuilder.UseSqlServer(configuration.GetConnectionString("DefaultConnection"));
    #if DEBUG
                optionsBuilder.LogTo(Console.WriteLine);
    #endif
                using (SecuWebModulesAuthenticateContext db = new(optionsBuilder.Options))
                {
                    ApiAcceso? acceso = await db.ApiAcceso.FirstOrDefaultAsync(aa => aa.ApiAccesoEliminadoEn == null && !aa.ApiAccesoBloqueado && aa.ApiAccesoUsuario == usuario && aa.ApiAccesoClave == clave);
                    if (acceso is not null)
                    {
                        var issuer = configuration["AuthJwt:Issuer"];
                        var audience = configuration["AuthJwt:Audience"];
                        var key = configuration["AuthJwt:Key"];
                        if (key is not null)
                        {
                            var tokenDescriptor = new SecurityTokenDescriptor
                            {
                                Subject = new ClaimsIdentity(new[]
                                {
                                    new Claim("Id", acceso.ApiAccesoId.ToString()),
                                    new Claim(JwtRegisteredClaimNames.Sub, acceso.ApiAccesoUsuario),
                                    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
                                }),
                                Expires = DateTime.UtcNow.AddMinutes(5),
                                Issuer = issuer,
                                Audience = audience,
                                SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), SecurityAlgorithms.HmacSha512Signature)
                            };
                            var tokenHandler = new JwtSecurityTokenHandler();
                            var token = tokenHandler.CreateToken(tokenDescriptor);
                            var jwtToken = tokenHandler.WriteToken(token);
                            var stringToken = tokenHandler.WriteToken(token);
                            return Results.Ok(stringToken);
                        }
                    }

                    return Results.Unauthorized();
                }
            })
        ); 
    }

Finally, I have a project called Modules.Integration which defines the following helper classes:

public class Module
{
    /// <summary>
    /// Gets the route prefix to all controller and endpoints in the module.
    /// </summary>
    public string RoutePrefix { get; }

    /// <summary>
    /// Gets the startup class of the module.
    /// </summary>
    public IModuleStartup Startup { get; }

    /// <summary>
    /// Gets the assembly of the module.
    /// </summary>
    public Assembly Assembly => Startup.GetType().Assembly;

    public Module(string routePrefix, IModuleStartup startup)
    {
        RoutePrefix = routePrefix;
        Startup = startup;
    }
}

public class ModuleRoutingConvention : IActionModelConvention
{
    private readonly IEnumerable<Module> _modules;

    public ModuleRoutingConvention(IEnumerable<Module> modules)
    {
        _modules = modules;
    }

    public void Apply(ActionModel action)
    {
        var module = _modules.FirstOrDefault(m => m.Assembly == action.Controller.ControllerType.Assembly);
        if (module == null)
        {
            return;
        }

        action.RouteValues.Add("module", module.RoutePrefix);
    }
}

public class ModuleRoutingMvcOptionsPostConfigure : IPostConfigureOptions<MvcOptions>
{
    private readonly IEnumerable<Module> _modules;

    public ModuleRoutingMvcOptionsPostConfigure(IEnumerable<Module> modules)
    {
        _modules = modules;
    }

    public void PostConfigure(string? name, MvcOptions options)
    {
        options.Conventions.Add(new ModuleRoutingConvention(_modules));
    }
}

public static class ModuleServiceCollection
{
    /// <summary>
    /// Adds a module.
    /// </summary>
    /// <param name="services"></param>
    /// <param name="routePrefix">The prefix of the routes to the module.</param>
    /// <typeparam name="TStartup">The type of the startup class of the module.</typeparam>
    /// <returns></returns>
    public static IServiceCollection AddModule<TStartup>(this IServiceCollection services, string routePrefix, IConfiguration configuration)
        where TStartup : IModuleStartup, new()
    {
        // Register assembly in MVC so it can find controllers of the module
        services.AddControllers().ConfigureApplicationPartManager(manager =>
            manager.ApplicationParts.Add(new AssemblyPart(typeof(TStartup).Assembly)));

        var startup = new TStartup();
        startup.ConfigureServices(services, configuration);

        services.AddSingleton(new Module(routePrefix, startup));

        return services;
    }
}

When I run the EntryPortal.Core project, swagger recognizes the route /Api/Authenticate/CreateToken, however, when I try to test it, a 404 error is thrown.

What else is missing in order the route to be recognized?

EDIT 2:

I realized that ModuleRoutingConvention.Apply method is not being called in my project but in the example project from that page, it is.

I noted that the Apply method, in the example code, is called in this statement:

app.UseEndpoints(endpoints => { endpoints.MapControllers(); });

In my case, I am using app.MapControllers() since when I used above instruction, a warning was shown telling that I need to use top level route registration. However, I used both ways to register routes and in neither case, the Apply method was called.

EDIT 3:

I realized that it works when I create a controller and an action in the module.

If I use endpoints.MapPost and I include the action code inside, a 404 error is shown, even when Swagger does show the route in its page.

Thanks Jaime

1

There are 1 best solutions below

3
Paul On

In .NET 6 and NET 7, Microsoft has Unifies Startup.cs and Program.cs into a single Program.cs file.(Web app template improvements)

Modules.Authenticate.Core project Move code to the program.cs

can you try Microsoft's Areas provides a way to partition an ASP.NET Core 7 Web app into smaller functional groups, each with its own set of Razor Pages, controllers, views, and models.

Microsoft's Areas in ASP.NET Core 7
Area folder structure

  • Project name
    • Areas
      • Customers
        • Controllers
          • HomeController.cs
        • Views
          • Home
            • Index.cshtml
      • Products
        • Controllers
          • HomeController.cs
        • Views
          • Home
            • Index.cshtml

Program.cs

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/Account/Login";
        options.LogoutPath = "/Account/Logout";
        options.AccessDeniedPath = "/Account/AccessDenied";
        options.ReturnUrlParameter = "ReturnUrl";
    })
    .AddJwtBearer(x=>
            {
                x.RequireHttpsMetadata = true;
                x.SaveToken = true;
                x.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = builder.configuration["AuthJwt:Issuer"],
                    ValidateAudience = true,
                    ValidAudience=builder.configuration["AuthJwt:Audience"],
                    ValidateIssuerSigningKey = true,
                    RequireExpirationTime = false,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.Zero,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["AuthJwt:Key"] ?? string.Empty))
                };
                };

            });


app.MapAreaControllerRoute(
    name: "MyAreaCustomers",
    areaName: "Customers",
    pattern: "Customers/{controller=Home}/{action=Index}/{id?}");

app.MapAreaControllerRoute(
    name: "MyAreaProducts",
    areaName: "Products",
    pattern: "Products/{controller=Home}/{action=Index}/{id?}");

_Layout.cshtml

    <li>
        <a asp-area="Customers" asp-page="/Index">
           customers/Index
        </a>
    </li>

<li>
 @Html.ActionLink("Products/Home/Index", "Index", "Home",new { area = "Products" })
</li>