OpenDDD.NET 1.0.0-alpha.16

This is a prerelease version of OpenDDD.NET.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package OpenDDD.NET --version 1.0.0-alpha.16
NuGet\Install-Package OpenDDD.NET -Version 1.0.0-alpha.16
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OpenDDD.NET" Version="1.0.0-alpha.16" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add OpenDDD.NET --version 1.0.0-alpha.16
#r "nuget: OpenDDD.NET, 1.0.0-alpha.16"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install OpenDDD.NET as a Cake Addin
#addin nuget:?package=OpenDDD.NET&version=1.0.0-alpha.16&prerelease

// Install OpenDDD.NET as a Cake Tool
#tool nuget:?package=OpenDDD.NET&version=1.0.0-alpha.16&prerelease

OpenDDD.NET

This is a framework for domain-driven design (DDD) using C# and .NET.

Star and/or follow the project to get notifications on new releases.

Purpose

Domain-driven design is an approach to software development where focus lies on an evolving domain model.

By utilizing the DDD principles and patterns, this framework is suitable for applying domain-driven design to your projects and implementing the bounded contexts with C# and the .NET framework.

Key Features

  • Built for domain modelling focus.
  • Near-infinite scaling using the entity pattern.
  • Easy configuration of bounded contexts to multiple environments.
  • A (growing) list of secondary adapters for multiple technologies.
  • Full test coverage of your domain actions using the built-in testing framework.
  • Easy to get started using project templates.

Design Patterns

The framework is based on the following design patterns:

Big thanks to Eric Evans for his seminal book on DDD and Vaughn Vernon for his reference implementation of DDD in Java.

Supported .NET Versions

  • .NET Core 3.1
  • .NET 5

Documentation

Documentation is available at readthedocs.

Installation

Install the nuget in an existing project:

dotnet add package OpenDDD.NET

Create a project

The quickest way to get started is using the project templates.

Start with installing the project templates:

$ dotnet new install OpenDDD.NET-Templates

Then create the project:

dotnet new openddd-net -n MyBoundedContext # replace with actual context name

Refer to the user guide for more details and next steps.

Examples

Here are some code examples:

CreateAccountAction.cs
using System.Threading;
using System.Threading.Tasks;
using OpenDDD.Application;
using OpenDDD.Domain.Model.Error;
using OpenDDD.Infrastructure.Ports.PubSub;
using Application.Actions.Commands;
using Domain.Model.User;

namespace Application.Actions
{
    public class CreateAccountAction : Action<CreateAccountCommand, User>
    {
        private readonly IDomainPublisher _domainPublisher;
        private readonly IUserRepository _userRepository;
        
        public CreateAccountAction(
            IDomainPublisher domainPublisher,
            IUserRepository userRepository,
            ITransactionalDependencies transactionalDependencies)
            : base(transactionalDependencies)
        {
            _domainPublisher = domainPublisher;
            _userRepository = userRepository;
        }

        public override async Task<User> ExecuteAsync(
            CreateAccountCommand command,
            ActionId actionId,
            CancellationToken ct)
        {
            // Validate
            var existing =
                await _userRepository.GetWithEmailAsync(
                    command.Email,
                    actionId,
                    ct);

            if (existing != null)
                throw DomainException.AlreadyExists("user", "email", command.Email);

            // Run
            var user =
                await User.CreateAccountAsync(
                    userId: UserId.Create(await _userRepository.GetNextIdentityAsync()),
                    firstName: command.FirstName,
                    lastName: command.LastName,
                    email: command.Email,
                    password: command.Password,
                    passwordAgain: command.RepeatPassword,
                    domainPublisher: _domainPublisher,
                    actionId: actionId,
                    ct: ct);

            // Persist
            await _userRepository.SaveAsync(user, actionId, ct);
            
            // Return
            return user;
        }
    }
}
User.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.WebUtilities;
using OpenDDD.Application;
using OpenDDD.Domain.Model.BuildingBlocks.Aggregate;
using OpenDDD.Domain.Model.BuildingBlocks.Entity;
using OpenDDD.Domain.Model.Error;
using OpenDDD.Domain.Model.Validation;
using OpenDDD.Infrastructure.Ports.Email;
using OpenDDD.Infrastructure.Ports.PubSub;
using Domain.Model.Realm;
using ContextDomainModelVersion = Domain.Model.DomainModelVersion;
using SaltClass = Domain.Model.User.Salt;

