From ae3e7646ac1296ce63c2ee8ae8fbe05760c5c7d5 Mon Sep 17 00:00:00 2001 From: bnymncoskuner Date: Thu, 26 Nov 2020 10:17:56 +0300 Subject: [PATCH 01/11] docs: update csrf angular part --- docs/en/CSRF-Anti-Forgery.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/en/CSRF-Anti-Forgery.md b/docs/en/CSRF-Anti-Forgery.md index ad838e3548..33b856f563 100644 --- a/docs/en/CSRF-Anti-Forgery.md +++ b/docs/en/CSRF-Anti-Forgery.md @@ -117,3 +117,31 @@ You don't need to make anything unless you need to change the `AntiforgeryOption }) export class AppModule {} ``` + +**Note:** XSRF-TOKEN is only valid if both frontend application and APIs run on the same domain. Therefore, when you make a request, you should use a relative path. + +For example, let's say your APIs is hosted at `https://testdomain.com/ws` +and your angular application is hosted at `https://testdomain.com/admin` + +So if your API request should look like this `https://testdomain.com/ws/api/identity/users` + +your `environment.prod.ts` has to be as follows: + +```typescript +export const environment = { + production: true, + // .... + apis: { + default: { + url: '/ws', // <- just use the context root here + // ... + }, + }, +} as Config.Environment; +``` + +Let's talk about why. + +First, take a look at [Angular's code](https://github.com/angular/angular/blob/master/packages/common/http/src/xsrf.ts#L81) + +It does not intercept any request that starts with `http://` or `https://`. There is a good reason for that. Any cross-site request does not need this token for security. This verification is only valid if the request is made to the same domain from which the web page is served. So, simply put, if you serve everything from a single domain, you just use a relative path. \ No newline at end of file From c901286c4a7ce1f6d45dffcb16dbad8854a26d03 Mon Sep 17 00:00:00 2001 From: liangshiwei Date: Thu, 26 Nov 2020 15:23:08 +0800 Subject: [PATCH 02/11] Enable DelayTextOnKeyPress by default --- framework/src/Volo.Abp.BlazoriseUI/AbpBlazoriseModule.cs | 6 ++++-- .../MyProjectNameBlazorModule.cs | 1 - .../MyProjectNameBlazorHostModule.cs | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/src/Volo.Abp.BlazoriseUI/AbpBlazoriseModule.cs b/framework/src/Volo.Abp.BlazoriseUI/AbpBlazoriseModule.cs index 7ab435a34e..9fd23cddc7 100644 --- a/framework/src/Volo.Abp.BlazoriseUI/AbpBlazoriseModule.cs +++ b/framework/src/Volo.Abp.BlazoriseUI/AbpBlazoriseModule.cs @@ -17,8 +17,10 @@ namespace Volo.Abp.BlazoriseUI private void ConfigureBlazorise(ServiceConfigurationContext context) { - context.Services - .AddBlazorise(); + context.Services.AddBlazorise(options => + { + options.DelayTextOnKeyPress = true; + }); context.Services.AddSingleton(typeof(AbpBlazorMessageLocalizerHelper<>)); } diff --git a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Blazor/MyProjectNameBlazorModule.cs b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Blazor/MyProjectNameBlazorModule.cs index 81304962fb..728d969244 100644 --- a/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Blazor/MyProjectNameBlazorModule.cs +++ b/templates/app/aspnet-core/src/MyCompanyName.MyProjectName.Blazor/MyProjectNameBlazorModule.cs @@ -62,7 +62,6 @@ namespace MyCompanyName.MyProjectName.Blazor private void ConfigureBlazorise(ServiceConfigurationContext context) { context.Services - .AddBlazorise() .AddBootstrapProviders() .AddFontAwesomeIcons(); } diff --git a/templates/module/aspnet-core/host/MyCompanyName.MyProjectName.Blazor.Host/MyProjectNameBlazorHostModule.cs b/templates/module/aspnet-core/host/MyCompanyName.MyProjectName.Blazor.Host/MyProjectNameBlazorHostModule.cs index 7fdc824b09..0500609c9a 100644 --- a/templates/module/aspnet-core/host/MyCompanyName.MyProjectName.Blazor.Host/MyProjectNameBlazorHostModule.cs +++ b/templates/module/aspnet-core/host/MyCompanyName.MyProjectName.Blazor.Host/MyProjectNameBlazorHostModule.cs @@ -61,7 +61,6 @@ namespace MyCompanyName.MyProjectName.Blazor.Host private void ConfigureBlazorise(ServiceConfigurationContext context) { context.Services - .AddBlazorise() .AddBootstrapProviders() .AddFontAwesomeIcons(); } From 29ac52e4d5e41d37c34e51c78d3bf06a818c0c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20Kalkan?= Date: Thu, 26 Nov 2020 11:07:47 +0300 Subject: [PATCH 03/11] Documented: Business Logic & Exceptions in the Entities --- ...main-Driven-Design-Implementation-Guide.md | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/docs/en/Domain-Driven-Design-Implementation-Guide.md b/docs/en/Domain-Driven-Design-Implementation-Guide.md index 9f5fce6618..e8f576efca 100644 --- a/docs/en/Domain-Driven-Design-Implementation-Guide.md +++ b/docs/en/Domain-Driven-Design-Implementation-Guide.md @@ -492,4 +492,135 @@ namespace IssueTracking.Issues ##### Business Logic & Exceptions in the Entities -TODO \ No newline at end of file +When you implement validation and business logic in the entities, you frequently need to manage exceptional cases. In these cases; + +* Create **domain specific exceptions**. +* **Throw these exceptions** in the entity methods when necessary. + +**Example** + +````csharp +public class Issue : AggregateRoot +{ + //... + + public bool IsLocked { get; private set; } + public bool IsClosed { get; private set; } + public IssueCloseReason? CloseReason { get; private set; } + + public void Close(IssueCloseReason reason) + { + IsClosed = true; + CloseReason = reason; + } + + public void ReOpen() + { + if (IsLocked) + { + throw new IssueStateException( + "Can not open a locked issue! Unlock it first." + ); + } + + IsClosed = false; + CloseReason = null; + } + + public void Lock() + { + if (!IsClosed) + { + throw new IssueStateException( + "Can not open a locked issue! Unlock it first." + ); + } + + IsLocked = true; + } + + public void Unlock() + { + IsLocked = false; + } +} +```` + +There are two business rules here; + +* A locked issue can not be re-opened. +* You can not lock an open issue. + +`Issue` class throws a `IssueStateException` in these cases to force business rules: + +````csharp +using System; + +namespace IssueTracking.Issues +{ + public class IssueStateException : Exception + { + public IssueStateException(string message) + : base(message) + { + + } + } +} +```` + +There are two potential problems of throwing such exceptions; + +1. In case of such an exception, should the **end user** see the exception (error) message? If so, how do you **localize** the exception message? You can not use the [localization](Localization.md) system, because you can't inject and use `IStringLocalizer` in the entities. +2. For a web application or HTTP API, what **HTTP Status Code** should return to the client? + +ABP's [Exception Handling](Exception-Handling.md) system solves these problems (and more). + +**Example: Throwing a business exception with code** + +````csharp +using Volo.Abp; + +namespace IssueTracking.Issues +{ + public class IssueStateException : BusinessException + { + public IssueStateException(string code) + : base(code) + { + + } + } +} +```` + +* `IssueStateException` class inherits the `BusinessException` class. ABP returns 403 (forbidden) HTTP Status code by default (instead of 500 - Internal Server Error) for the exceptions derived from the `BusinessException`. +* The `code` is used as a key in the localization resource file to find the localized message. + +Now, we can change the `ReOpen` method as shown below: + +````csharp +public void ReOpen() +{ + if (IsLocked) + { + throw new IssueStateException("IssueTracking:CanNotOpenLockedIssue"); + } + + IsClosed = false; + CloseReason = null; +} +```` + +> Use constants instead of magic strings. + +And add an entry to the localization resource. Example entry for the English language: + +````json +"IssueTracking:CanNotOpenLockedIssue": "Can not open a locked issue! Unlock it first." +```` + +* ABP automatically uses this localized message (based on the current language) to show to the end user when you throw the exception. +* The exception code (`IssueTracking:CanNotOpenLockedIssue` here) is also sent to the client, so it may handle the error case programmatically. + +> For this example, you could directly throw `BusinessException` instead of defining a specialized `IssueStateException`. The result will be same. See the [exception handling document](Exception-Handling.md) for all the details. \ No newline at end of file From 919afec0c507b5b8b0b29fd5c96e5744723c59be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20Kalkan?= Date: Thu, 26 Nov 2020 11:30:44 +0300 Subject: [PATCH 04/11] Business Logic in Entities Requiring External Services --- ...main-Driven-Design-Implementation-Guide.md | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/en/Domain-Driven-Design-Implementation-Guide.md b/docs/en/Domain-Driven-Design-Implementation-Guide.md index e8f576efca..137549c672 100644 --- a/docs/en/Domain-Driven-Design-Implementation-Guide.md +++ b/docs/en/Domain-Driven-Design-Implementation-Guide.md @@ -623,4 +623,49 @@ And add an entry to the localization resource. Example entry for the English lan * ABP automatically uses this localized message (based on the current language) to show to the end user when you throw the exception. * The exception code (`IssueTracking:CanNotOpenLockedIssue` here) is also sent to the client, so it may handle the error case programmatically. -> For this example, you could directly throw `BusinessException` instead of defining a specialized `IssueStateException`. The result will be same. See the [exception handling document](Exception-Handling.md) for all the details. \ No newline at end of file +> For this example, you could directly throw `BusinessException` instead of defining a specialized `IssueStateException`. The result will be same. See the [exception handling document](Exception-Handling.md) for all the details. + +##### Business Logic in Entities Requiring External Services + +It is simple to implement a business rule in an entity method when the business logic only uses to the properties of that entity. What if the business logic requires to **query database** or **use any external services** that should be resolved from the [dependency injection](Dependency-Injection.md) system. Remember; **Entities can not inject services**. + +There are two common ways of implementing such a business logic: + +* Implement the business logic on an entity method and **get external dependencies as parameters** of the method. +* Create a **Domain Service**. + +Domain Services will be explained later. But, now let's see how it can be implemented in the entity class. + +**Example: Business Rule: Can not assign more than 3 open issues to a user concurrently** + +````csharp +public class Issue : AggregateRoot +{ + //... + public Guid? AssignedUserId { get; private set; } + + public async Task AssignToAsync(AppUser user, IUserIssueService userIssueService) + { + var openIssueCount = await userIssueService.GetOpenIssueCountAsync(user.Id); + + if (openIssueCount >= 3) + { + throw new BusinessException("IssueTracking:ConcurrentOpenIssueLimit"); + } + + AssignedUserId = user.Id; + } + + public void CleanAssignment() + { + AssignedUserId = null; + } +} +```` + +* `AssignedUserId` property setter made private. So, the only way to change it to use the `AssignToAsync` and `CleanAssignment` methods. +* `AssignToAsync` gets an `AppUser` entity. Actually, it only uses the `user.Id`, so you could get a `Guid` value, like `userId`. However, this way ensures that the `Guid` value is `Id` of an existing user and not a random `Guid` value. +* `IUserIssueService` is an arbitrary service that is used to get open issue count for a user. It's the responsibility of the code part (that calls the `AssignToAsync`) to resolve the `IUserIssueService` and pass here. +* `AssignToAsync` throws exception if the business rule doesn't meet. +* Finally, if everything is correct, `AssignedUserId` property is set. + From f8d90f101769db3af1a38a605952ab9af97e9254 Mon Sep 17 00:00:00 2001 From: liangshiwei Date: Thu, 26 Nov 2020 18:00:38 +0800 Subject: [PATCH 05/11] Fix typo --- docs/en/Migration-Guides/Abp-4_0-Blazor.md | 2 +- docs/en/UI/Blazor/Theming.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/en/Migration-Guides/Abp-4_0-Blazor.md b/docs/en/Migration-Guides/Abp-4_0-Blazor.md index d381ffceb6..4098689f59 100644 --- a/docs/en/Migration-Guides/Abp-4_0-Blazor.md +++ b/docs/en/Migration-Guides/Abp-4_0-Blazor.md @@ -27,7 +27,7 @@ using Volo.Abp.Bundling; namespace MyCompanyName.MyProjectName.Blazor { - public class MyProjectNameBundleContributer : IBundleContributer + public class MyProjectNameBundleContributer : IBundleContributor { public void AddScripts(BundleContext context) { diff --git a/docs/en/UI/Blazor/Theming.md b/docs/en/UI/Blazor/Theming.md index a3ecca784b..2c8ef0de22 100644 --- a/docs/en/UI/Blazor/Theming.md +++ b/docs/en/UI/Blazor/Theming.md @@ -67,7 +67,7 @@ The easiest way to create a new theme is to copy the [Basic Theme Source Code](h ### Global Styles / Scripts -A theme generally needs to add a global style to the page. ABP provides a system to manage the [Global Styles and Scripts](Global-Scripts-Styles.md). A theme can implement the `IBundleContributer` to add global style or script files to the page. +A theme generally needs to add a global style to the page. ABP provides a system to manage the [Global Styles and Scripts](Global-Scripts-Styles.md). A theme can implement the `IBundleContributor` to add global style or script files to the page. **Example: Adding a style to the page** @@ -76,7 +76,7 @@ using Volo.Abp.Bundling; namespace MyTheme { - public class MyThemeBundleContributer : IBundleContributer + public class MyThemeBundleContributer : IBundleContributor { public void AddScripts(BundleContext context) { From 1dcc6b9468aba05520ee1731060807f92da79bd8 Mon Sep 17 00:00:00 2001 From: liangshiwei Date: Thu, 26 Nov 2020 18:06:44 +0800 Subject: [PATCH 06/11] Fix typo --- docs/en/Migration-Guides/Abp-4_0-Blazor.md | 2 +- docs/en/UI/Blazor/Theming.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/Migration-Guides/Abp-4_0-Blazor.md b/docs/en/Migration-Guides/Abp-4_0-Blazor.md index 4098689f59..90a9bc2e53 100644 --- a/docs/en/Migration-Guides/Abp-4_0-Blazor.md +++ b/docs/en/Migration-Guides/Abp-4_0-Blazor.md @@ -27,7 +27,7 @@ using Volo.Abp.Bundling; namespace MyCompanyName.MyProjectName.Blazor { - public class MyProjectNameBundleContributer : IBundleContributor + public class MyProjectNameBundleContributor : IBundleContributor { public void AddScripts(BundleContext context) { diff --git a/docs/en/UI/Blazor/Theming.md b/docs/en/UI/Blazor/Theming.md index 2c8ef0de22..cbc6341dab 100644 --- a/docs/en/UI/Blazor/Theming.md +++ b/docs/en/UI/Blazor/Theming.md @@ -76,7 +76,7 @@ using Volo.Abp.Bundling; namespace MyTheme { - public class MyThemeBundleContributer : IBundleContributor + public class MyThemeBundleContributor : IBundleContributor { public void AddScripts(BundleContext context) { From 69b1737d7646712bf0defd771058c18bf83210d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20Kalkan?= Date: Thu, 26 Nov 2020 13:20:14 +0300 Subject: [PATCH 07/11] Update Domain-Driven-Design-Implementation-Guide.md --- docs/en/Domain-Driven-Design-Implementation-Guide.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/en/Domain-Driven-Design-Implementation-Guide.md b/docs/en/Domain-Driven-Design-Implementation-Guide.md index 137549c672..6b7e60c80a 100644 --- a/docs/en/Domain-Driven-Design-Implementation-Guide.md +++ b/docs/en/Domain-Driven-Design-Implementation-Guide.md @@ -669,3 +669,9 @@ public class Issue : AggregateRoot * `AssignToAsync` throws exception if the business rule doesn't meet. * Finally, if everything is correct, `AssignedUserId` property is set. +This method perfectly guarantee to apply the business logic when you want to assign an issue to a user. However, it has some problems; + +* It makes the entity class **depends on an external service** which makes the entity **complicated**. +* It makes **hard to use** the entity. The code that uses the entity now needs to inject `IUserIssueService` and pass to the `AssignToAsync` method. + +An alternative way of implementing this business logic is to introduce a **Domain Service**, which will be explained later. \ No newline at end of file From a3f7294642f939a2a832104d125583f884d6348f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20Kalkan?= Date: Thu, 26 Nov 2020 14:38:29 +0300 Subject: [PATCH 08/11] Added Repositories and Specifications section to the DDD guide. --- ...main-Driven-Design-Implementation-Guide.md | 286 +++++++++++++++++- 1 file changed, 285 insertions(+), 1 deletion(-) diff --git a/docs/en/Domain-Driven-Design-Implementation-Guide.md b/docs/en/Domain-Driven-Design-Implementation-Guide.md index 6b7e60c80a..073b4be77d 100644 --- a/docs/en/Domain-Driven-Design-Implementation-Guide.md +++ b/docs/en/Domain-Driven-Design-Implementation-Guide.md @@ -674,4 +674,288 @@ This method perfectly guarantee to apply the business logic when you want to ass * It makes the entity class **depends on an external service** which makes the entity **complicated**. * It makes **hard to use** the entity. The code that uses the entity now needs to inject `IUserIssueService` and pass to the `AssignToAsync` method. -An alternative way of implementing this business logic is to introduce a **Domain Service**, which will be explained later. \ No newline at end of file +An alternative way of implementing this business logic is to introduce a **Domain Service**, which will be explained later. + +### Repositories + +A [Repository](Repositories.md) is a collection-like interface that is used by the Domain and Application Layers to access to the data persistence system (the database) to read and write the Business Objects, generally the Aggregates. + +Common Repository principles are; + +* Define a repository **interface in the Domain Layer** (because it is used in the Domain and Application Layers), **implement in the Infrastructure Layer** (*EntityFrameworkCore* project in the startup template). +* **Do not include business logic** inside the repositories. +* Repository interface should be **database provider / ORM independent**. For example, do not return a `DbSet` from a repository method. `DbSet` is an object provided by the EF Core. +* **Create repositories for aggregate roots**, not for all entities. Because, sub-collection entities (of an aggregate) should be accessed over the aggregate root. + +#### Do Not Include Domain Logic in Repositories + +While this rule seems obvious at the beginning, it is easy to leak business logic into repositories. + +**Example: Get inactive issues from a repository** + +````csharp +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace IssueTracking.Issues +{ + public interface IIssueRepository : IRepository + { + Task> GetInActiveIssuesAsync(); + } +} +```` + +`IIssueRepository` extends the standard `IRepository<...>` interface by adding a `GetInActiveIssuesAsync` method. This repository works with such an `Issue` class: + +````csharp +public class Issue : AggregateRoot, IHasCreationTime +{ + public bool IsClosed { get; private set; } + public Guid? AssignedUserId { get; private set; } + public DateTime CreationTime { get; private set; } + public DateTime? LastCommentTime { get; private set; } + //... +} +```` + +(the code shows only the properties we need for this example) + +The rule says the repository shouldn't know the business rules. The question here is "**What is an inactive issue**? Is it a business rule definition?" + +Let's see the implementation to understand it: + +````csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using IssueTracking.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace IssueTracking.Issues +{ + public class EfCoreIssueRepository : + EfCoreRepository, + IIssueRepository + { + public EfCoreIssueRepository( + IDbContextProvider dbContextProvider) + : base(dbContextProvider) + { + } + + public async Task> GetInActiveIssuesAsync() + { + var daysAgo30 = DateTime.Now.Subtract(TimeSpan.FromDays(30)); + + return await DbSet.Where(i => + + //Open + !i.IsClosed && + + //Assigned to nobody + i.AssignedUserId == null && + + //Created 30+ days ago + i.CreationTime < daysAgo30 && + + //No comment or the last comment was 30+ days ago + (i.LastCommentTime == null || i.LastCommentTime < daysAgo30) + + ).ToListAsync(); + } + } +} +```` + +(Used EF Core for the implementation. See the [EF Core integration document](Entity-Framework-Core.md) to learn how to create custom repositories with the EF Core.) + +When we check the `GetInActiveIssuesAsync` implementation, we see a **business rule that defines an in-active issue**: The issue should be **open**, **assigned to nobody**, **created 30+ days ago** and has **no comment in the last 30 days**. + +This is an implicit definition of business rule that is hidden inside a repository method. The problem occurs when we need to reuse this business logic. + +For example, let's say that we want to add an `bool IsInActive()` method on the `Issue` entity. In this way, we can check activeness when we have an issue entity. + +Let's see the implementation: + +````csharp +public class Issue : AggregateRoot, IHasCreationTime +{ + public bool IsClosed { get; private set; } + public Guid? AssignedUserId { get; private set; } + public DateTime CreationTime { get; private set; } + public DateTime? LastCommentTime { get; private set; } + //... + + public bool IsInActive() + { + var daysAgo30 = DateTime.Now.Subtract(TimeSpan.FromDays(30)); + return + //Open + !IsClosed && + + //Assigned to nobody + AssignedUserId == null && + + //Created 30+ days ago + CreationTime < daysAgo30 && + + //No comment or the last comment was 30+ days ago + (LastCommentTime == null || LastCommentTime < daysAgo30); + } +} +```` + +We had to copy/paste/modify the code. What if the definition of the activeness changes? We should not forget to update both places. This is a duplication of a business logic, which is pretty dangerous. + +A good solution to this problem is the *Specification Pattern*! + +### Specifications + +A specification is a **named**, **reusable**, **combinable** and **testable** class to filter the Domain Objects based on the business rules. + +ABP Framework provides necessary infrastructure to easily create specification classes and use them inside your application code. Let's implement the in-active issue filter as a specification class: + +````csharp +using System; +using System.Linq.Expressions; +using Volo.Abp.Specifications; + +namespace IssueTracking.Issues +{ + public class InActiveIssueSpecification : Specification + { + public override Expression> ToExpression() + { + var daysAgo30 = DateTime.Now.Subtract(TimeSpan.FromDays(30)); + return i => + + //Open + !i.IsClosed && + + //Assigned to nobody + i.AssignedUserId == null && + + //Created 30+ days ago + i.CreationTime < daysAgo30 && + + //No comment or the last comment was 30+ days ago + (i.LastCommentTime == null || i.LastCommentTime < daysAgo30); + } + } +} +```` + +`Specification` base class simplifies to create a specification class by defining an expression. Just moved the expression here, from the repository. + +Now, we can re-use the `InActiveIssueSpecification` in the `Issue` entity and `EfCoreIssueRepository` classes. + +#### Using within the Entity + +`Specification` class provides an `IsSatisfiedBy` method that returns `true` if the given object (entity) satisfies the specification. We can re-write the `Issue.IsInActive` method as shown below: + +````csharp +public class Issue : AggregateRoot, IHasCreationTime +{ + public bool IsClosed { get; private set; } + public Guid? AssignedUserId { get; private set; } + public DateTime CreationTime { get; private set; } + public DateTime? LastCommentTime { get; private set; } + //... + + public bool IsInActive() + { + return new InActiveIssueSpecification().IsSatisfiedBy(this); + } +} +```` + +Just created a new instance of the `InActiveIssueSpecification` and used its `IsSatisfiedBy` method to re-use the expression defined by the specification. + +#### Using with the Repositories + +First, starting from the repository interface: + +````csharp +public interface IIssueRepository : IRepository +{ + Task> GetIssuesAsync(ISpecification spec); +} +```` + +Renamed `GetInActiveIssuesAsync` to simple `GetIssuesAsync` by taking a specification object. Since the **specification (the filter) has been moved out of the repository**, we no longer need to create different methods to get issues with different conditions (like `GetAssignedIssues(...)`, `GetLockedIssues(...)`, etc.) + +Updated implementation of the repository can be like that: + +````csharp +public class EfCoreIssueRepository : + EfCoreRepository, + IIssueRepository +{ + public EfCoreIssueRepository( + IDbContextProvider dbContextProvider) + : base(dbContextProvider) + { + } + + public async Task> GetIssuesAsync(ISpecification spec) + { + return await DbSet + .Where(spec.ToExpression()) + .ToListAsync(); + } +} +```` + +Since `ToExpression()` method returns an expression, it can be directly passed to the `Where` method to filter the entities. + +Finally, we can pass any Specification instance to the `GetIssuesAsync` method: + +````csharp +public class IssueAppService : ApplicationService, IIssueAppService +{ + private readonly IIssueRepository _issueRepository; + + public IssueAppService(IIssueRepository issueRepository) + { + _issueRepository = issueRepository; + } + + public async Task DoItAsync() + { + var issues = await _issueRepository.GetIssuesAsync( + new InActiveIssueSpecification() + ); + } +} +```` + +##### With Default Repositories + +Actually, you don't have to create custom repositories to be able to use specifications. The standard `IRepository` already extends the `IQueryable`, so you can use the standard LINQ extension methods over it: + +````csharp +public class IssueAppService : ApplicationService, IIssueAppService +{ + private readonly IRepository _issueRepository; + + public IssueAppService(IRepository issueRepository) + { + _issueRepository = issueRepository; + } + + public async Task DoItAsync() + { + var issues = AsyncExecuter.ToListAsync( + _issueRepository.Where(new InActiveIssueSpecification()) + ); + } +} +```` + +`AsyncExecuter` is a utility provided by the ABP Framework to use asynchronous LINQ extension methods (like `ToListAsync` here) without depending on the EF Core NuGet package. See the [Repositories document](Repositories.md) for more information. \ No newline at end of file From 725a87104b108fb086f72ee5173d76bc7eac9960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20Kalkan?= Date: Thu, 26 Nov 2020 15:25:02 +0300 Subject: [PATCH 09/11] Documented: Combining the Specifications --- ...main-Driven-Design-Implementation-Guide.md | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/en/Domain-Driven-Design-Implementation-Guide.md b/docs/en/Domain-Driven-Design-Implementation-Guide.md index 073b4be77d..ece9130148 100644 --- a/docs/en/Domain-Driven-Design-Implementation-Guide.md +++ b/docs/en/Domain-Driven-Design-Implementation-Guide.md @@ -817,7 +817,7 @@ A good solution to this problem is the *Specification Pattern*! ### Specifications -A specification is a **named**, **reusable**, **combinable** and **testable** class to filter the Domain Objects based on the business rules. +A [specification](Specifications.md) is a **named**, **reusable**, **combinable** and **testable** class to filter the Domain Objects based on the business rules. ABP Framework provides necessary infrastructure to easily create specification classes and use them inside your application code. Let's implement the in-active issue filter as a specification class: @@ -958,4 +958,59 @@ public class IssueAppService : ApplicationService, IIssueAppService } ```` -`AsyncExecuter` is a utility provided by the ABP Framework to use asynchronous LINQ extension methods (like `ToListAsync` here) without depending on the EF Core NuGet package. See the [Repositories document](Repositories.md) for more information. \ No newline at end of file +`AsyncExecuter` is a utility provided by the ABP Framework to use asynchronous LINQ extension methods (like `ToListAsync` here) without depending on the EF Core NuGet package. See the [Repositories document](Repositories.md) for more information. + +#### Combining the Specifications + +One powerful side of the Specifications is they are combinable. Assume that we've another specification that returns `true` only if the `Issue` is in a Milestone: + +````csharp +public class MilestoneSpecification : Specification +{ + public Guid MilestoneId { get; } + + public MilestoneSpecification(Guid milestoneId) + { + MilestoneId = milestoneId; + } + + public override Expression> ToExpression() + { + return i => i.MilestoneId == MilestoneId; + } +} +```` + +This Specification is *parametric* as a difference from the `InActiveIssueSpecification`. We can combine both specifications to get a list of inactive issues in a specific milestone: + +````csharp +public class IssueAppService : ApplicationService, IIssueAppService +{ + private readonly IRepository _issueRepository; + + public IssueAppService(IRepository issueRepository) + { + _issueRepository = issueRepository; + } + + public async Task DoItAsync(Guid milestoneId) + { + var issues = AsyncExecuter.ToListAsync( + _issueRepository + .Where( + new InActiveIssueSpecification() + .And(new MilestoneSpecification(milestoneId)) + .ToExpression() + ) + ); + } +} +```` + +The example above uses the `And` extension method to combine the specifications. There are more combining methods are available, like `Or(...)` and `AndNot(...)`. + +> See the [Specifications document](Specifications.md) for more details about the specification infrastructure provided by the ABP Framework. + +### Domain Services + +TODO \ No newline at end of file From d94b82444ae84b0efe22385ef444beea132aa52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20Kalkan?= Date: Thu, 26 Nov 2020 15:54:16 +0300 Subject: [PATCH 10/11] Completed Domain Services part of the DDD guide. --- ...main-Driven-Design-Implementation-Guide.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/docs/en/Domain-Driven-Design-Implementation-Guide.md b/docs/en/Domain-Driven-Design-Implementation-Guide.md index ece9130148..6c4844b299 100644 --- a/docs/en/Domain-Driven-Design-Implementation-Guide.md +++ b/docs/en/Domain-Driven-Design-Implementation-Guide.md @@ -1013,4 +1013,96 @@ The example above uses the `And` extension method to combine the specifications. ### Domain Services +Domain Services implement domain logic that; + +* Depends on **services and repositories**. +* Needs to work with **multiple aggregates**, so the logic doesn't properly fit in any of the aggregates. + +Domain Services work with Domain Objects. Their methods can **get and return entities, value objects, primitive types**... etc. However, **they don't get/return DTOs**. DTOs is a part of the Application Layer. + +**Example: Assigning an issue to a user** + +Remember how issue assignment has been implemented in the `Issue` entity: + +````csharp +public class Issue : AggregateRoot +{ + //... + public Guid? AssignedUserId { get; private set; } + + public async Task AssignToAsync(AppUser user, IUserIssueService userIssueService) + { + var openIssueCount = await userIssueService.GetOpenIssueCountAsync(user.Id); + + if (openIssueCount >= 3) + { + throw new BusinessException("IssueTracking:ConcurrentOpenIssueLimit"); + } + + AssignedUserId = user.Id; + } + + public void CleanAssignment() + { + AssignedUserId = null; + } +} +```` + +Here, we will move this logic into a Domain Service. + +First, changing the `Issue` class: + +````csharp +public class Issue : AggregateRoot +{ + //... + public Guid? AssignedUserId { get; internal set; } +} +```` + +* Removed the assign-related methods. +* Changed `AssignedUserId` property's setter from `private` to `internal`, to allow to set it from the Domain Service. + +The next step is to create a domain service, named `IssueManager`, that has `AssignToAsync` to assign the given issue to the given user. + +````csharp +public class IssueManager : DomainService +{ + private readonly IRepository _issueRepository; + + public IssueManager(IRepository issueRepository) + { + _issueRepository = issueRepository; + } + + public async Task AssignToAsync(Issue issue, AppUser user) + { + var openIssueCount = await _issueRepository.CountAsync( + i => i.AssignedUserId == user.Id && !i.IsClosed + ); + + if (openIssueCount >= 3) + { + throw new BusinessException("IssueTracking:ConcurrentOpenIssueLimit"); + } + + issue.AssignedUserId = user.Id; + } +} +```` + +`IssueManager` can inject any service dependency and use to query open issue count on the user. + +> We prefer and suggest to use the `Manager` suffix for the Domain Services. + +The only problem of this design is that `Issue.AssignedUserId` is now open to set out of the class. However, it is not `public`. It is `internal` and changing it is possible only inside the same Assembly, the `IssueTracking.Domain` project for this example solution. We think this is reasonable; + +* Domain Layer developers are already aware of domain rules and they use the `IssueManager`. +* Application Layer developers are already forces to use the `IssueManager` since they don't directly set it. + +While there is a tradeoff between two approaches, we prefer to create Domain Services when the business logic requires to work with external services. + +### Application Services + TODO \ No newline at end of file From 015d527fef3a13bd87926aa2329f85d2257d3712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halil=20=C4=B0brahim=20Kalkan?= Date: Thu, 26 Nov 2020 16:27:09 +0300 Subject: [PATCH 11/11] DDD guide: Application Services --- ...main-Driven-Design-Implementation-Guide.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/en/Domain-Driven-Design-Implementation-Guide.md b/docs/en/Domain-Driven-Design-Implementation-Guide.md index 6c4844b299..230288cb28 100644 --- a/docs/en/Domain-Driven-Design-Implementation-Guide.md +++ b/docs/en/Domain-Driven-Design-Implementation-Guide.md @@ -1105,4 +1105,78 @@ While there is a tradeoff between two approaches, we prefer to create Domain Ser ### Application Services +An [Application Service](Application-Services.md) is a stateless service that implements **use cases** of the application. An application service typically **gets and returns DTOs**. It is used by the Presentation Layer. It **uses and coordinates the domain objects** (entities, repositories, etc.) to implement use cases. + +Common principles of an application service are; + +* Implement the **application logic** that is specific to the current use case. Do not implement the core domain logic inside the application services. We will come back to differences between Application Domain logics. +* **Never get or return entities** for an application service method. This breaks the encapsulation of the Domain Layer. Always get and return DTOs. + +**Example: Assign an Issue to a User** + +````csharp +using System; +using System.Threading.Tasks; +using IssueTracking.Users; +using Microsoft.AspNetCore.Authorization; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; + +namespace IssueTracking.Issues +{ + public class IssueAppService : ApplicationService, IIssueAppService + { + private readonly IssueManager _issueManager; + private readonly IRepository _issueRepository; + private readonly IRepository _userRepository; + + public IssueAppService( + IssueManager issueManager, + IRepository issueRepository, + IRepository userRepository) + { + _issueManager = issueManager; + _issueRepository = issueRepository; + _userRepository = userRepository; + } + + [Authorize] + public async Task AssignAsync(IssueAssignDto input) + { + var issue = await _issueRepository.GetAsync(input.IssueId); + var user = await _userRepository.GetAsync(input.UserId); + + await _issueManager.AssignToAsync(issue, user); + + await _issueRepository.UpdateAsync(issue); + } + } +} +```` + +An application service method typically has three steps those are implemented here; + +1. Get the related domain objects from database to implement the use case. +2. Use domain objects (domain services, entities, etc.) to perform the actual operation. +3. Update the changed entities in the database. + +> The last *Update* is not necessary if your are using EF Core since it has a Change Tracking system. If you want to take advantage of this EF Core feature, please see the *Discussion About the Database Independence Principle* section above. + +`IssueAssignDto` in this example is a simple DTO class: + +````csharp +using System; + +namespace IssueTracking.Issues +{ + public class IssueAssignDto + { + public Guid IssueId { get; set; } + public Guid UserId { get; set; } + } +} +```` + +### Data Transfer Objects + TODO \ No newline at end of file