Minimal APIs – Clean Architecture

By | 05/06/2024

In this post, we will see how to implement Clean Architecture with .NET Core Minimal APIs, enabling us to build applications that are both efficient and easy to maintain.
But first of all, what is the Clear Architecture?
Clean Architecture, proposed by Robert C. Martin (Uncle Bob), is a design philosophy that emphasizes the separation of concerns and dependency inversion. The core idea is to make the business logic of our application independent of external frameworks, UI, databases, and other infrastructure concerns. This separation is typically achieved through four layers:

  1. Domain Layer: Contains enterprise logic and business entities.
  2. Application Layer: Defines the application’s use cases and business rules.
  3. Infrastructure Layer: Handles the implementation details, such as data access and external services.
  4. Presentation Layer: Manages the UI or API layer that interacts with the users.

We will take the last version of the Minimal APIs project that we have used in all Minimal APIs’ posts and we will implement a Clean Architecture.


DOMAIN LAYER:
This layer contains all entities, value objects, exceptions, enums, and interfaces that are core to the business logic. It represents the business rules and is the innermost layer which does not depend on anything external.

[DOG.CS]

namespace DomainLayer.Entities
{
    public class Dog
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Breed { get; set; }
        public string Color { get; set; }
    }
}



APPLICATION LAYER:
This layer holds the use cases of our applications. It orchestrates the flow of data to and from the entities, and directs those entities to use their business logic to achieve the tasks demanded by the use cases.
In this layer we can insert object like DTOs, Interfaces, Business logic and so on.

[IDOGCOMMANDS.CS]

using DomainLayer.Entities;

namespace ApplicationLayer.Interfaces
{
    public interface IDogCommands
    {
        Task<bool> AddDog(Dog dog);

        Task<List<Dog>> GetAllDogs(int page, int pageSize);

        Task<Dog> GetDogById(int id);

        Task<bool> UpdateDog(Dog dog, int id);

        Task<bool> DeleteDog(int id);

        Task Save();
    }
}



INFRASTRUCTURE LAYER:
This layer contains classes for accessing external resources such as databases, file systems, web services, etc. It should implement interfaces defined in the application layer.
In this layer we can insert object like Database Context, Repository Implementations, External Service Implementations and so on.

[DATACONTEXT.CS]

using DomainLayer.Entities;
using Microsoft.EntityFrameworkCore;

namespace InfrastructureLayer.Data
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions<DataContext> options)
        : base(options) { }

        public DbSet<Dog> Dogs => Set<Dog>();
    }
}


[DOGCOMMANDS.CS]

using ApplicationLayer.Interfaces;
using DomainLayer.Entities;
using InfrastructureLayer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace InfrastructureLayer.Commands
{
    public class DogCommands : IDogCommands
    {
        private readonly DataContext _dataContext;
        private readonly ILogger<DogCommands> _logger;

        public DogCommands(DataContext dataContext, ILogger<DogCommands> logger)
        {
            _dataContext = dataContext;
            _logger = logger;
        }

        public async Task LoadDefaultValuesDB()
        {
            for (int i = 1; i <= 5; i++)
            {
                await _dataContext.Dogs.AddAsync(new Dog { Id = i, Name = $"Name_{i}", Breed = $"Breed_{i}", Color = $"Color_{i}" });
            }
            await _dataContext.SaveChangesAsync();
        }

        public async Task<bool> AddDog(Dog dog)
        {
            try
            {
                _logger.LogInformation($"BLL - Attempting to add a new dog: {dog.Name}");
                await _dataContext.Dogs.AddAsync(dog);
                return true;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, $"BLL - Failed to add dog: {dog.Name}");
                return false;
            }
        }

        public async Task<List<Dog>> GetAllDogs(int page, int pageSize)
        {
            _logger.LogInformation("BLL - Retrieving all dogs");
            var query = _dataContext.Dogs.AsNoTracking();

            if (!await query.AnyAsync())
            {
                await LoadDefaultValuesDB();

                // Refresh the query after loading default values
                query = _dataContext.Dogs.AsNoTracking();
            }

            List<Dog> lstDogs = null;

            if (page == 0 && pageSize == 0)
            {
                lstDogs = await query.ToListAsync();
            }
            else
            {
                lstDogs = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
            }

            return lstDogs;
        }

        public async Task<Dog> GetDogById(int id)
        {
            _logger.LogInformation($"BLL - Retrieving  dog with ID={id}");
            Dog objDog = await _dataContext.Dogs.FirstOrDefaultAsync(d => d.Id == id);

            return objDog;
        }

        public async Task<bool> UpdateDog(Dog dog, int id)
        {
            try
            {
                _logger.LogInformation($"BLL - Attempting to update dog with ID= {dog.Id}");
                var dogInput = await _dataContext.Dogs.FirstOrDefaultAsync(d => d.Id == id);

                if (dogInput == null)
                {
                    return false;
                }

                dogInput.Name = dog.Name;
                dogInput.Color = dog.Color;
                dogInput.Breed = dog.Breed;

                await _dataContext.SaveChangesAsync();

                return true;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, $"BLL - Failed to update dog: {dog.Name}");
                throw;
            }

        }

        public async Task<bool> DeleteDog(int id)
        {
            try
            {
                _logger.LogInformation($"BLL - Attempting to delete dog with ID= {id}");
                var dogInput = await _dataContext.Dogs.FirstOrDefaultAsync(d => d.Id == id);

                if (dogInput == null)
                {
                    return false;
                }

                _dataContext.Dogs.Remove(dogInput);
                return true;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, $"BLL - Failed to delete dog with ID: {id}");
                throw;
            }
        }

        public async Task Save()
        {
            try
            {
                _logger.LogInformation($"BLL - Attempting to save data");
                await _dataContext.SaveChangesAsync();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, $"BLL - Failed to save data");
                throw;
            }

        }
    }
}