namespace Domain.Model.User
{
    public class User : Aggregate, IAggregate, IEquatable<User>
    {
        public UserId UserId { get; set; }
        EntityId IAggregate.Id => UserId;
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public Email Email { get; set; }
        public DateTime? EmailVerifiedAt { get; set; }
        public DateTime? EmailVerificationRequestedAt { get; set; }
        public DateTime? EmailVerificationCodeCreatedAt { get; set; }
        public EmailVerificationCode? EmailVerificationCode { get; set; }
        public Password Password { get; set; }
        public Salt Salt { get; set; }
        public string ResetPasswordCode { get; set; }
        public DateTime? ResetPasswordCodeCreatedAt { get; set; }
        public bool IsSuperUser { get; set; }
        public ICollection<RealmId> RealmIds { get; set; }

        public User() {}

        // Public
        
        public static async Task<User> CreateAccountAsync(
            UserId userId,
            string firstName,
            string lastName,
            Email email,
            string password,
            string passwordAgain,
            IDomainPublisher domainPublisher,
            ActionId actionId,
            CancellationToken ct)
        {
            if (password != passwordAgain)
                throw DomainException.InvariantViolation("The passwords don't match.");
            
            var user =
                new User
                {
                    DomainModelVersion = ContextDomainModelVersion.Latest(),
                    UserId = userId,
                    FirstName = firstName,
                    LastName = lastName,
                    Email = email,
                    EmailVerifiedAt = null,
                    EmailVerificationRequestedAt = null,
                    EmailVerificationCodeCreatedAt = null,
                    EmailVerificationCode = null,
                    IsSuperUser = false,
                    RealmIds = new List<RealmId>()
                };
            
            user.SetPassword(password, actionId, ct);
            user.RequestEmailValidation(actionId, ct);

            user.Validate();

            await domainPublisher.PublishAsync(new AccountCreated(user, actionId));

            return user;
        }
        
        public static User CreateDefaultAccountAtIdpLogin(
            UserId userId,
            string firstName,
            string lastName,
            Email email,
            ActionId actionId,
            CancellationToken ct)
        {
            var user =
                new User
                {
                    DomainModelVersion = ContextDomainModelVersion.Latest(),
                    UserId = userId,
                    FirstName = firstName,
                    LastName = lastName,
                    Email = email,
                    EmailVerifiedAt = null,
                    EmailVerificationRequestedAt = null,
                    EmailVerificationCodeCreatedAt = null,
                    EmailVerificationCode = null,
                    IsSuperUser = false,
                    RealmIds = new List<RealmId>()
                };
            
            user.SetPassword(Password.Generate(), actionId, ct);

            user.Validate();

            return user;
        }
        
        public static User CreateRootAccountAtBoot(
            UserId userId,
            string firstName,
            string lastName,
            Email email,
            string password,
            ActionId actionId,
            CancellationToken ct)
        {
            var user =
                new User
                {
                    DomainModelVersion = ContextDomainModelVersion.Latest(),
                    UserId = userId,
                    FirstName = firstName,
                    LastName = lastName,
                    Email = email,
                    EmailVerifiedAt = null,
                    EmailVerificationRequestedAt = null,
                    EmailVerificationCodeCreatedAt = null,
                    EmailVerificationCode = null,
                    IsSuperUser = true,
                    RealmIds = new List<RealmId>()
                };
            
            user.SetPassword(password, actionId, ct);

            user.Validate();

            return user;
        }
        
        public bool IsEmailVerified()
            => EmailVerifiedAt != null;
        
        public bool IsEmailVerificationRequested()
            => EmailVerificationRequestedAt != null;
        
        public bool IsEmailVerificationCodeExpired()
            => DateTime.UtcNow.Subtract(EmailVerificationCodeCreatedAt!.Value).TotalSeconds >= (60 * 30);
        
