Skip to content

lukasz-strus/DomainSmith

Repository files navigation

DomainSmith

CICD codecov NuGet Badge

Roslyn Source Generator for .NET that automates Domain-Driven Design patterns and Clean Architecture components.

General

DomainSmith is a set of Roslyn source generators that generate DDD/Clean Architecture boilerplate from simple domain types annotated with attributes:

  • ValueObject (immutability / value-based equality + factories and updates)
  • Entity (ID + create/update API)
  • AggregateRoot (ID + support for entity collections inside the aggregate)
  • Repository (repository interface for an Aggregate Root)

The generator does not replace your domain logic. Its goal is to generate a consistent API and extensions (for example Create(...), Update(...), entity-collection helpers, etc.) from the fields/properties you already defined.

Packages in src/ are independent and target netstandard2.0 (generators are consumed by application projects). Additionally, DomainSmith.Abstraction provides shared DDD primitives (ValueObject, Entity<TId>, AggregateRoot<TId>) and a lightweight Result pattern.

Value Object ([ValueObject])

Attribute: DomainSmith.ValueObject.ValueObjectAttribute

Generator input:

  • a type (class/record) annotated with [ValueObject]
  • properties on that type (excluding [ExcludeFromGeneration])
  • whether the Result pattern is enabled
using DomainSmith.Abstraction.Core.Result;

namespace DomainSmith.ValueObject.Examples.ValueObjects;

[ValueObject]
public partial record Money
{
    public static int MaxAmount = 10000;
    public static int MinAmount = 0;
    public static int MaxCurrencyLength = 3;
    public decimal Amount { get; init; }
    public string Currency { get; init; } = "USD";

    static partial void OnCreating(ref decimal amount, ref string currency, ref bool canCreate, ref Error error)
    {
        if (amount > MaxAmount)
        {
            canCreate = false;
            error = new Error("Money.Amount.ExceedsMax", $"Amount cannot exceed {MaxAmount}.");
            return;
        }

        if (amount < MinAmount)
        {
            canCreate = false;
            error = new Error("Money.Amount.BelowMin", $"Amount cannot be below {MinAmount}.");
            return;
        }

        if (currency.Length > MaxCurrencyLength)
        {
            canCreate = false;
            error = new Error("Money.Currency.ExceedsMaxLength",
                $"Currency cannot exceed {MaxCurrencyLength} characters.");
            return;
        }
    }
}

What gets generated:

// <auto-generated/>
using DomainSmith.Abstraction.Core.Result;
using DomainSmith.Abstraction.Core.Primitives;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using DomainSmith.Abstraction.Core.Result;

namespace DomainSmith.ValueObject.Examples.ValueObjects;

partial record Money
{
    private Money(decimal amount, string currency)
    {
        Amount = amount;
		Currency = currency;
    }

    public static Result<Money> Create(decimal amount, string currency)
    {
        bool canCreate = true;
		Error error = Error.None;

        OnCreating(ref amount, ref currency, ref canCreate, ref error);
        
        var result = new Money(amount, currency);

        OnCreated(result, ref canCreate, ref error);
        if (!canCreate) return Result.Failure<Money>(error);

        return Result.Success(result);
    }

    static partial void OnCreating(ref decimal amount, ref string currency, ref bool canCreate, ref Error error);
    static partial void OnCreated(Money instance, ref bool canCreate, ref Error error);
}
  • code that provides a consistent way to create and update the object based on defined properties,
  • factory/update methods like Create(...) and Update(...) (variants depend on Result pattern),
  • an extension/partial API for the type (the generator also tracks “extension name” and type reference).

Result pattern:

  • by default, the generator produces APIs returning Result / Result<T>.
  • if you use [NoResultPattern], the generator switches to APIs without Result.

Entity ([Entity(typeof(TId))])

Attribute: DomainSmith.Entity.EntityAttribute

Generator input:

using DomainSmith.Abstraction.Common;
using DomainSmith.Abstraction.Core.Primitives;
using DomainSmith.Abstraction.Core.Result;

namespace DomainSmith.Entity.Examples.Entities;

[Entity(typeof(OwnerId))]
public partial class Owner
{
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string Email { get; private set; }
    [ExcludeFromGeneration] public bool IsEnabled { get; private set; }
    [AutoGenerated] public DateTime? ModifiedAt { get; private set; }
    [AutoGenerated] public DateTime CreatedAt { get; private set; }

    public Result Activate()
    {
        IsEnabled = true;
        ModifiedAt = DateTime.Now;
        return Result.Success();
    }

    public Result Deactivate()
    {
        IsEnabled = false;
        ModifiedAt = DateTime.Now;
        return Result.Success();
    }

    partial void CreateCreatedAt() => CreatedAt = DateTime.Now;
    partial void UpdateCreatedAt() => ModifiedAt = DateTime.Now;
    partial void UpdateModifiedAt() => ModifiedAt = DateTime.Now;
}

public record OwnerId(Guid Value) : EntityIdRecord<Guid>(Value);
  • a class annotated with [Entity(typeof(SomeIdType))] – the generator reads the ID type from the attribute argument,
  • ID metadata (for example whether it is a record/class and the underlying value type) is used to tailor generation,
  • entity properties (excluding [ExcludeFromGeneration]),
  • Result pattern configuration.

What gets generated:

// <auto-generated/>
using DomainSmith.Abstraction.Common;
using DomainSmith.Abstraction.Core.Primitives;
using DomainSmith.Abstraction.Core.Result;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using DomainSmith.Abstraction.Core.Result;

namespace DomainSmith.Entity.Examples.Entities;

partial class Owner : Entity<OwnerId>
{
    private Owner(OwnerId id, string firstname, string lastname, string email) : base(id)
    {
        FirstName = firstname;
		LastName = lastname;
		Email = email;
    }

    internal static Result<Owner> Create(string firstname, string lastname, string email)
    {
        bool canCreate = true;
		Error error = Error.None;
        OnCreatingAuto(ref firstname, ref lastname, ref email, ref canCreate, ref error);
        OnCreating(ref firstname, ref lastname, ref email, ref canCreate, ref error);

        var result = new Owner(new OwnerId(Guid.NewGuid()), firstname, lastname, email);

        OnCreated(result, ref canCreate, ref error);
        if(!canCreate) return Result.Failure<Owner>(error);

        result.CreateModifiedAt();
		result.CreateCreatedAt();

        return Result.Success(result);
    }

    internal Result<bool> Update(string firstname, string lastname, string email)
    {
        bool canUpdate = true;
		Error error = Error.None;
        OnUpdatingAuto(ref firstname, ref lastname, ref email, ref canUpdate, ref error);
        OnUpdating(ref firstname, ref lastname, ref email, ref canUpdate, ref error);

        var tmpFirstName = FirstName;
		var tmpLastName = LastName;
		var tmpEmail = Email;
        FirstName = firstname;
		LastName = lastname;
		Email = email;

        OnUpdated(ref canUpdate, ref error);
        if(!canUpdate)
        {
           FirstName = tmpFirstName;
			LastName = tmpLastName;
			Email = tmpEmail;
           return Result.Failure<bool>(error);
        }
        UpdateModifiedAt();
		UpdateCreatedAt();

        return Result.Success(true);
    }

    private static void OnCreatingAuto(ref string firstname, ref string lastname, ref string email, ref bool canCreate, ref Error error)
    {
        OnCreatingFirstName(ref firstname, ref canCreate, ref error);
		OnCreatingLastName(ref lastname, ref canCreate, ref error);
		OnCreatingEmail(ref email, ref canCreate, ref error);
    }