[TOKENGENERATOR.CS]

using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace InfrastructureLayer.Token
{
    public static class TokenGenerator
    {
        // Generates a JWT token with the specified secret key and token expiry time
        public static string GenerateToken(string jwtSecretKey, int tokenExpiryMinutes, string role)
        {
            var tokenHandler = new JwtSecurityTokenHandler();
            var key = Encoding.ASCII.GetBytes(jwtSecretKey);

            // Configure the token descriptor
            var tokenDescriptor = new SecurityTokenDescriptor
            {
                Subject = new ClaimsIdentity(new List<Claim>
                {
                    new Claim(ClaimTypes.Name, "token_user"), // Set the claim with the name of the token user
                    new Claim(ClaimTypes.Role, role)
                }),
                Expires = DateTime.UtcNow.AddMinutes(tokenExpiryMinutes), // Set the token expiration time
                SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) // Set the signing credentials for the token
            };

            // Create the JWT token based on the token descriptor
            var token = tokenHandler.CreateToken(tokenDescriptor);

            // Write the JWT token as a string
            var generatedToken = tokenHandler.WriteToken(token);

            return generatedToken;
        }

        // Generates a JWT token endpoint result for use in an API controller
        // We added the role as parameter
        public static IActionResult GenerateTokenEndpoint(string jwtSecretKey, int tokenExpiryMinutes, string role)
        {
            // Generate a JWT token using the provided secret key and token expiry time
            var token = GenerateToken(jwtSecretKey, tokenExpiryMinutes, role);

            // Return the generated token as an OK response
            return new OkObjectResult(token);
        }
    }
}


[MOCKUSERSTORE.CS]

namespace InfrastructureLayer.Authentication
{
    public static class MockUserStore
    {
        public static Dictionary<string, string> Users = new Dictionary<string, string>
        {
            // we define two users: admin and reader
            { "admin", "adminpass" },
            { "reader", "readerpass" }
        };
    }
}



PRESENTATION LAYER:
In the case of a Minimal APIs, this layer will be very slim, containing only the controllers or routes needed to expose our API endpoints.
In our project, we will insert in this layer the “AuthenticationExtension” as well because, it is used to configure middleware, services related to authentication.

[AUTHENTICATIONEXTENSIONS.CS]

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

namespace API.Extensions
{
    public static class AuthenticationExtensions
    {
        public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, string jwtSecretKey)
        {
            // Add JWT Bearer Authentication
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    // Configure token validation parameters
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        // Validate the signature of the token
                        ValidateIssuerSigningKey = true,
                        // Set the secret key used to validate the token's signature
                        IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtSecretKey)),
                        // Skip issuer validation (optional)
                        ValidateIssuer = false,
                        // Skip audience validation (optional)
                        ValidateAudience = false,
                        // Validate the token's expiration time
                        ValidateLifetime = true,
                        // Set the tolerance for validating the token's expiration time
                        ClockSkew = TimeSpan.Zero,
                        // Require the token to have an expiration time
                        RequireExpirationTime = true,
                        LifetimeValidator = (before, expires, token, parameters) =>
                        {
                            var tokenLifetimeMinutes = (expires - before)?.TotalMinutes;
                            return tokenLifetimeMinutes <= 10; // Set the maximum token lifetime to 10 minutes
                        }
                    };
                });

            return services;
        }
    }
}



