18 KiB
Module Development
Introduction
This document describes the best practices for who want to develop modules that satisfies the following specifications:
- Develop the module that applies the Domain Driven Design patterns & best practices.
- Develop the module as DBMS and ORM independent.
- Develop the module that can be used as a remote service / microservice as well as can be integrated to a monolithic application.
This guide is also usable for application development best practices, mostly.
Data Access Layer
The module should be completely independent of any DBMS and and ORM.
- Do not use
IQueryable<TEntity>features in the application code (domain, application... layers) except the data access layer. - Do always use the specifically created repository interface (like
IIdentityUserRepository) from the application code (as developed specified below). Do not use generic repository interfaces (likeIRepository<IdentityUser, Guid>).
Repositories
- Do define a repository interface (and create its corresponding implementations) for each aggregate root.
For the example aggregate root:
public class IdentityUser : AggregateRoot<Guid>
{
//...
}
Define the repository interface as below:
public interface IIdentityUserRepository : IBasicRepository<IdentityUser, Guid>
{
//...
}
- Do define repository interfaces in the domain layer.
- Do not inherit the repository interface from
IRepositoryinterfaces. Because it inheritsIQueryableand the repository should not exposeIQueryableto the application. - Do inherit the repository interface from
IBasicRepository<TEntity, TKey>(as normally) or a lower-featured interface, likeIReadOnlyRepository<TEntity, TKey>(if it's needed). - Do not define repositories for entities those are not aggregate roots.
- Do define all repository methods as asynchronous.
- Do add an optional
cancellationTokenparameter to every method of the repository. Example:
Task<IdentityUser> FindByNormalizedUserNameAsync(
[NotNull] string normalizedUserName,
CancellationToken cancellationToken = default
);
- Do create a synchronous extension method for each asynchronous repository method. Example:
public static class IdentityUserRepositoryExtensions
{
public static IdentityUser FindByNormalizedUserName(
this IIdentityUserRepository repository,
[NotNull] string normalizedUserName)
{
return AsyncHelper.RunSync(
() => repository.FindByNormalizedUserNameAsync(normalizedUserName)
);
}
}
This will allow synchronous code to use the repository methods easier.
- Do add an optional
bool includeDetails = trueparameter (default value istrue) for every repository method which returns a single entity. Example:
Task<IdentityUser> FindByNormalizedUserNameAsync(
[NotNull] string normalizedUserName,
bool includeDetails = true,
CancellationToken cancellationToken = default
);
This parameter will be implemented for ORMs to eager load sub collections of the entity.
- Do add an optional
bool includeDetails = falseparameter (default value isfalse) for every repository method which returns a list of entities. Example:
Task<List<IdentityUser>> GetListByNormalizedRoleNameAsync(
string normalizedRoleName,
bool includeDetails = false,
CancellationToken cancellationToken = default
);
- Do not create composite classes to combine entities to get from repository with a single method call. Examples: UserWithRoles, UserWithTokens, UserWithRolesAndTokens. Instead, properly use
includeDetailsoption to add all details of the entity when needed. - Avoid to create projection classes for entities to get less property of an entity from the repository. Example: Avoid to create BasicUserView class to select a few properties needed for the use case needs. Instead, directly use the aggregate root class. However, there may be some exceptions for this rule, where:
- Performance is so critical for the use case and getting the whole aggregate root highly impacts the performance.
Integrations
- Do create separated package/module for each ORM integration (like Company.Module.EntityFrameworkCore and Company.Module.MongoDB).
Entity Framework Core
- Do define a separated
DbContextinterface and class for each module.
DbContext Interface
- Do define an interface for the
DbContextthat inherits fromIEfCoreDbContext. - Do add a
ConnectionStringNameattribute to theDbContextinterface. - Do add
DbSet<TEntity>properties to theDbContextinterface for only aggregate roots. Example:
[ConnectionStringName("AbpIdentity")]
public interface IIdentityDbContext : IEfCoreDbContext
{
DbSet<IdentityUser> Users { get; set; }
DbSet<IdentityRole> Roles { get; set; }
}
DbContext class
- Do inherit the
DbContextfrom theAbpDbContext<TDbContext>class. - Do add a
ConnectionStringNameattribute to theDbContextclass. - Do implement the corresponding
interfacefor theDbContextclass. Example:
[ConnectionStringName("AbpIdentity")]
public class IdentityDbContext : AbpDbContext<IdentityDbContext>, IIdentityDbContext
{
public DbSet<IdentityUser> Users { get; set; }
public DbSet<IdentityRole> Roles { get; set; }
public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
: base(options)
{
}
//code omitted for brevity
}
Table Prefix and Schema
- Do add static
TablePrefixandSchemaproperties to theDbContextclass. Set default value from a constant. Example:
public static string TablePrefix { get; set; } = AbpIdentityConsts.DefaultDbTablePrefix;
public static string Schema { get; set; } = AbpIdentityConsts.DefaultDbSchema;
- Do always use a short
TablePrefixvalue for a module to create unique table names in a shared database.Abptable prefix is reserved for ABP core modules. - Do set
Schematonullas default.
Model Mapping
- Do explicitly configure all entities by overriding the
OnModelCreatingmethod of theDbContext. Example:
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ConfigureIdentity(options =>
{
options.TablePrefix = TablePrefix;
options.Schema = Schema;
});
}
- Do not configure model directly in the
OnModelCreatingmethod. Instead, create an extension method forModelBuilder. Use ConfigureModuleName as the method name. Example:
public static class IdentityDbContextModelBuilderExtensions
{
public static void ConfigureIdentity(
[NotNull] this ModelBuilder builder,
Action<IdentityModelBuilderConfigurationOptions> optionsAction = null)
{
Check.NotNull(builder, nameof(builder));
var options = new IdentityModelBuilderConfigurationOptions();
optionsAction?.Invoke(options);
builder.Entity<IdentityUser>(b =>
{
b.ToTable(options.TablePrefix + "Users", options.Schema);
//code omitted for brevity
});
builder.Entity<IdentityUserClaim>(b =>
{
b.ToTable(options.TablePrefix + "UserClaims", options.Schema);
//code omitted for brevity
});
//code omitted for brevity
}
}
- Do create a configuration options class by inheriting from the
ModelBuilderConfigurationOptions. Example:
public class IdentityModelBuilderConfigurationOptions : ModelBuilderConfigurationOptions
{
public IdentityModelBuilderConfigurationOptions()
: base(AbpIdentityConsts.DefaultDbTablePrefix, AbpIdentityConsts.DefaultDbSchema)
{
}
}
Repository Implementation
- Do inherit the repository from the
EfCoreRepository<TDbContext, TEntity, TKey>class and implement the corresponding repository interface. Example:
public class EfCoreIdentityUserRepository
: EfCoreRepository<IIdentityDbContext, IdentityUser, Guid>, IIdentityUserRepository
{
public EfCoreIdentityUserRepository(
IDbContextProvider<IIdentityDbContext> dbContextProvider)
: base(dbContextProvider)
{
}
}
- Do use the
DbContextinterface as the generic parameter, not the class. - Do pass the
cancellationTokento EF Core using theGetCancellationTokenhelper method. Example:
public virtual async Task<IdentityUser> FindByNormalizedUserNameAsync(
string normalizedUserName,
bool includeDetails = true,
CancellationToken cancellationToken = default)
{
return await DbSet
.IncludeDetails(includeDetails)
.FirstOrDefaultAsync(
u => u.NormalizedUserName == normalizedUserName,
GetCancellationToken(cancellationToken)
);
}
GetCancellationToken fallbacks to the ICancellationTokenProvider.Token to obtain the cancellation token if it is not provided by the caller code.
- Do create a
IncludeDetailsextension method for theIQueryable<TEntity>for each aggregate root which has sub collections. Example:
public static IQueryable<IdentityUser> IncludeDetails(
this IQueryable<IdentityUser> queryable,
bool include = true)
{
if (!include)
{
return queryable;
}
return queryable
.Include(x => x.Roles)
.Include(x => x.Logins)
.Include(x => x.Claims)
.Include(x => x.Tokens);
}
- Do use the
IncludeDetailsextension method in the repository methods just like used in the example code above (see FindByNormalizedUserNameAsync).
- Do override
IncludeDetailsmethod of the repository for aggregates root which have sub collections. Example:
protected override IQueryable<IdentityUser> IncludeDetails(IQueryable<IdentityUser> queryable)
{
return queryable.IncludeDetails(); //uses the extension method defined above
}
Module Class
- Do define a module class for the Entity Framework Core integration package.
- Do add
DbContextto theIServiceCollectionusing theAddAbpDbContext<TDbContext>method. - Do add implemented repositories to the options for the
AddAbpDbContext<TDbContext>method. Example:
[DependsOn(
typeof(AbpIdentityDomainModule),
typeof(AbpEntityFrameworkCoreModule)
)]
public class AbpIdentityEntityFrameworkCoreModule : AbpModule
{
public override void ConfigureServices(IServiceCollection services)
{
services.AddAbpDbContext<IdentityDbContext>(options =>
{
options.AddRepository<IdentityUser, EfCoreIdentityUserRepository>();
options.AddRepository<IdentityRole, EfCoreIdentityRoleRepository>();
});
services.AddAssemblyOf<AbpIdentityEntityFrameworkCoreModule>();
}
}
MongoDB
- Do define a separated
DbContextinterface and class for each module.
MongoDbContext Interface
- Do define an interface for the
MongoDbContextthat inherits fromIAbpMongoDbContext. - Do add a
ConnectionStringNameattribute to theMongoDbContextinterface. - Do add
IMongoCollection<TEntity>properties to theMongoDbContextinterface for only aggregate roots. Example:
[ConnectionStringName("AbpIdentity")]
public interface IAbpIdentityMongoDbContext : IAbpMongoDbContext
{
IMongoCollection<IdentityUser> Users { get; }
IMongoCollection<IdentityRole> Roles { get; }
}
MongoDbContext class
- Do inherit the
MongoDbContextfrom theAbpMongoDbContextclass. - Do add a
ConnectionStringNameattribute to theMongoDbContextclass. - Do implement the corresponding
interfacefor theMongoDbContextclass. Example:
[ConnectionStringName("AbpIdentity")]
public class AbpIdentityMongoDbContext : AbpMongoDbContext, IAbpIdentityMongoDbContext
{
public IMongoCollection<IdentityUser> Users => Collection<IdentityUser>();
public IMongoCollection<IdentityRole> Roles => Collection<IdentityRole>();
//code omitted for brevity
}
Collection Prefix
- Do add static
CollectionPrefixproperty to theDbContextclass. Set default value from a constant. Example:
public static string CollectionPrefix { get; set; } = AbpIdentityConsts.DefaultDbTablePrefix;
Used the same constant defined for the EF Core integration table prefix in this example.
- Do always use a short
CollectionPrefixvalue for a module to create unique collection names in a shared database.Abpcollection prefix is reserved for ABP core modules.
Collection Mapping
- Do explicitly configure all entities by overriding the
CreateModelmethod of theMongoDbContext. Example:
protected override void CreateModel(IMongoModelBuilder modelBuilder)
{
base.CreateModel(modelBuilder);
modelBuilder.ConfigureIdentity(options =>
{
options.CollectionPrefix = CollectionPrefix;
});
}
- Do not configure model directly in the
CreateModelmethod. Instead, create an extension method forIMongoModelBuilder. Use ConfigureModuleName as the method name. Example:
public static class AbpIdentityMongoDbContextExtensions
{
public static void ConfigureIdentity(
this IMongoModelBuilder builder,
Action<IdentityMongoModelBuilderConfigurationOptions> optionsAction = null)
{
Check.NotNull(builder, nameof(builder));
var options = new IdentityMongoModelBuilderConfigurationOptions();
optionsAction?.Invoke(options);
builder.Entity<IdentityUser>(b =>
{
b.CollectionName = options.CollectionPrefix + "Users";
});
builder.Entity<IdentityRole>(b =>
{
b.CollectionName = options.CollectionPrefix + "Roles";
});
}
}
- Do create a configuration options class by inheriting from the
MongoModelBuilderConfigurationOptions. Example:
public class IdentityMongoModelBuilderConfigurationOptions
: MongoModelBuilderConfigurationOptions
{
public IdentityMongoModelBuilderConfigurationOptions()
: base(AbpIdentityConsts.DefaultDbTablePrefix)
{
}
}
- Do explicitly configure
BsonClassMapfor all entities. Create a static method for this purpose. Example:
public static class AbpIdentityBsonClassMap
{
private static readonly OneTimeRunner OneTimeRunner = new OneTimeRunner();
public static void Configure()
{
OneTimeRunner.Run(() =>
{
BsonClassMap.RegisterClassMap<IdentityUser>(map =>
{
map.AutoMap();
map.ConfigureExtraProperties();
});
BsonClassMap.RegisterClassMap<IdentityRole>(map =>
{
map.AutoMap();
});
});
}
}
BsonClassMap works with static methods. So, it is only needed to configure entities once in an application. OneTimeRunner guarantees it in a thread safe manner. Such a mapping above ensures that unit test properly run. This code will be called by the module class below.
Repository Implementation
- Do inherit the repository from the
MongoDbRepository<TMongoDbContext, TEntity, TKey>class and implement the corresponding repository interface. Example:
public class MongoIdentityUserRepository
: MongoDbRepository<IAbpIdentityMongoDbContext, IdentityUser, Guid>,
IIdentityUserRepository
{
public MongoIdentityUserRepository(
IMongoDbContextProvider<IAbpIdentityMongoDbContext> dbContextProvider)
: base(dbContextProvider)
{
}
}
- Do pass the
cancellationTokento the MongoDB Driver using theGetCancellationTokenhelper method. Example:
public async Task<IdentityUser> FindByNormalizedUserNameAsync(
string normalizedUserName,
bool includeDetails = true,
CancellationToken cancellationToken = default)
{
return await GetMongoQueryable()
.FirstOrDefaultAsync(
u => u.NormalizedUserName == normalizedUserName,
GetCancellationToken(cancellationToken)
);
}
GetCancellationToken fallbacks to the ICancellationTokenProvider.Token to obtain the cancellation token if it is not provided by the caller code.
- Do ignore the
includeDetailsparameters for the repository implementation since MongoDB loads the aggregate root as a whole (including sub collections) by default. - Do use the
GetMongoQueryable()method to obtain anIQueryable<TEntity>to perform queries wherever possible. Because;GetMongoQueryable()method automatically uses theApplyDataFiltersmethod to filter the data based on the current data filters (like soft delete and multi-tenancy).- Using
IQueryable<TEntity>makes the code as much as similar to the EF Core repository implementation and easy to write and read.
- Do implement data filtering if the
GetMongoQueryable()method is not possible to use.
Module Class
- Do define a module class for the MongoDB integration package.
- Do add
MongoDbContextto theIServiceCollectionusing theAddMongoDbContext<TMongoDbContext>method. - Do add implemented repositories to the options for the
AddMongoDbContext<TMongoDbContext>method. Example:
[DependsOn(
typeof(AbpIdentityDomainModule),
typeof(AbpUsersMongoDbModule)
)]
public class AbpIdentityMongoDbModule : AbpModule
{
public override void ConfigureServices(IServiceCollection services)
{
AbpIdentityBsonClassMap.Configure();
services.AddMongoDbContext<AbpIdentityMongoDbContext>(options =>
{
options.AddRepository<IdentityUser, MongoIdentityUserRepository>();
options.AddRepository<IdentityRole, MongoIdentityRoleRepository>();
});
services.AddAssemblyOf<AbpIdentityMongoDbModule>();
}
}
Notice that this module class also calls the static BsonClassMap configuration method defined above.