An application built using .NET 9 and following a Domain-Driven Design (DDD) approach by using the BridgingIT DevKit (bIT DevKit).
- Modular architecture with CoreModule as an example. Modules
- Application layer with Commands (e.g., CustomerCreateCommand) and Queries (e.g., CustomerFindAllQuery, CustomerFindOneQuery) using IRequester. Requester, Commands and Queries
- Domain layer with Aggregates (Customer), Value Objects (EmailAddress, CustomerId), Enumerations (CustomerStatus), Domain Events (CustomerCreatedDomainEvent, CustomerUpdatedDomainEvent), and Business Rules (e.g., EmailShouldBeUniqueRule). Domain, DomainEvents, Rules
- Infrastructure layer with Entity Framework Core (CoreDbContext, migrations, configurations) and Generic Repositories with behaviors (logging, audit, domain event publishing). Repositories
- Presentation layer with Web API Endpoints for CRUD operations on Customers, using minimal API-style routing. Endpoints
- Startup tasks for seeding domain data (CoreDomainSeederTask). StartupTasks
- Job scheduling with Quartz (e.g., CustomerExportJob). JobScheduling
- Comprehensive testing: Unit tests (e.g., for command/query handlers, architecture rules), Integration tests (e.g., for endpoints).
- Architecture validation tests to enforce Onion Architecture dependencies and domain rules (e.g., no public constructors on entities/value objects).
- .NET 9
- ASP.NET Core
- Entity Framework Core for data access
- Serilog for structured logging
- Mapster for object mapping
- FluentValidation for validation
- Quartz.NET for job scheduling
- xUnit.net, NSubstitute, Shouldly for testing
- Ensure you have .NET 9/10 SDK installed.
- Configure the database connection string in
appsettings.jsonunder "CoreModule:ConnectionStrings:Default" (e.g., SQL Server LocalDB). - Optionally, start supporting containers with
docker-compose upordocker-compose up -dfor SQL Server and Seq logging. - Set
Presentation.Web.Serveras the startup project. - Run with
CTRL+F5to start the host at https://localhost:5001.
- SQL Server details: Use the connection string from
appsettings.json(e.g.,Server=(localdb)\\MSSQLLocalDB;Database=bit_devkit_gettingstarted;Trusted_Connection=True). - Swagger UI is available here.
- Seq Dashboard (if using containers) is available here.
The application will automatically migrate the database on startup (via DatabaseMigratorService in CoreModule) and seed initial data (via CoreDomainSeederTask) in development mode.
The GettingStarted project follows Clean/Onion Architecture principles, powered by bIT DevKit for modular DDD:
- Core (Domain): Business logic, aggregates, value objects, events.
- Application: Commands, queries, handlers with behaviors (retry, timeout).
- Infrastructure: Persistence (EF Core), repositories with behaviors (logging, audit).
- Presentation: Web API endpoints, module registration.
BridgingIT.DevKit.Examples.GettingStarted.sln
├── src
│ ├── Modules
│ │ └── CoreModule
│ │ ├── CoreModule.Application
│ │ ├── CoreModule.Domain
│ │ ├── CoreModule.Infrastructure
│ │ ├── CoreModule.Presentation
│ │ ├── CoreModule.UnitTests
│ │ └── CoreModule.IntegrationTests
│ └── Presentation.Web.Server
└── docker-compose.yml
Contains commands, queries, and handlers for business operations.
public class CustomerCreateCommand(CustomerModel model) : RequestBase<CustomerModel>
{
public CustomerModel Model { get; set; } = model;
public class Validator : AbstractValidator<CustomerCreateCommand>
{
public Validator()
{
this.RuleFor(c => c.Model).NotNull();
this.RuleFor(c => c.Model.FirstName).NotNull().NotEmpty().WithMessage("Must not be empty.");
this.RuleFor(c => c.Model.LastName).NotNull().NotEmpty().WithMessage("Must not be empty.");
this.RuleFor(c => c.Model.Email).NotNull().NotEmpty().WithMessage("Must not be empty.");
}
}
}(CustomerCreateCommandHandler.cs)
[HandlerRetry(2, 100)] // retry twice, wait 100ms between retries
[HandlerTimeout(500)] // timeout after 500ms execution
public class CustomerCreateCommandHandler(
ILoggerFactory loggerFactory,
IMapper mapper,
IGenericRepository<Customer> repository)
: RequestHandlerBase<CustomerCreateCommand, CustomerModel>(loggerFactory)
{
protected override async Task<Result<CustomerModel>> HandleAsync(
CustomerCreateCommand request,
SendOptions options,
CancellationToken cancellationToken)
{
var customer = mapper.Map<CustomerModel, Customer>(request.Model);
return await repository.InsertResultAsync(customer, cancellationToken: cancellationToken)
.Tap(_ => Console.WriteLine("AUDIT"))
.Map(mapper.Map<Customer, CustomerModel>);
}
}public class CustomerFindAllQuery : RequestBase<IEnumerable<CustomerModel>>
{
public Specification<Customer>? Filter { get; set; } = null;
}(CustomerFindAllQueryHandler.cs)
[HandlerRetry(2, 100)] // retry twice, wait 100ms between retries
[HandlerTimeout(500)] // timeout after 500ms execution
public class CustomerFindAllQueryHandler(
ILoggerFactory loggerFactory,
IMapper mapper,
IGenericRepository<Customer> repository)
: RequestHandlerBase<CustomerFindAllQuery, IEnumerable<CustomerModel>>(loggerFactory)
{
protected override async Task<Result<IEnumerable<CustomerModel>>> HandleAsync(
CustomerFindAllQuery request,
SendOptions options,
CancellationToken cancellationToken) =>
await repository.FindAllResultAsync(request.Filter, cancellationToken: cancellationToken)
.Tap(_ => Console.WriteLine("AUDIT"))
.Map(mapper.Map<Customer, CustomerModel>);
}Core business logic with domain models and aggregates.
[DebuggerDisplay("Id={Id}, Name={FirstName} {LastName}, Status={Status}")]
[TypedEntityId<Guid>]
public class Customer : AuditableAggregateRoot<CustomerId>, IConcurrency
{
public string FirstName { get; private set; }
public string LastName { get; private set; }
public EmailAddress Email { get; private set; }
public CustomerStatus Status { get; private set; } = CustomerStatus.Lead;
public Guid ConcurrencyVersion { get; set; }
public static Customer Create(string firstName, string lastName, string email)
{
var customer = new Customer(firstName, lastName, EmailAddress.Create(email));
customer.DomainEvents.Register(new CustomerCreatedDomainEvent(customer));
return customer;
}
// Additional methods for changing name, email, status with domain event registration
}[DebuggerDisplay("Value={Value}")]
public class EmailAddress : ValueObject
{
public string Value { get; private set; }
public static EmailAddress Create(string value)
{
value = value?.Trim()?.ToLowerInvariant();
Rule.Add(RuleSet.IsValidEmail(value)).Throw();
return new EmailAddress(value);
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return this.Value;
}
}[DebuggerDisplay("Id={Id}, Value={Value}")]
public class CustomerStatus : Enumeration
{
public static readonly CustomerStatus Lead = new(1, nameof(Lead), "Lead customer");
public static readonly CustomerStatus Active = new(2, nameof(Active), "Active customer");
public static readonly CustomerStatus Retired = new(3, nameof(Retired), "Retired customer");
// Additional properties and methods
}(CustomerCreatedDomainEvent.cs)
public class CustomerCreatedDomainEvent(Customer model) : DomainEventBase
{
public Customer Model { get; } = model;
}Persistence setup with EF Core.
public class CoreDbContext(DbContextOptions<CoreDbContext> options) : DbContext(options)
{
public DbSet<Customer> Customers { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}(CustomerTypeConfiguration.cs)
public class CustomerTypeConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("Customers").HasKey(x => x.Id).IsClustered(false);
// Additional property configurations for Id, Names, Email, Status, AuditState
}
}Initial migration creates Customers table with audit fields.
Web API endpoints and module registration.
public class CoreModule : WebModuleBase
{
public override IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration = null,
IWebHostEnvironment environment = null)
{
var moduleConfiguration = this.Configure<CoreModuleConfiguration, CoreModuleConfiguration.Validator>(services, configuration);
services.AddStartupTasks(o => o.WithTask<CoreDomainSeederTask>(o => o.Enabled(environment.IsLocalDevelopment())));
services.AddJobScheduling(o => o.StartupDelay(configuration["JobScheduling:StartupDelay"]), configuration)
.WithSqlServerStore(configuration["JobScheduling:Quartz:quartz.dataSource.default.connectionString"])
.WithJob<CustomerExportJob>().Cron(CronExpressions.EveryHour).Named(nameof(CustomerExportJob)).RegisterScoped();
services.AddSqlServerDbContext<CoreDbContext>(o => o.UseConnectionString(moduleConfiguration.ConnectionStrings["Default"]).UseLogger())
.WithDatabaseMigratorService(o => o.Enabled(environment.IsLocalDevelopment()).DeleteOnStartup(environment.IsLocalDevelopment()));
services.AddEntityFrameworkRepository<Customer, CoreDbContext>()
.WithBehavior<RepositoryLoggingBehavior<Customer>>()
.WithBehavior<RepositoryAuditStateBehavior<Customer>>()
.WithBehavior<RepositoryDomainEventPublisherBehavior<Customer>>();
services.AddEndpoints<CoreCustomerEndpoints>();
return services;
}
}public class CoreCustomerEndpoints : EndpointsBase
{
public override void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/core/customers").WithTags("Core.Customers");
group.MapGet("/{id:guid}", CustomerFindOne).WithName("Core.Customers.GetById");
group.MapGet("", CustomerFindAll).WithName("Core.Customers.GetAll");
group.MapPost("", CustomerCreate).WithName("Core.Customers.Create");
group.MapPut("/{id:guid}", CustomerUpdate).WithName("Core.Customers.Update");
group.MapDelete("/{id:guid}", CustomerDelete).WithName("Core.Customers.Delete");
}
// Handler methods for each endpoint using IRequester
}public class CoreModuleMapperRegister : IRegister
{
public void Register(TypeAdapterConfig config)
{
// Configurations for value objects, enumerations, and aggregate-DTO mappings
}
}Perfect 🚀 — let’s make it short and developer‑oriented, but also informative about how the OpenAPI part is setup (MSBuild, package references, and post‑build step).
Here’s a drop‑in section for your README that matches the style of what you already have:
The project uses build‑time OpenAPI documentation creation.
- On build, the OpenAPI specification is generated to
wwwroot/openapi.json. - ASP.NET Core serves this as a static file at https://localhost:5001/openapi.json.
- Swagger UI (https://localhost:5001/swagger/index.html) is configured to use the generated specification.
This ensures the specification is consistent across environments and available as a build artifact.
-
Packages used:
Microsoft.AspNetCore.OpenApiMicrosoft.Extensions.ApiDescription.ServerSwashbuckle.AspNetCore.SwaggerUI(for the UI only)
-
Project file configuration (
Presentation.Web.Server.csproj):<PropertyGroup> <OpenApiGenerateDocuments>true</OpenApiGenerateDocuments> <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/wwwroot</OpenApiDocumentsDirectory> <OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions> </PropertyGroup> <Target Name="GenerateOpenApiAfterBuild" AfterTargets="Build" DependsOnTargets="GenerateOpenApiDocuments" />
-
OpenApi services and static files (
Program.cs):builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); ... app.UseSwaggerUI(o => { o.SwaggerEndpoint("/openapi.json", "v1"); app.MapOpenApi(); }); app.UseStaticFiles();
This ensures openapi.json is automatically created after each build. More information can be found in the Microsoft Docs.
The generated specification can be used to build strongly‑typed clients:
# TypeScript client
nswag openapi2tsclient /input:src/Presentation.Web.Server/wwwroot/openapi.json /output:Client.ts
# C# client
nswag openapi2csclient /input:src/Presentation.Web.Server/wwwroot/openapi.json /output:Client.csSince Swagger UI serves the exact same specification as a static asset, it can be targeted at the following endpoint: https://localhost:5001/openapi.json
Run tests in CoreModule.UnitTests (e.g., CustomerCreateCommandHandlerTests, ArchitectureTests).
Run tests in CoreModule.IntegrationTests (e.g., CustomerEndpointTests for HTTP responses).
Use tools like Bruno/Postman or VS HTTP file to test endpoints:
- GET /api/core/customers
- POST /api/core/customers with body { "firstName": "John", "lastName": "Doe", "email": "[email protected]" }
A few example requests are in Core-API.http.
This appendix documents building, tagging, pushing, pulling and running the Presentation.Web.Server container image with the local registry (registry service in docker-compose.yml on port 5500). Local registry is useful for testing container images without pushing to a public registry, more details here.
- Docker installed (Desktop or Engine).
- Local registry running:
docker compose up -d.
docker build -t bdk_gettingstarted-web:latest -f src/Presentation.Web.Server/Dockerfile .docker tag bdk_gettingstarted-web:latest localhost:5500/bdk_gettingstarted-web:latestdocker push localhost:5500/bdk_gettingstarted-web:latestList Local Registry catalog:
curl http://localhost:5500/v2/_catalogdocker run -d -p8080:8080 --name bdk_gettingstarted-web localhost:5500/bdk_gettingstarted-web:latestor
docker run `
-d `
-p 8080:8080 `
--name bdk_gettingstarted-web `
--network bdk_gettingstarted `
-e ASPNETCORE_ENVIRONMENT=Development `
-e "Modules__CoreModule__ConnectionStrings__Default=Server=mssql,1433;Initial Catalog=bit_devkit_gettingstarted;User Id=sa;Password=Abcd1234!;Trusted_Connection=False;TrustServerCertificate=True;MultipleActiveResultSets=True;Encrypt=False;" `
-e "JobScheduling__Quartz__quartz.dataSource.default.connectionString=Server=mssql,1433;Initial Catalog=bit_devkit_gettingstarted;User Id=sa;Password=Abcd1234!;Trusted_Connection=False;TrustServerCertificate=True;MultipleActiveResultSets=True;Encrypt=False;" `
-e "Authentication__Authority=http://localhost:8080" `
localhost:5500/bdk_gettingstarted-web:latestTest Running Container:
curl http://localhost:8080/api/_system/info -vor browse to http://localhost:8080/scalar
Tail logs:
docker logs -f bdk_gettingstarted-webdocker build -t localhost:5500/bdk_gettingstarted-web:latest -f src/Presentation.Web.Server/Dockerfile .; if ($?) {
(docker stop bdk_gettingstarted-web 2>$null | Out-Null); (docker rm bdk_gettingstarted-web 2>$null | Out-Null);
New-Item -ItemType Directory -Force -Path "$PWD/logs" | Out-Null
docker run --name bdk_gettingstarted-web -p 8080:8080 --network bdk_gettingstarted `
-e ASPNETCORE_ENVIRONMENT=Development `
-e "Modules__CoreModule__ConnectionStrings__Default=Server=mssql,1433;Initial Catalog=bit_devkit_gettingstarted;User Id=sa;Password=Abcd1234!;Trusted_Connection=False;TrustServerCertificate=True;MultipleActiveResultSets=True;Encrypt=False;" `
-e "JobScheduling__Quartz__quartz.dataSource.default.connectionString=Server=mssql,1433;Initial Catalog=bit_devkit_gettingstarted;User Id=sa;Password=Abcd1234!;Trusted_Connection=False;TrustServerCertificate=True;MultipleActiveResultSets=True;Encrypt=False;" `
-e "Authentication__Authority=http://localhost:8080" `
-v "${PWD}/logs:/.logs" `
localhost:5500/bdk_gettingstarted-web:latest
}docker stop bdk_gettingstarted-web
docker rm -f bdk_gettingstarted-web
docker rmi localhost:5500/bdk_gettingstarted-web:latest bdk_gettingstarted-web:latestThis project is licensed under the MIT License - see the LICENSE file for details.

