Skip to content

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.


Suppose you are managing products in an online store. The simplest code looks like this:

// Naive code — any value is accepted
public 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 database
  • Price = -500m — a negative price appears in the catalog
  • Name and Description are both string — the compiler cannot tell them apart
  • new Product() — anyone can create an instance without validation

DDD’s Value Object and AggregateRoot block all of these problems through the type system.


Before writing code, define the business terms. These terms are reflected directly as class names and method names in the code.

TermCode NameDefinitionBusiness Rule
Product NameProductNameName of a product100 characters max, no empty strings
PriceMoneyA positive monetary amountZero and negative values not allowed
ProductProductA sellable itemCreated only with validated values
Product CreatedProduct.CreatedEventThe fact that a product was createdAutomatically published on creation

  1. Create a project

    Terminal window
    dotnet new classlib -n MyShop.Domain
  2. Install NuGet packages

    Terminal window
    cd MyShop.Domain
    dotnet add package Functorium
    dotnet add package Functorium.SourceGenerators

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 RuleCodeError 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)

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 with new 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:

PatternCodeProblem in Naive Code
Private constructorprivate Product(...)new Product() bypasses validation
Factory methodProduct.Create(name, price)Accepts only validated VOs, blocking invalid values
Domain eventAddDomainEvent(new CreatedEvent(...))State changes go unrecorded
Ulid IDProductId.New()Any string can serve as an ID

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 CRUD
public interface IProductRepository : IRepository<Product, ProductId>
{
// Add domain-specific queries here if needed
}

Default methods provided by IRepository<T, TId>:

MethodReturn TypeDescription
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 returns T on success or Error on 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:

  1. ProductName.Create(request.Name) — Validates “Product name must not be empty”. On failure: DomainErrors.ProductName.Empty
  2. Money.Create(request.Price) — Validates “Price must be positive”. On failure: DomainErrors.Money.NotPositive
  3. Product.Create(name, price) — Creates Aggregate only from validated VOs + publishes CreatedEvent
  4. UsecaseTransactionPipeline automatically handles SaveChanges + event publishing

Looking back at the naive code problems from the beginning and how they were resolved:

Problem in Naive CodeSolution with DDD + FunctoriumGuarantee Level
Name = "" (empty product name)ProductName.Create("")FailBlocked at creation
Price = -500m (negative price)Money.Create(-500m)FailBlocked at creation
new Product() (bypass validation)private constructor + factory methodCompile-time prevention
Unable to track state changesAddDomainEvent(CreatedEvent)Automatic on creation
string ID confusionProductId (Ulid-based type)Compile-time prevention

Code speaks business rules. ProductName means “product name,” Money means “price,” and Product.Create means “create a product.” The naive code’s string and decimal conveyed no meaning at all.



The complete code for this guide is included in the repository.

Terminal window
# Build
dotnet build Docs.Site/src/content/docs/quickstart/quickstart.slnx
# Test (10 tests)
dotnet test --solution Docs.Site/src/content/docs/quickstart/quickstart.slnx