Modules
A Module is a business boundary inside a Host: its Composition Root declares what it needs and offers, and its Gateway is the only way in.
A Host runs one or more Modules. Each Module owns its data, its rules and its public Contracts, and declares all of it in one public class, its Composition Root, which implements IModuleDefinition. Everything else in the Module stays internal.
Anything outside the Module runs one of its Operations through its Gateway, IGatewayProcessor<TModule>, and passes an Operation Context that says which Actor and tenant it runs for. An endpoint, a scheduled job, another Module's question and a test all come in the same way. PouryaKit.Modularity holds the Composition Root and the Gateway; OperationContext is in PouryaKit.Cqrs.
There is no anonymous or default Actor
Every Gateway call needs an Operation Context. Create a new one for each Operation that starts work, with Actor.Person or a named Actor.System, and pass Continue() of the running context for work an Operation causes.
When to use it
- You are writing a Module's Composition Root, or composing the Host from its Modules.
- Code outside a Module needs to run one of its commands or queries: an endpoint, a job, a test.
- One Module needs an answer from another before its Operation can go on.
- A Host fails when it is built or started, and you want to know which check stopped it.
- Your application has a single Module and you want to leave Modularity out.
How it works
Declare the Module
Write one public sealed class that implements IModuleDefinition. Its Configure method declares the Module's data, Features, handlers, publications, subscriptions and endpoints on the ModuleBuilder it receives. It never names its own Module or assembly: the Kit finds the Features in the assembly of the definition.
Add it to the Host
builder.AddPouryaKitModules(...) runs each Module's Composition Root once and registers its Gateway. All Modules share the Host's one container; each keeps its own Unit of Work, schema and stores.
Run Operations through the Gateway
SendAsync runs a command and QueryAsync a query, each as one Operation in a new scope that is disposed when the call returns. A command belongs to the Module that registers its handler, so another Module's Gateway refuses it.
Let the Host check its composition
A declaration refuses its own mistakes while the Host is composed. Then the Host's checks run when it is built, when it starts and once Wolverine has started. Each failure names the problem and its fix, before the Host serves anything.
In the Proving Sample
Billing's Composition Root declares its database schema, its Features, the Integration Events it subscribes to and a delayed command:
/// <summary>The Billing Module's Composition Root.</summary>
public sealed class BillingModule : IModuleDefinition
{
public void Configure(ModuleBuilder module)
{
module.UsePouryaKitPostgres<BillingDbContext>(connectionName: "app", schema: "billing");
module.AddPouryaKitCqrs();
module.Subscribe<ProductCreatedV1, OpenPriceListIntegrationEventHandler>(
"billing.product-created"
);
module.Subscribe<AccountOpenedV1, OpenWelcomeQuoteIntegrationEventHandler>(
"billing.account-opened"
);
module.AddPouryaKitDelayedCommand<ExpireQuoteCommand>("billing.expire-quote");
}
}Inventory's GetStockItem endpoint runs its query through Inventory's Gateway with the Operation Context the HTTP boundary built from the authenticated caller:
// No :guid route constraint: an id that does not parse is the caller's mistake (400), not a missing route (404).
internal sealed class GetStockItemEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder routes) =>
routes.MapGet(
"/stock-items/{productId}",
async (
Guid productId,
OperationContext context,
IGatewayProcessor<InventoryModule> inventory,
CancellationToken cancellationToken
) =>
(
await inventory.QueryAsync(
new GetStockItemQuery(productId),
context,
cancellationToken
)
).ToHttpResult()
);
}Guarantees and failures
| You want to know | Read |
|---|---|
| What a Composition Root declares, and how a Module's own settings are bound | The Composition Root |
| What each Gateway call opens and disposes | The Gateway |
| How a context starts, continues and becomes trusted | The Operation Context |
| Which Module runs a command, and what another Module's Gateway does with it | Which Module an Operation belongs to |
| Every check a Host runs, when it runs, and the message it fails with | The Host's checks, Host checks |
| How one Module asks another a question | Asking another Module a question |
Next steps
Host and Module
The two boundaries every Kit application is built from.
Operation Context, Actor and tenant
Who an Operation runs for, and how that travels with the work it causes.
Web
The endpoints that build the context and call the Gateway for you.
Reference
PouryaKit.Modularity: the Composition Root, the Gateway and one scope per Operation, in one Host container. Namespace PouryaKit.Modularity. The Operation Context it requires lives in PouryaKit.Cqrs.
- Adding a Module
- The Composition Root
- The Gateway
- The Operation Context
- Which Module an Operation belongs to
- The Host's checks
- Asking another Module a question
- An application of one Module
- Testing
Adding a Module
Start from the modular monolith template, or grow the monolith into it (templates.md). A new Module sits beside the generated ones.
- Give the Module a folder under
src/Modules/with its project, and a Contracts project when other Modules use what it publishes, as Catalog hasCatalog.Contracts. Its project referencesPouryaKit.Modularityand the Kit packages its Features use. - Write its Composition Root: one public sealed class that implements
IModuleDefinition(below). - In
AppHost.cs, add it beside the others withmodules.Add<…>()inAddPouryaKitModules, and give it its own section inappsettings.jsonwhen it has settings. - Give it a migration, then migrate it naming its project and its DbContext (persistence.md).
A tenant-scoped Module also follows multi-tenancy.md.
The Composition Root
A Module's Composition Root and the Host that composes it, after the Proving Sample:
// Catalog/CatalogModule.cs: the Module's one public type
public sealed class CatalogModule : IModuleDefinition
{
public void Configure(ModuleBuilder module)
{
module.AddPouryaKitTenancy(); // first: every Operation of Catalog needs a tenant; see multi-tenancy.md
module.UsePouryaKitPostgres<CatalogDbContext>(connectionName: "app", schema: "catalog");
module.MapPouryaKitUniqueConstraint<CatalogDbContext>(ProductConfiguration.NameIndex, ProductErrors.NameInUse); // see persistence.md
module.AddPouryaKitDapper(connectionName: "app"); // see dapper-reads.md
module.AddPouryaKitCqrs();
module.AddPouryaKitValidation();
module.AddPouryaKitNotificationHandlers(); // see messaging.md
module.Publish<ProductCreatedV1>("catalog.product-created.v1"); // for other Modules; see messaging.md
module.PublishSnapshot<Product, ProductSnapshotV1, ProductSnapshotMapper>("catalog.product-snapshot.v1"); // likewise
module.AddScoped<IProductRepository, ProductRepository>(); // see persistence.md, The repository
module.AddScoped<ICatalogProducts, CatalogProducts>(); // see Asking another Module a question
module.AddPouryaKitEndpoints("/catalog"); // see web.md
}
}
// ModularMonolith.Host: one builder method, shared by Program and the tests; its Build method adds the pipeline (web.md)
public static WebApplicationBuilder CreateBuilder(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPouryaKitJwtBearer(claims => claims.TenantClaim = "tenant"); // see security.md
builder.Services.AddAuthorization();
builder.Services.AddPouryaKitWeb(); // reads the tenant the bearer scheme verified; see web.md
builder.AddPouryaKitModules(modules => modules.Add<CatalogModule>().Add<InventoryModule>().Add<BillingModule>());
builder.UsePouryaKitWolverine(connectionName: "app"); // the Modules' deliveries; see messaging.md
return builder;
}- Implement
IModuleDefinitiononce per Module, as a public sealed class with a parameterless constructor. Everything else in the Module staysinternal, except the ports its Host implements for it with the records they carry (messaging.md) and the migrationsdotnet-efwrites; a structure test rejects any other public type (testing.md). Configure(ModuleBuilder module)declares everything the Module needs and offers: its data (UsePouryaKitPostgres,MapPouryaKitUniqueConstraint,AddPouryaKitDapper), its Features (AddPouryaKitCqrs,AddPouryaKitValidation), its Notification Handlers (AddPouryaKitNotificationHandlers), the Integration Events and Snapshots it publishes and the Integration Event Handlers it subscribes (Publish,PublishSnapshot,Subscribe), its endpoints (AddPouryaKitEndpoints) and its own services.ModuleBuilderis anIServiceCollectionover the Host's one container, so every registration extension works on it.- A Composition Root never names its own Module or its own assembly.
ModuleBuilderis the Module seam,IModuleServices(PouryaKit.Cqrs), which gives every Kit registration made on it the Module's definition type, and the Kit finds it at run time, so a helper of yours typed asIServiceCollectionkeeps it:- Its Features live in its definition's assembly.
AddPouryaKitCqrs(),AddPouryaKitValidation(),AddPouryaKitPermissions(),AddPouryaKitDapper(connectionName),AddPouryaKitNotificationHandlers(),AddPouryaKitEndpoints(prefix)andAddPouryaKitLocalization()with no assembly scan or record the assembly of the class that implementsIModuleDefinition. Features that live elsewhere are named instead,module.AddPouryaKitCqrs(typeof(CatalogModule).Assembly, typeof(SharedFeatures).Assembly): named assemblies replace the inferred one, in their order. - Its declarations belong to it.
Publish,Subscribe,PublishSnapshot,AddPouryaKitNotificationHandlers,AddPouryaKitDelayedCommandandAddPouryaKitRecurringCommandon its builder run in the Module: its Unit of Work, its Receipts. Each has a second form that names the Module,builder.Services.Subscribe<ProductCreatedV1, AuditHandler>("inventory.audit", typeof(InventoryModule)), for the Host's composition when it declares on a Module's behalf; named on another Module's builder it throws while the Host is composed, and naming a Module the Host never adds fails the Host's build.
- Its Features live in its definition's assembly.
- The Unit of Work registered on a Module's builder becomes that Module's own; every other registration goes into the Host's container as it is. The persistence and messaging packages key the Module's Receipts and idempotency store by the Module too, so an Operation keeps its Receipts and keys in its own Module's store, whatever write model another Module has and in whichever order the Host calls
AddPouryaKitModulesandUsePouryaKitWolverine. - The Host adds each Module once:
builder.AddPouryaKitModules(modules => modules.Add<CatalogModule>().Add<InventoryModule>().Add<BillingModule>()). That runs each Composition Root and registers the Module's Gateway. - A Module's own settings live under its name in the configuration of each Host that composes it,
Catalog:Section:Key, and an environment variable overrides one asCatalog__Section__Key.Configure(ModuleBuilder)receives no configuration: bind the section to an options type of the Module's, validate it at start and inject it,module.AddOptions<T>().BindConfiguration("Catalog:Section").Validate(options => ..., message).ValidateOnStart(), thenIOptions<T>where a Feature needs it, so a Host given an invalid value fails to start with anOptionsValidationExceptioncarrying that message, instead of at first use. Inventory's recurring command is the example: its Composition Root bindsInventory:ReconcileStockonto the options builderAddPouryaKitRecurringCommandreturns, which already carries the Kit's validation andValidateOnStart(), so the Module only binds (messaging.md).
The Gateway
IGatewayProcessor<TModule>is how anything outside a Module runs one of its Operations:SendAsync(command, context, ct)for a command,QueryAsync(query, context, ct)for a query, each returning the Result. Resolve it from the Host's services.- The
OperationContextis required. Each call is one Operation in a new scope, begun with that context and with the Module as itsOperation.Module, and disposed when the call returns. - Commands and queries stay
internal, so a caller that builds one lives in the Module's assembly or is grantedInternalsVisibleTo, as the Proving Sample's test project is. Another Module asks through the owning Module's Contracts instead.
A scheduled import, which is independent system work, creates a context for each Operation it runs:
var catalog = host.Services.GetRequiredService<IGatewayProcessor<CatalogModule>>();
var importer = Actor.System("catalog-import");
var lamps = await catalog.SendAsync(new CreateCategoryCommand(Guid.CreateVersion7(), "Lamps"), new OperationContext(importer), ct);
var created = await catalog.SendAsync(
new CreateProductCommand(Guid.CreateVersion7(), lamps.Value, "Desk lamp"),
new OperationContext(importer),
ct
);The Operation Context
OperationContext (PouryaKit.Cqrs) is plain data about who and what caused one Operation:
OperationId: a new version 7 Guid for each context created, so one context belongs to one Operation.Actor:Actor.Person(id), a person as the trust boundary that authenticated them identifies them, orActor.System(name), a named system identity for independent work no person caused, such as a recurring command.Actor.Kindsays which.Tenant: the tenant or company the Operation runs for; null where the application has none.CorrelationId: shared by every Operation in one chain of work; a new context with none given correlates with its ownOperationId.CausationId: the Operation or message that caused this one; null for the Operation that started the chain. Informational: it never makes a context trusted.
Starting and continuing work:
- A caller that starts work creates
new OperationContext(actor, tenant)and passes it to the Gateway. Create one for each Operation: two calls with one context share anOperationId. - Work one Operation causes runs with
context.Continue(): the same Actor, tenant and correlation, the causing Operation'sOperationIdas its causation, and a newOperationId. A Module that answers another Module's question passes the asking Operation'sContinue()to its own Gateway. - A continuation is trusted when the Kit restores it for a delivery, or when a running, authorized Operation continues its own context with
Continue(): its Actor is not authenticated again, and the Kit adds no authorization of its own for it but the tenant requirement of a tenant-scoped Module, which refuses a command or query of that Module continued without a tenant (multi-tenancy.md). A context application code builds, or continues outside the Operation that ran it, is never trusted. AnIAuthorizerregistered for the command or query it runs still runs, with the original Actor, and recognises a trusted continuation byIsTrustedContinuation, never byCausationId. - There is no anonymous or default Actor.
Actor.PersonandActor.Systemrefuse a blank id or name, the Gateway throwsArgumentNullExceptionfor a null context, and resolvingOperationContextin a scope whose Operation was never begun throwsInvalidOperationException. Name system work withActor.System("...").
Reading it:
- Inject
OperationContextinto a handler, an authorizer, a validator or a Domain Event Handler. Nested commands and queries and the Domain Event Handlers of an Operation run in its scope, so they read the same context. Read it only by injection, never from an ambient request.
using PouryaKit.Cqrs;
using PouryaKit.Domain;
// Products/Features/RemoveProducts/: only system work removes products in bulk
internal sealed class RemoveProductsCommandAuthorizer(OperationContext context) : IAuthorizer<RemoveProductsCommand>
{
public Task<Result> AuthorizeAsync(RemoveProductsCommand command, CancellationToken ct) =>
Task.FromResult(context.Actor.Kind == ActorKind.System ? Result.Success() : ProductErrors.BulkRemoveForbidden);
}Setting it:
- Whoever opens an Operation's scope begins it once, before anything in the scope runs, with
scope.ServiceProvider.BeginOperation(context, module)(PouryaKit.Cqrs). The Gateway does this for every call, so Feature code never calls it; only an application of one Module calls it itself (below). Beginning a scope's Operation twice throws. A message delivery also passes the trace parent it restored from the message, as its third argument (observability.md); nothing else passes one. - At the HTTP boundary, the Web Capability begins each request's Operation with a context built from the authenticated caller, and the endpoint passes that context to the Gateway (web.md). The delivery of a Notification begins its scope with a continuation of the raising Operation's context and the subscribing Module, and the delivery of an Integration Event with a continuation of the publishing Notification Handler's delivery and the receiving Module (messaging.md).
Which Module an Operation belongs to
Operation.Module(PouryaKit.Cqrs, aType) is the Module the scope's opener named; the Gateway names its own. It belongs to the scope, not to the context, because work an Operation causes may run in another Module. It routes each command to that Module's Unit of Work: a command sent throughIDispatcherin a scope whose Operation names no Module throwsInvalidOperationExceptionin a Host of Modules.- A command or query belongs to the Module whose Composition Root registers its handler. Its Gateway is the only one that runs it: sent through another Module's Gateway, it is refused with an
InvalidOperationExceptionnaming both Modules, and one that no Module handles is refused too. Neither opens a scope. - A Composition Root that registers, replaces or removes the handler of a command or query another Module owns fails while the Host is composed, naming both Modules.
The Host's checks
Each declaration refuses its own mistakes while the Host is composed; then the Host's checks run at three moments, each failing before the Host serves: when it is built, when it starts, and once Wolverine has started. An endpoint and an HTTP client are also checked as each is built (web.md):
- While the Host is composed, in
AddPouryaKitModules: a Module added twice, in one call or across calls, throws naming it. A Module whose Composition Root registers the handler of a command but no Unit of Work throws, naming the Module and the command (… handles the command … but registers no Unit of Work. Declare its data in its Composition Root, for example with UsePouryaKitPostgres.); a Module that only answers queries needs none. A declaration that names another Module on a Module's builder throws (The Composition Root of … declares for …). - When the Host is built:
AddPouryaKitModulescomposes the Host's checks withAddPouryaKitCompositionChecks(PouryaKit.Cqrs), whose container is validated in every environment: every service it builds through a constructor must have its dependencies registered, and no singleton may depend on a scoped service, orBuild()throws anAggregateExceptionnaming the problem; the validation does not look inside a factory registration or an open generic one. A service that needs the Gateway of a Module the Host never added fails here, naming that Gateway. Then the Kit's composition checks run on the built container, beforeBuild()returns, since a built Host runs Operations through its Gateways before it starts, andBuild()throws oneInvalidOperationException, one line per problem, each naming its fix:- a command or query whose handler is registered and that declares an authorizer the Kit must register, such as a
[RequiresPermission], when no Composition Root registered it; another authorizer of the same command or query does not count (… declares RequiresPermissionAttribute, but its check is not registered, so it would run unchecked. Call AddPouryaKitPermissions with the assembly … in the Composition Root that registers its handler.). Fix it withmodule.AddPouryaKitPermissions()in that Module's Composition Root (security.md); - a cacheable query whose handler is registered in a Host without
AddPouryaKitCaching()(caching.md); - a command or query a tenant-scoped Module, or the tenant-scoped application, can dispatch without the tenant requirement, as when its
AddPouryaKitCqrsscans an assembly itsAddPouryaKitTenancywas not given; another authorizer of the same command or query does not count. The line names the Module or the application, the operation and the assembly to name toAddPouryaKitTenancy(TenantRequirementCheck, multi-tenancy.md); - a recurring command a tenant-scoped Module, or the tenant-scoped application, declares: its occurrences run for a system Actor with no tenant, and none runs per tenant. The line names the command and the Module (
The recurring command … is declared in …, which is tenant-scoped, …; multi-tenancy.md); - with
UsePouryaKitWolverine(connectionName), a Snapshot a tenant-scoped Module, or the tenant-scoped application, declares of an aggregate whose mapped key neither includes its tenant nor is a single Guid, since a Snapshot's identity and version key hold no tenant. The line names the Module, the aggregate and its key (… publishes the Snapshot of …, whose key (…) neither includes the tenant nor is a Guid, …; multi-tenancy.md); - a publication, Snapshot, subscription, Notification Handler, delayed or recurring command declared without a Module in a Host of Modules, or naming a Module the Host never added: made on
builder.Services, it would run in no Module of the Host and fail at its first use. The line names the form on the Module's builder and the form that names the Module (messaging.md). Every ModuleAddPouryaKitModulesadded counts, whatever its Composition Root registers; - a subscription, of an Integration Event Handler or a Notification Handler, whose Module registers no Unit of Work, or, in an application without Modules, of the application when it registers none: each delivery runs in its Module's Unit of Work, where its Receipt is recorded, so every one would fail before its handler runs. One line per Module, naming its subscriptions (
… has the subscriptions … but registers no Unit of Work, so each of their deliveries would fail before its handler runs. Declare its data in its Composition Root, for example with UsePouryaKitPostgres.); a Module that only answers queries needs none (messaging.md); - the routes of the Host's messages: a subscription whose Integration Event or Snapshot has no wire name, a type received from another Host that no subscription or Saga handles, a message type Wolverine would also route where another type goes, a delayed or recurring command whose name a subscription has, and a wire name the process already sends the type under, or one Wolverine gave the type before it was declared (messaging.md);
- Notification Handlers, Integration Events, Snapshots or delayed commands declared in a Host whose Unit of Work writes no Outbox, and a Notification Handler in the Feature assemblies of a composed Module, or of the application, that no
AddPouryaKitNotificationHandlersdeclares, so it would never run (messaging.md); - a write model whose Unit of Work has no Outbox in a Host that has one: Stream Modules beside the EF message store and
AddPouryaKitMarten, or EF Core Modules in a Host whose only message store isUsePouryaKitMarten, since their Operations would commit and never write what they publish. The line names the Modules and the registration to call (stream.md); - a DbContext that
UsePouryaKitPostgresstores over a connection string naming another database than the message store's (UsePouryaKitWolverine(connectionName)), or one the application registered and made a Unit of Work withAddPouryaKitEfCore<TDbContext>()on another database, read from the connection EF Core gives the DbContext, unopened and compared on host, port and database as Npgsql reads them: its Outbox is written in its own transaction, so it would never reach the store. The line names the store's connection name, the DbContext's connection name or the DbContext itself, both databases, and the fix for its case: one connection string for the store and every Module's DbContext forUsePouryaKitPostgres, registering the DbContext on the store's database otherwise (messaging.md); - a connection string that
UsePouryaKitPostgres,AddPouryaKitEfCore<TDbContext>(connectionName)orAddPouryaKitDappernames and the Host's final configuration does not set, or sets blank: the line names the connection and its key,ConnectionStrings:{name}; nothing connects (persistence.md, dapper-reads.md). A Host-builder API that has the configuration in hand, such asUsePouryaKitWolverine(connectionName)andUsePouryaKitRabbitMq, checks its own connection string while the Host is composed: a missing connection string fails at composition where the builder is in hand, when the Host is built otherwise, never at first use.
- a command or query whose handler is registered and that declares an authorizer the Kit must register, such as a
- When the Host starts, before any hosted service starts, Wolverine and the web server included: a
ConfigureContainercall afterAddPouryaKitModulesreplaced the Kit's container, so neither the validation nor the composition checks ran, andStartAsyncthrows, naming the call, what it lost and the fix. LeaveConfigureContainerout of a Host of Modules. A Host that registers the dispatcher, as every Kit Host does, but never installs the checks fails here too, namingAddPouryaKitCompositionChecks():AddPouryaKitModulesandUsePouryaKitWolverinecall it, and any other Host calls it itself, so no Kit Host runs without its checks. Otherwise the startup checks run (IStartupCheck), for what only the built application shows, since it maps its routes afterBuild(), andStartAsyncthrows oneInvalidOperationExceptionin the same shape:- an
IEndpointin the Feature assemblies of a composed Module, or of the application, that noAddPouryaKitEndpointsdeclares, or that noMapPouryaKitModuleEndpointsmapped, so it would never answer (web.md); - API versions declared for a route prefix no Module's endpoints are mapped under (openapi.md).
- an
- Once Wolverine has started: a subscription's or delayed command's queue that does not store what it receives, and a broker listener that does not, or whose dead letters go to a broker queue (messaging.md). This is the one check at Wolverine's own moment, because Wolverine compiles its endpoints, applying its policies and the Host's queue configuration, only as it starts.
Asking another Module a question
Modules talk only in these ways. Inside a Module, Domain Events. Across Modules, asynchronously by default: an Integration Event when a Module reacts to something that happened in another, and a Snapshot when it keeps its own copy of another's data. The owning Module's Notification Handler publishes an Integration Event after its change commits, and its Unit of Work stores a Snapshot in the commit of the change; the receiving Module applies either in an Operation of its own, as Inventory opens a stock item and Billing a price list for each product Catalog creates (messaging.md), and Inventory keeps its copy of Catalog's products (messaging.md). A synchronous question only when the asking Operation genuinely needs the answer before it can go on, such as a permission, membership or availability check. Never HTTP or gRPC between Modules of one Host.
The owning Module publishes the question as an interface in its Contracts project and serves it through its own Gateway. From the Proving Sample, where Catalog answers product questions:
// Catalog.Contracts/Products/: public, plain types only
public interface ICatalogProducts
{
/// <summary>The product with <paramref name="productId"/>, or null when Catalog has no such product.</summary>
Task<ProductSummary?> FindProductAsync(Guid productId, CancellationToken cancellationToken = default);
}
public sealed record ProductSummary(Guid Id, string Name);
// Catalog/Products/Features/GetProduct/: registered scoped, so it receives the asking Operation's context
internal sealed class CatalogProducts(IGatewayProcessor<CatalogModule> catalog, OperationContext asking) : ICatalogProducts
{
public async Task<ProductSummary?> FindProductAsync(Guid productId, CancellationToken cancellationToken = default)
{
var found = await catalog.QueryAsync(new GetProductQuery(productId), asking.Continue(), cancellationToken);
if (found.IsSuccess) return new ProductSummary(found.Value.Id, found.Value.Name);
// Only "no such product" is an answer; any other failure is a bug in the question.
return found.Errors is [var error] && error == ProductErrors.NotFound
? null
: throw new InvalidOperationException($"Catalog could not answer: {string.Join(", ", found.Errors.Select(e => e.Code))}.");
}
}
// In Catalog's Composition Root: module.AddScoped<ICatalogProducts, CatalogProducts>();
// Ordering/Orders/Features/AddOrderLine/: Ordering references Catalog.Contracts, never Catalog
internal sealed class AddOrderLineCommandHandler(ICatalogProducts catalog, IRepository<Order, OrderId> orders)
: CommandHandler<AddOrderLineCommand>
{
public override async Task<Result> Handle(AddOrderLineCommand command, CancellationToken ct)
{
var product = await catalog.FindProductAsync(command.ProductId, ct);
if (product is null) return OrderErrors.UnknownProduct;
var order = await orders.FindAsync(new OrderId(command.OrderId), ct);
if (order is null) return OrderErrors.NotFound;
return order.AddLine(product.Id, product.Name);
}
}- Declare the interface and the types it returns in the owning Module's Contracts project, public, with plain values such as
Guidand records of them, never the Module's internal types. The Proving Sample'sCatalog.Contractsreferences no other project. Name its members as questions. - Implement it in the owning Module,
internal sealed, and register it scoped in its Composition Root. Resolved in the asking Operation's scope, it receives the askingOperationContextby injection. That scope belongs to the asking Module, so the implementation uses nothing of its own Module but its Gateway. - Answer each question with a query sent through the Gateway with
asking.Continue(). The answer is then its own Operation in its own scope, through the owning Module's full pipeline, and it changes nothing. - Map the Result to the Contracts types. Turn only the expected failures into an answer, such as null for "not found"; any other failure is a bug in the question.
- The asking Module's project references the owning Module's Contracts project only, and injects the interface into its handler. A Contracts project references nothing from the Kit and no Module. A structure test rejects a Module that references another Module's implementation, and Contracts that reference the Kit or a Module, even when no code uses the reference (testing.md).
An application of one Module
A Host with a single Module may leave out PouryaKit.Modularity: it registers the Kit on its own services and opens a scope for each Operation, beginning it itself.
// Composition: everything on the application's own services, with no AddPouryaKitModules
builder.Services.UsePouryaKitPostgres<NotesDbContext>(connectionName: "app", schema: "notes");
builder.Services.AddPouryaKitCqrs(typeof(NotesDbContext).Assembly);
builder.Services.AddPouryaKitValidation(typeof(NotesDbContext).Assembly);
builder.AddPouryaKitCompositionChecks(); // the validated container and the composition checks, as in a Host of Modules
// Each Operation: a new scope, begun with its context and no Module, then the dispatcher
await using var scope = services.CreateAsyncScope();
scope.ServiceProvider.BeginOperation(new OperationContext(Actor.System("nightly-notes")));
var written = await scope.ServiceProvider.GetRequiredService<IDispatcher>().SendAsync(new WriteNoteCommand("solo"), ct);- The one Unit of Work is the application's own, so
Operation.Modulestays null and nothing routes. Its registrations name their assembly: the application's own services are no Module's builder, so nothing is inferred, andAddPouryaKitCqrs()with no assembly registers the dispatcher alone. Its declarations (Publish,Subscribe) belong to the application, and so doesAddPouryaKitTenancy(assembly), which makes it tenant-scoped (multi-tenancy.md). - Everything else holds as in a Host of Modules: one scope per Operation, the same pipeline, the same commit. There is no Gateway and no ownership check.
builder.AddPouryaKitCompositionChecks()turns on whatAddPouryaKitModulesturns on for a Host of Modules: the container validated in every environment (HostApplicationBuilderalone validates only in Development) and the composition checks when it is built, among them a command of its Features with no Unit of Work and a declaration that names a Module, which belongs to no Module here, then the startup checks when it starts.UsePouryaKitWolverinecalls it too. Call it after anyConfigureContainerof the Host's own; the monolith template does (templates.md). dotnet new pouryakit-monolithgenerates such an application: the Proving Sample's Catalog with a plain registration andIDispatcherendpoints behind the Kit's HTTP pipeline (templates.md).
Testing
- Test a Module through its Gateway against the hosted application,
Fixture.Gateway<TModule>(), which runs each call as one Operation in its own scope, as the Host does (testing.md). Pass anOperationContextof your own where the Actor, tenant or correlation matters (testing.md). - Test a question to another Module through the asking Module's command: assert what the command did with an answer and with "not found", and that the answering Operation continued the asking one's context.
- Test a mistake in a Host's composition with a Host of the test's own that makes it: assert the exception and its message at the moment its check runs, while the Host is composed, at
Build()or atStartAsync. Compose it in Production, where only the Kit validates the container, when the mistake is one that validation catches. - Test an application of one Module through
IDispatcher, in a scope the test begins withBeginOperation.
Commands, queries and validation
Write each Feature as a command or a query with one handler and an optional validator, and let the Kit run every one through the same fixed pipeline.
Persistence and the Unit of Work
Each Module keeps its aggregates in its own PostgreSQL schema through EF Core, and the Unit of Work saves and commits every Operation once, with no save in your code.