OpenDDD.NET
1.0.0-alpha.16
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
<PackageReference Include="OpenDDD.NET" Version="1.0.0-alpha.16" />
paket add OpenDDD.NET --version 1.0.0-alpha.16
#r "nuget: OpenDDD.NET, 1.0.0-alpha.16"
// 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:
- Domain-Driven Design
- Hexagonal Architecture
- Event-Carried State Transfer
- Near-infinite Scalability
- xUnit
- Expand and Contract
- Env files
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 | Versions 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. |
-
.NETCoreApp 3.1
- Azure.Messaging.ServiceBus (>= 7.5.1)
- CompareNETObjects (>= 4.77.0)
- dotenv.net (>= 3.1.1)
- Microsoft.ApplicationInsights (>= 2.20.0)
- Microsoft.AspNet.WebApi.WebHost (>= 5.2.8)
- Microsoft.AspNetCore.TestHost (>= 3.1.26)
- Microsoft.Extensions.Http (>= 6.0.0)
- Microsoft.Extensions.Logging (>= 6.0.0)
- Microsoft.Extensions.Logging.ApplicationInsights (>= 2.20.0)
- Microsoft.Extensions.Logging.Console (>= 6.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 6.0.0)
- Newtonsoft.Json (>= 13.0.3)
- Npgsql (>= 6.0.4)
- NSwag.AspNetCore (>= 13.15.10)
- PowerIAM (>= 2.3.0)
- RabbitMQ.Client (>= 6.2.2)
- System.IdentityModel.Tokens.Jwt (>= 6.17.0)
- WireMock.Net (>= 1.5.0)
- xunit (>= 2.4.1)
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 | 98 | 10/3/2023 |
2.0.0-alpha.2 | 85 | 10/3/2023 |
2.0.0-alpha.1 | 83 | 10/1/2023 |
1.0.0-alpha.17 | 94 | 6/27/2023 |
1.0.0-alpha.16 | 95 | 5/7/2023 |
1.0.0-alpha.15 | 103 | 5/1/2023 |
1.0.0-alpha.14 | 95 | 4/30/2023 |
1.0.0-alpha.13 | 96 | 4/28/2023 |
1.0.0-alpha.12 | 101 | 4/28/2023 |