    private void OnUpdatingAuto(ref string firstname, ref string lastname, ref string email, ref bool canUpdate, ref Error error)
    {
        OnUpdatingFirstName(ref firstname, ref canUpdate, ref error);
		OnUpdatingLastName(ref lastname, ref canUpdate, ref error);
		OnUpdatingEmail(ref email, ref canUpdate, ref error);
    }

    static partial void OnCreating(ref string firstname, ref string lastname, ref string email, ref bool canCreate, ref Error error);
    static partial void OnCreated(Owner result, ref bool canCreate, ref Error error);

    partial void OnUpdating(ref string firstname, ref string lastname, ref string email, ref bool canUpdate, ref Error error);
    partial void OnUpdated(ref bool canUpdate, ref Error error);

    partial void CreateModifiedAt();
	partial void CreateCreatedAt();
    partial void UpdateModifiedAt();
	partial void UpdateCreatedAt();

    static partial void OnCreatingFirstName(ref string firstname, ref bool canCreate, ref Error error);
	static partial void OnCreatingLastName(ref string lastname, ref bool canCreate, ref Error error);
	static partial void OnCreatingEmail(ref string email, ref bool canCreate, ref Error error);
    partial void OnUpdatingFirstName(ref string firstname, ref bool canUpdate, ref Error error);
	partial void OnUpdatingLastName(ref string lastname, ref bool canUpdate, ref Error error);
	partial void OnUpdatingEmail(ref string email, ref bool canUpdate, ref Error error);
}
  • a consistent API for entity creation (Create(...)) and modification (Update(...)),
  • integration with Entity<TId> (from DomainSmith.Abstraction),
  • Result-based variants (default) or non-Result variants (with [NoResultPattern]).

Aggregate Root ([AggregateRoot(typeof(TId))])

Attribute: DomainSmith.AggregateRoot.AggregateRootAttribute

Generator input:

using DomainSmith.Abstraction.Common;
using DomainSmith.Abstraction.Core.Primitives;
using DomainSmith.Entity;

namespace DomainSmith.AggregateRoot.Examples.AggregateRoots;

public record OwnerId(Guid Value) : EntityIdRecord<Guid>(Value);

public record CarId(Guid Value) : EntityIdRecord<Guid>(Value);

[Entity(typeof(CarId))]
public partial class Car
{
    public string Name { get; private set; }
    public string Type { get; private set; }
    public decimal Price { get; private set; }
}

[AggregateRoot(typeof(OwnerId))]
public partial class Owner
{
    private readonly HashSet<Car> _newCars = [];
    private readonly HashSet<Car> _oldCars = [];
    [ExcludeFromGeneration] private readonly HashSet<Car> _allCars = [];

    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string Email { get; private set; }
}
  • a class annotated with [AggregateRoot(typeof(SomeIdType))] (similar to Entity),
  • aggregate properties (excluding [ExcludeFromGeneration]),
  • Result pattern configuration,
  • EntityCollections – the generator analyzes the class and detects entity collections for which it should generate collection-management APIs.

What gets generated:

// <auto-generated/>
using DomainSmith.Abstraction.Common;
using DomainSmith.Abstraction.Core.Primitives;
using DomainSmith.Entity;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using DomainSmith.Abstraction.Core.Result;
using System.Linq;
using DomainSmith.Abstraction.Common;
using DomainSmith.Abstraction.Core.Result;

namespace DomainSmith.AggregateRoot.Examples.AggregateRoots;

partial class Owner : AggregateRoot<OwnerId>
{
    private Owner(OwnerId id, string firstname, string lastname, string email) : base(id)
    {
        FirstName = firstname;
		LastName = lastname;
		Email = email;
    }

