5-Minute Quickstart
In 5 minutes, you will build a Product domain model with Functorium. See what breaks in naive code, then make your code speak business rules using DDD patterns.
1. The Problem: Pitfalls of Naive Code
Section titled “1. The Problem: Pitfalls of Naive Code”Suppose you are managing products in an online store. The simplest code looks like this:
// Naive code — any value is acceptedpublic class Product{ public string Name { get; set; } = ""; public decimal Price { get; set; }}This code allows all of the following:
Name = ""— an empty product name gets saved to the databasePrice = -500m— a negative price appears in the catalogNameandDescriptionare bothstring— the compiler cannot tell them apartnew Product()— anyone can create an instance without validation
DDD’s Value Object and AggregateRoot block all of these problems through the type system.
2. Product Domain Terminology
Section titled “2. Product Domain Terminology”Before writing code, define the business terms. These terms are reflected directly as class names and method names in the code.
| Term | Code Name | Definition | Business Rule |
|---|---|---|---|
| Product Name | ProductName | Name of a product | 100 characters max, no empty strings |
| Price | Money | A positive monetary amount | Zero and negative values not allowed |
| Product | Product | A sellable item | Created only with validated values |
| Product Created | Product.CreatedEvent | The fact that a product was created | Automatically published on creation |
3. Project Setup
Section titled “3. Project Setup”-
Create a project
Terminal window dotnet new classlib -n MyShop.Domain -
Install NuGet packages
Terminal window cd MyShop.Domaindotnet add package Functoriumdotnet add package Functorium.SourceGenerators
4. Value Object — Rules as Types
Section titled “4. Value Object — Rules as Types”A Value Object replaces the naive string and decimal with types that embed business rules.
The Create() method returns Fin<T> (success or error) instead of throwing exceptions.
ProductName — “Product name must not be empty”
Section titled “ProductName — “Product name must not be empty””Business rule: Product name cannot be null or empty, must be 100 characters or fewer, and leading/trailing whitespace is automatically trimmed.
using Functorium.Domains.ValueObjects;
public sealed class ProductName : SimpleValueObject<string>{ public const int MaxLength = 100;
private ProductName(string value) : base(value) { }
// Factory: validates then creates. Returns Fin<ProductName> without exceptions public static Fin<ProductName> Create(string? value) => CreateFromValidation(Validate(value), v => new ProductName(v));
// Validation rule chain: Null → Empty → MaxLength → Normalize public static Validation<Error, string> Validate(string? value) => ValidationRules<ProductName> .NotNull(value) // On failure: "DomainErrors.ProductName.Null" .ThenNotEmpty() // On failure: "DomainErrors.ProductName.Empty" .ThenMaxLength(MaxLength) // On failure: "DomainErrors.ProductName.TooLong" .ThenNormalize(v => v.Trim());
// For ORM restoration (skips validation) public static ProductName CreateFromValidated(string value) => new(value); public static implicit operator string(ProductName name) => name.Value;}You can trace which business rule each validation step enforces:
| Business Rule | Code | Error on Violation |
|---|---|---|
| No nulls | .NotNull(value) | DomainErrors.ProductName.Null |
| No empty strings | .ThenNotEmpty() | DomainErrors.ProductName.Empty |
| 100 characters max | .ThenMaxLength(MaxLength) | DomainErrors.ProductName.TooLong |
| Trim whitespace | .ThenNormalize(v => v.Trim()) | (always succeeds) |
Money — “Price must be positive”
Section titled “Money — “Price must be positive””Business rule: The price must be strictly positive. Zero-priced products and negative prices cannot exist.
using Functorium.Domains.ValueObjects;
public sealed class Money : ComparableSimpleValueObject<decimal>{ public static readonly Money Zero = new(0m);
private Money(decimal value) : base(value) { }
public static Fin<Money> Create(decimal value) => CreateFromValidation(Validate(value), v => new Money(v));
public static Validation<Error, decimal> Validate(decimal value) => ValidationRules<Money>.Positive(value); // On failure: "DomainErrors.Money.NotPositive"
public static Money CreateFromValidated(decimal value) => new(value); public static implicit operator decimal(Money money) => money.Value;}Money.Create(-500m) fails. In naive code, -500 would have been saved directly to the database.
5. AggregateRoot — State Changes as Events
Section titled “5. AggregateRoot — State Changes as Events”Why AggregateRoot? Product is the root of a consistency boundary. Product creation must always occur together with a
CreatedEvent, and external code must not bypass validation withnew Product().
The [GenerateEntityId] attribute auto-generates a Ulid-based ID type.
using Functorium.Domains.Entities;using Functorium.Domains.Events;using Functorium.SourceGenerators;
[GenerateEntityId] // → Auto-generates ProductId struct (Ulid-based)public sealed class Product : AggregateRoot<ProductId>{ #region Domain Events
// Domain event: defined as a nested record inside the Aggregate public sealed record CreatedEvent( ProductId ProductId, ProductName Name, Money Price) : DomainEvent;
#endregion
// Value Object properties (immutability via private set) public ProductName Name { get; private set; } public Money Price { get; private set; } public DateTime CreatedAt { get; private set; }
// Private constructor: prevents external new private Product(ProductId id, ProductName name, Money price) : base(id) { Name = name; Price = price; CreatedAt = DateTime.UtcNow; }
// Factory method: creates from validated VOs + publishes event public static Product Create(ProductName name, Money price) { var product = new Product(ProductId.New(), name, price); product.AddDomainEvent(new CreatedEvent(product.Id, name, price)); return product; }}Comparing with naive code makes it clear which problem each pattern prevents:
| Pattern | Code | Problem in Naive Code |
|---|---|---|
| Private constructor | private Product(...) | new Product() bypasses validation |
| Factory method | Product.Create(name, price) | Accepts only validated VOs, blocking invalid values |
| Domain event | AddDomainEvent(new CreatedEvent(...)) | State changes go unrecorded |
| Ulid ID | ProductId.New() | Any string can serve as an ID |
6. Repository Port
Section titled “6. Repository Port”A port defined by the domain and implemented by infrastructure. Domain code does not depend on database technology.
using Functorium.Domains.Repositories;
// IRepository<TAggregate, TId> provides basic CRUDpublic interface IProductRepository : IRepository<Product, ProductId>{ // Add domain-specific queries here if needed}Default methods provided by IRepository<T, TId>:
| Method | Return Type | Description |
|---|---|---|
Create(aggregate) | FinT<IO, T> | Create |
GetById(id) | FinT<IO, T> | Get by ID |
Update(aggregate) | FinT<IO, T> | Update |
Delete(id) | FinT<IO, int> | Delete |
FinT<IO, T>represents the result of an operation with side effects. It returnsTon success orErroron failure, without throwing exceptions.
7. Command Usecase — Orchestrating the Flow
Section titled “7. Command Usecase — Orchestrating the Flow”A Command Usecase is Application Layer logic that orchestrates write operations.
It chains Repository calls via FinT<IO, T> LINQ composition.
using Functorium.Applications.Usecases;
public sealed class CreateProductCommand{ // Request / Response (CQRS) public sealed record Request(string Name, decimal Price) : ICommandRequest<Response>;
public sealed record Response(string ProductId, string Name, decimal Price);
// Usecase Handler public sealed class Usecase(IProductRepository productRepository) : ICommandUsecase<Request, Response> { public async ValueTask<FinResponse<Response>> Handle( Request request, CancellationToken cancellationToken) { // Pipeline Validator completed validation. Create() is for normalization. var name = ProductName.Create(request.Name).Unwrap(); var price = Money.Create(request.Price).Unwrap();
// 2. Create Aggregate var product = Product.Create(name, price);
// 3. Repository call (FinT<IO, T> LINQ composition) FinT<IO, Response> usecase = from created in productRepository.Create(product) select new Response( created.Id.ToString(), created.Name, created.Price);
// 4. Execute IO monad → convert to FinResponse Fin<Response> response = await usecase.Run().RunAsync(); return response.ToFinResponse(); } }}Core flow — which business rule each step enforces:
ProductName.Create(request.Name)— Validates “Product name must not be empty”. On failure:DomainErrors.ProductName.EmptyMoney.Create(request.Price)— Validates “Price must be positive”. On failure:DomainErrors.Money.NotPositiveProduct.Create(name, price)— Creates Aggregate only from validated VOs + publishesCreatedEventUsecaseTransactionPipelineautomatically handles SaveChanges + event publishing
8. What Was Prevented
Section titled “8. What Was Prevented”Looking back at the naive code problems from the beginning and how they were resolved:
| Problem in Naive Code | Solution with DDD + Functorium | Guarantee Level |
|---|---|---|
Name = "" (empty product name) | ProductName.Create("") → Fail | Blocked at creation |
Price = -500m (negative price) | Money.Create(-500m) → Fail | Blocked at creation |
new Product() (bypass validation) | private constructor + factory method | Compile-time prevention |
| Unable to track state changes | AddDomainEvent(CreatedEvent) | Automatic on creation |
string ID confusion | ProductId (Ulid-based type) | Compile-time prevention |
Code speaks business rules.
ProductNamemeans “product name,”Moneymeans “price,” andProduct.Createmeans “create a product.” The naive code’sstringanddecimalconveyed no meaning at all.
9. Next Steps
Section titled “9. Next Steps”Build and Test the Complete Code
Section titled “Build and Test the Complete Code”The complete code for this guide is included in the repository.
# Builddotnet build Docs.Site/src/content/docs/quickstart/quickstart.slnx
# Test (10 tests)dotnet test --solution Docs.Site/src/content/docs/quickstart/quickstart.slnx