2 · Add a domain module
In step 1 the host had a single starter module. Now you’ll author Acme.Store.Modules.Products from scratch and watch the engine discover it automatically.
2.1 Create the project
Section titled “2.1 Create the project”From the repo root:
dotnet new classlib -n Acme.Store.Modules.Products -o ./src/Acme.Store.Modules.Productsdotnet sln Acme.Store.slnx add ./src/Acme.Store.Modules.Products/Acme.Store.Modules.Products.csprojAdd the references in the new .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup>
<ItemGroup> <PackageReference Include="Cephalon.Abstractions" /> <PackageReference Include="Cephalon.AspNetCore" /> </ItemGroup>
</Project>The
<PackageReference>lines have noVersion=— centralised package management resolves them throughDirectory.Packages.props. Add the version there if it’s not already pinned.
Reference the new project from the host:
dotnet add ./src/Acme.Store.Host/Acme.Store.Host.csproj reference ./src/Acme.Store.Modules.Products/Acme.Store.Modules.Products.csproj2.2 Author the module class
Section titled “2.2 Author the module class”Create the module class:
using Cephalon.Abstractions.Modules;using Cephalon.AspNetCore.Behaviors;using Microsoft.Extensions.DependencyInjection;
namespace Acme.Store.Modules.Products;
public sealed class ProductsModule : RestBehaviorModuleBase{ public override ModuleDescriptor Describe() => new( name: "Acme.Store.Modules.Products", version: "1.0.0", capabilities: [Capability.Data, Capability.Audit]);
public override void RegisterServices(IServiceCollection services) { services.AddSingleton<IProductCatalog, InMemoryProductCatalog>(); }
protected override void ConfigureRestBehaviors(IRestBehaviorBuilder builder) { builder.MapProfile<ListProductsBehavior>(); builder.MapProfile<GetProductBehavior>(); }}Why this works
Section titled “Why this works”RestBehaviorModuleBaseis a convenience base that turns the module into a participant in the REST behavior pipeline. The host adapter scans for it during composition.Describe()returns the descriptor that flows into the runtime manifest.capabilitiesis what the engine validates providers for.RegisterServicesis where DI registrations live. Use the standardIServiceCollectionAPI.ConfigureRestBehaviorsdeclares the REST surface usingMapProfile<TBehavior>(). Each behavior is its own type, which keeps the module class readable.
2.3 Author the domain
Section titled “2.3 Author the domain”Create the domain types:
namespace Acme.Store.Modules.Products.Domain;
public sealed record Product(string Id, string Name, string Sku, decimal Price);namespace Acme.Store.Modules.Products.Domain;
public interface IProductCatalog{ IReadOnlyList<Product> List(); Product? Find(string id);}namespace Acme.Store.Modules.Products.Domain;
public sealed class InMemoryProductCatalog : IProductCatalog{ private readonly List<Product> _products = [6 collapsed lines
new("p-001", "Mechanical Keyboard", "KB-MX01", 149.00m), new("p-002", "27\" 4K Monitor", "MN-4K27", 489.00m), new("p-003", "USB-C Hub", "HB-USBC", 59.00m), ];
public IReadOnlyList<Product> List() => _products;
public Product? Find(string id) => _products.FirstOrDefault(p => p.Id == id);}We’ll replace this with EF Core in step 3. For now it’s a stand-in.
2.4 Author the REST behaviors
Section titled “2.4 Author the REST behaviors”Add the REST behaviors:
using Acme.Store.Modules.Products.Domain;using Cephalon.AspNetCore.Behaviors;using Microsoft.AspNetCore.Http;
namespace Acme.Store.Modules.Products.Behaviors;
public sealed class ListProductsBehavior : IRestBehavior{ public RestRoute Route => RestRoute.Get("/products");
public IResult Handle(IProductCatalog catalog) => Results.Ok(catalog.List());}using Acme.Store.Modules.Products.Domain;using Cephalon.AspNetCore.Behaviors;using Microsoft.AspNetCore.Http;
namespace Acme.Store.Modules.Products.Behaviors;
public sealed class GetProductBehavior : IRestBehavior{ public RestRoute Route => RestRoute.Get("/products/{id}");
public IResult Handle(string id, IProductCatalog catalog) { var product = catalog.Find(id); return product is null ? Results.NotFound() : Results.Ok(product); }}Why behaviors instead of endpoints?
Section titled “Why behaviors instead of endpoints?”A behavior is a typed unit the engine knows how to compose. Compared with app.MapGet(...) calls:
- Each behavior is independently testable.
- The route, the handler, and the dependencies live in one place.
- The engine can apply cross-cutting decorators (audit, metrics, auth) deterministically.
- The OpenAPI document and the Scalar UI pick up the behavior automatically.
2.5 Update Program.cs
Section titled “2.5 Update Program.cs”In step 1, AddModulesFromAssemblies(typeof(Program).Assembly) only scanned the host assembly. Add the module assembly:
using Acme.Store.Modules.Products;using Cephalon.AspNetCore;using Cephalon.Engine;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Services .AddCephalonAspNetCore() .AddModulesFromAssemblies( typeof(Program).Assembly) typeof(Program).Assembly, typeof(ProductsModule).Assembly) .Build(builder);
app.MapCephalon();app.MapHealthChecks("/health");
app.Run();Tip. A cleaner pattern is to call
AddModulesFromAssemblies(AppDomain.CurrentDomain.GetAssemblies())— but only after the host references all module projects. We’re explicit here to make the wiring visible.
2.6 Run and verify
Section titled “2.6 Run and verify”dotnet run --project ./src/Acme.Store.HostThe startup banner should now list two modules:
modules: Acme.Store.Modules.Health (1.0.0), Acme.Store.Modules.Products (1.0.0)Hit the endpoints:
curl http://localhost:5000/productscurl http://localhost:5000/products/p-001curl http://localhost:5000/products/does-not-exist # 404Open http://localhost:5000/scalar/v1 in the browser. The Products endpoints should be documented automatically, with schemas, examples, and a “Try it” button.
2.7 Add a behavior test
Section titled “2.7 Add a behavior test”Add the spec class:
public sealed class ProductsBehaviorSpecifications{ [Fact] public async Task Listing_products_returns_the_seeded_catalog() { await using var host = await TestHostFactory.CreateAsync(); var client = host.CreateClient();
var response = await client.GetAsync("/products");
response.StatusCode.Should().Be(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync<Product[]>(); body.Should().HaveCount(3); }
[Fact] public async Task Requesting_an_unknown_product_returns_404() { await using var host = await TestHostFactory.CreateAsync(); var client = host.CreateClient();
var response = await client.GetAsync("/products/missing");
response.StatusCode.Should().Be(HttpStatusCode.NotFound); }}Run tests:
dotnet testBoth should pass.
What you should have now
Section titled “What you should have now”- A
Productsmodule in a separate project. - Two REST endpoints (
GET /products,GET /products/{id}). - OpenAPI/Scalar documentation generated automatically.
- Behavior specifications for happy-path and 404.
Pitfalls we hit the first time
Section titled “Pitfalls we hit the first time”- Forgetting to reference the module from the host. Module discovery only sees assemblies that are loaded. The host project must reference the module project (or the module DLL has to live next to the host binary at runtime).
- Using
Endpointinstead ofBehavior. Plain ASP.NET Coreapp.MapGet(...)still works but bypasses the behavior pipeline — no automatic OpenAPI binding, no decorators, no manifest entry. - Module name collisions. Two modules with the same
Describe().namewill fail composition. Always include the module’s project name as the prefix.