An application built using .NET 10 and following a Domain-Driven Design (DDD) approach by using the bITdevKit.
- bITdevKit GettingStarted Example
- Table of Contents
- Features
- Frameworks and Libraries
- Getting Started
- Developer Guidelines
- Architecture Boundaries
- Aggregate Boundaries
- Commands and Queries
- Endpoint Conventions
- Persistence and Events
- Job Changes
- Mapping Changes
- Testing Changes
- Observability Rules
- Host Composition Extensions
- Accelerate Agent Development with BDK MCP
- DevKit API Changes
- Verify a Change
- Change Checklist
- Commit Messages
- Branching Strategy
- EF Core Migrations
- Architecture
- Core Patterns
- Result Pattern (Railway-Oriented Programming)
- Aggregate Consistency Boundary Pattern
- Command-Query Separation Pattern
- Requester/Notifier Pattern (Mediator)
- Repository with Behaviors Pattern (Decorator)
- Domain Event and Outbox Pattern
- Jobs Pattern (Durable Background Work)
- Module System (Vertical Slices)
- Application Bootstrap
- Configuration Stages
- Step-by-Step Breakdown
- Step 1: Create Builder and Configure Logging
- Step 2: Register Modules
- Step 3: Register Requester and Notifier
- Step 4: Configure Durable Jobs in CoreModule
- Step 5. Register Application Endpoints
- Step 6. Configure JSON Serialization
- Step 7. Configure OpenAPI
- Step 8. Configure CORS
- Step 9. Configure Authentication/Authorization
- Step 10. Configure Health Checks
- Step 11. Configure Observability (OpenTelemetry)
- Middleware Pipeline Configuration
- Complete Request Flow
- Solution Structure
- Quick Code Examples
- Appendix A: Docker & Local Registry Usage
- Appendix B: OpenAPI Specification and API Clients
- 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 the
Customeraggregate, theEmailAddressandCustomerNumbervalue objects, the generatedCustomerId, theCustomerStatusenumeration, domain events, and domain invariants. The Application layer contains the repository-backedEmailShouldBeUniqueRule. Domain, Domain Events, Rules - Infrastructure layer with Entity Framework Core (
CoreModuleDbContext, migrations, and configurations) and a generic repository with tracing, logging, metrics, audit, and outbox behaviors. Repositories - Presentation layer with Web API Endpoints for CRUD operations on Customers, using minimal API-style routing. Endpoints
- Startup tasks for seeding domain data (CoreModuleDomainSeederTask). StartupTasks
- Durable background jobs with the DevKit Jobs subsystem (for example,
CustomerExportJob), including retries, history, dashboard pages, console commands, and MCP diagnostics. Jobs - A protected DevKit dashboard with Jobs, Metrics, Profiling, and application-specific customer management pages. Dashboard
- In-process metrics and development profiling for operational inspection. Metrics, Profiling
- Agent-assisted development through the repository-installed BDK MCP, with DevKit guidance, documentation, API lookup, project orientation, and runtime diagnostics. AI Agent Support
- Comprehensive testing: Unit tests (command/query handlers, architecture rules), Integration tests (endpoints, persistence), Architecture tests (boundary enforcement).
- A tracked OpenAPI document generated at build time and suitable for Kiota client generation.
- .NET 10
- ASP.NET Core
- Entity Framework Core for data access
- Serilog for structured logging
- Mapster for object mapping
- FluentValidation for validation
- RazorSlices for application dashboard pages
- xUnit.net, NSubstitute, Shouldly for testing
- Install the .NET 10 SDK.
global.jsonselects SDK10.0.400and permitslatestFeatureroll-forward. - Configure the database connection string in
appsettings.jsonunderModules:CoreModule:ConnectionStrings:Default. - Optionally, run
docker compose up -dto start SQL Server, Seq, and the other local infrastructure services. - Set
Presentation.Web.Serveras the startup project. - Run with
CTRL+F5to start the host at https://localhost:5001.
Access points:
- Scalar UI: https://localhost:5001/scalar
- OpenAPI Spec: https://localhost:5001/openapi.json
- Health Checks: https://localhost:5001/health
- DevKit Dashboard: https://localhost:5001/_bdk/dashboard
- Seq Dashboard (if using containers): http://localhost:15349
The application applies EF Core migrations through DatabaseMigratorService and seeds customer data through CoreModuleDomainSeederTask. Both services run only in local development or container environments.
The Core Patterns section explains the design. The rules below define how to preserve that design when changing the application.
- Keep domain invariants, aggregate behavior, value objects, and domain events in
CoreModule.Domain. The Domain project does not reference another solution layer. - Put use-case coordination in
CoreModule.Application. Application code depends on domain types and repository abstractions, not onCoreModuleDbContextor another infrastructure type. - Keep Entity Framework Core configurations, migrations, and durable runtime storage in
CoreModule.Infrastructure. - Use
CoreModule.Presentationfor module registration, Mapster configuration, endpoints, and dashboard pages. Endpoints and dashboard actions dispatch application requests throughIRequester. - Treat
Presentation.Web.Serveras the composition root. KeepProgram.csfocused on assembling the host and its middleware pipeline. - Do not reference another module's internal projects. Add an explicit contracts project when modules need a shared synchronous contract.
The architecture tests enforce the project dependencies and selected domain conventions.
- Model each consistency boundary behind one aggregate root. Keep constructors and state setters private unless a framework requires a narrower exception.
- Return
Result<T>from aggregate factories and change methods so expected validation and business-rule failures remain explicit. - Represent the complete aggregate in REST models. Create and update actions accept that representation, while focused actions still address the aggregate root by ID.
- Register repositories for aggregate roots, not for their child entities. Load and persist the aggregate as one unit.
- Change child entities only through methods on the aggregate root. Do not add independent repositories or top-level REST resources for aggregate children.
- Include the current concurrency token in update requests and return conflicts instead of overwriting a newer aggregate version.
- Define state-changing use cases as
partialclasses marked with[Command]. - Define read-only use cases as
partialclasses marked with[Query]. Queries do not intentionally change application state and remain safe to repeat. - Mark one handler method with
[Handle]and returnResultorResult<T>. Use exceptions only for unexpected failures. - Keep business invariants in the aggregate. Put rules that need repositories or another application dependency in the Application layer.
- Pass
CancellationTokenthrough the handler, repository, and other asynchronous calls. - Dispatch requests through
IRequester. Do not instantiate or invoke generated handlers directly from endpoints, dashboard actions, or jobs.
- Derive endpoint sets from
EndpointsBaseand keep routes grouped by module resource. - Keep endpoint delegates limited to HTTP binding, request dispatch, and result mapping. Do not implement business rules in an endpoint.
- Require authorization on application route groups. The included identity provider is for local development and tests, not production authentication.
- Use the DevKit result mapping extensions so the HTTP status and Problem Details response follow the
Resultoutcome. - Give every route a stable name, summary, description, accepted content type, success response, and applicable problem responses.
- Propagate the request cancellation token to
IRequester.SendAsync. - Regenerate and review
src/Presentation.Web.Server/wwwroot/openapi.jsonafter changing a route, model, response, or authorization contract.
- Access application data through
IGenericRepository<TAggregate>. Do not injectCoreModuleDbContextinto Domain or Application code. - Register repository behaviors in
CoreModuleModule. Their order is part of the operation pipeline, so review the complete chain when adding or moving a behavior. - Register a past-tense domain event inside the aggregate only after a business-significant change.
- Put event reactions in application event handlers. Keep external work and infrastructure dependencies out of the aggregate and the event type.
- Use the outbox behavior when event delivery must survive a process or dependency failure.
- Do not assume that the outbox behavior makes aggregate and outbox writes atomic. Add a surrounding database transaction when both writes must commit or roll back together.
- Add EF Core migrations in
CoreModule.Infrastructureand update the model snapshot for every persistence-model change. Do not edit historical migrations unless a correction is required before release.
- Register jobs and triggers in the owning module through
AddJobScheduler. - Give each job and trigger a stable name. Keep those names in constants when application code or operational tools depend on them.
- Inject scoped dependencies into the job constructor. Do not create a nested service scope inside the job.
- Return a failed
Resultwhen the work fails so retries and execution history record the failure. - Add a concise execution summary to the Jobs context. Do not include customer email addresses, credentials, access tokens, or other sensitive data.
- Set concurrency and retry policies deliberately. Match the policy to whether the operation is safe to repeat.
- Use
WithEntityFramework<CoreModuleDbContext>()when occurrences, attempts, leases, and history must survive a restart. UpdateIJobsContextmappings and add a migration when the Jobs persistence model changes.
- Define Mapster mappings in
CoreModuleMapperRegisterinstead of spreading mapping expressions across handlers and endpoints. - Map API models to domain concepts through their supported factories and conversions. Do not bypass value-object validation or aggregate methods.
- Return application models from commands and queries. Do not expose Entity Framework Core types or tracked domain instances as HTTP contracts.
- Add focused mapping coverage when a new value object, enumeration, child entity, or transport field needs a custom conversion.
- Test aggregate invariants and value-object validation in Domain unit tests.
- Test commands, queries, rules, and jobs through the real DevKit registration supplied by the test base or the appropriate test harness.
- Test HTTP binding, authorization, result mapping, optimistic concurrency, and persistence through
WebApplicationFactoryand the endpoint fixture. - Give endpoint tests unique data and verify important writes with a follow-up API read.
- Cover the success path and the failures owned by the changed layer. Typical endpoint cases include validation, not found, conflict, and unauthorized responses.
- Keep tests independent of execution order and shared mutable data.
- Do not add tests for dashboard page markup or dashboard routing. Test the application requests shared by the dashboard and the API instead.
- Use structured logging templates and stable property names. Do not build log messages through string interpolation.
- Preserve request correlation, tracing, and metrics when adding a new entry point or background operation.
- Keep metric tags bounded. Do not use customer IDs, email addresses, request paths with IDs, or exception messages as metric dimensions.
- Do not log customer email addresses, passwords, credentials, authorization headers, or access tokens.
- Propagate cancellation through asynchronous work and distinguish cancellation from an operation failure.
- Keep feature-specific service registration and middleware mapping in the matching
ProgramExtensions.*.csfile. - Name application service-registration methods with an
AddAppprefix and application-mapping methods with aMapprefix. Keep the fluentWithprefix for builder configuration. - Add XML documentation to every public type and public member in
ProgramExtensions.*. - Keep module-specific persistence, repository, endpoint, dashboard, startup-task, and job registration in the module rather than the host.
- Keep extension methods focused on one host concern so
Program.csremains an ordered composition overview.
This repository is ready for agent-assisted DevKit development. The local tool manifest pins BridgingIT.DevKit.Cli, .vscode/mcp.json configures the bdk MCP server, and the web host calls AddMcp(). An MCP-capable coding agent can use official DevKit knowledge while it edits code and can inspect the running application after the change.
The BDK MCP supports each stage of the development loop:
| Stage | Tools | Purpose |
|---|---|---|
| Plan | bdk_guidance_get |
Get a focused implementation checklist and the related feature areas. |
| Read | bdk_docs_search, bdk_docs_get |
Find and load the official DevKit guidance for the task. |
| Confirm | bdk_api_search, bdk_api_get |
Check concrete types, members, overloads, and signatures before writing code. |
| Orient | bdk_project_summary, bdk_capabilities_get |
Inspect the selected runtime, registered modules, and advertised capabilities. |
| Verify | bdk_mcp_self_test and feature tools |
Check the runtime connection and confirm the changed feature in the running application. |
Restore the repository tools after cloning or after the tool manifest changes:
dotnet tool restore
dotnet tool run bdk --versionThe checked-in VS Code configuration starts dotnet tool run bdk mcp over standard input and output. Reload the MCP client after restoring the tool. Other MCP-capable clients can use the same command and arguments.
Documentation, guidance, and API reference tools work without a running application. Runtime tools require the web host to run separately in local development. The BDK MCP does not start the application.
Use this workflow for DevKit changes:
- Ask the agent to load the relevant BDK guidance.
- Read the routed documentation and confirm exact API symbols.
- Compare the guidance with the current module and its tests.
- Implement the change and run the focused tests.
- Start the application, run
bdk_mcp_self_test, and inspect the affected runtime capability.
For example:
Use bdk_guidance_get for this DevKit change. Read the linked documentation, confirm the exact API symbols, and compare the guidance with this repository before editing code.
Start the application and run the bdk MCP self-test. If the selected runtime is healthy, inspect the affected capability and verify the implemented behavior.
The checked-in MCP command uses the default diagnostics toolset. Enable operations only when the task requires a controlled runtime action. Enable admin tools only for an explicit maintenance request with the required confirmation arguments.
The published DevKit documentation lives on the bITdevKit documentation site. This repository also includes the DevKit documentation under .bdk/docs/. Direct file research starts at .bdk/docs/INDEX.md. BDK MCP adds curated guidance, API reference lookup, workspace-aware runtime selection, and live evidence from the selected application. See the official AI Agent Support guide for client setup, prompts, toolsets, and safety controls.
- The DevKit documentation for this repository lives in
.bdk/docs/. Start with.bdk/docs/INDEX.mdand use it only to locate the relevant DevKit guide. - Use the repository-configured BDK MCP to get guidance, search documentation, confirm API symbols, and verify the running application.
- Read the routed guide and the XML documentation for the installed package API before changing DevKit registrations or behavior.
- Treat the installed
BridgingIT.DevKit.*NuGet packages as the source of the available API. Do not add project references to a local DevKit checkout. - Use DevKit examples to confirm a composition pattern after checking the documentation. Do not infer unsupported behavior from an example.
- Keep related DevKit packages on the same approved version and verify the restored dependency graph after a package change.
Run the smallest relevant check while developing, then run the affected test projects and a solution build before handing off the change.
dotnet test tests/Modules/CoreModule/CoreModule.UnitTests/CoreModule.UnitTests.csproj --nologo
dotnet test tests/Modules/CoreModule/CoreModule.IntegrationTests/CoreModule.IntegrationTests.csproj --nologo
dotnet build --nologo /p:UseSharedCompilation=falseAfter changing Markdown, lint every included Markdown file:
npx --yes markdownlint-cli2 "**/*.md" "#.bdk/**" "#.agents/**" "#.github/**" "#**/bin/**" "#**/obj/**"After changing runtime registration or operational behavior, start the application and use the BDK MCP self-test. Verify the affected capability through its MCP tools when those tools are available.
Before handing off a change, confirm each applicable item:
- The change preserves project and module boundaries.
- Domain changes go through the aggregate root and return expected failures through
Result<T>. - REST actions and repositories preserve the aggregate boundary.
- Commands, queries, endpoints, jobs, and repository calls propagate cancellation.
- Endpoint metadata matches the runtime request and response contract.
- Logs, job messages, and telemetry contain no credentials, tokens, or customer email addresses.
- Persistence changes include an EF Core migration and an updated model snapshot.
- Endpoint changes include an updated tracked OpenAPI document.
- Public members in
ProgramExtensions.*have XML documentation. - Relevant unit tests, integration tests, Markdown lint, and the solution build pass.
- Runtime changes pass the BDK MCP self-test and the affected capability check.
Commit messages use this format:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Common types:
| Type | Purpose |
|---|---|
feat |
New feature |
fix |
Bug fix |
docs |
Documentation only |
style |
Formatting/style (no logic) |
refactor |
Code refactor (no feature/fix) |
perf |
Performance improvement |
test |
Add/update tests |
build |
Build system/dependencies |
ci |
CI/config changes |
chore |
Maintenance/misc |
revert |
Revert commit |
Breaking changes are marked either with an exclamation mark after type/scope or with a BREAKING CHANGE: footer.
feat(core): add customer export endpoint
fix(core): handle missing email address
docs: describe branching strategy
feat!: remove deprecated endpoint
feat: allow config to extend other configs
BREAKING CHANGE: `extends` key behavior changed
Trunk-based development with short-lived feature branches. Changes merge into main through Pull Requests (PRs). Keep branches small, rebase frequently, and merge quickly to reduce drift.
A source-control branching model, where developers collaborate on code in a single branch called ‘trunk/main’ *, resist any pressure to create other long-lived development branches by employing documented techniques. They therefore avoid merge hell, do not break the build, and live happily ever after.
Key rules:
mainis always releasable- Feature branches are short-lived and scoped to a single change
- PRs are required for all merges to
main - Commit messages follow the Conventional Commits standard described in Commit Messages
gitGraph
commit id: "init"
branch feature/add-tasking
checkout feature/add-tasking
commit id: "implement"
commit id: "tests"
checkout main
merge feature/add-tasking tag: "PR merge"
commit id: "release"
flowchart LR
A[Create feature branch] --> B[Implement change]
B --> C[Open PR to main]
C --> D[Review and checks]
D -->|Approved| E[Merge to main]
D -->|Changes requested| B
Use the tasks for migrations to keep the workflow consistent and repeatable:
- Add a migration with the EF task for migration creation.
- Apply migrations with the EF task for applying migrations or updating the database.
- Keep migrations in the module infrastructure project and avoid direct edits unless a correction is required.
- For the underlying
dotnet efcommand equivalents, see src/Modules/CoreModule/CoreModule.Infrastructure/EntityFramework/README.md.
Migrations are applied automatically on application startup in development mode:
services.AddSqlServerDbContext<CoreModuleDbContext>(o => o
.UseConnectionString(moduleConfiguration.ConnectionStrings["Default"]))
.WithDatabaseMigratorService(o => o // create the database and apply existing migrations
.Enabled(environment.IsLocalDevelopment() || environment.IsContainerized()));The bITdevKit GettingStarted project implements Clean/Onion Architecture principles combined with Domain-Driven Design (DDD) and a Modular Monolith approach. This section explains the architectural decisions, layer responsibilities and how components interact.
Architectural Decisions: For rationale and alternatives behind key choices, see the Architectural Decision Records. Relevant decisions include Clean Architecture (ADR-0001), the Result pattern (ADR-0002), the modular monolith (ADR-0003), durable DevKit Jobs (ADR-0015), observability (ADR-0016), and testing (ADR-0013, ADR-0017).
Clean Architecture enforces strict dependency rules where inner layers never depend on outer layers. Dependencies flow inward toward the domain core, ensuring business logic remains independent of infrastructure concerns and delivery mechanisms.
graph TB
Client([HTTP Client]) --> Endpoints
subgraph Presentation["Presentation Layer (Outer)"]
Endpoints[Endpoints<br/>Minimal APIs]
DTOs[Request/Response DTOs]
end
subgraph Application["Application Layer"]
Requester[IRequester<br/>Mediator]
CMD[Commands & Queries<br/>CQRS]
BEHAV[Pipeline Behaviors<br/>Metrics through Timeout]
HAND[Handlers<br/>Business Orchestration]
Jobs[Background Jobs<br/>CustomerExportJob]
RULES[Application Rules<br/>EmailShouldBeUnique]
end
subgraph Domain["Domain Layer (Inner Core)"]
AGG[Aggregates<br/>Customer]
VO[Value Objects<br/>EmailAddress, CustomerNumber]
EVENTS[Domain Events<br/>CustomerCreated]
INVARIANTS[Domain Invariants<br/>Customer Changes]
end
subgraph Infrastructure["Infrastructure Layer (Outer)"]
Repos[Repositories<br/>Generic Repository]
DB[(Entity Framework<br/>SQL Server)]
Scheduler[DevKit Jobs<br/>Durable EF storage]
end
%% Request Flow
Endpoints --> DTOs
DTOs --> Requester
Requester --> BEHAV
BEHAV --> CMD
CMD --> HAND
%% Handler to Domain
HAND --> AGG
HAND --> RULES
%% Jobs to Domain & Infrastructure
Jobs --> AGG
Jobs --> Repos
%% Domain Internal
AGG --> VO
AGG --> EVENTS
AGG --> INVARIANTS
%% Persistence Flow
HAND --> Repos
Repos --> DB
Scheduler -.triggers.-> Jobs
%% Styling
style Domain fill:#E8F5E9,stroke:#4CAF50,stroke-width:3px
style AGG fill:#66BB6A,color:#fff
style VO fill:#66BB6A,color:#fff
style EVENTS fill:#66BB6A,color:#fff
style INVARIANTS fill:#66BB6A,color:#fff
style Application fill:#E3F2FD,stroke:#2196F3,stroke-width:2px
style Presentation fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px
style Infrastructure fill:#FFF3E0,stroke:#FF9800,stroke-width:2px
Location: src/Modules/CoreModule/CoreModule.Domain
Responsibilities:
- Pure business logic and domain rules
- Aggregates, Entities (e.g.,
Customer) - Value Objects (e.g.,
EmailAddress,CustomerNumber) - Domain Events (e.g.,
CustomerCreatedDomainEvent) - Domain invariants and rule composition inside aggregate operations
- Enumerations (e.g.,
CustomerStatus)
Solution project dependencies: None. The project references the DevKit domain packages.
Key Principle: The domain layer is persistence-ignorant. It depends on the DevKit domain abstractions but not on Entity Framework Core, ASP.NET Core, or another solution layer.
Location: src/Modules/CoreModule/CoreModule.Application
Responsibilities:
- Use cases orchestration via Commands and Queries
- Request/Response DTOs (
CustomerModel) - Handlers that coordinate domain operations
- Validation logic (FluentValidation)
- Repository-backed application rules such as
EmailShouldBeUniqueRule - Background Jobs (e.g.,
CustomerExportJob)
Project dependencies: Domain layer only. DevKit application packages provide Jobs, mapping abstractions, generated commands, queries, and handlers.
Key Principle: Application defines what the system does, not how it's implemented (infrastructure) or how it's exposed (presentation).
Location: src/Modules/CoreModule/CoreModule.Infrastructure
Responsibilities:
- Database context and EF Core configurations
- Migrations
- Durable outbox and Jobs persistence
Project dependencies: Domain and Application layers
Key Principle: Infrastructure provides implementations of abstractions defined by inner layers.
Location: src/Modules/CoreModule/CoreModule.Presentation
Responsibilities:
- HTTP endpoints (Minimal APIs)
- Module registration and configuration
- Mapster mapping configuration
- DevKit dashboard pages for customer management
- Request/Response transformations
Project dependencies: Application and Infrastructure layers. The module registration uses CoreModuleDbContext, while endpoints and dashboard actions dispatch through IRequester.
Key Principle: Presentation is a thin adapter that translates HTTP requests into application commands/queries and responses back to HTTP.
The architecture enforces these strict dependency rules (validated by architecture tests):
- Domain → no solution layer: Domain references only DevKit domain packages.
- Application → Domain: Application has one solution project reference, to Domain.
- Infrastructure → Domain + Application: Infrastructure owns Entity Framework Core persistence.
- Presentation → Application + Infrastructure: Presentation owns module composition, endpoints, mapping registration, and dashboard pages.
- Host → Presentation + Infrastructure: The web host is the composition root.
The architecture tests verify the Domain, Application, and Infrastructure dependency restrictions. They also enforce aggregate construction, value-object construction, and configured module-boundary namespaces.
Understanding how a request flows through the architecture is crucial. Here's a complete end-to-end flow for creating a customer:
sequenceDiagram
participant Client
participant Endpoint as CustomerEndpoints<br/>(Presentation)
participant Req as IRequester<br/>(Mediator)
participant Pipeline as Pipeline Behaviors
participant Handler as CustomerCreateCommandGeneratedHandler<br/>(Application)
participant Domain as Customer Aggregate<br/>(Domain)
participant Repo as IGenericRepository<br/>(Abstraction)
participant RepoBehaviors as Repository Behaviors
participant DbCtx as CoreModuleDbContext<br/>(Infrastructure)
participant DB as SQL Server Database
Client->>Endpoint: POST /api/coremodule/customers<br/>{firstName, lastName, email}
Endpoint->>Req: SendAsync(CustomerCreateCommand)
Req->>Pipeline: Process request
Note over Pipeline: 1. Metrics<br/>2. Tracing<br/>3. Module Scope<br/>4. Validation<br/>5. Retry<br/>6. Timeout
Pipeline->>Handler: HandleAsync(command)
Handler->>Handler: Create CustomerCreateContext
Handler->>Handler: Check application rules
Note over Handler: Required names<br/>Forbidden last name<br/>Unique email
Handler->>Domain: Customer.Create(...)
Domain->>Domain: Validate invariants
Domain->>Domain: Register CustomerCreatedDomainEvent
Domain-->>Handler: Result<Customer>
Handler->>Repo: InsertResultAsync(customer)
Repo->>RepoBehaviors: Execute behavior chain
Note over RepoBehaviors: 1. Tracing<br/>2. Logging<br/>3. Metrics<br/>4. Audit State<br/>5. Outbox Events
RepoBehaviors->>DbCtx: SaveChangesAsync()
DbCtx->>DB: INSERT INTO Customers
DB-->>DbCtx: Success
DbCtx-->>RepoBehaviors: Saved entity
RepoBehaviors-->>Repo: Result<Customer>
Repo-->>Handler: Result<Customer>
Handler->>Handler: Map to CustomerModel
Handler-->>Pipeline: Result<CustomerModel>
Pipeline-->>Req: Result<CustomerModel>
Req-->>Endpoint: Result<CustomerModel>
Endpoint->>Endpoint: MapHttpCreated()
Endpoint-->>Client: 201 Created<br/>Location: /api/coremodule/customers/{id}
Key Stages:
- HTTP Request: Client sends JSON payload to endpoint
- Command Creation: Endpoint creates
CustomerCreateCommandwith DTO - Pipeline processing: Metrics, tracing, module scope, validation, retry, and timeout behaviors wrap the request.
- Handler execution: The generated handler calls
CustomerCreateCommand.HandleAsync. - Application and domain checks: The handler checks repository-backed rules, and
Customer.Createenforces aggregate invariants. - Repository persistence: The repository behavior chain stores the aggregate and its outbox events.
- Response mapping: The handler maps the aggregate to
CustomerModel, and the endpoint maps the result to HTTP 201.
The application follows a Modular Monolith pattern where each module is a vertical slice containing all layers:
src/Modules/CoreModule/
├── CoreModule.Domain/ (Business logic)
├── CoreModule.Application/ (Use cases)
├── CoreModule.Infrastructure/ (Persistence)
└── CoreModule.Presentation/ (HTTP endpoints)
Module Characteristics:
- Self-contained: CoreModule groups its domain, use cases, persistence, endpoints, dashboard pages, and tests.
- Single deployment: The module runs inside
Presentation.Web.Serveras part of one application process. - Explicit persistence: CoreModule owns
CoreModuleDbContextand its EF Core migrations.
Module Boundary Rules (enforced by architecture tests):
- Modules cannot directly reference namespaces configured as another module's internal layers.
- The current solution has one module and no
.Contractsproject. - If another module needs synchronous integration, add an explicit contracts project instead of referencing its internal layers.
See CoreModule README for module-specific implementation details.
The bITdevKit GettingStarted application is built on several key design patterns that work together to create a robust, maintainable and testable architecture.
The Result Pattern replaces exception-based error handling with explicit success/failure types, enabling functional composition and railway-oriented programming.
graph LR
Start([Start]) --> Step1{Step 1<br/>Validation}
Step1 -->|Success| Step2{Step 2<br/>Business Rule}
Step1 -->|Failure| Failure([Failure Path])
Step2 -->|Success| Step3{Step 3<br/>Persistence}
Step2 -->|Failure| Failure
Step3 -->|Success| Step4[Step 4<br/>Mapping]
Step3 -->|Failure| Failure
Step4 --> Success([Success Path])
style Success fill:#4CAF50
style Failure fill:#f44336
Key Concept: Once a step fails, all subsequent steps are skipped and the failure flows directly to the end.
public struct Result<T> : IResult<T>
{
public T Value { get; }
public bool IsSuccess { get; }
public bool IsFailure { get; }
public IReadOnlyList<string> Messages { get; }
public IReadOnlyList<IResultError> Errors { get; }
}Transformation Methods:
Bind(): Chain an operation that returns anotherResultBindAsync(): Chain an asynchronous operation that returns anotherResultBindResult(): Run an innerResultoperation and merge its value into the current context
Validation Methods:
Ensure(): Inline validationUnless()/UnlessAsync(): Business rule checking
Mapping Methods:
Map(): Transform to different type
Side Effect Methods:
Tap(): Execute action without changing resultLog(): bITdevKit logging extension
See CoreModule README - Handler Implementation Example for detailed examples.
An aggregate is the consistency boundary for a related set of domain objects. Callers create and change the aggregate through methods that enforce its invariants. They do not set its state directly.
The domain model divides these responsibilities across a small set of building blocks:
| Building block | Responsibility |
|---|---|
| Aggregate root | Controls access to the aggregate and protects rules that span its entities and value objects. |
| Entity | Has a stable identity and owns behavior for state that changes over time. |
| Value object | Represents a validated domain concept through its value and structural equality. |
| Typed identifier | Prevents identifiers for unrelated entity types from being mixed accidentally. |
| Enumeration | Gives a closed set of domain choices behavior and type safety. |
The same boundary shapes the REST API. CustomerModel represents the complete aggregate for API clients, including child addresses and the concurrency token. Create and update actions accept this aggregate representation, and read actions return it. Focused actions, such as a status change or deletion, still address the aggregate root by ID and dispatch a command for that root. The API does not expose child entities as independent top-level resources or let an endpoint mutate them directly.
The repository boundary follows the same rule. IGenericRepository<Customer> loads and persists the Customer aggregate as one unit. The application does not register a separate repository for Address. Callers add, change, or remove addresses through Customer methods, which prevents persistence code from bypassing the aggregate root's invariants.
Factory methods and change methods return Result<T> so validation and business-rule failures remain explicit. The fluent change API applies operations in declaration order and registers domain events only when the change succeeds. Application handlers coordinate use cases and invoke aggregate behavior.
See the official DevKit Domain guide for aggregate, entity, value object, identifier, and change-operation APIs.
Command-query separation gives every application request one clear purpose. A command may change application state. A query reads state without intentional side effects and should be safe to repeat.
| Request type | Responsibility | Typical result |
|---|---|---|
| Command | Perform a state-changing use case. | Result or Result<T> describing the outcome. |
| Query | Retrieve data without changing application state. | Result<T> containing a model or collection. |
DevKit source generation turns classes marked with [Command] or [Query] into request types. A method marked with [Handle] supplies the handler implementation, and its Result<T> return type determines the generated response type.
Command-query separation defines the meaning of a request. The Requester pattern handles dispatch and applies the shared behavior pipeline. This distinction keeps write rules separate from read concerns without coupling callers to handler implementations.
See the official DevKit Commands and Queries guide for declaration, dispatch, validation, and testing APIs.
The Requester/Notifier pattern is bITdevKit's implementation of the Mediator pattern, decoupling request senders from handlers and enabling cross-cutting concerns through pipeline behaviors.
graph TB
subgraph "Client Code (Endpoint)"
Client[CustomerEndpoints]
end
subgraph "Mediator (IRequester)"
Req[IRequester.SendAsync]
Pipeline[Pipeline Behaviors]
end
subgraph HandlerStage["Handler"]
GeneratedHandler[CustomerCreateCommandGeneratedHandler]
end
subgraph "Cross-Cutting Behaviors"
B1[MetricsRequestBehavior]
B2[TracingBehavior]
B3[ModuleScopeBehavior]
B4[ValidationPipelineBehavior]
B5[RetryPipelineBehavior]
B6[TimeoutPipelineBehavior]
end
Client -->|CustomerCreateCommand| Req
Req --> B1
B1 --> B2
B2 --> B3
B3 --> B4
B4 --> B5
B5 --> B6
B6 --> GeneratedHandler
GeneratedHandler -->|Result<CustomerModel>| B6
B6 --> B5
B5 --> B4
B4 --> B3
B3 --> B2
B2 --> B1
B1 --> Req
Req -->|Result<CustomerModel>| Client
style GeneratedHandler fill:#4CAF50
style Pipeline fill:#2196F3
Pipeline behaviors wrap handlers to provide cross-cutting concerns:
- MetricsRequestBehavior: Records bounded request metrics
- TracingBehavior: Creates request activities
- ModuleScopeBehavior: Sets the module context
- ValidationPipelineBehavior: Runs generated FluentValidation rules
- RetryPipelineBehavior: Retries thrown exceptions according to handler policy
- TimeoutPipelineBehavior: Enforces the handler timeout
builder.Services.AddRequester()
.AddHandlers()
.WithDefaultBehaviors();
builder.Services.AddNotifier()
.AddHandlers()
.WithDefaultBehaviors();The Repository pattern abstracts data access, while the Decorator pattern adds cross-cutting concerns through behavior chains.
graph LR
Handler[Handler] --> Tracing[TracingBehavior]
Tracing --> Logging[LoggingBehavior]
Logging --> Metrics[MetricsBehavior]
Metrics --> Audit[AuditStateBehavior]
Audit --> Outbox[OutboxDomainEventBehavior]
Outbox --> Repo[EntityFrameworkRepository]
Repo --> DB[(Database)]
style Tracing fill:#2196F3
style Logging fill:#2196F3
style Metrics fill:#2196F3
style Audit fill:#2196F3
style Outbox fill:#2196F3
style Repo fill:#4CAF50
- RepositoryTracingBehavior: OpenTelemetry spans for distributed tracing
- RepositoryLoggingBehavior: Structured logging with duration measurement
- RepositoryMetricsBehavior: Repository operation metrics
- RepositoryAuditStateBehavior: Automatic audit metadata (CreatedBy, UpdatedBy)
- RepositoryOutboxDomainEventBehavior: Outbox pattern for reliable event delivery
services.AddEntityFrameworkRepository<Customer, CoreModuleDbContext>()
.WithBehavior<RepositoryTracingBehavior<Customer>>()
.WithBehavior<RepositoryLoggingBehavior<Customer>>()
.WithBehavior<RepositoryMetricsBehavior<Customer>>()
.WithBehavior<RepositoryAuditStateBehavior<Customer>>()
.WithBehavior<RepositoryOutboxDomainEventBehavior<Customer, CoreModuleDbContext>>();See CoreModule README - Repository Behaviors Configuration for a detailed explanation.
A domain event records a business fact that has already happened. The aggregate registers the event as part of a successful state change but does not know which handlers will react to it.
The repository outbox behavior separates aggregate persistence from event delivery. It captures registered domain events and writes durable outbox records. A background worker later claims pending records, publishes them through the notifier, and records whether delivery succeeded. Event handlers therefore stay independent of the storage and retry mechanism.
sequenceDiagram
participant Aggregate
participant Repository
participant Outbox as Outbox behavior
participant Database
participant Worker as Outbox worker
participant Notifier
participant Handler as Event handler
Aggregate->>Aggregate: Register a domain event
Repository->>Database: Persist the aggregate
Repository->>Outbox: Pass registered events
Outbox->>Database: Persist outbox records
Worker->>Database: Claim pending records
Worker->>Notifier: Publish the domain event
Notifier->>Handler: Handle the event
Handler-->>Notifier: Complete
Worker->>Database: Record the delivery result
The outbox behavior does not make the aggregate write and outbox write atomic by itself. When atomic persistence is required, a surrounding database transaction must include both writes. The durable record then protects delivery across process restarts and lets the worker retry failed publications.
Use direct domain-event publication only when losing an event after the aggregate has been stored is acceptable. Use the outbox when event delivery must survive a host or dependency failure. See the official DevKit Domain Events guide for registration, publication, outbox processing, and transaction guidance.
The DevKit Jobs pattern separates background work from scheduling and runtime coordination. A job defines what to execute. A trigger defines when to create work. The scheduler owns dispatch, concurrency, retries, leases, and execution history.
Job and trigger definitions stay in code. A store provider persists operational state around those definitions. Persisted runtime state can pause or disable a registration, but it does not replace the code-first definition.
The Jobs model separates authoring concerns from runtime records:
| Concept | Responsibility |
|---|---|
| Job definition | Gives the job a stable name, implementation, data contract, lifetime, concurrency limit, and execution policies. |
| Trigger definition | Describes how work starts, such as a cron schedule, a delay, a startup delay, or manual dispatch. |
| Occurrence | Represents one unit of work created from a trigger or dispatch request. |
| Execution | Records one attempt to run an occurrence. A retry creates another execution for the same occurrence. |
| Lease | Gives one scheduler instance temporary ownership of an occurrence during execution. |
| Store provider | Persists occurrences, executions, runtime state, leases, batches, and history. |
A class-based job implements IJob, usually through JobBase or JobBase<TData>. ExecuteAsync receives an IJobExecutionContext and a cancellation token, then returns a Result. A successful result completes the attempt. A failed result lets the scheduler apply the configured retry policy and retain the failure in execution history.
The execution context keeps different kinds of data separate. Data is the typed durable payload. Properties contains immutable values that travel with the occurrence. Messages collects human-readable execution notes. Items stores attempt-local values and is not persisted as occurrence data.
A trigger evaluation or manual dispatch creates an occurrence. An occurrence is one durable unit of work. Each retry creates another execution attempt for the same occurrence.
sequenceDiagram
participant Source as Trigger or dispatcher
participant Scheduler as Jobs runtime
participant Store as Store provider
participant Job as IJob
Source->>Scheduler: Create occurrence
Scheduler->>Store: Persist occurrence
Scheduler->>Store: Acquire lease
Scheduler->>Job: ExecuteAsync(context)
Job-->>Scheduler: Result
Scheduler->>Store: Store execution and history
Scheduler->>Store: Release lease
alt Failure and attempts remain
Scheduler->>Store: Schedule the next attempt
else Terminal result
Scheduler->>Store: Complete the occurrence
end
AddJobScheduler() collects code-first definitions from the host and its modules. The default in-memory provider suits transient use and tests. WithEntityFramework<TContext>() selects durable persistence and requires a context that implements IJobsContext.
The operational services use the same stored state. IJobSchedulerService dispatches work and changes runtime state. IJobSchedulerQueryService returns views for dashboards and support tools. IJobSchedulerMaintenanceService handles cleanup and repair. Optional endpoints and console commands expose these operations without giving callers direct access to the store.
Use Jobs when background work needs a schedule, durable dispatch, retry control, concurrency control, or execution history. Use a command or query when the caller needs an immediate application response without scheduler state. See the official DevKit Jobs guide for registration and testing APIs.
The Modular Monolith pattern organizes code into self-contained vertical slices, each representing a business capability.
src/Modules/CoreModule/
├── CoreModule.Domain/ # Business logic layer
│ ├── Model/ # Aggregates, Value Objects
│ └── Events/ # Domain Events
├── CoreModule.Application/ # Use cases layer
│ ├── Commands/ # Write operations
│ ├── Queries/ # Read operations
│ ├── Models/ # DTOs
│ ├── Jobs/ # Background jobs
│ └── Events/ # Event handlers
├── CoreModule.Infrastructure/ # Persistence layer
│ └── EntityFramework/ # DbContext, Configurations, Migrations
└── CoreModule.Presentation/ # API layer
├── Dashboard/ # Customer dashboard page set and RazorSlices
├── Web/Endpoints/ # HTTP endpoints
└── CoreModuleModule.cs # Module registration
var builder = DevKitWebApplication.CreateBuilder(args)
.AddConfiguration()
.AddLogging()
.AddModules(modules => modules
.WithModule(new CoreModuleModule()))
.AddMcp();Program.cs is the composition root. It coordinates module registration, shared application services, middleware, and endpoints. The ProgramExtensions.*.cs files keep authentication, OpenAPI, health checks, observability, and other host-specific registrations in focused methods.
graph TD
A[Create DevKit web builder] --> B[Configuration and logging]
B --> C[Modules and MCP]
C --> D[Requester, notifier, and mapping]
D --> E[JSON, Problem Details, and endpoints]
E --> F[OpenAPI and CORS]
F --> G[Authentication, identity provider, and dashboard]
G --> H[Health, profiling, metrics, and OpenTelemetry]
H --> I[Build application]
I --> J[Configure middleware]
J --> K[Map endpoints]
K --> L[Run application]
style A fill:#4CAF50
style I fill:#4CAF50
style L fill:#4CAF50
var builder = DevKitWebApplication.CreateBuilder(args)
.AddConfiguration()
.AddLogging()
.AddModules(modules => modules
.WithModule(new CoreModuleModule()))
.AddMcp();This creates a DevKit-aware wrapper around WebApplicationBuilder. The starter extensions configure the host, Serilog, CoreModule, and local MCP discovery.
AddModules invokes CoreModuleModule.Register. CoreModule registers its startup task, Jobs scheduler, CoreModuleDbContext, repository behavior chain, and customer endpoints. Handler discovery remains a shared host registration in the next step.
builder.Services.AddRequester()
.AddHandlers()
.WithDefaultBehaviors();
builder.Services.AddNotifier()
.AddHandlers()
.WithDefaultBehaviors();AddHandlers discovers generated and manual handlers. The local WithDefaultBehaviors extensions register metrics, tracing, module scope, validation, retry, and timeout behaviors for requests. The notifier pipeline also records notification and notification-handler metrics.
services.AddJobScheduler(configuration)
.StartupDelay(TimeSpan.FromSeconds(30))
.WithJob<CustomerExportJob>(CustomerExportJob.JobName, job => job
.Description("Exports all customers from the repository.")
.Module(this.Name)
.UseLifetime(ServiceLifetime.Scoped)
.WithConcurrency(1)
.WithRetry(retry => retry.MaxAttempts(3).FixedDelay(TimeSpan.FromSeconds(1)))
.AddTrigger(CustomerExportJob.TriggerName, trigger => trigger.Cron(CronExpressions.EveryMinute)))
.WithEntityFramework<CoreModuleDbContext>()
.WithBehavior<ModuleScopeBehavior>()
.AddEndpoints()
.AddConsoleCommands();CoreModule registers CoreModule_CustomerExportJob with the cron trigger. The trigger runs every minute. Jobs uses scoped resolution, one concurrent execution, three attempts with a one-second fixed delay, and durable storage in CoreModuleDbContext. The registration also adds module scope, operational endpoints, and Jobs console commands. The host Metrics, Dashboard, and MCP registrations expose the scheduler state to their respective tools.
// CoreModuleModule.Register
services.AddEndpoints<CustomerEndpoints>();
// Program.cs
builder.Services.AddEndpoints<SystemEndpoints>(
builder.Environment.IsLocalDevelopment() || builder.Environment.IsContainerized());CoreModule owns the customer API registration. The host adds DevKit system endpoints only for local development and container environments. Jobs, Metrics, and Dashboard register their own endpoint sets through their feature builders.
builder.Services.ConfigureJson();
builder.Services.AddControllers();
builder.Services.AddProblemDetails(options => Configure.ProblemDetails(options, true));The host applies the shared JSON conventions and Problem Details mapping. Controllers remain registered because build-time OpenAPI generation requires their API explorer services.
builder.Services.AddAppOpenApi(builder.Configuration);AddAppOpenApi registers the DevKit diagnostic, result-problem, document-info, and authentication transformers. In local development and containers, MapOpenApi and MapScalar expose the runtime document and Scalar UI.
builder.Services.AddCors(builder.Configuration);The CORS extension reads the policies from application configuration. UseCors applies the configured default policy in the HTTP pipeline.
builder.Services.AddScoped<ICurrentUserAccessor, HttpCurrentUserAccessor>();
builder.Services.AddJwtBearerAuthentication(builder.Configuration);
builder.Services.AddAppIdentityProvider(
builder.Environment.IsLocalDevelopment() || builder.Environment.IsContainerized(),
builder.Configuration);
builder.Services.AddAppDashboard(
builder.Environment.IsLocalDevelopment() || builder.Environment.IsContainerized(),
builder.Configuration);JWT bearer authentication protects the customer endpoints. Local development and container environments also enable the fake identity provider and the role-protected DevKit dashboard. The dashboard loads the Jobs pages and the CoreModule customer page set as plugins.
builder.Services.AddAppHealthChecks();The host registers a self check and maps /health/live, /health/ready, and /health.
builder.Services.AddProfiling(options => options
.Enabled(builder.Environment.IsLocalDevelopment()))
.AddConsoleCommands(builder.Environment.IsLocalDevelopment());
builder.Services.AddMetrics(options => options
.Enabled()
.AddEndpoints());
builder.Services.AddAppOpenTelemetry(builder.Configuration, builder.Environment);
builder.Services.AddConsoleCommandsInteractive();DevKit Metrics records application, request, repository, and Jobs measurements. OpenTelemetry exports runtime, ASP.NET Core, HTTP client, SQL client, and DevKit telemetry according to configuration. Profiling and its console commands run only in local development. The interactive console host is always registered.
The middleware pipeline processes HTTP requests in order:
graph TD
Request[HTTP Request] --> Rule[UseRuleLogger]
Rule --> Result[UseResultLogger]
Result --> ProblemDetails[UseProblemDetails]
ProblemDetails --> HTTPS[UseHttpsRedirection]
HTTPS --> Static[UseDefaultFiles and UseStaticFiles]
Static --> Correlation[UseRequestCorrelation]
Correlation --> ModuleCtx[UseRequestModuleContext]
ModuleCtx --> ReqLog[UseRequestLogging]
ReqLog --> Metrics[UseRequestMetrics]
Metrics --> CORS[UseCors]
CORS --> Modules[UseModules]
Modules --> Auth[UseAuthentication]
Auth --> Authz[UseAuthorization]
Authz --> UserLog[UseCurrentUserLogging]
UserLog --> Routing{Endpoint selection}
Routing --> HealthChecks[Health endpoints]
Routing --> MapModules[Module routes]
Routing --> Controllers[Controllers]
Routing --> Endpoints[DevKit and application endpoints]
Routing --> Readme[Local README endpoint]
HealthChecks --> Response[HTTP Response]
MapModules --> Response
Controllers --> Response
Endpoints --> Response
Readme --> Response
style Request fill:#4CAF50
style Response fill:#4CAF50
Key middleware:
- UseRequestCorrelation: Assigns unique correlation ID
- UseRequestModuleContext: Determines handling module
- UseRequestMetrics: Records HTTP request measurements
- UseProblemDetails: RFC 7807 error responses
- UseAuthentication/UseAuthorization: Security layer
UseDefaultFiles and MapReadme activate only in local development. OpenAPI and Scalar endpoints activate in local development and container environments. After endpoint mapping, the host starts interactive console statistics and commands.
1. The client sends POST /api/coremodule/customers.
2. Logging and Problem Details middleware wrap the request, then HTTPS redirection runs.
3. Correlation, module context, request logging, request metrics, and CORS middleware run.
4. Authentication validates the JWT, and authorization checks the endpoint requirement.
5. CustomerEndpoints creates CustomerCreateCommand and calls IRequester.SendAsync.
6. Metrics, tracing, module scope, validation, retry, and timeout behaviors wrap the generated handler.
7. CustomerCreateCommand.HandleAsync checks application rules and calls Customer.Create.
8. The repository behavior chain persists the aggregate and its outbox events.
9. The handler maps the aggregate to CustomerModel and returns Result<CustomerModel>.
10. MapHttpCreated converts the result to HTTP 201 with a Location header.
11. The middleware pipeline records completion and returns the response.
├── src
│ ├── Modules
│ │ └── CoreModule
│ │ ├── CoreModule.Application # Commands, Queries, Models, Jobs, Seeder
│ │ ├── CoreModule.Domain # Aggregates, Value Objects, Events
│ │ ├── CoreModule.Infrastructure # DbContext, Configurations, Migrations
│ │ └── CoreModule.Presentation # Module, Endpoints, Mapping, Dashboard
│ └── Presentation.Web.Server # Host, ProgramExtensions, Static UI
├── tests
│ └── Modules
│ ├── CoreModule.UnitTests # Unit tests (handlers, domain)
│ ├── CoreModule.IntegrationTests # Integration tests (endpoints, DB)
│ └── CoreModule.Benchmarks # Performance benchmarks
├── bITdevKit.Examples.GettingStarted.slnx # Solution file
└── docker-compose.yml # Container definitions
These examples show how one customer request moves through the solution. Start with the linked source file when you copy a pattern. The snippets omit supporting methods and metadata so that the responsibility of each layer stays visible.
Commands describe application operations that change state. The [Command] attribute generates the request and handler plumbing, while [Validate] and [Handle] keep validation and orchestration beside the command contract. Dependencies declared by the [Handle] method are resolved from dependency injection when IRequester dispatches the command.
[Command]
public partial class CustomerCreateCommand
{
public CustomerCreateCommand(CustomerModel model)
{
this.Model = model;
}
public CustomerModel Model { get; set; }
[Validate]
private static void Validate(InlineValidator<CustomerCreateCommand> validator)
{
validator.RuleFor(command => command.Model).NotNull();
validator.When(command => command.Model != null, () =>
{
validator.RuleFor(command => command.Model.FirstName).NotNull().NotEmpty();
validator.RuleFor(command => command.Model.LastName).NotNull().NotEmpty();
validator.RuleFor(command => command.Model.Email).NotNull().NotEmpty();
});
}
}The linked [Handle] method checks the email uniqueness rule, obtains a customer number, creates the aggregate, persists it through IGenericRepository<Customer>, and maps it to CustomerModel. When you add a command, keep request validation in [Validate], business invariants in the aggregate, and persistence behind a repository. Return failures through Result<T> so that endpoints and pipeline behaviors can handle them consistently.
Queries retrieve data without intentionally changing application state. [Query] generates the same requester integration as [Command], and the return type of [Handle] defines the generated query result type.
[Query]
public partial class CustomerFindAllQuery
{
public FilterModel Filter { get; set; }
}The linked [Handle] method passes FilterModel to FindAllResultAsync, propagates the cancellation token, and maps the returned aggregates to CustomerModel instances. For a new query, expose only the lookup or filter inputs that the caller needs. Use a result-based repository method and return application models rather than persistence types.
Customer is the consistency boundary for customer state. Private constructors and private setters prevent callers from bypassing its factory and change methods. [TypedEntityId<Guid>] generates the customer-specific ID type used by the aggregate.
[TypedEntityId<Guid>]
public class Customer : AuditableAggregateRoot<CustomerId>, IConcurrency
{
private Customer() { }
private Customer(string firstName, string lastName, EmailAddress email, CustomerNumber number)
{
this.FirstName = firstName;
this.LastName = lastName;
this.Email = email;
this.Number = number;
}
public string FirstName { get; private set; }
public string LastName { get; private set; }
public CustomerNumber Number { get; private set; }
public EmailAddress Email { get; private set; }
public CustomerStatus Status { get; private set; } = CustomerStatus.Lead;
public static Result<Customer> Create(
string firstName,
string lastName,
EmailAddress email,
CustomerNumber number)
{
return Result<Customer>.Success()
.Ensure(_ => !string.IsNullOrWhiteSpace(firstName) && !string.IsNullOrWhiteSpace(lastName),
Errors.Validation.Error(Resources.Validator_NameBothFirstAndLastRequired, nameof(firstName)))
.Ensure(_ => lastName != "notallowed",
Errors.Validation.Error(Resources.Validator_NotAllowedValue, nameof(lastName)))
.Ensure(_ => email != null,
Errors.Validation.Error(Resources.Validator_MustNotBeEmpty, nameof(email)))
.Ensure(_ => number != null,
Errors.Validation.Error(Resources.Validator_MustNotBeEmpty, nameof(number)))
.Bind(_ => new Customer(firstName, lastName, email, number))
.Tap(customer => customer.DomainEvents
.Register(new CustomerCreatedDomainEvent(customer))
.Register(new EntityCreatedDomainEvent<Customer>(customer)));
}
public Result<Customer> ChangeEmail(EmailAddress email)
{
return this.Change()
.Ensure(_ => email != null,
Errors.Validation.Error(Resources.Validator_MustNotBeEmpty, nameof(email)))
.Set(customer => customer.Email, email)
.Register(customer => new CustomerUpdatedDomainEvent(customer))
.Apply();
}
}Create and ChangeEmail return Result<Customer> so that invalid state does not become a normal success path. The fluent Change() builder applies guards, updates changed values, and registers events in declaration order. Add new customer behavior as an aggregate method, keep infrastructure dependencies outside the domain, and register a domain event only when the change represents a useful business fact.
Value objects give validation and equality rules a domain name. EmailAddress.Create trims and normalizes input before it runs the DevKit email rule. GetAtomicValues makes two instances with the same normalized value equal.
public class EmailAddress : ValueObject
{
private EmailAddress()
{
}
private EmailAddress(string value) => this.Value = value;
public string Value { get; private set; }
public static Result<EmailAddress> Create(string value)
{
return Result<string>.Success(value?.Trim()?.ToLowerInvariant())
.Bind(normalized => Rule
.Add(RuleSet.IsValidEmail(normalized))
.Check()
.ToResult(new EmailAddress(normalized)));
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return this.Value;
}
}Create an EmailAddress through its factory when data enters the domain and propagate a failed result to the caller. Follow the same pattern for a new value object: keep construction controlled, normalize once, validate in the factory, and list every value that participates in equality. Add focused tests for valid input, rejected input, normalization, and equality.
DevKit enumerations model a fixed set of domain choices that need more data than a C# enum can hold. Each CustomerStatus has a stable numeric ID, a transport value, an enabled flag, and a description. The partial-class generator adds constructors, lookup methods such as GetAll, and conversions.
public partial class CustomerStatus : Enumeration
{
public static readonly CustomerStatus Lead = new(1, nameof(Lead), true, "Lead customer");
public static readonly CustomerStatus Active = new(2, nameof(Active), true, "Active customer");
public static readonly CustomerStatus Retired = new(3, nameof(Retired), true, "Retired customer");
public bool Enabled { get; }
public string Description { get; }
}The presentation mapper converts CustomerStatus to and from its string Value, while command validation checks incoming values against CustomerStatus.GetAll(). To add a status, declare another static instance with a unique ID and update the status, mapping, and endpoint tests that describe the accepted values.
Domain events record business facts after the aggregate changes. Customer.Create registers CustomerCreatedDomainEvent; the repository outbox behavior stores the event after persistence, and the outbox worker later publishes it to the application handler.
(CustomerCreatedDomainEvent.cs)
public partial class CustomerCreatedDomainEvent(Customer model) : DomainEventBase
{
public Customer Model { get; private set; } = model;
}Name events in the past tense and keep infrastructure work out of the event and the aggregate. Put reactions in a DomainEventHandlerBase<TEvent> implementation such as CustomerCreatedDomainEventHandler.cs. This separation lets the same handler receive direct or durable outbox-backed publication.
CoreModuleDbContext is the module's Entity Framework persistence boundary. IOutboxDomainEventContext supplies durable domain-event storage. IJobsContext supplies the runtime state, occurrence, execution, history, batch, accepted-event, and lease sets required by the durable Jobs provider.
public class CoreModuleDbContext(DbContextOptions<CoreModuleDbContext> options)
: ModuleDbContextBase(options), IOutboxDomainEventContext, IJobsContext
{
public DbSet<Customer> Customers { get; set; }
public DbSet<OutboxDomainEvent> OutboxDomainEvents { get; set; }
public DbSet<JobRuntimeStateEntity> JobRuntimeStates { get; set; }
public DbSet<JobTriggerRuntimeStateEntity> JobTriggerRuntimeStates { get; set; }
public DbSet<JobOccurrenceEntity> JobOccurrences { get; set; }
public DbSet<JobOccurrenceDependencyEntity> JobOccurrenceDependencies { get; set; }
public DbSet<JobBatchEntity> JobBatches { get; set; }
public DbSet<JobBatchOccurrenceEntity> JobBatchOccurrences { get; set; }
public DbSet<JobExecutionEntity> JobExecutions { get; set; }
public DbSet<JobExecutionHistoryEntity> JobExecutionHistory { get; set; }
public DbSet<JobBatchHistoryEntity> JobBatchHistory { get; set; }
public DbSet<JobAcceptedEventEntity> JobAcceptedEvents { get; set; }
public DbSet<JobLeaseEntity> JobLeases { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasSequence<int>(CodeModuleConstants.CustomerNumberSequenceName)
.StartsAt(100000);
base.OnModelCreating(modelBuilder);
}
}CoreModuleModule connects this context to the SQL Server repository, database migrator, outbox worker, and Jobs scheduler. When you persist a new aggregate or infrastructure feature, add its EF configuration and create a migration in CoreModule.Infrastructure. Application handlers continue to use repository contracts instead of depending on CoreModuleDbContext.
Endpoint classes translate HTTP requests into application commands and queries. The route group applies authorization once, and each route sends a request through IRequester before a DevKit result mapper creates the HTTP response.
public class CustomerEndpoints : EndpointsBase
{
public override void Map(IEndpointRouteBuilder app)
{
var group = app
.MapGroup("api/coremodule/customers")
.RequireAuthorization()
.WithTags("CoreModule.Customers");
group.MapPost("",
async ([FromServices] IRequester requester,
[FromBody] CustomerModel model,
CancellationToken ct) =>
(await requester.SendAsync(new CustomerCreateCommand(model), cancellationToken: ct))
.MapHttpCreated(v => $"/api/coremodule/customers/{v.Id}"))
.WithName("CoreModule.Customers.Create")
.WithSummary("Create a new customer")
.Accepts<CustomerModel>("application/json")
.Produces<CustomerModel>(StatusCodes.Status201Created, "application/json");
}
}The complete endpoint class contains create, read, search, update, status-change, and delete routes. Keep new endpoints thin: bind HTTP input, dispatch one command or query, pass the cancellation token, and map the result. Add a stable endpoint name plus Accepts, Produces, and problem-response metadata so that the generated OpenAPI document matches the runtime behavior.
The solution has two test layers: unit tests, and integration tests.
Application unit tests dispatch commands through the real IRequester registration from CoreModuleTestsBase. The base class supplies Mapster mappings, generated handlers, a fixed TimeProvider, an in-memory repository, and an in-memory customer-number sequence. This setup exercises the application flow without starting SQL Server or the web host.
(CustomerCreateCommandHandlerTests.cs)
[Fact]
public async Task Process_ValidRequest_SuccessResult()
{
// Arrange
var requester = this.ServiceProvider.GetService<IRequester>();
var command = new CustomerCreateCommand(
new CustomerModel() { FirstName = "John", LastName = "Doe", Email = "john.doe@example.com" });
// Act
var response = await requester.SendAsync(command, null, CancellationToken.None);
// Assert
response.ShouldBeSuccess();
response.Value.ShouldNotBeNull();
response.Value.Id.ShouldNotBe(Guid.Empty.ToString());
response.Value.FirstName.ShouldBe(command.Model.FirstName);
response.Value.LastName.ShouldBe(command.Model.LastName);
}For each command or query, cover the successful result and the failures that the application layer owns. Test aggregate invariants and value-object rules in domain tests. Leave HTTP binding, authentication, response mapping, and database behavior to the endpoint integration tests below.
Run all CoreModule unit tests:
dotnet test tests/Modules/CoreModule/CoreModule.UnitTests/CoreModule.UnitTests.csproj --nologoThe endpoint integration tests start the complete web application through WebApplicationFactory. The create test below sends a request to the customer API, checks the HTTP response, and reads the customer back through the API to verify persistence.
[Fact]
public async Task Create_ValidCustomer_ReturnsCreatedCustomerAndLocation()
{
var request = CreateCustomerRequest();
using var response = await this.fixture.Client.PostAsJsonAsync(Route, request);
response.Should().Be201Created();
var created = await ReadCustomerAsync(response);
AssertCreatedCustomer(created, request);
response.Headers.Location.ShouldNotBeNull();
response.Headers.Location.OriginalString.ShouldBe($"{Route}/{created.Id}");
var persisted = await this.GetCustomerAsync(created.Id);
AssertCustomer(persisted, created);
}CreateCustomerRequest gives each test unique customer data. AssertCreatedCustomer verifies the generated ID, customer number, concurrency version, and submitted values. The final GET confirms that the API returns the stored customer. Together, these calls exercise the endpoint, validation, requester, repository, and database paths used by an application client.
EndpointTestFixture creates one isolated database for the endpoint test collection. It starts SQL Server in a disposable Testcontainers container and applies the EF Core migrations. If Docker is unavailable on Windows, the fixture creates a uniquely named LocalDB database instead. The fixture removes the container or LocalDB database after the tests finish. The tests stop with an error if neither database option is available.
Customer endpoint tests use the DevKit fake authentication scheme. The shared client sends Authorization: FakeUser endpoint.tests@example.com, which keeps customer API tests independent of token issuance. IdentityProviderEndpointTests covers the real password grant separately and verifies that the identity provider returns a bearer token. The test does not write the access token to its output.
The endpoint suite covers:
- create requests, generated fields, response locations, persistence, and validation errors
- retrieval by ID, collection queries, JSON query filters, and
POST /search - updates, route and body ID mismatches, optimistic concurrency conflicts, and missing customers
- valid and invalid status changes
- deletion and unauthorized requests
Run all CoreModule integration tests:
dotnet test tests/Modules/CoreModule/CoreModule.IntegrationTests/CoreModule.IntegrationTests.csproj --nologoRun only the endpoint tests:
dotnet test tests/Modules/CoreModule/CoreModule.IntegrationTests/CoreModule.IntegrationTests.csproj --nologo --filter "FullyQualifiedName~Presentation.Web"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).
- Docker installed (Desktop or Engine)
- Local registry running:
docker compose up -d
docker build -t bit_devkit_gettingstarted-web:latest -f src/Presentation.Web.Server/Dockerfile .docker tag bit_devkit_gettingstarted-web:latest localhost:5500/bit_devkit_gettingstarted-web:latestdocker push localhost:5500/bit_devkit_gettingstarted-web:latestdocker run `
-d `
-p 8080:8080 `
--name bit_devkit_gettingstarted-web `
--network bit_devkit_gettingstarted `
-e ASPNETCORE_ENVIRONMENT=Development `
-e "Modules__CoreModule__ConnectionStrings__Default=Server=mssql,1433;Initial Catalog=bit_devkit_gettingstarted;User Id=sa;Password=Abcd1234!;TrustServerCertificate=True;MultipleActiveResultSets=True;Encrypt=False" `
localhost:5500/bit_devkit_gettingstarted-web:latestTest Running Container:
curl http://localhost:8080/_bdk/api/system/info -vThe project uses build-time OpenAPI document generation with Kiota for client generation.
The OpenAPI specification is generated automatically during compilation:
- On build: OpenAPI spec generated to
wwwroot/openapi.json - At runtime: Served as static file at
/openapi.json - UI: Scalar UI available at
/scalar(Development/Container only)
Kiota is Microsoft's OpenAPI-based API client generator that produces idiomatic, strongly-typed clients for multiple languages.
dotnet tool install --global Microsoft.OpenApi.Kiotakiota generate \
--openapi src/Presentation.Web.Server/wwwroot/openapi.json \
--language CSharp \
--class-name GettingStartedApiClient \
--namespace BridgingIT.DevKit.Examples.GettingStarted.Client \
--output ./generated/csharpusing var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_JWT_TOKEN");
var requestAdapter = new HttpClientRequestAdapter(
new AnonymousAuthenticationProvider(),
httpClient: httpClient);
var client = new GettingStartedApiClient(requestAdapter);
// Get all customers
var customers = await client.Api.Coremodule.Customers.GetAsync();
// Create new customer
var newCustomer = new CustomerModel
{
FirstName = "Jane",
LastName = "Doe",
Email = "jane.doe@example.com"
};
var created = await client.Api.Coremodule.Customers.PostAsync(newCustomer);
Console.WriteLine($"Created customer: {created.Id}");kiota generate \
--openapi src/Presentation.Web.Server/wwwroot/openapi.json \
--language TypeScript \
--class-name GettingStartedApiClient \
--output ./generated/typescript