This CleanCodeJN package streamlines the development of web APIs in .NET applications by providing a robust framework for CRUD operations and facilitating the implementation of complex business logic in a clean and maintainable manner.
$ dotnet add package CleanCodeJN.GenericApisBuild production-ready APIs instantly – from Minimal APIs and Controllers to fully integrated GraphQL endpoints with CRUD, filtering, sorting & paging – powered by Mediator, AutoMapper, EF Core, FluentValidation, and the IOSP architecture pattern.
dotnet add package CleanCodeJN.GenericApis
// Program.cs
builder.Services.AddCleanCodeJN<MyDbContext>(options => {...}); // Mandatory: Configure CleanCodeJN options
app.UseCleanCodeJNWithMinimalApis(); // For REST APIs
app.UseCleanCodeJNWithGraphQL(); // For autogenerated GraphQL APIs
app.UseCleanCodeJNDocumentation(); // Optional: For autogenerated Command Documentation, visit /docs
// add <GenerateDocumentationFile>true</GenerateDocumentationFile> to your .csproj file
// Entity
public class Customer : IEntity<int>
{
public int Id { get; set; }
public string Name { get; set; }
}
// DTO
public class CustomerGetDto : IDto
{
public int Id { get; set; }
public string Name { get; set; }
}
// Minimal API
public class CustomersV1Api : IApi
{
public List<string> Tags => ["Customers Minimal API"];
public string Route => $"api/v1/Customers";
public List<Func<WebApplication, RouteHandlerBuilder>> HttpMethods =>
[
app => app.MapGet<Customer, CustomerGetDto, int>(
Route,
Tags,
where: x => x.Name.StartsWith("a"),
select: x => new Customer { Name = x.Name }),
];
}
// IOSP Workflow
public class YourIntegrationCommand(ICommandExecutionContext executionContext)
: IntegrationCommand<YourIntegrationRequest, Customer>(executionContext)
{
public override async Task<BaseResponse<Customer>> Handle(YourIntegrationRequest request, CancellationToken cancellationToken) =>
await ExecutionContext
.DoSomethingRequest1()
.DoSomethingRequest2()
.DoSomethingRequest3()
.Execute<Customer>(cancellationToken);
}
Integration Operation Segregation Principle
Split your request handlers into: Operations → contain real logic (DB, API, etc.)
Integrations → just orchestrate other request handlers
builder.Services.AddCleanCodeJN<MyDbContext>(options => {});
/// <summary>
/// The options for the CleanCodeJN.GenericApis
/// </summary>
public class CleanCodeOptions
{
/// <summary>
/// The assemblies that contain the command types, Entity types and DTO types for automatic registration of commands, DTOs and entities.
/// </summary>
public List<Assembly> ApplicationAssemblies { get; set; } = [];
/// <summary>
/// The assembly that contains the validators types for using Fluent Validation.
/// </summary>
public Assembly ValidatorAssembly { get; set; }
/// <summary>
/// The assembly that contains the automapper mapping profiles.
/// </summary>
public Action<IMapperConfigurationExpression> MappingOverrides { get; set; }
/// <summary>
/// If true: Use distributed memory cache. If false: you can add another Distributed Cache implementation.
/// </summary>
public bool UseDistributedMemoryCache { get; set; } = true;
/// <summary>
/// If true: Add default logging behavior. If false: you can add another logging behavior.
/// </summary>
public bool AddDefaultLoggingBehavior { get; set; }
/// <summary>
/// Mediatr Types of Open Behaviors to register
/// </summary>
public List<Type> OpenBehaviors { get; set; } = [];
/// <summary>
/// Mediatr Types of Closed Behaviors to register
/// </summary>
public List<Type> ClosedBehaviors { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether GraphQL auto-wiring is enabled.
/// </summary>
public GraphQLOptions GraphQLOptions { get; set; }
}
app.UseCleanCodeJNWithMinimalApis();
app.UseCleanCodeJNWithGraphQL();
app.UseCleanCodeJNDocumentation(); // add <GenerateDocumentationFile>true</GenerateDocumentationFile> to your .csproj file
builder.Services.AddControllers()
.AddNewtonsoftJson(); // this is needed for "http patch" only. If you do not need to use patch, you can remove this line
// After Build()
app.MapControllers();
builder.Services.AddCleanCodeJN<MyDbContext>(options =>
{
options.ApplicationAssemblies =
[
typeof(CleanCodeJN.GenericApis.Sample.Business.AssemblyRegistration).Assembly,
typeof(CleanCodeJN.GenericApis.Sample.Core.AssemblyRegistration).Assembly,
typeof(CleanCodeJN.GenericApis.Sample.Domain.AssemblyRegistration).Assembly
];
options.ValidatorAssembly = typeof(CleanCodeJN.GenericApis.Sample.Core.AssemblyRegistration).Assembly;
// Enable GraphQL with all CRUD operations
options.GraphQLOptions = new GraphQLOptions
{
Get = true,
Create = true,
Update = true,
Delete = true,
AddAuthorizationWithPolicyName = "MyPolicy", // optional for adding authorization policy
};
});
// Optional: Add Authentication and Authorization if needed
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("MyPolicy", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("role", "admin");
});
});
public class CustomersV1Api : IApi
{
public List<string> Tags => ["Customers Minimal API"];
public string Route => $"api/v1/Customers";
public List<Func<WebApplication, RouteHandlerBuilder>> HttpMethods =>
[
app => app.MapGet<Customer, CustomerGetDto, int>(
Route,
Tags,
where: x => x.Name.StartsWith("Customer"),
includes: [x => x.Invoices],
select: x => new Customer { Name = x.Name },
ignoreQueryFilters: true),
app => app.MapGetPaged<Customer, CustomerGetDto, int>(Route, Tags),
app => app.MapGetFiltered<Customer, CustomerGetDto, int>(Route, Tags),
app => app.MapGetById<Customer, CustomerGetDto, int>(Route, Tags),
app => app.MapPut<Customer, CustomerPutDto, CustomerGetDto>(Route, Tags),
app => app.MapPost<Customer, CustomerPostDto, CustomerGetDto>(Route, Tags),
app => app.MapPatch<Customer, CustomerGetDto, int>(Route, Tags),
// Or use a custom Command with MapDeleteRequest()
app => app.MapDeleteRequest<Customer, CustomerGetDto, int>(Route, Tags, id => new DeleteCustomerIntegrationRequest { Id = id })
];
}
public class CustomersV1Api : IApi
{
public List<string> Tags => ["Customers Minimal API"];
public string Route => $"api/v1/Customers";
public List<Func<WebApplication, RouteHandlerBuilder>> HttpMethods =>
[
app => app.MapGet<Customer, CustomerGetDto, int>(Route, Tags, where: x => x.Name.StartsWith("a"), select: x => new Customer { Name = x.Name }),
];
}
[Tags("Customers Controller based")]
[Route($"api/v2/[controller]")]
public class CustomersController(IMediator commandBus, IMapper mapper)
: ApiCrudControllerBase<Customer, CustomerGetDto, CustomerPostDto, CustomerPutDto, int>(commandBus, mapper)
{
}
/// <summary>
/// Customers Controller based
/// </summary>
/// <param name="commandBus">IMediatr instance.</param>
/// <param name="mapper">Automapper instance.</param>
[Tags("Customers Controller based")]
[Route($"api/v2/[controller]")]
public class CustomersController(IMediator commandBus, IMapper mapper)
: ApiCrudControllerBase<Customer, CustomerGetDto, CustomerPostDto, CustomerPutDto, int>(commandBus, mapper)
{
/// <summary>
/// Where clause for the Get method.
/// </summary>
public override Expression<Func<Customer, bool>> GetWhere => x => x.Name.StartsWith("Customer");
/// <summary>
/// Includes for the Get method.
/// </summary>
public override List<Expression<Func<Customer, object>>> GetIncludes => [x => x.Invoices];
/// <summary>
/// Select for the Get method.
/// </summary>
public override Expression<Func<Customer, Customer>> GetSelect => x => new Customer { Id = x.Id, Name = x.Name };
/// <summary>
/// AsNoTracking for the Get method.
/// </summary>
public override bool AsNoTracking => true;
}
{
"Condition" : 0, // 0 = AND; 1 = OR
"Filters": [
{
"Field": "Name",
"Value": "aac",
"Type": 0
},
{
"Field": "Id",
"Value": "3",
"Type": 1
}
]
}
Which means: Give me all Names which CONTAINS "aac" AND have Id EQUALS 3. So string Types use always CONTAINS and integer types use EQUALS. All filters are combined with ANDs.
public enum FilterTypeEnum
{
STRING = 0,
INTEGER = 1,
DOUBLE = 2,
INTEGER_NULLABLE = 3,
DOUBLE_NULLABLE = 4,
DATETIME = 5,
DATETIME_NULLABLE = 6,
GUID = 7,
GUID_NULLABLE = 8,
}
Just write your AbstractValidators. They will be automatically executed on generic POST and generic PUT actions:
public class CustomerPostDtoValidator : AbstractValidator<CustomerPostDto>
{
public CustomerPostDtoValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(10);
}
public class CustomerPutDtoValidator : AbstractValidator<CustomerPutDto>
{
public CustomerPutDtoValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0);
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(10)
.CreditCard();
}
}
public class SpecificDeleteRequest : IRequest<BaseResponse<Customer>>
{
public required int Id { get; init; }
}
public class SpecificDeleteRequest : IRequest<BaseResponse<Customer>>, ICachableRequest
{
public required int Id { get; init; }
public bool BypassCache { get; }
public string CacheKey => "Your Key";
public TimeSpan? CacheDuration => TimeSpan.FromHours(168);
}
public class SpecificDeleteCommand(IRepository<Customer, int> repository) : IRequestHandler<SpecificDeleteRequest, BaseResponse<Customer>>
{
public async Task<BaseResponse<Customer>> Handle(SpecificDeleteRequest request, CancellationToken cancellationToken)
{
var deletedCustomer = await repository.Delete(request.Id, cancellationToken);
return await BaseResponse<Customer>.Create(deletedCustomer is not null, deletedCustomer);
}
}
CleanCodeJN.GenericApis is fully compatible with the standard ASP.NET Core middleware pipeline.
You can easily add custom middlewares for authentication, logging, exception handling, or any other cross-cutting concern — before or after the CleanCodeJN setup.
Custom middlewares should be registered in your Program.cs after the AddCleanCodeJN() call, but before the CleanCodeJN
middlewares such as UseCleanCodeJNWith. Global mediator behaviours for logging or caching can directly be added in the AddCleanCodeJN() options.
There already is a default logging behaviour included, which can be enabled in the options. This behaviour logs the execution and exection time of each command.
var builder = WebApplication.CreateBuilder(args);
// Add CleanCodeJN
builder.Services.AddCleanCodeJN<MyDbContext>(options =>
{
options.AddDefaultLoggingBehavior = true; // Enables default logging behaviour
options.OpenBehaviors = [typeof(CustomBehavior<,>)]; // Adds custom behaviour with 2 generic parameters for TRequest, TResponse
options.ApplicationAssemblies = [typeof(Program).Assembly];
options.ValidatorAssembly = typeof(Program).Assembly;
});
// Add custom services
builder.Services.AddLogging();
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://your-keycloak-domain/auth/realms/yourrealm";
options.Audience = "your-api";
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Add your middlewares in the right order
// Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
// Custom Logging Middleware
app.Use(async (context, next) =>
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation("➡️ Request: {Method} {Path}", context.Request.Method, context.Request.Path);
await next();
logger.LogInformation("⬅️ Response: {StatusCode}", context.Response.StatusCode);
});
// Global Exception Handling
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (Exception ex)
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "Unhandled exception occurred");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new
{
Title = "Unexpected Error",
Detail = ex.Message
});
}
});
// CleanCodeJN Middlewares
app.UseCleanCodeJNWithMinimalApis();
app.UseCleanCodeJNWithGraphQL();
app.UseCleanCodeJNDocumentation();
// Run
app.Run();
public class YourIntegrationCommand(ICommandExecutionContext executionContext)
: IntegrationCommand<YourIntegrationRequest, YourDomainObject>(executionContext)
public static ICommandExecutionContext CustomerGetByIdRequest(
this ICommandExecutionContext executionContext, int customerId)
=> executionContext.WithRequest(
() => new GetByIdRequest<Customer>
{
Id = customerId,
Includes = [x => x.Invoices, x => x.OtherDependentTable],
},
CommandConstants.CustomerGetById);
executionContext.WithParallelWhenAllRequests(
[
() => new GetByIdRequest<Customer, int>
{
Id = request.Id,
},
() => new GetByIdRequest<Customer, int>
{
Id = request.Id,
},
])
.WithRequest(
() => new YourSpecificRequest
{
Results = executionContext.GetListParallelWhenAll("Parallel Block"),
})
.WithRequest(
() => new GetByIdRequest<Invoice, Guid>
{
Id = executionContext.GetParallelWhenAllByIndex<Invoice>("Parallel Block", 1).Id,
})
executionContext.IfRequest(() => new GetByIdRequest<Customer, int> { Id = request.Id },
ifBeforePredicate: () => true,
ifAfterPredicate: response => response.Succeeded)
executionContext.IfBreakRequest(() => new GetByIdRequest<Customer, int> { Id = request.Id },
ifBeforePredicate: () => true,
ifAfterPredicate: response => response.Succeeded)
public class YourIntegrationCommand(ICommandExecutionContext executionContext)
: IntegrationCommand<YourIntegrationRequest, Customer>(executionContext)
{
public override async Task<BaseResponse<Customer>> Handle(YourIntegrationRequest request, CancellationToken cancellationToken) =>
await ExecutionContext
.CandidateGetByIdRequest(request.Dto.CandidateId)
.CustomerGetByIdRequest(request.Dto.CustomerIds)
.GetOtherStuffRequest(request.Dto.XYZType)
.PostSomethingRequest(request.Dto)
.SendMailRequest()
.Execute<Customer>(cancellationToken);
}