From 04e8e596e9af3f46492ad91925c2746a0c0edba4 Mon Sep 17 00:00:00 2001 From: Alexander Turtsevich Date: Tue, 19 Sep 2023 13:02:31 +0300 Subject: [PATCH] update README.md --- README.md | 233 +++++++++++++++++++++++++++--------------------------- 1 file changed, 115 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index ac8adfd..dd622dc 100644 --- a/README.md +++ b/README.md @@ -17,73 +17,60 @@ PM> Install-Package ProjectionTools I've also published an article on Medium [Alternative specification pattern implementation in C#](https://medium.com/@nimrod97/alternative-specification-pattern-implementation-in-c-f5d88a7ed364). -## Projections - -My initial goal was to replace packages like AutoMapper and similar. - -The common drawbacks of using mappers: - -- IDE can not show code usages, mappings are resolved in runtime (sometimes source generators are used); -- API is complex yet limited in many cases; -- Maintenance costs are high, authors frequently change APIs without considering other options; -- Do not properly separate instance API (mapping object instances) and expression API (mapping through LINQ projections) which leads to bugs in runtime; -- Despite all the claims you can not be sure in anything unless you manually test mapping of each field and each scenario (instance/LINQ); -- Poor testing experience, sometimes you have to create your own "tools" specifically for testing mappings; -- Compatibility with LINQ providers, recently AutoMapper has broken compatibility with EF6 for no reason at all; - -In the most cases mapping splits into two independent scenarios: - -1. Fetch DTOs from DB using automatic projections; -2. Map DTOs to entities and then save modified entities to DB; +## Specifications -In reality direct mapping from DTO to entity is rarely viable: there are validations, access rights, business logic. It means that you end up writing custom code for each save operation. +Predicates can be complex, often a combination of different predicates depending on business logic. -In case we want to support only 1st scenario there is no need to deal with complex mapper configurations. +There is a well-known specification pattern and there are many existing .NET implementations but they all share similar problems: -`Projection` - provides an option to define reusable mappings. +- Verbose syntax for declaration and usage; +- Many intrusive extensions methods that pollute project code; +- Can only be used in certain contexts; -You can create projection using mapping expression: +`Specification` can solve all of these problems. +You can create specification using an expression: ```csharp - Projection DepartmentDtoProjection = new ( - x => new DepartmentDto - { - Name = x.Name - } + Specification ActiveDepartment = new ( + x => x.Active ); ``` -or delegate: - +or a delegate: ```csharp - Projection DepartmentDtoProjection = new ( + Specification ActiveDepartment = new ( default, - x => new DepartmentDto - { - Name = x.Name - } + x => x.Active ); ``` - -or both (e.g. when DB only features are used like DBFunctions, delegate should match DB behavior): - +or both (e.g. when you have to use EF specific DbFunctions): ```csharp - Projection DepartmentDtoProjection = new ( - x => new DepartmentDto - { - Name = x.Name - }, - x => new DepartmentDto - { - Name = x.Name - } + Specification ActiveDepartment = new ( + x => x.Active, + x => x.Active ); ``` -You can use projections in other projections. +You can also easily combine specifications (using `&&`, `||`,`!` operators): +```csharp + Specification CustomerServiceDepartment = new ( + x => x.Name == "Customer Service" + ); + + Specification ActiveCustomerServiceDepartment = ActiveDepartment && CustomerServiceDepartment; +``` -Thanks to `DelegateDecompiler` package and built-in ability to compile expression trees all of the options above will work but with different performance implications. +Specifications can be nested: +```csharp + Specification CustomerServiceDepartment = new ( + x => x.Name == "Customer Service" + ); + + Specification ActiveUserInCustomerServiceDepartment = new ( + x => x.Active && x.Departments.Any(CustomerServiceDepartment.IsSatisfiedBy) + ); +``` -Full example, controller should return only active users and users should have only active departments: +Full example: ```csharp public class UserEntity @@ -94,6 +81,8 @@ public class UserEntity public bool Active { get; set; } + public bool IsAdmin { get; set; } + public List Departments { get; set; } } @@ -118,28 +107,28 @@ public class DepartmentDto public string Name { get; set; } } -public static class UserProjections +public static class UserSpec { - public static readonly Projection DepartmentDtoProjection = new ( - x => new DepartmentDto - { - Name = x.Name - } + public static readonly Specification ActiveDepartment = new( + x => x.Active ); - public static readonly Projection UserDtoProjection = new ( - x => new UserDto - { - Name = x.Name, - Departments = x.Departments - .Where(z => z.Active) - .Select(DepartmentDtoProjection.Project) - .ToList() - } + public static readonly Specification ActiveUser = new( + x => x.Active + ); + + public static readonly Specification AdminUser = new( + x => x.IsAdmin + ); + + public static readonly Specification ActiveAdminUser = ActiveUser && AdminUser; + + public static readonly Specification ActiveUserInActiveDepartment = new( + x => x.Active && x.Departments.Any(ActiveDepartment) ); } -public class UserController : Controller +public class UserController : Controller { private readonly DbContext _context; @@ -148,75 +137,91 @@ public class UserController : Controller _context = context; } - // option 1: DB projection - public Task GetUser(int id) + public Task GetUser(int id) { return context.Set() - .Where(x => x.Active) - .Where(x => x.Id == id) - .Select(UserProjections.UserProjection.ProjectExpression) - .SingleAsync(); + .Where(ActiveUserInActiveDepartment) + .Where(x => x.Id == id) + .SingleAsync(); } - // option 2: in-memory projection - public async Task GetUser(int id) + public Task GetAdminUser(int id) { - var user = await context.Set() - .Include(x => x.Departments) - .Where(x => x.Active) - .Where(x => x.Id == id) - .SingleAsync(); - - return UserProjections.UserProjection.Project(user); + return context.Set() + .Where(ActiveAdminUser) + .Where(x => x.Id == id) + .SingleAsync(); } } ``` -## Specifications +## Projections -Projections work but we have a problem: we do not reuse `Where(x => x.Active)` checks. There is one predicate in `UserController.GetUser` method and another in `UserDtoProjection`. +My initial goal was to replace packages like AutoMapper and similar. -This predicates can be more complex, often a combination of different predicates depending on business logic. +The common drawbacks of using mappers: -There is a well-known specification pattern and there are many existing .NET implementations but they all share similar problems: +- IDE can not show code usages, mappings are resolved in runtime (sometimes source generators are used); +- API is complex yet limited in many cases; +- Maintenance costs are high, authors frequently change APIs without considering other options; +- Do not properly separate instance API (mapping object instances) and expression API (mapping through LINQ projections) which leads to bugs in runtime; +- Despite all the claims you can not be sure in anything unless you manually test mapping of each field and each scenario (instance/LINQ); +- Poor testing experience, sometimes you have to create your own "tools" specifically for testing mappings; +- Compatibility with LINQ providers, recently AutoMapper has broken compatibility with EF6 for no reason at all; -- Verbose syntax for declaration and usage; -- Many intrusive extensions methods that pollute project code; -- Can only be used in certain contexts; +In most cases mapping splits into two independent scenarios: -`Specification` can solve these problems. +1. Fetch DTOs from DB using automatic projections; +2. Map DTOs to entities and then save modified entities to DB; + +In reality direct mapping from DTO to entity is rarely viable: there are validations, access rights, business logic. It means that you end up writing custom code for each save operation. + +In case we want to support only 1st scenario there is no need to deal with complex mapper configurations. + +`Projection` - provides an option to define reusable mapping. + +You can create projection using mapping expression: -You can create specification using expression: ```csharp - Specification ActiveDepartment = new ( - x => x.Active + Projection DepartmentDtoProjection = new ( + x => new DepartmentDto + { + Name = x.Name + } ); ``` or delegate: + ```csharp - Specification ActiveDepartment = new ( + Projection DepartmentDtoProjection = new ( default, - x => x.Active - ); -``` -or both (e.g. when DB has case-insensitive collation, delegate should match DB behavior): -```csharp - Specification ActiveDepartment = new ( - x => x.Active, - x => x.Active + x => new DepartmentDto + { + Name = x.Name + } ); ``` -You can also combine specifications (using `&&`, `||`,`!`): +or both (e.g. when DB only features are used like DBFunctions, delegate should match DB behavior): + ```csharp - Specification CustomerServiceDepartment = new ( - x => x.Name == "Customer Service" + Projection DepartmentDtoProjection = new ( + x => new DepartmentDto + { + Name = x.Name + }, + x => new DepartmentDto + { + Name = x.Name + } ); - - Specification ActiveCustomerServiceDepartment = ActiveDepartment && CustomerServiceDepartment; ``` -Full example: +You can use projections in other projections. + +Thanks to `DelegateDecompiler` package and built-in ability to compile expression trees all of the options above will work but with different performance implications. + +Full example, controller should return only active users and users should have only active departments: ```csharp public class UserEntity @@ -253,14 +258,6 @@ public class DepartmentDto public static class UserProjections { - public static readonly Specification ActiveDepartment = new ( - x => x.Active - ); - - public static readonly Specification ActiveUser = new ( - x => x.Active - ); - public static readonly Projection DepartmentDtoProjection = new ( x => new DepartmentDto { @@ -273,7 +270,7 @@ public static class UserProjections { Name = x.Name, Departments = x.Departments - .Where(ActiveDepartment) + .Where(z => z.Active) .Select(DepartmentDtoProjection.Project) .ToList() } @@ -289,11 +286,11 @@ public class UserController : Controller _context = context; } - // option 1: Db projection + // option 1: DB projection public Task GetUser(int id) { return context.Set() - .Where(ActiveUser) + .Where(x => x.Active) .Where(x => x.Id == id) .Select(UserProjections.UserProjection.ProjectExpression) .SingleAsync(); @@ -304,7 +301,7 @@ public class UserController : Controller { var user = await context.Set() .Include(x => x.Departments) - .Where(ActiveUser) + .Where(x => x.Active) .Where(x => x.Id == id) .SingleAsync();