Your First Solution
This walkthrough creates an EShop modular monolith from scratch. By the end, you will have a running API with two modules, a domain entity, CQRS commands and queries, and a minimal endpoint -- all generated by the Modulus CLI.
Time estimate
This tutorial takes about 10 minutes to complete.
Step 1 -- Initialize the Solution
Create a new solution with Aspire integration and RabbitMQ as the messaging transport:
modulus init EShop --aspire --transport rabbitmqThis generates a full solution structure with shared building blocks, a host web API, Aspire orchestration projects, and test scaffolding.
Generated Directory Structure
EShop/
├── EShop.slnx
├── Directory.Build.props
├── Directory.Packages.props
├── .editorconfig
├── .gitignore
├── src/
│ ├── BuildingBlocks.Domain/ # Entity, AggregateRoot, ValueObject, DomainEvent
│ ├── BuildingBlocks.Application/ # IRepository, Pagination
│ ├── BuildingBlocks.Infrastructure/ # BaseDbContext, EfRepository, IModuleRegistration, IEndpoint
│ ├── BuildingBlocks.Integration/ # IIntegrationEvent re-exports
│ └── EShop.WebApi/ # Host application (Program.cs)
├── aspire/
│ ├── EShop.AppHost/ # Aspire orchestrator (when --aspire used)
│ └── EShop.ServiceDefaults/ # Aspire defaults (when --aspire used)
└── tests/
├── EShop.Tests.Common/
├── EShop.Tests.Architecture/
└── EShop.Tests.Integration/Solution file format
Modulus generates an .slnx file, which is the modern XML-based solution format introduced in .NET 10. It replaces the older .sln format and is easier to read, merge, and diff.
Key pieces of the scaffold:
Directory.Build.propsandDirectory.Packages.props-- Central package management so every project uses consistent dependency versions (allModulusKit.*packages are pinned to one version).- BuildingBlocks projects -- Four shared projects (
BuildingBlocks.Domain/.Application/.Infrastructure/.Integration) with the base classes and interfaces used across all modules. - EShop.WebApi -- The single deployable host that composes all modules via
IModuleRegistrationand the module auto-discovery source generator. - Aspire projects --
AppHostandServiceDefaults(underaspire/) provide distributed tracing, health checks, and the developer dashboard. - Solution-level test projects --
Tests.Common,Tests.Architecture, andTests.Integrationat the solution root; each module later brings its own unit/integration/architecture test projects.
Navigate into the solution directory for the remaining steps:
cd EShopStep 2 -- Add Modules
Add two feature modules to the solution:
modulus add-module Catalog
modulus add-module OrdersEach module is created with five layers and three dedicated test projects, all under the module's own directory (project names are {Module}.{Layer}; namespaces are EShop.Catalog.{Layer}):
src/Modules/Catalog/
├── src/
│ ├── Catalog.Api/ # Endpoints & endpoint registration
│ ├── Catalog.Application/ # Commands, queries, handlers, DTOs
│ ├── Catalog.Domain/ # Entities, value objects, domain events
│ ├── Catalog.Infrastructure/ # EF Core, CatalogModule.cs (module registration)
│ └── Catalog.Integration/ # Integration events (shared contracts)
└── tests/
├── Catalog.Tests.Unit/
├── Catalog.Tests.Integration/
└── Catalog.Tests.Architecture/Layer responsibilities
| Layer | Purpose | References |
|---|---|---|
| Domain | Entities, aggregate roots, value objects, domain events | BuildingBlocks.Domain only |
| Application | Commands, queries, handlers, DTOs, interfaces | Domain, BuildingBlocks.Application |
| Infrastructure | EF Core DbContexts, repositories, CatalogModule registration | Application, Domain, Api, BuildingBlocks.Infrastructure |
| Api | Minimal API endpoints (IEndpoint classes) | Application, BuildingBlocks.Infrastructure |
| Integration | Integration event contracts shared between modules | BuildingBlocks.Integration only |
The Orders module follows the same structure. add-module adds every project to the solution, wires a ProjectReference from EShop.WebApi to the module's Infrastructure project, and runs dotnet restore -- at startup the source generator discovers each CatalogModule/OrdersModule automatically, so there is no manual composition root file to maintain. Each fresh module already answers at GET /api/catalog/sample (a scaffolded sample query + endpoint).
Step 3 -- Add an Entity
Scaffold a Product aggregate root in the Catalog module:
modulus add-entity Product --module Catalog --aggregate --properties "Name:string,Price:decimal"This generates five files:
Product.csin the Domain layer (src/Modules/Catalog/src/Catalog.Domain/Entities/) -- anAggregateRootwith the specified properties and a staticCreatefactory method.IProductRepository.csin the Domain layer -- a repository interface for the aggregate.ProductRepository.csin the Infrastructure layer -- the EF Core implementation, built onEfRepository<,>.ProductConfiguration.csin the Infrastructure layer -- an EF Core entity type configuration, picked up automatically by the DbContext'sApplyConfigurationsFromAssembly.ProductTests.csinsrc/Modules/Catalog/tests/Catalog.Tests.Unit/Domain/-- a starter unit test for the factory method.
The CLI prints the remaining manual steps: register IProductRepository in CatalogModule.cs, and optionally add a DbSet<Product> to CatalogDbContext.
Strongly Typed IDs
For type-safe entity identifiers, use the [StronglyTypedId] attribute. The source generator automatically creates EF Core value converters, JSON converters, and type converters -- no manual boilerplate. See Strongly Typed IDs for details.
Aggregate roots vs. plain entities
Use the --aggregate flag for entities that serve as aggregate roots. Aggregate roots can raise domain events and are the entry point for all state changes within the aggregate boundary. Omit the flag for child entities within an aggregate.
Step 4 -- Add a Command
Generate a CreateProduct command with a Guid result type:
modulus add-command CreateProduct --module Catalog --result-type GuidThis produces four files -- three in the Application layer under src/Modules/Catalog/src/Catalog.Application/Commands/CreateProduct/, one in the unit test project:
CreateProduct.cs-- a record implementingICommand<Guid>.CreateProductHandler.cs-- a handler implementingICommandHandler<CreateProduct, Guid>with a skeletonHandlemethod.CreateProductValidator.cs-- a FluentValidation validator, auto-registered by the source generator.CreateProductHandlerTests.cs-- a starter unit test insrc/Modules/Catalog/tests/Catalog.Tests.Unit/Commands/.
Step 5 -- Add a Query
Generate a GetProductById query:
modulus add-query GetProductById --module Catalog --result-type ProductDtoThis produces, under src/Modules/Catalog/src/Catalog.Application/Queries/GetProductById/ (plus a starter test in src/Modules/Catalog/tests/Catalog.Tests.Unit/Queries/):
GetProductById.cs-- a record implementingIQuery<ProductDto>.GetProductByIdHandler.cs-- a handler implementingIQueryHandler<GetProductById, ProductDto>with a skeletonHandlemethod.
Note that ProductDto itself is not generated -- define the record in the Application layer (queries reference whatever result type you name).
Step 6 -- Add an Endpoint
Wire the command to an HTTP endpoint:
modulus add-endpoint CreateProductEndpoint --module Catalog --method POST --route / --command CreateProduct --result-type GuidThis generates src/Modules/Catalog/src/Catalog.Api/Endpoints/CreateProductEndpoint.cs -- an IEndpoint class that maps POST / inside the module's /api/catalog route group, sends the command through the mediator, and converts the Result<Guid> to an HTTP response with Match (201 Created on success, RFC 7807 problem details on failure).
The scaffolded endpoint dispatches new CreateProduct() without a request body -- the generated command record has no properties yet. You wire the body in the next step.
Step 7 -- Fill in the Slice
The scaffolded files contain TODO placeholders. Three small edits make the slice real.
First, give the command its properties (src/Modules/Catalog/src/Catalog.Application/Commands/CreateProduct/CreateProduct.cs):
namespace EShop.Catalog.Application.Commands.CreateProduct;
public sealed record CreateProduct(string Name, decimal Price) : ICommand<Guid>;Second, implement the handler (CreateProductHandler.cs in the same folder):
using EShop.Catalog.Domain.Entities;
using EShop.Catalog.Domain.Repositories;
namespace EShop.Catalog.Application.Commands.CreateProduct;
public sealed class CreateProductHandler : ICommandHandler<CreateProduct, Guid>
{
private readonly IProductRepository _repository;
private readonly IUnitOfWork _unitOfWork;
public CreateProductHandler(IProductRepository repository, IUnitOfWork unitOfWork)
{
_repository = repository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Guid>> Handle(
CreateProduct command,
CancellationToken cancellationToken = default)
{
var product = Product.Create(Guid.NewGuid(), command.Name, command.Price);
await _repository.AddAsync(product, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result<Guid>.Success(product.Id);
}
}(Remember the CLI's next-steps output from Step 3: register IProductRepository against ProductRepository in CatalogModule.ConfigureServices, and make sure CatalogModule registers an IUnitOfWork for the context -- the scaffolded module already maps IUnitOfWork to its DbContext.)
Third, bind the request body in the generated endpoint (src/Modules/Catalog/src/Catalog.Api/Endpoints/CreateProductEndpoint.cs) by adding the command parameter to the lambda:
app.MapPost("/", async (CreateProduct command, IMediator mediator, CancellationToken ct) =>
{
var result = await mediator.Send(command, ct);
return result.Match(
id => Results.Created($"/api/catalog/{id}", id),
ApiResults.Problem);
})The same pattern applies to GetProductByIdHandler -- inject IProductRepository, return Error.NotFound("Product.NotFound", "Product not found.") when the id is unknown, and map the entity to your ProductDto.
Result pattern
All handlers return Result<T> rather than throwing exceptions for expected failure cases. Use Result<T>.Success(value) for the happy path and return an Error factory value (e.g. Error.NotFound(code, message)) for known errors -- it converts implicitly to Result<T>. The pipeline and endpoint infrastructure handle the mapping to appropriate HTTP status codes.
Step 8 -- Run the Solution
Build and run the host:
dotnet run --project src/EShop.WebApiRunning with Aspire
If you initialized with --aspire, you can run through the Aspire AppHost instead to get the developer dashboard, distributed tracing, and health check UI:
dotnet run --project aspire/EShop.AppHostThe dashboard URL (with its login token) is printed to the console on startup.
The host listens on https://localhost:5001 (and http://localhost:5000) in Development, with the Scalar API reference at /scalar/v1. Module endpoints are grouped under /api/{module}. Test them:
# The scaffolded sample endpoint works out of the box
curl https://localhost:5001/api/catalog/sample
# -> "Catalog module is running"
# The endpoint you wired in Steps 6-7
curl -X POST https://localhost:5001/api/catalog/ \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "price": 9.99}'You should receive a 201 Created response with the new product's ID.
Database connection
CatalogModule registers its DbContext with UseSqlServer(configuration.GetConnectionString("Default")), so the create endpoint needs a reachable SQL Server and a ConnectionStrings:Default value in appsettings.json (plus created tables) before the POST succeeds end to end. Generate the schema with modulus add-migration InitialCreate --module Catalog and apply it with dotnet ef database update (or context.Database.MigrateAsync() at startup). The sample endpoint has no database dependency and works immediately.
Summary
In this walkthrough you:
- Initialized a full modular monolith solution with Aspire and RabbitMQ support.
- Added two feature modules with clean architecture layers (auto-discovered by the source generator).
- Scaffolded a domain entity as an aggregate root.
- Generated CQRS command and query pairs with handlers.
- Wired an HTTP endpoint to the command through the mediator.
- Implemented business logic in the generated handlers.
- Ran the solution and verified the API.
What's Next
Dive deeper into specific areas of Modulus:
- Architecture Overview -- Understand the modular monolith structure, module boundaries, and dependency rules.
- Mediator -- Learn about pipeline behaviors, domain events, streaming queries, and the Result pattern.
- Messaging -- Set up integration events, configure transports, and enable the transactional outbox.
- CLI Reference -- Full reference for every CLI command and flag.