        public async Task SendEmailVerificationEmailAsync(Uri verifyEmailUrl, IEmailPort emailAdapter, ActionId actionId, CancellationToken ct)
        {
            if (Email == null)
                throw DomainException.InvariantViolation("The user has no email.");
            
            if (IsEmailVerified())
                throw DomainException.InvariantViolation("The email is already verified.");
            
            if (!IsEmailVerificationRequested())
                throw DomainException.InvariantViolation("Email verification hasn't been requested.");

            // Re-generate code
            if (EmailVerificationCode != null)
                RegenerateEmailVerificationCode();

            var link = $"{verifyEmailUrl}?code={EmailVerificationCode}&userId={UserId}";

            await emailAdapter.SendAsync(
                "no-reply@poweriam.com", 
                "PowerIAM", 
                Email.Value,
                $"{FirstName} {LastName}",
                $"Verify your email", 
                $"Hi, please verify this email address belongs to you by clicking the link: <a href=\"{link}\">Verify Your Email</a>",
                true,
                ct);
        }
        
        public async Task VerifyEmail(EmailVerificationCode code, ActionId actionId, CancellationToken ct)
        {
            if (Email == null)
                throw VerifyEmailException.UserHasNoEmail();
            
            if (IsEmailVerified())
                throw VerifyEmailException.AlreadyVerified();

            if (!IsEmailVerificationRequested())
                throw VerifyEmailException.NotRequested();

            if (!code.Equals(EmailVerificationCode))
                throw VerifyEmailException.InvalidCode();
                
            if (IsEmailVerificationCodeExpired())
                throw VerifyEmailException.CodeExpired();

            EmailVerifiedAt = DateTime.UtcNow;
            EmailVerificationRequestedAt = null;
            EmailVerificationCode = null;
            EmailVerificationCodeCreatedAt = null;
        }

        public void AddToRealm(RealmId realmId, ActionId actionId)
        {
            if (IsInRealm(realmId))
                throw DomainException.InvariantViolation($"User {UserId} already belongs to realm {realmId}.");
            
            RealmIds.Add(realmId);
        }
        
        public async Task ForgetPasswordAsync(Uri resetPasswordUri, IEmailPort emailAdapter, ActionId actionId, CancellationToken ct)
        {
            if (Email == null)
                throw DomainException.InvariantViolation("Can't send reset password email, the user has no email.");

            ResetPasswordCode = Guid.NewGuid().ToString("n").Substring(0, 24);
            ResetPasswordCodeCreatedAt = DateTime.UtcNow;

            resetPasswordUri = new Uri(QueryHelpers.AddQueryString(resetPasswordUri.ToString(), "code", ResetPasswordCode));
            
            var link = resetPasswordUri.ToString();

            await emailAdapter.SendAsync(
                "no-reply@poweriam.com", 
                "PowerIAM", 
                Email.Value, 
                $"{FirstName} {LastName}",
                $"Your reset password link", 
                $"Hi, someone said you forgot your password. If this wasn't you then ignore this email.<br>" +
                $"Follow the link to set your new password: <a href=\"{link}\">Reset Your Password</a>",
                true,
                ct);
        }
        
        public bool IsInRealm(RealmId realmId)
            => RealmIds.Contains(realmId);
        
        public bool IsValidPassword(string password)
            => Salt != null && Password != null && (Password.CreateAndHash(password, Salt) == Password);
        
        public void RemoveFromRealm(RealmId realmId, ActionId actionId)
        {
            if (!IsInRealm(realmId))
                throw DomainException.InvariantViolation($"User {UserId} doesn't belong to realm {realmId}.");
            
            RealmIds.Remove(realmId);
        }
        
        public async Task ResetPassword(string newPassword, ActionId actionId, CancellationToken ct)
        {
            if (ResetPasswordCode == null)
                throw DomainException.InvariantViolation(
                    "Can't reset password, there's no reset password code.");
            
            if (DateTime.UtcNow.Subtract(ResetPasswordCodeCreatedAt.Value).TotalMinutes > 59)
                throw DomainException.InvariantViolation(
                    "The reset password link has expired. Please generate a new one and try again.");
            
            SetPassword(newPassword, actionId, ct);
            
            ResetPasswordCode = null;
            ResetPasswordCodeCreatedAt = null;
        }
        
        public void SetPassword(string password, ActionId actionId, CancellationToken ct)
        {
            Salt = SaltClass.Generate();
            Password = Password.CreateAndHash(password, Salt);
        }
        
