Skip to content

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:

bash
modulus init EShop --aspire --transport rabbitmq

This 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.props and Directory.Packages.props -- Central package management so every project uses consistent dependency versions (all ModulusKit.* 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 IModuleRegistration and the module auto-discovery source generator.
  • Aspire projects -- AppHost and ServiceDefaults (under aspire/) provide distributed tracing, health checks, and the developer dashboard.
  • Solution-level test projects -- Tests.Common, Tests.Architecture, and Tests.Integration at the solution root; each module later brings its own unit/integration/architecture test projects.

Navigate into the solution directory for the remaining steps:

bash
cd EShop

Step 2 -- Add Modules

Add two feature modules to the solution:

bash
modulus add-module Catalog
modulus add-module Orders

Each 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

LayerPurposeReferences
DomainEntities, aggregate roots, value objects, domain eventsBuildingBlocks.Domain only
ApplicationCommands, queries, handlers, DTOs, interfacesDomain, BuildingBlocks.Application
InfrastructureEF Core DbContexts, repositories, CatalogModule registrationApplication, Domain, Api, BuildingBlocks.Infrastructure
ApiMinimal API endpoints (IEndpoint classes)Application, BuildingBlocks.Infrastructure
IntegrationIntegration event contracts shared between modulesBuildingBlocks.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:

bash
modulus add-entity Product --module Catalog --aggregate --properties "Name:string,Price:decimal"

This generates five files:

  • Product.cs in the Domain layer (src/Modules/Catalog/src/Catalog.Domain/Entities/) -- an AggregateRoot with the specified properties and a static Create factory method.
  • IProductRepository.cs in the Domain layer -- a repository interface for the aggregate.
  • ProductRepository.cs in the Infrastructure layer -- the EF Core implementation, built on EfRepository<,>.
  • ProductConfiguration.cs in the Infrastructure layer -- an EF Core entity type configuration, picked up automatically by the DbContext's ApplyConfigurationsFromAssembly.
  • ProductTests.cs in src/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:

bash
modulus add-command CreateProduct --module Catalog --result-type Guid

This 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 implementing ICommand<Guid>.
  • CreateProductHandler.cs -- a handler implementing ICommandHandler<CreateProduct, Guid> with a skeleton Handle method.
  • CreateProductValidator.cs -- a FluentValidation validator, auto-registered by the source generator.
  • CreateProductHandlerTests.cs -- a starter unit test in src/Modules/Catalog/tests/Catalog.Tests.Unit/Commands/.

Step 5 -- Add a Query

Generate a GetProductById query:

bash
modulus add-query GetProductById --module Catalog --result-type ProductDto

This 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 implementing IQuery<ProductDto>.
  • GetProductByIdHandler.cs -- a handler implementing IQueryHandler<GetProductById, ProductDto> with a skeleton Handle method.

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:

bash
modulus add-endpoint CreateProductEndpoint --module Catalog --method POST --route / --command CreateProduct --result-type Guid

This 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):

csharp
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):

csharp
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:

csharp
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:

bash
dotnet run --project src/EShop.WebApi

Running 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:

bash
dotnet run --project aspire/EShop.AppHost

The 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:

bash
# 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:

  1. Initialized a full modular monolith solution with Aspire and RabbitMQ support.
  2. Added two feature modules with clean architecture layers (auto-discovered by the source generator).
  3. Scaffolded a domain entity as an aggregate root.
  4. Generated CQRS command and query pairs with handlers.
  5. Wired an HTTP endpoint to the command through the mediator.
  6. Implemented business logic in the generated handlers.
  7. 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.

Released under the MIT License.