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
│   │   ├── Application/       # IUnitOfWork, IRepository, Pagination
│   │   ├── Infrastructure/    # BaseDbContext, EfRepository, IModuleRegistration
│   │   └── Integration/       # IIntegrationEvent base types
│   ├── EShop.WebApi/          # Host application (Program.cs, endpoints)
│   ├── EShop.AppHost/         # Aspire orchestrator (when --aspire used)
│   └── EShop.ServiceDefaults/ # Aspire defaults (when --aspire used)
└── tests/
    ├── EShop.Tests.Unit/
    ├── EShop.Tests.Integration/
    └── EShop.Tests.Architecture/

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.
  • BuildingBlocks -- Shared base classes and interfaces used across all modules.
  • EShop.WebApi -- The single deployable host that composes all modules via IModuleRegistration.
  • Aspire projects -- AppHost and ServiceDefaults provide distributed tracing, health checks, and the developer dashboard.
  • Test projects -- Unit, integration, and architecture test projects are ready from day one.

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:

src/Modules/Catalog/
├── EShop.Modules.Catalog.Api/             # Endpoints & module registration
├── EShop.Modules.Catalog.Application/     # Commands, queries, handlers, DTOs
├── EShop.Modules.Catalog.Domain/          # Entities, value objects, domain events
├── EShop.Modules.Catalog.Infrastructure/  # EF Core, repositories, outbox
└── EShop.Modules.Catalog.Integration/     # Integration events (shared contracts)

tests/Modules/Catalog/
├── EShop.Modules.Catalog.Tests.Unit/
├── EShop.Modules.Catalog.Tests.Integration/
└── EShop.Modules.Catalog.Tests.Architecture/

Layer responsibilities

LayerPurposeReferences
DomainEntities, aggregate roots, value objects, domain eventsBuildingBlocks.Domain only
ApplicationCommands, queries, handlers, DTOs, interfacesDomain, BuildingBlocks.Application
InfrastructureEF Core DbContext, repositories, outbox, external servicesApplication, BuildingBlocks.Infrastructure
ApiMinimal API endpoints, module registrationApplication, Infrastructure
IntegrationIntegration event contracts shared between modulesBuildingBlocks.Integration only

The Orders module follows the same structure. Both modules are automatically wired into the host project through IModuleRegistration.

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:

  • Product.cs in the Domain layer -- an AggregateRoot with the specified properties and a static Create factory method.
  • ProductConfiguration.cs in the Infrastructure layer -- an EF Core entity type configuration.
  • The entity is registered in the module's DbContext.

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 two files in the Application layer:

  • CreateProduct.cs -- a record implementing ICommand<Guid>.
  • CreateProductHandler.cs -- a handler implementing ICommandHandler<CreateProduct, Guid> with a skeleton Handle method.

Step 5 -- Add a Query

Generate a GetProductById query:

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

This produces:

  • GetProductById.cs -- a record implementing IQuery<ProductDto>.
  • GetProductByIdHandler.cs -- a handler implementing IQueryHandler<GetProductById, ProductDto> with a skeleton Handle method.

Step 6 -- Add an Endpoint

Wire the command to an HTTP endpoint:

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

This generates a minimal API endpoint in the Api layer that:

  1. Accepts the CreateProduct command as the request body.
  2. Sends it through the mediator.
  3. Returns the result.

Step 7 -- Fill in Handler Logic

The scaffolded handlers contain TODO placeholders. Open CreateProductHandler.cs in the Application layer and add the business logic:

csharp
using EShop.Modules.Catalog.Domain;

namespace EShop.Modules.Catalog.Application.Products.Commands.CreateProduct;

public class CreateProductHandler : ICommandHandler<CreateProduct, Guid>
{
    private readonly IRepository<Product> _repository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateProductHandler(IRepository<Product> repository, IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(
        CreateProduct command,
        CancellationToken cancellationToken)
    {
        var product = Product.Create(command.Name, command.Price);

        await _repository.AddAsync(product, cancellationToken);
        await _unitOfWork.CommitAsync(cancellationToken);

        return Result<Guid>.Success(product.Id);
    }
}

Similarly, fill in the GetProductByIdHandler:

csharp
namespace EShop.Modules.Catalog.Application.Products.Queries.GetProductById;

public class GetProductByIdHandler : IQueryHandler<GetProductById, ProductDto>
{
    private readonly IRepository<Product> _repository;

    public GetProductByIdHandler(IRepository<Product> repository)
    {
        _repository = repository;
    }

    public async Task<Result<ProductDto>> Handle(
        GetProductById query,
        CancellationToken cancellationToken)
    {
        var product = await _repository.GetByIdAsync(query.Id, cancellationToken);

        if (product is null)
        {
            return Result<ProductDto>.Failure("Product.NotFound", "Product not found.");
        }

        return Result<ProductDto>.Success(new ProductDto(
            product.Id,
            product.Name,
            product.Price));
    }
}

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 Result<T>.Failure(code, message) for known errors. 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 src/EShop.AppHost

The Aspire dashboard is available at https://localhost:17222 by default.

Once running, test the endpoint:

bash
# Create a product
curl -X POST https://localhost:5001/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.

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.
  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.