        public void RequestEmailValidation(ActionId actionId, CancellationToken ct)
        {
            EmailVerifiedAt = null;
            EmailVerificationRequestedAt = DateTime.UtcNow;
            RegenerateEmailVerificationCode();
        }

        // Private
        
        private void RegenerateEmailVerificationCode()
        {
            EmailVerificationCode = EmailVerificationCode.Generate();
            EmailVerificationCodeCreatedAt = DateTime.UtcNow;
        }

        protected void Validate()
        {
            var validator = new Validator<User>(this);

            var errors = validator
                .NotNull(bb => bb.UserId.Value)
                .NotNullOrEmpty(bb => bb.FirstName)
                .NotNullOrEmpty(bb => bb.LastName)
                .NotNullOrEmpty(bb => bb.Email.Value)
                .Errors()
                .ToList();

            if (errors.Any())
            {
                throw DomainException.InvariantViolation(
                    $"User is invalid with errors: " +
                    $"{string.Join(", ", errors.Select(e => $"{e.Key} {e.Details}"))}");
            }
        }

        // Equality

        public bool Equals(User? other)
        {
            if (ReferenceEquals(null, other)) return false;
            if (ReferenceEquals(this, other)) return true;
            return base.Equals(other) && UserId.Equals(other.UserId) && FirstName == other.FirstName && LastName == other.LastName && Email.Equals(other.Email) && Nullable.Equals(EmailVerifiedAt, other.EmailVerifiedAt) && Nullable.Equals(EmailVerificationRequestedAt, other.EmailVerificationRequestedAt) && Nullable.Equals(EmailVerificationCodeCreatedAt, other.EmailVerificationCodeCreatedAt) && Equals(EmailVerificationCode, other.EmailVerificationCode) && Password.Equals(other.Password) && Salt.Equals(other.Salt) && ResetPasswordCode == other.ResetPasswordCode && Nullable.Equals(ResetPasswordCodeCreatedAt, other.ResetPasswordCodeCreatedAt) && IsSuperUser == other.IsSuperUser && RealmIds.Equals(other.RealmIds);
        }

        public override bool Equals(object? obj)
        {
            if (ReferenceEquals(null, obj)) return false;
            if (ReferenceEquals(this, obj)) return true;
            if (obj.GetType() != this.GetType()) return false;
            return Equals((User)obj);
        }

        public override int GetHashCode()
        {
            var hashCode = new HashCode();
            hashCode.Add(base.GetHashCode());
            hashCode.Add(UserId);
            hashCode.Add(FirstName);
            hashCode.Add(LastName);
            hashCode.Add(Email);
            hashCode.Add(EmailVerifiedAt);
            hashCode.Add(EmailVerificationRequestedAt);
            hashCode.Add(EmailVerificationCodeCreatedAt);
            hashCode.Add(EmailVerificationCode);
            hashCode.Add(Password);
            hashCode.Add(Salt);
            hashCode.Add(ResetPasswordCode);
            hashCode.Add(ResetPasswordCodeCreatedAt);
            hashCode.Add(IsSuperUser);
            hashCode.Add(RealmIds);
            return hashCode.ToHashCode();
        }
    }
}

Roadmap v1.0.0

  • GitHub README
  • NuGet README
  • User Guide
  • Project Templates
  • .NET Core 3.1 Support
  • .NET 5 Support
  • Start Context
  • Stop Context
  • On-the-fly Aggregate Migration
  • Auto-Code Generation
  • Transactional Outbox
  • Domain Event Publishing
  • Integration Event Publishing
  • Rabbit Event Adapter
  • Memory Event Adapter
  • Authentication Domain Service
  • Aggregate
  • Entity
  • Value Object
  • Domain Event
  • Integration Event
  • Repository
  • Application Service
  • Domain Service
  • Infrastructure Service
  • Postgres Repository
  • Memory Repository
  • Swagger Documentation (auto-generated)
  • HTTP Adapter
  • Email Adapter
  • Persistence Service
  • Postgres Repository
  • Memory Repository

Backlog

  • .NET 8 Support
  • .NET 7 Support
  • .NET 6 Support
  • Full Reference/Sample Project
  • Full Test Coverage
  • Monitoring
  • All-At-Once Migration
  • Tasks/Jobs Support
  • Command Line Interface (CLI)
  • Admin Dashboard