[APPSETTINGS.JSON]

{
  "Serilog": {
    "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "System": "Error"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "File",
        "Args": {
          "path": "ApiLogs.txt",
          "rollingInterval": "Day"
        }
      }
    ],
    "Enrich": [ "FromLogContext" ],
    "Properties": {
      "Application": "YourApplicationName"
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}



[PROGRAM.CS]

using API.Extensions;
using ApplicationLayer.Interfaces;
using DomainLayer.Entities;
using InfrastructureLayer.Authentication;
using InfrastructureLayer.Commands;
using InfrastructureLayer.Data;
using InfrastructureLayer.Token;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Serilog;

var builder = WebApplication.CreateBuilder(args);

// Configure Serilog
var logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .CreateLogger();

Log.Logger = logger;

builder.Host.UseSerilog();

// definition of DataContext
builder.Services.AddDbContext<DataContext>(opt => opt.UseInMemoryDatabase("DbDog"));
// definition of Dependency Injection
builder.Services.AddScoped<IDogCommands, DogCommands>();

// Add services to the container.
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthorization();

// Add services to the container.
builder.Services.AddMemoryCache();

var jwtSecretKey = "password123casdsadsaiodiasdsadas";
var tokenExpiryMinutes = 10;

// Add JWT Authentication
builder.Services.AddJwtAuthentication(jwtSecretKey);

var app = builder.Build();

// Use Authentication and HTTPS redirection
app.UseAuthentication();
app.UseHttpsRedirection();
app.UseAuthorization();

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

// Definition Get Method
app.MapGet("/dog", async (IDogCommands commands, ILogger<Program> loggerInput, IMemoryCache cache, int? page, int? pageSize) =>
{
    page ??= 1;
    pageSize ??= 10;

    string cacheKey = $"dogsList_page_{page.Value}_size_{pageSize.Value}";
    List<Dog> dogs = null;

    // Log the beginning of the request to get all dogs
    loggerInput.LogInformation("Requesting all dogs");

    // Try to retrieve the cached list of dogs
    if (!cache.TryGetValue(cacheKey, out dogs))
    {
        loggerInput.LogInformation("Cache miss. Fetching dogs from database...");

        // Execute the GetAllDogs command to retrieve all dogs
        dogs = await commands.GetAllDogs(0, 0);

        // Check if the result is null or empty
        if (dogs == null || !dogs.Any())
        {
            // Log a warning indicating that no dogs were found
            loggerInput.LogWarning("No dogs found");

            // Return a NotFound result to indicate that no dogs were found
            return Results.NotFound();
        }

        // Set cache with a relative expiration time of 5 minutes
        var cacheEntryOptions = new MemoryCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromMinutes(2));
        cache.Set(cacheKey, dogs, cacheEntryOptions);

        loggerInput.LogInformation($"Fetched {dogs.Count} dogs from the database and cached.");
    }
    else
    {
        loggerInput.LogInformation($"Retrieved {dogs.Count} dogs from cache.");
    }

    var paginatedDogs = dogs.Skip((page.Value - 1) * pageSize.Value).Take(pageSize.Value).ToList();
    if (!paginatedDogs.Any())
    {
        loggerInput.LogWarning("No dogs found on the requested page");
        return Results.NotFound();
    }

    // Return an Ok result with the list of retrieved dogs
    return Results.Ok(paginatedDogs);
}).RequireAuthorization();

// Definition Get{Id} Method
app.MapGet("/dog/{id}", async (int id, IDogCommands commands, ILogger<Program> loggerInput) =>
{
    // Log the attempt to retrieve a dog with a specific ID
    loggerInput.LogInformation("Attempting to retrieve dog with ID: {DogId}", id);

    // Execute the GetDogById command to retrieve the dog with the specified ID
    var dog = await commands.GetDogById(id);

    // Check if a dog with the specified ID was found
    if (dog == null)
    {
        // Log a warning indicating that no dog was found with the specified ID
        loggerInput.LogWarning("No dog found with ID: {DogId}", id);

        // Return a NotFound result to indicate that the dog was not found
        return Results.NotFound();
    }

    // Log the successful retrieval of the dog
    loggerInput.LogInformation("Dog with ID: {DogId} retrieved successfully", id);

    // Return an Ok result with the retrieved dog
    return Results.Ok(dog);
}).RequireAuthorization();

