Domain primitives
The plain types every Module's business code is written with: Result and Error, typed IDs, aggregates and entities, Domain Events and business rules.
PouryaKit.Domain is the package your aggregates are made of. An aggregate derives from AggregateRoot<TId> and is keyed by a typed ID, a sealed record over a Guid, long, int or string. A method that can fail checks its business rules and returns a Result: a value, or an Error for an expected business failure. When something worth reacting to happens, the aggregate raises a Domain Event. The package has no dependencies, so it adds nothing to your domain but these types.
A business failure is a Result, never an exception
Return an Error for anything a caller can cause, such as a missing name or an unknown product. Exceptions are for bugs and infrastructure. And never set an aggregate's Version or clear its Domain Events: the Unit of Work owns both.
When to use it
- You are writing an aggregate, an entity or the typed ID that keys it.
- A business rule decides whether a change may happen.
- Something in the domain happened that other code reacts to: a Domain Event.
- You are declaring the Errors of a Business Area.
- A business rule compares with the current time, or a test fixes the time a Domain Event records.
How it works
Key each aggregate by its own ID type
Declare one typed ID per aggregate. Two IDs are equal only when their type and value are, so a product's ID can never be passed where a category's is expected. TypedId.New<ProductId>() creates a new, time-ordered one, and the Kit's JSON, EF Core and Dapper setup all read and write it as its bare value, with no converter of yours.
Return a Result from a behaviour that can fail
A value or an Error converts to a Result on its own, so a method returns either directly. Check a rule with CheckRule from inside the aggregate: a broken rule comes back as a failed Result, and you pass it on with return result.Errors;.
Raise what happened
Call Raise with a Domain Event, a sealed record named in the past tense. It carries typed IDs and values, never the aggregate. Inside the Module, a Domain Event Handler reacts in the same commit; after the commit, a Notification Handler can tell the world.
Declare each Error once
Give every Error a stable code, a message and a kind through its factory, such as Error.Validation or Error.NotFound. The kind, not an HTTP status, is what the Web package turns into a response.
In the Proving Sample
Catalog's product Errors, declared once as static fields:
public static readonly Error NameRequired = Error.Validation(
"Product.NameRequired",
"A product needs a name.",
"Name"
);
public static readonly Error NotFound = Error.NotFound(
"Product.NotFound",
"The product does not exist."
);
// Mapped to the unique index on the name in CatalogModule: the database, not a check beforehand, refuses a duplicate.
public static readonly Error NameInUse = Error.Conflict(
"Product.NameInUse",
"Another product already has this name."
);The product aggregate creates itself, refusing a blank name with an Error and raising ProductCreatedDomainEvent when it succeeds:
public static Result<Product> Create(
ProductId id,
CategoryId categoryId,
string name,
string tenant
)
{
ArgumentNullException.ThrowIfNull(id);
ArgumentNullException.ThrowIfNull(categoryId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
if (string.IsNullOrWhiteSpace(name))
{
return ProductErrors.NameRequired;
}
var product = new Product
{
Id = id,
Tenant = tenant,
CategoryId = categoryId,
Name = name.Trim(),
};
product.Raise(new ProductCreatedDomainEvent(id, categoryId, product.Name));
return product;
}Guarantees and failures
| You want to know | Read |
|---|---|
| How a value or an Error becomes a Result, and what reading a failed one does | Result and Error |
| How a typed ID is created, compared and written as JSON | Typed IDs |
| What the Unit of Work owns on an aggregate, and how equality works | Aggregates and entities |
| How a Domain Event keeps its identity and time | Domain Events |
| How to make "now" testable | The clock |
Next steps
Operation, Result and Error
Why a failure travels as a value through the whole Operation.
Commands, queries and validation
The handlers that call your aggregates and return their Results.
Persistence and the Unit of Work
How an aggregate is mapped, saved and versioned without code of yours.
Reference
PouryaKit.Domain: Result and Error, typed IDs, entities, aggregates and their audit and soft-delete contracts, Domain Events and business rules. One namespace, PouryaKit.Domain; no dependencies.
An aggregate with a typed ID that checks a business rule, raises a Domain Event and returns a Result:
using PouryaKit.Domain;
internal sealed record ProductId(Guid Value) : TypedId<Guid>(Value);
internal sealed record CategoryId(Guid Value) : TypedId<Guid>(Value);
internal sealed record ProductCreatedDomainEvent(ProductId ProductId, CategoryId CategoryId, string Name) : DomainEvent;
internal static class ProductErrors
{
public static readonly Error NameRequired = Error.Validation("Product.NameRequired", "A product needs a name.");
public static readonly Error NotFound = Error.NotFound("Product.NotFound", "The product does not exist.");
}
internal sealed class ProductNameMustNotBeBlank(string name) : IBusinessRule
{
public Error Error => ProductErrors.NameRequired;
public bool IsBroken() => string.IsNullOrWhiteSpace(name);
}
internal sealed class Product : AggregateRoot<ProductId>
{
public string Name { get; private set; } = "";
public CategoryId CategoryId { get; private set; } = null!;
public static Result<Product> Create(ProductId id, CategoryId categoryId, string name)
{
var rule = CheckRule(new ProductNameMustNotBeBlank(name));
if (rule.IsFailure) return rule.Errors;
var product = new Product { Id = id, CategoryId = categoryId, Name = name.Trim() };
product.Raise(new ProductCreatedDomainEvent(id, categoryId, product.Name));
return product;
}
}
// Elsewhere: var created = Product.Create(TypedId.New<ProductId>(), new CategoryId(command.CategoryId), command.Name);Result and Error
- Return a value or an Error directly; each converts to a Result.
Result.Success()is the success without a value. When the value's static type is an interface (such asIReadOnlyList<T>), C# applies no implicit conversion, so returnResult.Success(value). - Pass a failure on with
return result.Errors;; it converts to a failed Result of any type and keeps every Error. - Check
IsSuccessorIsFailurebefore readingValue; reading it from a failure throws. No conversion turns a Result into its value, and a null value throws instead of becoming a success. - Give each Error a stable code (
Product.NameRequired), a message and a kind through its factory:Validation,NotFound,Conflict,ForbiddenorUnexpected. Never an HTTP status; the Web package maps kinds to statuses (web.md). A validation Error may name the input it is about as its target:Error.Validation(code, message, "Name"). - Declare a Business Area's Errors once, as static fields.
Typed IDs
- Declare one per aggregate as a sealed record over
Guid,long,intorstring, as above. Two IDs are equal only when type and value are equal, so IDs of different aggregates cannot be mixed up. - Create a new Guid ID with
TypedId.New<ProductId>(); it is time-ordered (version 7). Wrap a value from input withnew ProductId(value). - The constructor throws on an empty Guid or a blank string: that is a bug, so validate input before it reaches the constructor.
- Write no converter and set up no JSON options of your own.
options.UsePouryaKitJson()is the Kit's one JSON representation: camelCase names and every typed ID as its bare value ("0199b2a4-...",42,"LAMP-01"), never{"value":...}. Names are read ignoring case, and a body that names one of its type's members twice,priceandPriceorpricetwice, fails to read with aJsonExceptioninstead of keeping the last value.AddPouryaKitWebapplies it to request and response bodies (web.md), andUsePouryaKitWolverineappliesUsePouryaKitWireJson(), the same representation read strictly, to stored and delivered messages (messaging.md), so an ID reads the same in both. Where code needs options of its own, create them withnew JsonSerializerOptions().UsePouryaKitJson(); a structure test rejects setting an option anywhere else, and the typed-ID converter is internal toPouryaKit.Domain, so no other code can add it. The converter also handles typed IDs as dictionary keys, and reports an empty ID in JSON as aJsonException. EF Core needs nothing either:PouryaKitDbContextstores every typed ID as its value (see persistence.md). Nor does Dapper:AddPouryaKitDappermakes every typed ID a parameter and a result (see dapper-reads.md).
Aggregates and entities
- Raise a Domain Event with
Raisefrom inside the aggregate. Never clear Domain Events and never setVersion: the Unit of Work owns both. It takes and clears Domain Events when it runs their handlers (see persistence.md), and a structure test rejects a Module that clears them itself (testing.md). Versionis 0 until the aggregate is saved, 1 after its first commit, and exactly one more for each later Operation that changes the aggregate or anything it owns. It is the concurrency token that turns a concurrent change into a conflict Error (persistence.md). Raising an event does not change the version.- Opt an aggregate or entity into audit with
IAuditableand into soft delete withISoftDeletable. Declare each of their properties with a private setter and never set it: the Unit of Work fills them before it saves (persistence.md). Mark an aggregate as one tenant's withITenantScoped, whoseTenantthe aggregate sets itself, from the tenant its handler takes from the Operation Context (multi-tenancy.md). Removing a soft-deletable aggregate raises no Domain Event of its own; when other aggregates care, give it a domain method that raises one, such as the Proving Sample'sProduct.Remove(). - Entities are equal when type and ID are equal; do not override equality.
- Equality and hashing use the current ID. Do not change an entity's ID while it is in a hash-based collection using entity equality. For database-generated IDs, add the entity after its ID is assigned, or use
ReferenceEqualityComparer.Instancewhen the collection tracks object instances across persistence. With reference equality, different instances remain distinct even when their IDs are equal.
Domain Events
- Declare each as a sealed record deriving from
DomainEvent, named in the past tense with theDomainEventsuffix. ItsIdand UTCOccurredOnare assigned once when it is created and survive serialization; never set them yourself. - Carry typed IDs and values, never the aggregate itself or its version.
- Serialize a Domain Event by its runtime type (
JsonSerializer.Serialize(e, e.GetType(), options)); serializing it asDomainEventdrops the derived members. - A payload without
Iddeserializes with a new identity, so a stored or sent event must always include itsId. - React to one inside the Module with a Domain Event Handler; see persistence.md.
Business rules
- Implement
IBusinessRulewithIsBroken()and the Error it returns.CheckRule(rule)in an entity or aggregate returns a Result; a broken rule is a failed Result, never an exception.
The clock
- A Domain Event takes its
OccurredOnfrom the running Operation's clock: the dispatcher binds the Host's registeredTimeProviderasDomainClockfor each Operation. Outside an Operation,DomainClockis the system clock. - To change the time Operations see, the Host registers its own
TimeProvider(builder.Services.AddSingleton<TimeProvider>(time));AddPouryaKitCqrsaddsTimeProvider.Systemonly when none is registered. - A business rule that compares with "now" takes the time as a parameter. The handler injects
TimeProviderand passestimeProvider.GetUtcNow()in. Feature code takes time only from thatTimeProviderand never bindsDomainClock.
internal sealed class SaleMustBeOpen(DateTimeOffset endsOn, DateTimeOffset now) : IBusinessRule
{
public Error Error => Error.Conflict("Sale.Ended", "The sale has ended.");
public bool IsBroken() => now >= endsOn;
}
// In the aggregate: public Result Buy(DateTimeOffset now) => CheckRule(new SaleMustBeOpen(EndsOn, now));
// In its handler, with TimeProvider timeProvider injected: var bought = sale.Buy(timeProvider.GetUtcNow());Testing
- Test an aggregate through its public methods and assert on
DomainEventsand on the returned Result'sErrors. - To fix the time of Domain Events an aggregate raises in a test, create a
TestClock(PouryaKit.Testing) withusinginside the test method itself:using var clock = new TestClock(monday);, thenclock.Time.Advance(...). A binding made in a constructor or fixture is not relied on to reach the test.