Persistence Layer
This layer is responsible for all database communication. Here you'll learn how to properly set up this layer in detail. Let's start with the database context.
Database Context
To connect to a database you must create a database context. We provide support to the databases supported by Entity Framework Core. You can find the complete list here. We'll provide some examples here, for all other databases, please check their own documentation.
If you're using multi tenancy, make sure you pass true to GetConnectionString(true) and add the appropriate connection strings by
tenant.
If you're using a connection string with a name different from "DefaultConnection", you can specify that in
GetConnectionString(defaultConnectionName: "MyCustomConnectionString"). Also, all your connection strings must be inside the
ConnectionStrings tag in your appsettings.json file.
SQL Server
public class DatabaseContext : BaseContext
{
public DatabaseContext()
{
SourceAssemblies.Add(typeof(DatabaseContext).Assembly);
}
protected override void Setup(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
optionsBuilder.UseSqlServer(GetConnectionString());
}
}
SQLite
public class DatabaseContext : BaseContext
{
public DatabaseContext()
{
SourceAssemblies.Add(typeof(DatabaseContext).Assembly);
SQLitePCL.Batteries_V2.Init();
Database.EnsureCreated();
}
protected override void Setup(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "MySQLiteDB.db3");
optionsBuilder.UseSqlite($"Filename={dbPath}");
}
}
MySql/MariaDB
For MySql/MariaDB we recommend using the Pomelo package, not the Oracle one due to compatibility issues.
public class DatabaseContext : BaseContext
{
public DatabaseContext()
{
SourceAssemblies.Add(typeof(DatabaseContext).Assembly);
}
protected override void Setup(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
{
return;
}
// Replace with your server version and type.
// Use 'MariaDbServerVersion' for MariaDB.
// Alternatively, use 'ServerVersion.AutoDetect(connectionString)'.
var serverVersion = new MySqlServerVersion(new Version(8, 0, 29));
optionsBuilder
.UseMySql(GetConnectionString(), serverVersion)
// The following three options help with debugging, but should
// be changed or removed for production.
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
}
}
Entities
In order to create entities we must inherit from one of the four provided base classes. They all use the same identifier name, "Id", but they have different types, they are:
| Base class | ID's data type |
|---|---|
| BaseEntity | Guid |
| BaseIntEntity | int |
| BaseLongEntity | long |
| BaseStringEntity | string |
Usage:
[Table("Categories")]
public class Category : BaseEntity
{
/// <summary>
/// Name.
/// </summary>
public string Name { get; set; }
}
This code will generate a table Categories with the properties Id as unique identifier and Name as nvarchar.
Custom Base Entity
Should you need to create a custom base entity with another type or a different identifier name, you must implement IEntity:
public abstract class BaseDoubleEntity : IEntity
{
[Key]
public double MyIdentifier { get; set; }
protected BaseDoubleEntity()
{
MyIdentifier = 0;
}
}
Many-to-many
In order to create a many-to-many relationship, we just need to add referring collections to each end.
Should you need to customize the relationship, take a look at the Mappers section.
[Table("Tags")]
public class Tag : BaseEntity
{
public string Name { get; set; }
public List<KnowledgeArticle> KnowledgeArticles { get; set; }
public Tag()
{
KnowledgeArticles = new List<KnowledgeArticle>();
}
}
[Table("KnowledgeBase")]
public class KnowledgeArticle : BaseEntity
{
public string Title { get; set; }
public string Content { get; set; }
public List<Tag> Tags { get; set; }
public KnowledgeArticle()
{
Tags = new List<Tag>();
}
}
One-to-many
In order to create a one-to-many relationship, we need to add a collection to one end and the referring foreign key to the other end.
Should you need to customize the relationship, take a look at the Mappers section.
[Table("Users")]
public class User : BaseEntity
{
public string Name { get; set; }
public List<Email> Emails { get; set; }
public User()
{
Emails = new List<Email>();
}
}
[Table("Emails")]
public class Email : BaseEntity
{
public string Address { get; set; }
public Guid UserId { get; set; }
public User User { get; set; }
}
One-to-one
In order to create a one-to-one relationship, we need to add the referring foreign key to only one end but the navigation properties should be added on both ends. This will ensure a true one-to-one relationship.
Should you need to customize the relationship, take a look at the Mappers section.
[Table("Users")]
public class User : BaseEntity
{
public string Name { get; set; }
public Guid EmailId { get; set; }
public Email Email { get; set; }
}
[Table("Emails")]
public class Email : BaseEntity
{
public string Address { get; set; }
public User User { get; set; }
}
Mappers
With mappers, you can customize the entities' mappings. Here you have access to EntityTypeBuilder<TEntity>.
Many-to-many With More Than One Navigation
When we have more than one navigation to the same entity, we can use a mapper to help us define which property belongs to which collection.
[Table("ProfilesOfMembers")]
public class MemberProfile : BaseEntity
{
public string Name { get; set; }
public List<ScheduledVisit> VisitsAsMain { get; set; }
public List<ScheduledVisit> VisitsAsHelper { get; set; }
public MemberProfile()
{
VisitsAsMain = new List<ScheduledVisit>();
VisitsAsHelper = new List<ScheduledVisit>();
}
}
[Table("ScheduledVisits")]
public class ScheduledVisit : RemovableEntity
{
public DateTime? ScheduledDate { get; set; }
public Guid? AssignedToId { get; set; }
public MemberProfile? AssignedTo { get; set; }
public Guid? HelperId { get; set; }
public MemberProfile? Helper { get; set; }
}
public class MemberProfileMapper : BaseEntityMapper<MemberProfile>
{
public override void Map(EntityTypeBuilder<MemberProfile> entityBuilder)
{
entityBuilder
.HasMany(m => m.VisitsAsMain)
.WithOne(v => v.AssignedTo)
.OnDelete(DeleteBehavior.Restrict);
entityBuilder
.HasMany(m => m.VisitsAsHelper)
.WithOne(v => v.Helper)
.OnDelete(DeleteBehavior.Restrict);
}
}
To learn more about EntityTypeBuilder and what's available, please check
here.
Validators
This functionality is used to validate entities before they're saved. Inside your persistence project, create a folder named Validators,
if it doesn't exist and add your validator. It must inherit from BaseEntityValidator. You may make use of the FluentValidation library
in order to perform most validations. When you use ValidateRules the error context is already populated with the errors found, if any.
Should you need to add extra errors, you can do so by invoking the ValidationErrorContext.AddError() method.
Using the RuleFor method you can create rules for specific fields and then use FluentValidator to define the constraints. For collections,
you can use the RulesForEach method.
A very detailed example is found below:
public class InstitutionValidator : BaseEntityValidator<Institution>
{
private readonly ValidationErrorContext _validationContext;
public InstitutionValidator(ValidationErrorContext validationContext)
: base(validationContext)
{
_validationContext = validationContext;
}
public override async Task Validate(
IUnitOfWork unitOfWork,
Institution current,
Institution original,
EntityState state
)
{
switch (state)
{
case EntityState.Added:
RuleFor(p => p.Description).MinimumLength(10);
RuleFor(p => p.Name).NotEmpty();
RulesForEach(i => i.Doctors).Set(builder =>
{
builder.RuleFor(d => d.Name).NotEmpty()
.WithMessage("Doctor's name is required.");
builder.RuleFor(d => d.Age).GreaterThan(17)
.WithMessage("Doctor must be at least 18 years old.");
});
RulesForEach(i => i.Doctors).Must((entity, element) =>
{
var license = await unitOfWork.GetRepository<MedicalLicense>()
.GetFirstOrDefaultAsync(ml => ml.Code == element.LicenseCode);
if (license == null)
{
_validationContext.AddError(
Constants.Validator.Hidden,
"No license found for this doctor."
);
return false;
}
return true;
});
var isValid = ValidateRules(current);
if (!isValid)
{
return;
}
var instWithSameName = await unitOfWork.GetRepository<Institution>()
.GetFirstOrDefaultAsync(i => i.Name == current.Name);
if (instWithSameName != null)
{
_validationContext.AddError(
Constants.Validator.Hidden,
"There is already an institution with the provided name."
);
}
break;
case EntityState.Modified:
// ...
}
}
}
If you're using RulesForEach to validate children, you can make use of the following methods:
| Method | Description |
|---|---|
| Set | Defines a set of rules for each item of the collection. |
| NotNull | Asserts no item of the collection is null. |
| Must | Asserts all items of the collection match the informed condition. |
| Where | Filters the collection according to the predicate. |
Once all validations have been created, the ValidateRules method should be invoked passing the current entity (current).
The available states are:
| State | Description |
|---|---|
| Added | Being managed by the context. This data has been created. |
| Modified | Being managed by the context and exists in the database. One or more properties have been changed. |
| Deleted | Being managed by the context and exists in the database. It has been removed. |
| Unchanged | Being managed by the context and exists in the database. Its data has not been changed. |
| Detached | Not being managed by the context. |
Most of the time you'll only be using the most common states, like Added, Modified and Deleted, but the others are provided, should
you need them.
Events
Events allow you to perform actions before and/or after an entity is persisted. Inside your persistence project, create a folder named
EventHandlers, if it doesn't exist and add your event. It must inherit from one of the following types:
- To perform actions before saving:
BeforeSaveEntityNotificationHandler<TEntity> - To perform actions after saving:
AfterSaveEntityNotificationHandler<TEntity>
Then, implement the required Handle method and add your logic.
Custom repositories inheriting from Repository instead of Repository<TEntity cannot have an event attached. They work only for specific
entities. If you're using a custom repository with more than one entity (inheriting from Repository), you should implement your before/after
save logic manually.
In the following example, every time the User entity is created, before it's persisted, a new validation token will be created and
associated with that user:
public class UserBeforeSave : BeforeSaveEntityNotificationHandler<User>
{
public override async Task Handle(
BeforeSaveEntityNotification<User> notification,
CancellationToken cancellationToken
)
{
if (notification.EntityState == EntityState.Added)
{
var validationToken = new ValidationToken
{
CreationDate = DateTime.UtcNow,
ExpirationDate = DateTime.UtcNow.AddDays(1),
Token = $"{RandomProvider.GetThreadRandom().Next(100000, 1000000)}"
};
notification.Entity.ValidationToken =
await notification.UnitOfWork.GetRepository<ValidationToken>().AddAsync(
validationToken,
cancellationToken
);
}
}
}
In the next one, an email is sent every time the User entity is created, after it's persisted. If the entity was modified instead and the
change was the password, we also send an email:
In the notification parameter you can find the current entity (property Entity) and the one in the database (property OriginalEntity).
OriginalEntity is null when the entity is being created.
public class UserAfterSave : AfterSaveEntityNotificationHandler<User>
{
public override async Task Handle(
AfterSaveEntityNotification<User> notification,
CancellationToken cancellationToken
)
{
switch (notification.EntityState)
{
case EntityState.Modified:
if (notification.Entity.Password != notification.OriginalEntity.Password)
{
await EmailSender.SendPasswordHasChanged(notification.Entity.Email);
}
break;
case EntityState.Added:
await EmailSender.SendValidationRequest(
notification.Entity.Email,
notification.Entity.ValidationToken!.Token
);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
}
The available states are:
| State | Description |
|---|---|
| Added | Being managed by the context. This data has been created. |
| Modified | Being managed by the context and exists in the database. One or more properties have been changed. |
| Deleted | Being managed by the context and exists in the database. It has been removed. |
| Unchanged | Being managed by the context and exists in the database. Its data has not been changed. |
| Detached | Not being managed by the context. |
Most of the time you'll only be using the most common states, like Added, Modified and Deleted, but the others are provided, should
you need them.
Repository
A repository allows communication with a database table or tables. It also provides CRUD operations.
The UpDevs SDK automatically creates repositories for all entities, so you don't have to do so manually. Once you create an entity, you can already request its repository. You're only required to create a repository class file when you need to add extra methods to that repository, or you need an aggregate root.
Methods Provided
When a method has the [Async] text it means it's available in both forms. Whenever async is available, usage of CancellationToken is also
provided.
| Method/Signature | Description |
|---|---|
| GetAll Signature: IQueryable<TEntity> GetAll( | Gets the IQueryable<TEntity> based on a predicate and orderby delegate. |
| GetAll Signature: IQueryable<TEntity> GetAll( | Gets the IQueryable<TEntity> based on the search request, search expression and extra query. |
| GetPagedList[Async] Signature: IPagedList<TEntity> GetPagedList( | Gets the IPagedList<TEntity> based on a predicate, orderby delegate and page information. |
| GetPagedList[Async] Signature: IPagedList<TResult> GetPagedList<TResult>( | Gets the IPagedList<TResult> based on a predicate, orderby delegate and page information. |
| GetPagedList[Async] Signature: IPagedList<TEntity> GetPagedList( | Gets the IPagedList<TResult> based on the search request, search expression and extra query. |
| GetById[Async] Signature: TEntity? GetById<TId>( | Retrieves a record with the informed ID and all its navigation properties loaded. |
| GetFirstOrDefault[Async] Signature: TEntity? GetFirstOrDefault( | Gets the first or default entity based on a predicate, orderby delegate and include delegate. |
| FromSql Signature: IQueryable<TEntity> FromSql(string sql, params object[] parameters) | Uses raw SQL queries to fetch the specified TEntity data. |
| Find[Async] Signature: TEntity? Find(params object[] keyValues) | Finds an entity with the given primary key values. If found, it is attached to the context and returned. If no entity is found, then null is returned. |
| Find[Async] Signature: TEntity? Find<TId>(params TId[] keyValues) | Finds an entity with the given primary key values. If found, it is attached to the context and returned. If no entity is found, then null is returned. |
| Filter[Async] Signature: IPagedList<TEntity> Filter(ColumnFilter[] filters, int pageIndex = 0) | Filters the entities according to the informed filters. |
| FilterAsQueryable[Async] Signature: IQueryable<TEntity> FilterAsQueryable( | Filters the entities according to the informed filters. |
| FilterBySpec[Async] Signature: IPagedList<TEntity> FilterBySpec( | Filters the entities according to the informed specification. |
| FilterBySpecAsQueryable[Async] Signature: IQueryable<TEntity> FilterBySpecAsQueryable( | Filters the entities according to the informed specification. |
| Count[Async] Signature: int Count(Expression<Func<TEntity, bool>>? predicate = null) | Gets the count based on a predicate, if informed. |
| Count[Async] Signature: int Count( | Gets the count based on the parameters, if informed. |
| Sum[Async] Signature: decimal Sum(Expression<Func<TEntity, decimal>> sumPredicate) | Gets the sum of all the values in the property informed. Has overloads for double, float, int and long. |
| Sum[Async] Signature: decimal Sum( | Gets the sum of all the values in the property informed. Has overloads for double, float, int and long. |
| Add[Async] Signature: TEntity Add(TEntity entity) | Inserts a new entity. |
| Add[Async] Signature: void Add(params TEntity[] entities) | Inserts a range of entities. |
| Update Signature: TEntity Update(TEntity entity) | Updates the specified entity. |
| Update Signature: TEntity Update( | Updates only the entity properties informed. |
| Update Signature: void Update(params TEntity[] entities) | Updates the specified entities. |
| Delete[Async] Signature: void Delete(params object[] keyValues) | Deletes the entity by the specified primary keys. |
| SoftDelete[Async] Signature: void SoftDelete( | Updates the entity to a removed status. It doesn't remove the entity from the database, instead it updates the flag that indicates whether that entity has been removed. |
| Delete Signature: void Delete(TEntity entity) | Deletes the specified entity. |
| SoftDelete Signature: void SoftDelete(TEntity entity, string propertyName = "IsDeleted") | Updates the entity to a removed status. It doesn't remove the entity from the database, instead it updates the flag that indicates whether that entity has been removed. |
| Delete Signature: void Delete(params TEntity[] entities) | Deletes the specified entities. |
| SoftDelete Signature: void SoftDelete( | Updates the entities to a removed status. It doesn't remove the entities from the database, instead it updates the flag that indicates whether that entities have been removed. |
| DeleteWhere Signature: void DeleteWhere(Expression<Func<TEntity, bool>>? predicate = null) | Deletes one or more entities matching the predicate. |
| SoftDeleteWhere Signature: void SoftDeleteWhere( | Updates the entities matching the predicate to a removed status. |
Custom Repository
The UpDevs SDK automatically creates repositories for all entities, so you don't have to do so manually. Once you create an entity, you can already request its repository. You're only required to create a repository class file when you need to add extra methods to that repository, or you need an aggregate root. For those cases, here's how you do it:
Single Entity
public class DoctorRepository : Repository<Doctor>
{
public DoctorRepository(
DbContext dbContext,
DataEfExceptionMessagesResource dataEfExceptionMessagesResource
) : base(dbContext, dataEfExceptionMessagesResource) { }
public async Task UpdateCustomData()
{
// Here you can use DbSet or the default repository methods.
return;
}
}
Multiple Entities
public class DoctorInstitutionRepository : Repository
{
public DoctorInstitutionRepository(DbContext dbContext) : base(dbContext) { }
public async Task<Entity[]> GetAllDoctorsWithInstitutions()
{
// Here you can use DbSet or the default repository methods.
return;
}
}
UnitOfWork
UnitOfWork 1 is responsible for providing access to repositories, keeping track of changes made to the database, transactions as well
and persisting everything to the database.
Methods Provided
Even though all these methods are provided, usually, you'll only be using GetRepository<TEntity> and SaveChanges[Async].
| Method | Signature | Description |
|---|---|---|
| GetRepository | IRepository<TEntity> GetRepository<TEntity>() | Gets the specified repository for the TEntity provided. |
| GetRepository | TRepository GetRepository<TRepository, TEntity>() | Gets the specific repository specified. |
| GetCustomRepository | TRepository GetCustomRepository<TRepository>() | Gets the specific repository specified. Utilized for repositories not related to a specific entity, AggregateRoots for example. |
| SaveChanges | SaveResponse SaveChanges() | Saves all changes made in this context to the database. |
| SaveChangesAsync | Task<SaveResponse> SaveChangesAsync() | Saves all changes made in this context to the database in an asynchronous way. |
| SaveChangesAsync | Task<SaveResponse> SaveChangesAsync(params IUnitOfWork[] unitsOfWork) | Saves all changes made in this context to the database with distributed transaction in an asynchronous way. |
| ExecuteSqlCommand | int ExecuteSqlCommand(string sql, params object[] parameters) | Executes the specified raw SQL command. |
| FromSql | IQueryable<TEntity> FromSql<TEntity>(string sql, params object[] parameters) | Uses raw SQL queries to fetch the specified TEntity data. |
| TrackGraph | void TrackGraph(object rootEntity, Action<EntityEntryGraphNode> callback) | Begins tracking an entity and any entities that are reachable by traversing its navigation properties. |
| Dispose | void Dispose() | Finalizes all tasks and free resources. |
Usage
Like any other service, you just need to inject it into any constructor, but its usage is so common that we have helpers in certain places. Here you can find a list of those places. If you're trying to use it in a place not informed here, you must inject it into the constructor.
Domain Service
Inside all domain services you can access UnitOfWork through the protected property UnitOfWork. Here's an example on line 8:
public class MyDomainService : BaseDomainService, IMyDomainService
{
public MyDomainService(IServiceProvider serviceProvider, IUnitOfWork unitOfWork)
: base(serviceProvider, unitOfWork) { }
public async Task<DoctorResponse[]> GetAllDoctorsWhoMadeTransfersRecently()
{
var historyItems = await UnitOfWork.GetRepository<CaseHistoryItem>()
.FilterBySpecAsQueryableAsync(
new DoctorsWhoMadeTransfersInPastMonthsSpecification(4)
);
var doctors = historyItems.Select(ch => ch.Doctor).ToArray();
return ToResponse<DoctorResponse[]>(doctors);
}
}
Domain Event
Inside any domain event handler (BeforeSave/AfterSave), you can access the UnitOfWork from the notification parameter. Here's an example
on line 17:
public class UserBeforeSave : BeforeSaveEntityNotificationHandler<User>
{
public override async Task Handle(
BeforeSaveEntityNotification<User> notification,
CancellationToken cancellationToken
)
{
if (notification.EntityState == EntityState.Added)
{
var validationToken = new ValidationToken
{
CreationDate = DateTime.UtcNow,
ExpirationDate = DateTime.UtcNow.AddDays(1),
Token = $"{RandomProvider.GetThreadRandom().Next(100000, 1000000)}"
};
notification.Entity.ValidationToken =
await notification.UnitOfWork.GetRepository<ValidationToken>().AddAsync(
validationToken,
cancellationToken
);
}
}
}
Validator
Inside any validator, you can access the UnitOfWork from the unitOfWork parameter. Here's an example on line 20:
Inside the validator, you can find the current entity (property current) and the one in the database (property original).
original is null when the entity is being created.
public class InstitutionValidator : BaseEntityValidator<Institution>
{
public InstitutionValidator(ValidationErrorContext validationContext)
: base(validationContext) { }
public override async Task Validate(
IUnitOfWork unitOfWork,
Institution current,
Institution original,
EntityState state
)
{
switch (state)
{
case EntityState.Added:
RuleFor(p => p.Description).MinimumLength(10);
RuleFor(p => p.Name).NotEmpty();
ValidateRules(current);
var instWithSameName = await unitOfWork.GetRepository<Institution>()
.GetFirstOrDefaultAsync(i => i.Name == current.Name);
if (instWithSameName != null)
{
// ...
}
break;
case EntityState.Modified:
// ...
}
}
}
Specification
Inside all specifications you can access UnitOfWork through the protected property UnitOfWork. Here's an example on line 13:
public class ManagersWhoMadeTransfersInPastMonthsSpecification
: BaseSpecification<HistoryItem>
{
private readonly int _pastMonths;
private readonly Hospital _hospital;
public ManagersWhoMadeTransfersInPastMonthsSpecification(
Guid hospitalId,
int pastMonths = 3
)
{
_pastMonths = pastMonths;
_hospital = UnitOfWork.GetRepository<Hospital>().GetById(hospitalId);
PageSize = 10;
Include = historyItem => historyItem.Include(hi => hi.HospitalManager);
OrderBy = historyItem => historyItem.OrderBy(hi => hi.CreationDate)
}
public override Expression<Func<CaseHistoryItem, bool>> IsSatisfiedBy()
{
var startDate = DateTime.UtcNow.AddMonths(-_pastMonths);
return historyItem => historyItem.IsTransferRelated
&& historyItem.CreationDate >= startDate
&& historyItem.HospitalManagerId != null
&& historyItem.HospitalManagerId == _hospital.HospitalManagerId;
}
}