    public static Result<Owner> Create(string firstname, string lastname, string email)
    {
        bool canCreate = true;
		Error error = Error.None;
        OnCreatingAuto(ref firstname, ref lastname, ref email, ref canCreate, ref error);
        OnCreating(ref firstname, ref lastname, ref email, ref canCreate, ref error);

        var result = new Owner(new OwnerId(Guid.NewGuid()), firstname, lastname, email);

        OnCreated(result, ref canCreate, ref error);
        if(!canCreate) return Result.Failure<Owner>(error);

        

        return Result.Success(result);
    }

    public Result<bool> Update(string firstname, string lastname, string email)
    {
        bool canUpdate = true;
		Error error = Error.None;
        OnUpdatingAuto(ref firstname, ref lastname, ref email, ref canUpdate, ref error);
        OnUpdating(ref firstname, ref lastname, ref email, ref canUpdate, ref error);

        var tmpFirstName = FirstName;
		var tmpLastName = LastName;
		var tmpEmail = Email;
        FirstName = firstname;
		LastName = lastname;
		Email = email;

        OnUpdated(ref canUpdate, ref error);
        if(!canUpdate)
        {
           FirstName = tmpFirstName;
			LastName = tmpLastName;
			Email = tmpEmail;
           return Result.Failure<bool>(error);
        }
        

        return Result.Success(true);
    }

    private static void OnCreatingAuto(ref string firstname, ref string lastname, ref string email, ref bool canCreate, ref Error error)
    {
        OnCreatingFirstName(ref firstname, ref canCreate, ref error);
		OnCreatingLastName(ref lastname, ref canCreate, ref error);
		OnCreatingEmail(ref email, ref canCreate, ref error);
    }

    private void OnUpdatingAuto(ref string firstname, ref string lastname, ref string email, ref bool canUpdate, ref Error error)
    {
        OnUpdatingFirstName(ref firstname, ref canUpdate, ref error);
		OnUpdatingLastName(ref lastname, ref canUpdate, ref error);
		OnUpdatingEmail(ref email, ref canUpdate, ref error);
    }

    static partial void OnCreating(ref string firstname, ref string lastname, ref string email, ref bool canCreate, ref Error error);
    static partial void OnCreated(Owner result, ref bool canCreate, ref Error error);

    partial void OnUpdating(ref string firstname, ref string lastname, ref string email, ref bool canUpdate, ref Error error);
    partial void OnUpdated(ref bool canUpdate, ref Error error);

    
    

    static partial void OnCreatingFirstName(ref string firstname, ref bool canCreate, ref Error error);
	static partial void OnCreatingLastName(ref string lastname, ref bool canCreate, ref Error error);
	static partial void OnCreatingEmail(ref string email, ref bool canCreate, ref Error error);
    partial void OnUpdatingFirstName(ref string firstname, ref bool canUpdate, ref Error error);
	partial void OnUpdatingLastName(ref string lastname, ref bool canUpdate, ref Error error);
	partial void OnUpdatingEmail(ref string email, ref bool canUpdate, ref Error error);

    
	public IReadOnlyCollection<Car> NewCars => _newCars;

	public Result<Car> AddNewElementToNewCars(string name, string type, decimal price)
	{
		var result = Car.Create(name, type, price);
		if (result.IsFailure)
			return result;

		var entity = result.Value();
		_newCars.Add(entity);

		return entity;
	}

	public Result UpdateElementInNewCars(CarId id, string name, string type, decimal price)
	{
		var entity = _newCars.FirstOrDefault(a => a.Id == id);
		if (entity is null)
			return Result.Failure(new Error("Owner.NewCars", "Not found"));

		var result = entity.Update(name, type, price);

		return result;
	}

	public Result DeleteElementFromNewCars(CarId id)
	{
		var entity = _newCars.FirstOrDefault(a => a.Id == id);
		if (entity is null)
			return Result.Failure(new Error("Owner.NewCars", "Not found"));

		_newCars.Remove(entity);

		return Result.Success();
	}

	public IReadOnlyCollection<Car> OldCars => _oldCars;