Release Notes

1.0.0-alpha.16 - 2023-05-07

  • Add support for multiple listeners per event.
  • Setting MaxDeliveryRetries of '0' now means '0 retries' (not infinite retries).
  • Add test method to simulate receiving a domain event.

1.0.0-alpha.15 - 2023-05-01

  • Re-enable previously disabled publisher service.
  • Change message bus topic name format for events.

1.0.0-alpha.14 - 2023-04-30

  • Change listeners to wildcard both minor and patch versions.

1.0.0-alpha.13 - 2023-04-28

  • Rename 'Serialization' to 'Conversion'.
  • Add 'PositiveIamAdapter' that permits everything.

1.0.0-alpha.12 - 2023-04-28

  • Rename framework to 'OpenDDD.NET'.
  • Add project template for .NET Core 3.1.
  • Add project template for .NET 5.
  • Introduce Transactional and use in Action. (breaking)
  • Add extension method 'AddDomainService()'.

1.0.0-alpha.11 - 2023-04-25

  • Add support to disable emails in tests.
  • Fix code generation templates.
  • Replace IApplicationLifetime with IHostApplicationLifetime. (breaking)

1.0.0-alpha.10 - 2023-04-24

  • Add more synchronous versions of methods used by tests.
  • Break out application error classes.
  • Fix minor issue in code generation tool.

1.0.0-alpha.9 - 2023-04-19

  • Add synchronous versions of methods. (breaking)

1.0.0-alpha.8 - 2023-04-11

  • Add support for context hooks.
  • Add error codes support. (breaking)
  • Fix database connections leak.
  • Add support for enabling/disabling publishers in tests.
  • Add assertion methods.
  • Fix issues with running tests in parallell.
  • Use newtonsoft json everywhere. (breaking)
  • Add base email adapter. (breaking)
  • Properly start & stop outbox. (breaking)
  • Properly start & stop repositories. (breaking)

1.0.0-alpha.7 - 2023-01-01

  • Add credentials support to smtp adapter.
  • Use api version 2.0.0 in poweriam adapter.

1.0.0-alpha.6 - 2023-01-01

  • Add base class for domain services.
  • Use new permissions string format: "<domain>:<permission>". (breaking)

1.0.0-alpha.5 - 2022-12-26

  • Refactor to follow semver2.0 strictly in http adapter. (breaking)
  • Add support for configuring persistence pooling.
  • Add html support to email port. (breaking)
  • Fix memory leak where db connections weren't closed.

1.0.0-alpha.4 - 2022-12-10

  • Add configuration setting for which server urls to listen to. (breaking)
  • Fix concurrency issues with memory repositories.
  • Add support for IAM ports.
  • Add 'PowerIAM' adapter.
  • Add RBAC auth settings. (breaking)
  • Add a base 'Migrator' class. (breaking)

1.0.0-alpha.3 - 2022-11-20

  • Refactor JwtToken and add IdToken. (breaking)
  • Add more tasks to code generation tool.
  • Add support for http put methods to code generation tool.
  • Add some missing repository method implementations.
  • Add GetAsync(IEnumerable<...> ...) to repositories.
  • Add convenience methods to ApplicationExtensions.
  • Return 400 http status code on domain- and invariant exceptions in primary http adapter.

1.0.0-alpha.2 - 2022-10-09

  • Make the hexagonal architecture more represented in the namespaces.

1.0.0-alpha.1 - 2022-10-02

This is the first (alpha) release of the framework. Please try it out and submit tickets or otherwise reach out if you find any issues or have any questions.

0.9.0-alpha7 - 2022-07-31

First alpha release on nuget.org.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp3.1 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
2.0.0-alpha.3 75 10/3/2023
2.0.0-alpha.2 65 10/3/2023
2.0.0-alpha.1 66 10/1/2023
1.0.0-alpha.17 77 6/27/2023
1.0.0-alpha.16 79 5/7/2023
1.0.0-alpha.15 87 5/1/2023
1.0.0-alpha.14 81 4/30/2023
1.0.0-alpha.13 81 4/28/2023
1.0.0-alpha.12 85 4/28/2023