// Definition Post Method
app.MapPost("/dog", async (Dog dog, IDogCommands commands, ILogger<Program> loggerInput) =>
{
    try
    {
        // Log the attempt to add a new dog with the dog's details
        loggerInput.LogInformation("Attempting to add a new dog: {@Dog}", dog);

        // Attempt to add the dog using the command pattern implementation
        var addSuccess = await commands.AddDog(dog);

        // Check if the addition was successful
        if (!addSuccess)
        {
            // Log a warning if adding the dog failed and return a problem result
            loggerInput.LogWarning("Failed to add a new dog: {@Dog}", dog);
            return Results.Problem("Failed to add a new dog.");
        }

        // Save changes to the database
        await commands.Save();

        // Log the successful addition of the dog
        loggerInput.LogInformation("New dog added successfully: {@Dog}", dog);

        // Return an OK result upon successful addition
        return Results.Ok();
    }
    catch (Exception ex)
    {
        // Log any exceptions that occur during the process
        loggerInput.LogError(ex, "Error occurred while adding a new dog: {@Dog}", dog);

        // Return a problem result in case of exceptions
        return Results.Problem("An error occurred while processing your request.");
    }
}).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });

// Definition Put Method
app.MapPut("/dog/{id}", async (int id, Dog dog, IDogCommands commands, ILogger<Program> loggerInput) =>
{
    // Log the attempt to update a dog with a specific ID
    loggerInput.LogInformation("Attempting to update dog with ID: {DogId}", id);

    // Execute the UpdateDog command to update the dog's information with the specified ID
    var updateOk = await commands.UpdateDog(dog, id);

    // Check if the update was successful
    if (!updateOk)
    {
        // Log a warning if the dog with the specified ID was not found or could not be updated
        loggerInput.LogWarning("Failed to update dog with ID: {DogId} - not found", id);

        // Return a NotFound result to indicate that the dog was not found or could not be updated
        return Results.NotFound();
    }

    // Log the successful update of the dog
    loggerInput.LogInformation("Dog with ID: {DogId} updated successfully", id);

    // Return a NoContent result indicating successful update
    return Results.NoContent();
}).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });

// Definition Delete Method
app.MapDelete("/dog/{id}", async (int id, IDogCommands commands, ILogger<Program> loggerInput) =>
{
    // Log the attempt to delete a dog with a specific ID
    loggerInput.LogInformation("Attempting to delete dog with ID: {DogId}", id);

    // Execute the DeleteDog command to delete the dog with the specified ID
    var deleteOk = await commands.DeleteDog(id);

    // Check if the deletion was successful
    if (deleteOk)
    {
        // Save changes
        await commands.Save();

        // Log the successful deletion of the dog
        loggerInput.LogInformation("Dog with ID: {DogId} deleted successfully", id);

        // Return an Ok result indicating successful deletion
        return Results.Ok();
    }

    // If the dog was not found or could not be deleted, log a warning
    loggerInput.LogWarning("Failed to delete dog with ID: {DogId} - not found", id);

    // Return a NotFound result indicating the dog was not found
    return Results.NotFound();
}).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });


// POST /token to generate JWT token
app.MapPost("/token", async (HttpContext context, ILogger<Program> loggerInput) =>
{
    // Log the start of the token generation process
    loggerInput.LogInformation("Starting token generation process");

    // Obviously, this authentication way is NOT valid
    // in a real project where, we'd get these from a POST body,
    // and we'd need to ensure everything is securely encrypted.https://www.zoneofdevelopment.com/2023/11/22/minimal-apis-authorization/
    var username = context.Request.Query["username"];
    var password = context.Request.Query["password"];

    // Check if the username and password are valid using a mock user store
    if (!MockUserStore.Users.ContainsKey(username) || MockUserStore.Users[username] != password)
    {
        // Log invalid credentials attempt
        loggerInput.LogWarning("Invalid credentials attempt for username: {Username}", username);

        // Return BadRequest if credentials are invalid
        return Results.BadRequest("Invalid credentials");
    }

    // Determine the user's role based on the username (simplified logic)
    var role = username == "admin" ? "Admin" : "Reader";

    // Generate the JWT token using the username, role, and other required parameters
    var generatedToken = TokenGenerator.GenerateToken(jwtSecretKey, tokenExpiryMinutes, role);

    // Log the successful token generation
    loggerInput.LogInformation("Token generated successfully for username: {Username}", username);

    // Return the generated token
    return Results.Ok(generatedToken);
}).AllowAnonymous();

app.Run();





We have done and now, if we run the application, the following will be the result:





Leave a Reply

Your email address will not be published. Required fields are marked *