	public Result<Car> AddNewElementToOldCars(string name, string type, decimal price)
	{
		var result = Car.Create(name, type, price);
		if (result.IsFailure)
			return result;

		var entity = result.Value();
		_oldCars.Add(entity);

		return entity;
	}

	public Result UpdateElementInOldCars(CarId id, string name, string type, decimal price)
	{
		var entity = _oldCars.FirstOrDefault(a => a.Id == id);
		if (entity is null)
			return Result.Failure(new Error("Owner.OldCars", "Not found"));

		var result = entity.Update(name, type, price);

		return result;
	}

	public Result DeleteElementFromOldCars(CarId id)
	{
		var entity = _oldCars.FirstOrDefault(a => a.Id == id);
		if (entity is null)
			return Result.Failure(new Error("Owner.OldCars", "Not found"));

		_oldCars.Remove(entity);

		return Result.Success();
	}

}
  • a consistent API for aggregate creation (Create(...)) and modification (Update(...)),
  • integration with AggregateRoot<TId> (from DomainSmith.Abstraction),
  • Result-based variants (default) or non-Result variants (with [NoResultPattern]),
  • helpers for managing entity collections inside the aggregate.

EntityCollections in AggregateRoot

If the generator detects that the aggregate has an entity collection (for example with a backing field), it generates:

  • optionally, an IReadOnlyCollection<T> property exposing the collection,
  • helper methods for collection management:
    • AddNewElementTo{CollectionName}(...)
    • UpdateElementIn{CollectionName}(id, ...)
    • DeleteElementFrom{CollectionName}(id)

Result pattern variants:

  • with Result pattern:
    • Add... returns Result<TElement>
    • Update... / Delete... return Result
    • when an element is not found, it returns Result.Failure(new Error("{Aggregate}.{Collection}", "Not found"))
  • without Result pattern:
    • Add... returns TElement? (null on failure)
    • Update... / Delete... are void (silent failure, e.g., element not found)

Additionally, the generator detects whether the element entity itself uses the Result pattern (for example TElement.Create(...) may return Result<TElement>). In that case the generated code can:

  • call TElement.Create(...)
  • short-circuit and propagate the failure (or return null) if creation/update fails.

Repository (for an Aggregate Root)

The repository generator is based on types annotated with [AggregateRoot].

Input:

using DomainSmith.AggregateRoot;

namespace DomainSmith.Repository.Examples.AggregateRoots;

[AggregateRoot(typeof(Guid))]
public partial class Owner
{
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string Email { get; private set; }
}
  • an Aggregate Root class annotated with [AggregateRoot(typeof(TId))]
  • the ID type TId

What gets generated:

// <auto-generated/>
using DomainSmith.AggregateRoot;
using DomainSmith.Abstraction.Core.Primitives;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using DomainSmith.Abstraction.Core.Maybe;

namespace DomainSmith.Repository.Examples.AggregateRoots;

public interface IOwnerRepository
{
    Task<DomainSmith.Abstraction.Core.Maybe.Maybe<Owner>> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    Task<IReadOnlyCollection<Owner>> GetAllAsync(Guid id, CancellationToken cancellationToken = default);

    Task AddAsync(Owner owner, CancellationToken cancellationToken = default);

    void Remove(Owner owner);
}
  • a repository interface named I{AggregateRootName}Repository (the output filename is set explicitly in the generator),
  • method signatures tailored to the aggregate ID,
  • Maybe-based GetByIdAsync(...) to represent the possibility of a missing entity.

Practical notes

  • For source generators, the target type should typically be partial and have stable properties that define the generated API.
  • If you do not want a member to participate in generation (technical fields, caches, etc.), mark it with [ExcludeFromGeneration].
  • If you do not use the Result pattern, add [assembly: NoResultPattern] or annotate individual types with [NoResultPattern].

About

Roslyn Source Generator for .NET that automates Domain-Driven Design patterns and Clean Architecture components.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages