Installation
You could install the package as a nuget into an existing project, using the dotnet CLI:
$ dotnet add package OpenDDD.NET
Create a project
The easiest way to get started with your bounded context is using the project template
.
By using the template, you will get the boilerplate code for free, which makes sure you create all configuration files and bootup code correctly.
Get started by installing the project templates package:
$ dotnet new install OpenDDD.NET-Templates
Then create your bounded context project:
$ dotnet new openddd-net -n MyBoundedContext
Your project will be created in a folder with the name MyBoundedContext
.
Note
Replace MyBoundedContext with the actual name of your project.
Example Application
There is some example code on the start page.
Use the project template to quickly create a project with boiler plate code you can look at.
Basic Concepts
We will now give you a walkthrough of the framework’s basic concepts
:
Env files
An env file
is used to configure your bounded context for a specific environment.
It’s part of the Twelve-Factor App pattern.
You will have one env file for each of your environments:
env.prod
env.staging
env.local
env.test
Load your configuration at boot time using the ENV_FILE
environment variable. Set the value to the env file’s filename. This way it will be read and loaded at boot.
Some hosting environments don’t support files accessible to the deployed code package. In this case, you can put the (serialized json content of) the env file directly in the ENV_FILE
variable.
Tip
In each of the directories that you need to create an env file there is a *.sample
file that you can duplicate, rename and edit accordingly.
Note
The example env file below has memory adapters and authentication disabled. This helps you get started quickly. However, it also makes it not suitable for production environments.
Example env file:
# Logging
CFG_LOGGING_LEVEL_DOTNET=Information
CFG_LOGGING_LEVEL=Debug
# General
CFG_GENERAL_CONTEXT=Weather
# Auth
CFG_AUTH_ENABLED=false
CFG_AUTH_RBAC_PROVIDER=
CFG_AUTH_RBAC_EXTERNAL_REALM_ID=
CFG_AUTH_JWT_TOKEN_PRIVATE_KEY=
CFG_AUTH_JWT_TOKEN_NAME=
CFG_AUTH_JWT_TOKEN_LOCATION=
CFG_AUTH_JWT_TOKEN_SCHEME=
# Http Adapter
CFG_HTTP_URLS=http://localhost:5051
CFG_HTTP_CORS_ALLOWED_ORIGINS=http://localhost:5051
CFG_HTTP_DOCS_MAJOR_VERSIONS=1
CFG_HTTP_DOCS_DEFINITIONS=
CFG_HTTP_DOCS_ENABLED=true
CFG_HTTP_DOCS_HTTP_ENABLED=true
CFG_HTTP_DOCS_HTTPS_ENABLED=false
CFG_HTTP_DOCS_HOSTNAME=localhost:5051
CFG_HTTP_DOCS_HTTP_PORT=80
CFG_HTTP_DOCS_HTTPS_PORT=443
CFG_HTTP_DOCS_AUTH_EXTRA_TOKENS=
CFG_HTTP_DOCS_TITLE=Weather API
# Persistence
CFG_PERSISTENCE_PROVIDER=Memory
CFG_PERSISTENCE_POOLING_ENABLED=true
CFG_PERSISTENCE_POOLING_MIN_SIZE=0
CFG_PERSISTENCE_POOLING_MAX_SIZE=100
# Postgres
CFG_POSTGRES_CONN_STR=
# PubSub
CFG_PUBSUB_PROVIDER=Memory
CFG_PUBSUB_MAX_DELIVERY_RETRIES=3
CFG_PUBSUB_PUBLISHER_ENABLED=true
# Monitoring
CFG_MONITORING_PROVIDER=Memory
# Rabbit
CFG_RABBIT_HOST=
CFG_RABBIT_PORT=
CFG_RABBIT_USERNAME=
CFG_RABBIT_PASSWORD=
# Email
CFG_EMAIL_ENABLED=true
CFG_EMAIL_PROVIDER=memory
CFG_EMAIL_SMTP_HOST=
CFG_EMAIL_SMTP_PORT=
CFG_EMAIL_SMTP_USERNAME=
CFG_EMAIL_SMTP_PASSWORD=
Domain Model Version
Since this framework is all about focusing on an evolving and up-to-date domain model, we need to have a representation of a domain model version.
Create this class by subclassing the DomainModelVersion
base class.
As your model evolves, you will increment the LatestString
and add appropriate migration methods to the entity migrators. More on migrators in a later section.
Example domain model version:
namespace Domain.Model
{
public class DomainModelVersion : DDD.Domain.Model.DomainModelVersion
{
public const string LatestString = "1.0.0";
public DomainModelVersion(string dotString) : base(dotString) { }
public static DomainModelVersion Latest()
{
return new DomainModelVersion(LatestString);
}
}
}
Program.cs
Use the AddXxx()
extension methods of the framework to properly configure the .NET host and application.
Tip
Use the weather forecast project template and you won’t need to create this file.
Example Program.cs file:
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using OpenDDD.NET.Extensions;
using Main.Extensions;
namespace Main
{
public class Program
{
public static void Main(string[] args)
=> CreateWebHostBuilder(args).Build().Run();
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseKestrel()
.UseStartup<Startup>()
.AddEnvFile("ENV_FILE", "CFG_")
.AddSettings()
.AddCustomSettings()
.AddLogging();
}
}
Startup.cs
Since part of the design philosophy behind this framwork is to follow the hexagonal architecture, and to make this intent clear through the structure of the code, the Startup.cs
file is written according to a specific convention.
See the example below and create your Startup.cs file.
Tip
Use the weather forecast project template and you won’t need to create this file.
Example Startup.cs file:
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenDDD.Application.Settings;
using OpenDDD.Application.Settings.Persistence;
using OpenDDD.NET.Extensions;
using OpenDDD.NET.Hooks;
using Main.Extensions;
using Main.NET.Hooks;
using Application.Actions;
using Application.Actions.Commands;
using Domain.Model.Forecast;
using Domain.Model.Summary;
using Infrastructure.Ports.Adapters.Domain;
using Infrastructure.Ports.Adapters.Http.v1;
using Infrastructure.Ports.Adapters.Interchange.Translation;
using Infrastructure.Ports.Adapters.Repositories.Memory;
using Infrastructure.Ports.Adapters.Repositories.Migration;
using Infrastructure.Ports.Adapters.Repositories.Postgres;
using HttpCommonTranslation = Infrastructure.Ports.Adapters.Http.Common.Translation;
namespace Main
{
public class Startup
{
private ISettings _settings;
public Startup(
ISettings settings)
{
_settings = settings;
}
public void ConfigureServices(IServiceCollection services)
{
// OpenDDD.NET
services.AddAccessControl(_settings);
services.AddMonitoring(_settings);
services.AddPersistence(_settings);
services.AddPubSub(_settings);
services.AddTransactional(_settings);
// App
AddDomainServices(services);
AddApplicationService(services);
AddSecondaryAdapters(services);
AddPrimaryAdapters(services);
AddConversion(services);
AddHooks(services);
}
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime lifetime)
{
// OpenDDD.NET
app.AddAccessControl(_settings);
app.AddHttpAdapter(_settings);
app.AddControl(lifetime);
}
// App
private void AddDomainServices(IServiceCollection services)
{
services.AddDomainService<IForecastDomainService, ForecastDomainService>();
}
private void AddApplicationService(IServiceCollection services)
{
AddActions(services);
}
private void AddSecondaryAdapters(IServiceCollection services)
{
services.AddEmailAdapter(_settings);
AddRepositories(services);
}
private void AddPrimaryAdapters(IServiceCollection services)
{
AddHttpAdapters(services);
AddInterchangeEventAdapters(services);
AddDomainEventAdapters(services);
}
private void AddHooks(IServiceCollection services)
{
services.AddTransient<IOnBeforePrimaryAdaptersStartedHook, OnBeforePrimaryAdaptersStartedHook>();
}
private void AddConversion(IServiceCollection services)
{
services.AddConversion(_settings);
}
private void AddActions(IServiceCollection services)
{
services.AddAction<GetAverageTemperatureAction, GetAverageTemperatureCommand>();
services.AddAction<NotifyWeatherPredictedAction, NotifyWeatherPredictedCommand>();
services.AddAction<PredictWeatherAction, PredictWeatherCommand>();
}
private void AddHttpAdapters(IServiceCollection services)
{
var mvcCoreBuilder = services.AddHttpAdapter(_settings);
AddHttpAdapterCommon(services);
AddHttpAdapterV1(services, mvcCoreBuilder);
}
private void AddHttpAdapterV1(IServiceCollection services, IMvcCoreBuilder mvcCoreBuilder)
{
mvcCoreBuilder.AddApplicationPart(Assembly.GetAssembly(typeof(HttpAdapter)));
services.AddTransient<HttpCommonTranslation.Commands.PredictWeatherCommandTranslator>();
services.AddTransient<HttpCommonTranslation.ForecastIdTranslator>();
services.AddTransient<HttpCommonTranslation.ForecastTranslator>();
services.AddTransient<HttpCommonTranslation.SummaryIdTranslator>();
services.AddTransient<HttpCommonTranslation.SummaryTranslator>();
}
private void AddHttpAdapterCommon(IServiceCollection services)
{
services.AddHttpCommandTranslator<HttpCommonTranslation.Commands.PredictWeatherCommandTranslator>();
services.AddHttpBuildingBlockTranslator<HttpCommonTranslation.ForecastIdTranslator>();
services.AddHttpBuildingBlockTranslator<HttpCommonTranslation.ForecastTranslator>();
services.AddHttpBuildingBlockTranslator<HttpCommonTranslation.SummaryIdTranslator>();
services.AddHttpBuildingBlockTranslator<HttpCommonTranslation.SummaryTranslator>();
}
private void AddInterchangeEventAdapters(IServiceCollection services)
{
services.AddTransient<IIcForecastTranslator, IcForecastTranslator>();
}
private void AddDomainEventAdapters(IServiceCollection services)
{
services.AddListener<WeatherPredictedListener>();
}
private void AddRepositories(IServiceCollection services)
{
if (_settings.Persistence.Provider == PersistenceProvider.Memory)
{
services.AddRepository<IForecastRepository, MemoryForecastRepository>();
services.AddRepository<ISummaryRepository, MemorySummaryRepository>();
}
else if (_settings.Persistence.Provider == PersistenceProvider.Postgres)
{
services.AddRepository<IForecastRepository, PostgresForecastRepository>();
services.AddRepository<ISummaryRepository, PostgresSummaryRepository>();
}
services.AddMigrator<ForecastMigrator>();
services.AddMigrator<SummaryMigrator>();
}
}
}
Commands
All command classes need to subclass the Command
class.
The command class is basically a data transfer object (DTO), except of course it has a very specific meaning in terms of your domain model.
The command is passed to the relevant action when an actor requests it.
Example command:
using System.Collections.Generic;
using System.Linq;
using DDD.Application;
using DDD.Application.Error;
using DDD.Domain.Model.Validation;
using Domain.Model.User;
namespace Application.Actions.Commands
{
public class CreateAccountCommand : Command
{
public string FirstName { get; set; }
public string LastName { get; set; }
public Email Email { get; set; }
public string Password { get; set; }
public string RepeatPassword { get; set; }
public override void Validate()
{
var errors = GetErrors();
if (errors.Any())
throw new InvalidCommandException(this, errors);
}
public override IEnumerable<ValidationError> GetErrors()
{
var errors = new Validator<CreateAccountCommand>(this)
.NotNullOrEmpty(command => command.FirstName)
.NotNullOrEmpty(command => command.LastName)
.Email(command => command.Email.ToString())
.NotNullOrEmpty(command => command.Password.ToString())
.NotNullOrEmpty(command => command.RepeatPassword.ToString())
.Errors();
return errors;
}
}
}
Actions
All action classes need to subclass the Action<TCommand, TReturn>
class.
The ExecuteAsync()
method is where you fetch your aggregates and delegate domain logic to them and/or domain services.
If your aggregates or domain services need to publish events or use any adapter, you inject them via the constructor and pass along in the calls that drive your domain logic through these objects.
Remember that an aggregate is only allowed to change the state of a single aggregate at a time. It must also delegate all domain logic to the aggregates and/or domain services. Domain logic doesn’t belong in the application layer.
You register your action classes with the DI container like this:
services.AddAction<CreateAccountAction, CreateAccountCommand>();
Warning
Delegate all domain logic to aggregates or domain services.
Warning
Only act upon one aggregate per action.
Example action:
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;
}
}
}
Entities
The entities subclass either the Aggregate
class if it’s an aggregate, or the Entity
class otherwise.
They need to implement the IEquatable<>
interface, so that assertions in the unit tests can compare them to each other.
Actions use the methods of aggregate roots to drive the domain logic, passing adapters and publishers needed as arguments.
Example aggregate:
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();
}
}
}
Repositories
A repository is the interface for getting & saving your aggregates from/to the database.
Subclass the Repository
base class for each aggregate.
There are some base methods for e.g. getting all aggregates, getting by ID, saving an aggregate, etc. You will need to add methods for the queries that are specific to your aggregate and domain model.
You will create one interface per repository, and one adapter for each of the technology implementations you want to support.
E.g. for a user repository, you might need to create the following classes:
IUserRepository
MemoryUserRepository
PostgresUserRepository
Example repository:
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using OpenDDD.Application;
using OpenDDD.Application.Settings;
using OpenDDD.Infrastructure.Ports.Adapters.Common.Translation.Converters;
using OpenDDD.Infrastructure.Ports.Adapters.Repository.Postgres;
using OpenDDD.Infrastructure.Services.Persistence;
using Domain.Model.Realm;
using Domain.Model.User;
using Infrastructure.Ports.Adapters.Repository.Migration;
namespace Infrastructure.Ports.Adapters.Repository.Postgres
{
public class PostgresUserRepository : PostgresRepository<User, UserId>, IUserRepository
{
public PostgresUserRepository(ISettings settings, UserMigrator migrator, IPersistenceService persistenceService, ConversionSettings conversionSettings)
: base(settings, "users", migrator, persistenceService, conversionSettings)
{
}
public Task<IEnumerable<User>> GetInRealmAsync(RealmId realmId, ActionId actionId, CancellationToken ct)
=> GetWithAsync(user => user.RealmIds.Contains(realmId), actionId, ct);
public Task<User?> GetWithEmailAsync(Email email, ActionId actionId, CancellationToken ct)
=> GetFirstOrDefaultWithAsync(new List<(string, object)>() { ("Email", email) }, actionId, ct);
public Task<User?> GetWithEmailVerificationCodeAsync(EmailVerificationCode code, ActionId actionId, CancellationToken ct)
=> GetFirstOrDefaultWithAsync(u => u.EmailVerificationCode != null && u.EmailVerificationCode.Equals(code), actionId, ct);
public Task<User?> GetWithResetPasswordCodeAsync(string code, ActionId actionId, CancellationToken ct)
=> GetFirstOrDefaultWithAsync(u => u.ResetPasswordCode == code, actionId, ct);
}
}
Events
There are two classes for implementing events, DomainEvent
and IntegrationEvent
.
Subclass the appropriate one depending on the type of event you’re implementing.
Note
Integration event names are prefixed with Ic
to easily separate them from domain events.
Example domain event:
using System;
using OpenDDD.Application;
using OpenDDD.Domain.Model.BuildingBlocks.Event;
namespace Domain.Model.User
{
public class AccountCreated : DomainEvent, IEquatable<AccountCreated>
{
public UserId UserId { get; set; }
public Email Email { get; set; }
public AccountCreated() : base("AccountCreated", DomainModelVersion.Latest(), "IAM", ActionId.Create()) { }
public AccountCreated(User user, ActionId actionId)
: base("AccountCreated", DomainModelVersion.Latest(), "IAM", actionId)
{
UserId = user.UserId;
Email = user.Email;
}
// Equality
public bool Equals(AccountCreated? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return base.Equals(other) && UserId.Equals(other.UserId) && Email.Equals(other.Email);
}
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((AccountCreated)obj);
}
public override int GetHashCode()
{
return HashCode.Combine(base.GetHashCode(), UserId, Email);
}
}
}
Example integration event:
using System;
using OpenDDD.Application;
using OpenDDD.Domain.Model.BuildingBlocks.Event;
using ContextDomainModelVersion = Interchange.Domain.Model.DomainModelVersion;
namespace Interchange.Domain.Model.Forecast
{
public class IcWeatherPredicted : IntegrationEvent, IEquatable<IcWeatherPredicted>
{
public string ForecastId { get; set; }
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public string SummaryId { get; set; }
public IcWeatherPredicted() { }
public IcWeatherPredicted(ActionId actionId) : base("WeatherPredicted", ContextDomainModelVersion.Latest(), "Weather", actionId) { }
public IcWeatherPredicted(IcForecast forecast, ActionId actionId)
: base("WeatherPredicted", ContextDomainModelVersion.Latest(), "Interchange", actionId)
{
ForecastId = forecast.ForecastId;
Date = forecast.Date;
TemperatureC = forecast.TemperatureC;
SummaryId = forecast.SummaryId;
}
// Equality
public bool Equals(IcWeatherPredicted other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return base.Equals(other) && ForecastId == other.ForecastId && Date.Equals(other.Date) && TemperatureC == other.TemperatureC && SummaryId == other.SummaryId;
}
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((IcWeatherPredicted)obj);
}
public override int GetHashCode()
{
return HashCode.Combine(base.GetHashCode(), ForecastId, Date, TemperatureC, SummaryId);
}
}
}
Listeners
A listener is used to react to domain- and integration events.
Your listeners will basically just create a command and pass it to the action that will be run to perform the reaction necessary.
In the example below you can see how the AccountCreated
event is reacted to by calling the SendEmailVerification
action.
Subscribe to an event by registering the listener with the DI container:
services.AddListener<AccountCreatedListener>();
Example domain event listener:
using Application.Actions;
using Application.Actions.Commands;
using OpenDDD.Application;
using OpenDDD.Infrastructure.Ports.Adapters.Common.Translation.Converters;
using OpenDDD.Infrastructure.Ports.PubSub;
using OpenDDD.Logging;
using Domain.Model.User;
using ContextDomainModelVersion = Domain.Model.DomainModelVersion;
namespace Infrastructure.Ports.Adapters.Domain
{
public class AccountCreatedListener
: EventListener<AccountCreated, SendEmailVerificationEmailAction, SendEmailVerificationEmailCommand>
{
public AccountCreatedListener(
SendEmailVerificationEmailAction action,
IDomainEventAdapter eventAdapter,
IOutbox outbox,
IDeadLetterQueue deadLetterQueue,
ILogger logger,
ConversionSettings conversionSettings)
: base(
Context.Domain,
"AccountCreated",
ContextDomainModelVersion.Latest(),
action,
eventAdapter,
outbox,
deadLetterQueue,
logger,
conversionSettings)
{
}
public override SendEmailVerificationEmailCommand CreateCommand(AccountCreated theEvent)
{
var command =
new SendEmailVerificationEmailCommand
{
UserId = theEvent.UserId
};
return command;
}
}
}
Domain Services
All domain service classes need to subclass the DomainService
class.
You register your domain services with the DI container like this:
services.AddDomainService<IRoleDomainService, RoleDomainService>();
Example domain service:
using System.Threading;
using System.Threading.Tasks;
using OpenDDD.Application;
using OpenDDD.Domain.Model.Error;
using OpenDDD.Domain.Services;
using Domain.Model.Assignment;
using Domain.Model.Permission;
using Domain.Model.Realm;
namespace Domain.Model.Role
{
public class RoleDomainService : DomainService, IRoleDomainService
{
private readonly IAssignmentDomainService _assignmentDomainService;
private readonly IPermissionRepository _permissionRepository;
private readonly IRealmRepository _realmRepository;
private readonly IRoleRepository _roleRepository;
public RoleDomainService(
IAssignmentDomainService assignmentDomainService,
IPermissionRepository permissionRepository,
IRealmRepository realmRepository,
IRoleRepository roleRepository)
{
_assignmentDomainService = assignmentDomainService;
_permissionRepository = permissionRepository;
_realmRepository = realmRepository;
_roleRepository = roleRepository;
}
public async Task<Role> AddPermissionToRoleAsync(
RoleId roleId, PermissionId permissionId, ActionId actionId, CancellationToken ct)
{
var role = await _roleRepository.GetAsync(roleId, actionId, ct);
var permission = await _permissionRepository.GetAsync(permissionId, actionId, ct);
if (role == null)
throw DomainException.NotFound("role", roleId.ToString());
if (permission == null)
throw DomainException.NotFound("permission", permissionId.ToString());
// Authorize
if (role.IsInWorld())
{
await _assignmentDomainService.AssurePermissionsInWorldAsync(
permissions: new[] { ("IAM", "ADD_PERMISSION_TO_ROLE") },
actionId: actionId,
ct: ct);
}
else
{
await _assignmentDomainService.AssurePermissionsInRealmAsync(
realmId: role.RealmId.ToString(),
externalRealmId: "",
permissions: new[] { ("IAM", "ADD_PERMISSION_TO_ROLE") },
actionId: actionId,
ct: ct);
}
if (role.IsInWorld() && !permission.IsInWorld())
throw DomainException.InvariantViolation(
"Role is in world but the permission is in a realm.");
if (role.IsInRealm() && !(permission.IsInRealm(role.RealmId) || permission.IsInWorld()))
throw DomainException.InvariantViolation(
"Role is in a realm but the permission is neither in that realm nor the world.");
role.AddPermission(permissionId, actionId);
return role;
}
public async Task<Role> CreateRoleInWorldAsync(string name, string description, ActionId actionId, CancellationToken ct)
{
// Authorize
await _assignmentDomainService.AssurePermissionsInWorldAsync(
new[] { ("IAM", "CREATE_ROLE") },
actionId,
ct);
// Run
var existing = await _roleRepository.GetWithNameInWorldAsync(name, actionId, ct);
if (existing != null)
throw DomainException.AlreadyExists("role", "name", name);
var role = await Role.CreateInWorldAsync(
RoleId.Create(await _roleRepository.GetNextIdentityAsync()),
null,
name,
description,
actionId);
// Return
return role;
}
public async Task<Role> CreateRoleInRealmAsync(string name, string description, RealmId realmId, string externalRealmId, ActionId actionId, CancellationToken ct)
{
// Validate
if (!(realmId != null ^ externalRealmId != null))
throw DomainException.InvariantViolation(
"You must supply exactly one of realmId and externalRealmId.");
var isExternalRealmId = realmId == null;
// Authorize
await _assignmentDomainService.AssurePermissionsInRealmAsync(
realmId?.ToString(),
externalRealmId,
new[] { ("IAM", "CREATE_ROLE") },
actionId,
ct);
// Run
Realm.Realm realm;
if (isExternalRealmId)
realm = await _realmRepository.GetWithExternalIdAsync(externalRealmId, actionId, ct);
else
realm = await _realmRepository.GetAsync(realmId, actionId, ct);
if (realm == null)
throw DomainException.NotFound("realm", (isExternalRealmId ? null : realmId).ToString());
// Exists?
var existing = await _roleRepository.GetWithNameInRealmAsync(name, realm.RealmId, actionId, ct);
if (existing != null)
throw DomainException.AlreadyExists("role", "name", name);
var role = await Role.CreateInRealmAsync(
RoleId.Create(await _roleRepository.GetNextIdentityAsync()),
realmId,
null,
name,
description,
actionId);
// Return
return role;
}
}
}
Errors
When an error occurs in your domain model, you manifest it by throwing an exception containing the DomainError
.
The DomainError
is of the following model:
Code
Message
User Message
The Code
is simply an identifier for the error.
The Message
should contain a message with a description useful and aimed towards understanding the error by an integrating developer.
The User Message
should contain a message with a description useful and aimed towards understanding the error in a frontend by an end user.
Tip
It’s recommeded that the frontend development team utilizes the Code
to craft the most helpful and precise user message, instead of simply relying on the more generic User Message
.
Note
The generic domain errors are to be found in the DomainError
base class of the framework.
Example domain error:
using OpenDDD.Domain.Model.Error;
namespace Domain.Model.Error
{
public class DomainError : OpenDDD.Domain.Model.Error.DomainError
{
// Codes
private const int VerifyEmail_NotRequested_Code = 1001;
private const string VerifyEmail_NotRequested_Msg = "Email verification hasn't been requested.";
private const string VerifyEmail_NotRequested_UsrMsg = "No verification of your email has been requested.";
private const int VerifyEmail_AlreadyVerified_Code = 1002;
private const string VerifyEmail_AlreadyVerified_Msg = "The email has already been verified.";
private const string VerifyEmail_AlreadyVerified_UsrMsg = "You email address has already been verified.";
private const int VerifyEmail_NoCode_Code = 1003;
private const string VerifyEmail_NoCode_Msg = "The user has no email verification code.";
private const string VerifyEmail_NoCode_UsrMsg = "An unknown error has occured. You can't verify your email because there's no email verification code.";
private const int VerifyEmail_InvalidCode_Code = 1004;
private const string VerifyEmail_InvalidCode_Msg = "The code is invalid.";
private const string VerifyEmail_InvalidCode_UsrMsg = "The email verification code you provided is invalid. Please request a new verification code and try again.";
private const int VerifyEmail_CodeExpired_Code = 1005;
private const string VerifyEmail_CodeExpired_Msg = "The code has expired.";
private const string VerifyEmail_CodeExpired_UsrMsg = "The verification code you provided has expired. Please request a new verification code.";
private const int VerifyEmail_NoUserWithCode_Code = 1006;
private const string VerifyEmail_NoUserWithCode_Msg = "There's no user with that code.";
private const string VerifyEmail_NoUserWithCode_UsrMsg = "We couldn't find a user with that email verification code. Please make sure you entered the correct code and try again. Alternatively request a new verification code.";
private const int VerifyEmail_UserHasNoEmail_Code = 1007;
private const string VerifyEmail_UserHasNoEmail_Msg = "The user has no email.";
private const string VerifyEmail_UserHasNoEmail_UsrMsg = "We couldn't verify your email because you haven't provided one. Please provide one and try verification again.";
public static IDomainError VerifyEmail_NotRequested() => Create(VerifyEmail_NotRequested_Code, VerifyEmail_NotRequested_Msg, VerifyEmail_NotRequested_UsrMsg);
public static IDomainError VerifyEmail_AlreadyVerified() => Create(VerifyEmail_AlreadyVerified_Code, VerifyEmail_AlreadyVerified_Msg, VerifyEmail_AlreadyVerified_UsrMsg);
public static IDomainError VerifyEmail_NoCode() => Create(VerifyEmail_NoCode_Code, VerifyEmail_NoCode_Msg, VerifyEmail_NoCode_UsrMsg);
public static IDomainError VerifyEmail_InvalidCode() => Create(VerifyEmail_InvalidCode_Code, VerifyEmail_InvalidCode_Msg, VerifyEmail_InvalidCode_UsrMsg);
public static IDomainError VerifyEmail_CodeExpired() => Create(VerifyEmail_CodeExpired_Code, VerifyEmail_CodeExpired_Msg, VerifyEmail_CodeExpired_UsrMsg);
public static IDomainError VerifyEmail_NoUserWithCode() => Create(VerifyEmail_NoUserWithCode_Code, VerifyEmail_NoUserWithCode_Msg, VerifyEmail_NoUserWithCode_UsrMsg);
public static IDomainError VerifyEmail_UserHasNoEmail() => Create(VerifyEmail_UserHasNoEmail_Code, VerifyEmail_UserHasNoEmail_Msg, VerifyEmail_UserHasNoEmail_UsrMsg);
}
}
Exceptions
The error(s) are manifested by throwing an DomainException
, containing the error(s).
There are two types of exceptions:
Highly precise
Custom exceptions
that are specific to your domain model andGeneric exceptions
that are part of the framework and can be used by any bounded context.
It’s up to you to decided which would be best to use in each of your cases.
In the example below, the VerifyEmailException.AlreadyVerified()
exception is used, but it could also have been implemented using the generic DomainException.InvariantViolation("Email is already verified.")
exception, (with a custom message sent as argument).
Example exception:
using OpenDDD.Domain.Model.Error;
using DomainError = Domain.Model.Error.DomainError;
namespace Domain.Model.User
{
public class VerifyEmailException : DomainException
{
public static VerifyEmailException NotRequested()
=> new VerifyEmailException(DomainError.VerifyEmail_NotRequested());
public static VerifyEmailException AlreadyVerified()
=> new VerifyEmailException(DomainError.VerifyEmail_AlreadyVerified());
public static VerifyEmailException NoCode()
=> new VerifyEmailException(DomainError.VerifyEmail_NoCode());
public static VerifyEmailException InvalidCode()
=> new VerifyEmailException(DomainError.VerifyEmail_InvalidCode());
public static VerifyEmailException CodeExpired()
=> new VerifyEmailException(DomainError.VerifyEmail_CodeExpired());
public static VerifyEmailException UserHasNoEmail()
=> new VerifyEmailException(DomainError.VerifyEmail_UserHasNoEmail());
public static VerifyEmailException NoUserWithCode()
=> new VerifyEmailException(DomainError.VerifyEmail_NoUserWithCode());
public VerifyEmailException(IDomainError error) : base(error)
{
}
}
}
Example of throwing exceptions:
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;
}
Converters
Converters are used to serialize and deserialize your aggregates and events into strings and back, so that they can be persisted and/or sent on a message bus.
The OpenDDD.NET framework bases conversion on the Json.NET framework by Newtonsoft.
Json.NET comes with converters for many non-primitive generic types, such as e.g. DateTime and classes themselves. OpenDDD.NET provides missing converters for DDD-generic types such as EntityId and DomainModelVersion.
However, for all the entities and value objects that are unique
to your domain model, you need to create a corresponding converter
.
You create a converter by subclassing the Converter<T>
base class.
Tip
Utilize the ReadJsonUsingMethod()
method of the OpenDDD framework base class to conveniently deserialize strings using your entity- and value object classes static factory methods.
Note
Don’t mistake the Converter<T> class for the class with the same name in the Json.NET framework.
Example converter:
using System;
using Newtonsoft.Json;
using OpenDDD.Infrastructure.Ports.Adapters.Common.Translation.Converters;
using Domain.Model.User;
namespace Infrastructure.Ports.Adapters.Common.Translation.Converters
{
public class EmailConverter : Converter<Email>
{
public override void WriteJson(
JsonWriter writer,
object? value,
JsonSerializer serializer)
{
writer.WriteValue(value.ToString());
}
public override object ReadJson(
JsonReader reader,
Type objectType,
object? existingValue,
JsonSerializer serializer)
{
if (reader.Value == null)
return null;
return ReadJsonUsingMethod(reader, "Create", objectType);
}
}
}
Registering your converter dependencies is a three-step process:
Create the ConversionSettings class, (if you haven’t already).
Add the converter to the
Converters
collection in the constructor.Register your ConversionSettings class with the DI container.
Example conversion settings:
using DddConversionSettings = OpenDDD.Infrastructure.Ports.Adapters.Common.Translation.Converters.ConversionSettings;
namespace Infrastructure.Ports.Adapters.Common.Translation.Converters
{
public class ConversionSettings : DddConversionSettings
{
public ConversionSettings()
{
Converters.Add(new EmailConverter());
Converters.Add(new EmailVerificationCodeConverter());
Converters.Add(new PasswordConverter());
Converters.Add(new SaltConverter());
}
}
}
You register your serializer settings with the DI container like this:
services.AddTransient<OpenDddConversionSettings, ConversionSettings>();
Note
The AddConversion()
call in Startup.cs of the project template does almost all of this work for you. You just need to create your converters and add them to the collection in the constructor.
Migrators
Whenver you bump your domain model version, you need to create a migration for all the entities that have changed.
Subclass the Migrator
base class and implement the FromVX_X_X()
method for all your entities affected by the change.
Domain model versioning is a first-class citizen in this DDD framework. Thus, migration should be as easy as possible so that the domain model can be evolved continuously with minimal effort.
You register your migrator classes with the DI container like this:
services.AddMigrator<UserMigrator>();
Note
Entities will migrate on-the-fly next time they are fetched and saved by the repositories.
Note
If an entity has not changed it’s model from one version to another, simply don’t add a method for that version to the migrator class.
Example migrator:
using System.Collections.Generic;
using OpenDDD.Infrastructure.Ports.Adapters.Repository;
using Domain.Model.Realm;
using Domain.Model.User;
using ContextDomainModelVersion = Domain.Model.DomainModelVersion;
namespace Infrastructure.Ports.Adapters.Repository.Migration
{
public class UserMigrator : Migrator<User>
{
public UserMigrator() : base(ContextDomainModelVersion.Latest())
{
}
public User FromV1_0_2(User userV1_0_2)
{
var salt = Salt.Generate();
var password = Password.GenerateAndHash(salt);
userV1_0_2.Salt = salt;
userV1_0_2.Password = password;
userV1_0_2.ResetPasswordCode = null;
userV1_0_2.ResetPasswordCodeCreatedAt = null;
userV1_0_2.DomainModelVersion = new ContextDomainModelVersion("1.0.3");
return userV1_0_2;
}
/* There's no changes in model for v1.0.2. */
public User FromV1_0_0(User userV1_0_0)
{
userV1_0_0.RealmIds = new List<RealmId>();
userV1_0_0.IsSuperUser = false;
userV1_0_0.DomainModelVersion = new ContextDomainModelVersion("1.0.1");
return userV1_0_0;
}
}
}
Unit Tests
To achieve full test coverage of your bounded context, you need to implement a full suite of unit tests for each of your domain model actions.
Subclass ActionUnitTests
for each of your action unit test suites. Then add your test methods to cover all paths.
The test methods are based on the standard xUnit
testing model, so you will be familiar with the Arrange
, Act
and Assert
sections.
Note
You need to create your own action unit tests base class. See the section below on how to do this.
Warning
Remember that the unit tests need to reflect the domain model and ubiquitous language.
Example action unit tests:
using Xunit;
using Application.Actions.Commands;
using Domain.Model.User;
namespace Tests.Actions;
public class VerifyEmailTests : ActionUnitTests
{
public VerifyEmailTests()
{
Configure();
EmptyDb();
}
[Fact]
public async Task TestSuccess_EmailVerified()
{
// Arrange
await EnsureRootUserAsync();
await EnsureIamDomainAsync();
await EnsureIamPermissionsAsync();
await CreateAccount(email: "test.testsson@poweriam.com");
// Act
var command = new VerifyEmailCommand { Code = User.EmailVerificationCode };
await VerifyEmailAction.ExecuteAsync(command, ActionId, CancellationToken.None);
await Refresh(User);
// Assert
AssertTrue(User.IsEmailVerified());
AssertNow(User.EmailVerifiedAt);
}
[Fact]
public async Task TestFail_UserHasNoEmail()
{
// Arrange
await EnsureRootUserAsync();
await EnsureIamDomainAsync();
await EnsureIamPermissionsAsync();
await CreateAccount(email: "test.testsson@poweriam.com");
// ..hack
await Refresh(User);
User.Email = null;
await UserRepository.SaveAsync(User, ActionId, CancellationToken.None);
// Act & Assert
var command = new VerifyEmailCommand()
{
Code = User.EmailVerificationCode
};
await AssertFailure(VerifyEmailException.UserHasNoEmail(), VerifyEmailAction.ExecuteAsync(command, ActionId, CancellationToken.None));
}
[Fact]
public async Task TestFail_AlreadyVerified()
{
// Arrange
await EnsureRootUserAsync();
await EnsureIamDomainAsync();
await EnsureIamPermissionsAsync();
await CreateAccount(email: "test.testsson@poweriam.com");
var command = new VerifyEmailCommand()
{
Code = User.EmailVerificationCode
};
await VerifyEmailAction.ExecuteAsync(command, ActionId, CancellationToken.None);
// ..hack
await Refresh(User);
User.EmailVerificationCode = command.Code;
await UserRepository.SaveAsync(User, ActionId, CancellationToken.None);
// Act & Assert
await AssertFailure(VerifyEmailException.AlreadyVerified(), VerifyEmailAction.ExecuteAsync(command, ActionId, CancellationToken.None));
}
[Fact]
public async Task TestFail_NotRequested()
{
// Arrange
await EnsureRootUserAsync();
await EnsureIamDomainAsync();
await EnsureIamPermissionsAsync();
await CreateAccount(email: "test.testsson@poweriam.com");
// ..hack
await Refresh(User);
User.EmailVerificationRequestedAt = null;
await UserRepository.SaveAsync(User, ActionId, CancellationToken.None);
// Act & Assert
var command = new VerifyEmailCommand()
{
Code = User.EmailVerificationCode
};
await AssertFailure(VerifyEmailException.NotRequested(), VerifyEmailAction.ExecuteAsync(command, ActionId, CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("some-invalid-code")]
public async Task TestFail_InvalidCode(string? code)
{
// Arrange
await EnsureRootUserAsync();
await EnsureIamDomainAsync();
await EnsureIamPermissionsAsync();
await CreateAccount(email: "test.testsson@poweriam.com");
// Act & Assert
var command = new VerifyEmailCommand()
{
Code = EmailVerificationCode.Create(code)
};
await AssertFailure(VerifyEmailException.InvalidCode(), VerifyEmailAction.ExecuteAsync(command, ActionId, CancellationToken.None));
}
[Fact]
public async Task TestFail_ExpiredCode()
{
// Arrange
await EnsureRootUserAsync();
await EnsureIamDomainAsync();
await EnsureIamPermissionsAsync();
await CreateAccount(email: "test.testsson@poweriam.com");
User.EmailVerificationCodeCreatedAt = DateTime.MinValue;
await UserRepository.SaveAsync(User, ActionId, CancellationToken.None);
// Act & Assert
var command = new VerifyEmailCommand()
{
Code = User.EmailVerificationCode
};
await AssertFailure(VerifyEmailException.CodeExpired(), VerifyEmailAction.ExecuteAsync(command, ActionId, CancellationToken.None));
}
}
The ActionUnitTests class
The purpose of your ActionUnitTests
class is to provide a set of convenience methods and properties for your action unit tests to use.
The design philosophy of this framework states that the unit tests should be easy to read, understand and maintain. Furthermore they need to reflect and express the domain model in a clear manner.
To achive all of the above, your subclass will contain the following:
Action excecution methods.
State properties.
CreateWebHostBuilder()
(used to setup the TestServer).EmptyAggregateRepositories()
(used to empty your repositories before each test)Dependency properties.
Assertion methods.
Subclass ActionUnitTests
to create your own base class for the unit tests.
Note
This is a very concise description of the relatively big ActionUnitTests
concept. Later we’ll add more documentation and guides on the topic of testing but for now you should be able to look at the example code and get started with your action testing.
Example action unit tests class:
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using OpenDDD.NET.Extensions;
using OpenDDD.Domain.Model.Auth;
using OpenDDD.Domain.Services.Auth;
using OpenDDD.NET.Hooks;
using Main;
using Main.Extensions;
using Main.NET.Hooks;
using Application.Actions;
using Application.Actions.Commands;
using Application.Settings;
using Domain.Model.Assignment;
using Domain.Model.Domain;
using Domain.Model.Permission;
using Domain.Model.Realm;
using Domain.Model.Role;
using Domain.Model.User;
using DddActionUnitTests = OpenDDD.Tests.ActionUnitTests;
namespace Tests
{
public class ActionUnitTests : DddActionUnitTests
{
protected global::Domain.Model.Domain.Domain Domain => Domains.First();
protected List<global::Domain.Model.Domain.Domain> Domains = new();
protected Permission Permission => Permissions.First();
protected List<Permission> Permissions = new();
protected Realm Realm => Realms.First();
protected List<Realm> Realms = new();
protected Role Role => Roles.First();
protected List<Role> Roles = new();
protected AccessToken Token;
protected User User => Users.First();
protected List<User> Users = new();
// Setup
protected override IWebHostBuilder CreateWebHostBuilder()
{
var builder = WebHost.CreateDefaultBuilder()
.UseKestrel()
.UseStartup<Startup>()
.AddEnvFile($"ENV_FILE_{ActionName}", $"CFG_{ActionName}_", "", false)
.AddSettings()
.AddCustomSettings()
.AddLogging();
return builder;
}
protected override void EmptyAggregateRepositories(CancellationToken ct)
{
AssignmentRepository.DeleteAll(ActionId, CancellationToken.None);
DomainRepository.DeleteAll(ActionId, CancellationToken.None);
PermissionRepository.DeleteAll(ActionId, CancellationToken.None);
RealmRepository.DeleteAll(ActionId, CancellationToken.None);
RoleRepository.DeleteAll(ActionId, CancellationToken.None);
UserRepository.DeleteAll(ActionId, CancellationToken.None);
}
protected override async Task EmptyAggregateRepositoriesAsync(CancellationToken ct)
{
await AssignmentRepository.DeleteAllAsync(ActionId, CancellationToken.None);
await DomainRepository.DeleteAllAsync(ActionId, CancellationToken.None);
await PermissionRepository.DeleteAllAsync(ActionId, CancellationToken.None);
await RealmRepository.DeleteAllAsync(ActionId, CancellationToken.None);
await RoleRepository.DeleteAllAsync(ActionId, CancellationToken.None);
await UserRepository.DeleteAllAsync(ActionId, CancellationToken.None);
}
protected Task EnsureRootUserAsync()
=> new EnsureRootUser(CustomSettings, UserRepository).ExecuteAsync();
protected Task EnsureIamDomainAsync()
=> new EnsureIamDomain(DomainRepository).ExecuteAsync();
protected Task EnsureIamPermissionsAsync()
=> new EnsureIamPermissions(CustomSettings, UserRepository, DomainRepository, PermissionRepository).ExecuteAsync();
// Do as actor
protected async Task DoAsRoot(Func<Task> actionsAsync)
{
await AuthenticateRootUser();
await actionsAsync();
Credentials.JwtToken = null;
}
protected async Task DoAsUser(Func<Task> actionsAsync)
{
await AuthenticateUser();
await actionsAsync();
Credentials.JwtToken = null;
}
// Actions
protected AddPermissionToRoleAction AddPermissionToRoleAction => TestServer.Host.Services.GetRequiredService<AddPermissionToRoleAction>();
protected AddUserToRealmAction AddUserToRealmAction => TestServer.Host.Services.GetRequiredService<AddUserToRealmAction>();
protected AssignRoleAction AssignRoleAction => TestServer.Host.Services.GetRequiredService<AssignRoleAction>();
protected AuthenticateAction AuthenticateAction => TestServer.Host.Services.GetRequiredService<AuthenticateAction>();
protected CreateAccountAction CreateAccountAction => TestServer.Host.Services.GetRequiredService<CreateAccountAction>();
protected CreateDomainAction CreateDomainAction => TestServer.Host.Services.GetRequiredService<CreateDomainAction>();
protected CreatePermissionAction CreatePermissionAction => TestServer.Host.Services.GetRequiredService<CreatePermissionAction>();
protected CreateRealmAction CreateRealmAction => TestServer.Host.Services.GetRequiredService<CreateRealmAction>();
protected CreateRoleAction CreateRoleAction => TestServer.Host.Services.GetRequiredService<CreateRoleAction>();
protected DeleteDomainAction DeleteDomainAction => TestServer.Host.Services.GetRequiredService<DeleteDomainAction>();
protected ForgetPasswordAction ForgetPasswordAction => TestServer.Host.Services.GetRequiredService<ForgetPasswordAction>();
protected GetDomainsAction GetDomainsAction => TestServer.Host.Services.GetRequiredService<GetDomainsAction>();
protected GetPermissionsGrantedAction GetPermissionsGrantedAction => TestServer.Host.Services.GetRequiredService<GetPermissionsGrantedAction>();
protected GetRoleAssignmentsAction GetRoleAssignmentsAction => TestServer.Host.Services.GetRequiredService<GetRoleAssignmentsAction>();
protected SendEmailVerificationEmailAction SendEmailVerificationEmailAction => TestServer.Host.Services.GetRequiredService<SendEmailVerificationEmailAction>();
protected VerifyEmailAction VerifyEmailAction => TestServer.Host.Services.GetRequiredService<VerifyEmailAction>();
// Auth
protected IAuthDomainService AuthDomainService => TestServer.Host.Services.GetRequiredService<IAuthDomainService>();
// Credentials
protected ICredentials Credentials => TestServer.Host.Services.GetRequiredService<ICredentials>();
// Settings
protected ICustomSettings CustomSettings => TestServer.Host.Services.GetRequiredService<ICustomSettings>();
// Domains
protected Task<global::Domain.Model.Domain.Domain> GetIamDomainAsync()
=> DomainRepository.GetWithNameInWorldAsync("IAM", ActionId, CancellationToken.None);
// Permissions
protected async Task<Permission> GetIamPermissionAsync(string name)
=> (await PermissionRepository.GetWithNameInWorldAsync(name, (await GetIamDomainAsync()).DomainId, ActionId, CancellationToken.None))!;
// Hooks
protected IOnBeforePrimaryAdaptersStartedHook OnBeforePrimaryAdaptersStartedHook => TestServer.Host.Services.GetRequiredService<IOnBeforePrimaryAdaptersStartedHook>();
// Repositories
protected IAssignmentRepository AssignmentRepository => TestServer.Host.Services.GetRequiredService<IAssignmentRepository>();
protected IDomainRepository DomainRepository => TestServer.Host.Services.GetRequiredService<IDomainRepository>();
protected IPermissionRepository PermissionRepository => TestServer.Host.Services.GetRequiredService<IPermissionRepository>();
protected IRealmRepository RealmRepository => TestServer.Host.Services.GetRequiredService<IRealmRepository>();
protected IRoleRepository RoleRepository => TestServer.Host.Services.GetRequiredService<IRoleRepository>();
protected IUserRepository UserRepository => TestServer.Host.Services.GetRequiredService<IUserRepository>();
// Assertions
protected void AssertEmailSent(Email toEmail)
=> AssertEmailSent(toEmail: toEmail, msgContains: null);
protected void AssertEmailSent(Email toEmail, string? msgContains)
{
var subString = "";
if (msgContains != null)
subString = $" containing '{msgContains}'";
Assert.True(
EmailAdapter.HasSent(
toEmail: toEmail.ToString(),
msgContains: msgContains),
$"Expected an email{subString} to be sent to {toEmail}.");
}
// Execute
protected async Task AddPermissionToRole(PermissionId permissionId, RoleId roleId)
{
var command = new AddPermissionToRoleCommand
{
PermissionId = permissionId,
RoleId = roleId
};
await AddPermissionToRoleAction.ExecuteAsync(command, ActionId, CancellationToken.None);
}
protected async Task AddUserToRealm(UserId userId, RealmId realmId)
{
var command = new AddUserToRealmCommand
{
UserId = userId,
RealmId = realmId
};
await AddUserToRealmAction.ExecuteAsync(command, ActionId, CancellationToken.None);
}
protected async Task AssignRole(RoleId roleId, UserId? toUserId, RealmId? inRealmId = null)
{
var command = new AssignRoleCommand
{
RoleId = roleId,
ToUserId = toUserId,
InRealmId = inRealmId
};
await AssignRoleAction.ExecuteAsync(command, ActionId, CancellationToken.None);
}
protected async Task Authenticate(Email email, string password)
{
var command = new AuthenticateCommand
{
Email = email,
Password = password
};
var accessToken = await AuthenticateAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Credentials.JwtToken = JwtToken.Read(accessToken.ToString());
}
protected async Task AuthenticateRootUser()
{
var command = new AuthenticateCommand
{
Email = CustomSettings.RootUser.Email,
Password = CustomSettings.RootUser.Password
};
var accessToken = await AuthenticateAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Credentials.JwtToken = JwtToken.Read(accessToken.ToString());
}
protected async Task AuthenticateUser(string password = "test-password")
{
var command = new AuthenticateCommand
{
Email = User.Email,
Password = password
};
var accessToken = await AuthenticateAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Credentials.JwtToken = JwtToken.Read(accessToken.ToString());
}
protected async Task CreateAccount(string email = "test.testsson@poweriam.com", string password = "test-password")
{
var command = new CreateAccountCommand
{
FirstName = "Test",
LastName = "Testsson",
Email = Email.Create(email),
Password = password,
RepeatPassword = password
};
var user = await CreateAccountAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Users.Add(user);
}
protected async Task CreateDomain(RealmId inRealmId, string name = "Test Domain", string description = "Test description")
{
var command = new CreateDomainCommand
{
Name = name,
Description = description,
InRealmId = inRealmId
};
var domain = await CreateDomainAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Domains.Add(domain);
}
protected async Task CreatePermission(string name = "Test Permission", RealmId? inRealmId = null, DomainId? inDomainId = null)
{
var command = new CreatePermissionCommand
{
Name = name,
Description = "Test Permission",
ExternalId = "some-external-id",
InRealmId = inRealmId,
InDomainId = inDomainId
};
var permission = await CreatePermissionAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Permissions.Add(permission);
}
protected async Task CreateRealm(string name = "Test Realm")
{
var command = new CreateRealmCommand
{
Name = name,
Description = "Test Realm",
ExternalId = "some-external-id"
};
var realm = await CreateRealmAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Realms.Add(realm);
}
protected async Task CreateRole(string name = "Test Permission", RealmId? inRealmId = null, string? inExternalRealmId = null)
{
var command = new CreateRoleCommand
{
Name = name,
Description = "Test Role",
InRealmId = inRealmId,
InExternalRealmId = inExternalRealmId
};
var role = await CreateRoleAction.ExecuteAsync(command, ActionId, CancellationToken.None);
Roles.Add(role);
}
protected async Task<IEnumerable<Assignment>> GetRoleAssignments(UserId toUserId, RealmId? inRealmId = null)
{
var command = new GetRoleAssignmentsCommand
{
ToUserId = toUserId,
InRealmId = inRealmId
};
var assignments = await GetRoleAssignmentsAction.ExecuteAsync(command, ActionId, CancellationToken.None);
return assignments;
}
// Data
protected async Task Refresh(User user)
{
var users = new List<User>();
foreach (var u in Users)
if (u.UserId == user.UserId)
users.Add(await UserRepository.GetAsync(u.UserId, ActionId, CancellationToken.None));
else
users.Add(u);
Users = users;
}
}
}
Troubleshooting
If you suspect something in the nuget isn’t working as expected, it will be helpful to increase the logging level of the
framework to the DEBUG
level in the env file
like this:
CFG_LOGGING_LEVEL=Debug
This should provide useful information about what’s going on inside the OpenDDD.NET core.