Skip to content

Commit

Permalink
FIXED: bug #23
Browse files Browse the repository at this point in the history
  • Loading branch information
thangchung committed Jul 29, 2021
1 parent 8012c4e commit 19a2057
Show file tree
Hide file tree
Showing 14 changed files with 134 additions and 48 deletions.
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ In the end of our journey, we would like to give these simplified and effortless
[![Sparkline](https://stars.medv.io/thangchung/clean-architecture-dotnet.svg)](https://stars.medv.io/thangchung/clean-architecture-dotnet)

> ### DISCLAIMER
>
>
> **IMPORTANT:** Because we are constantly evolving towards newly released technologies, .NET 6 is in the preview state so that it might change a lot in every release by the .NET team. So even we're trying to do the best for this OSS. But be careful if you use it for your project, and make sure that you do a stress test, benchmarking these libraries, and refactor them subsequently to adapt to your business carefully.
>
>
> **Feedback** with improvements and pull requests from the community will be highly appreciated. Thank you!
# ⭐ Give a star
Expand Down Expand Up @@ -47,6 +47,49 @@ If you're using this repository for your learning, samples, workshop, or your pr
![](assets/DomainDrivenHexagon.png)
Reference to https://github.com/Sairyss/domain-driven-hexagon

## Design Overview

![](assets/design_overview.png)

On the top of the above picture on the left (purple color), we can see we have several types of hosts in our project:

- REST API (ASP.NET Web API with controllers, .NET Routing, or GraphQL)
- gRPC Server
- Subscriber (Dapr pub/sub)
- Background Jobs (Dapr cron job binding)

And on the right box (white color), we can see several external services that we use for the project:

- Message Broker (Redis Stream)
- Cache Server (Redis Cache)
- Database server (PostgresQL)

Let explanation a bit about what CQRS means in the application as below:

### Query side

1. When end-user submits a query request - `Query DTO`, the end-user will need to compose the JSON object and submit it to these host endpoints above
2. The application uses `MediatR` to send `Query DTO` into the Query handler
3. The application then injects a `Query Repository` to help them query the projection data of this query. In this step, we can compose the criterion using the `Specification` pattern.
4. The application composes some of the `Domain Entity` to aggregate the data needs. But notice that we can use some of the technologies such as de-normalize data, materialized view in this step.

### Command side

5. When end-user submits a command request - `Command DTO`, the end-user will need to compose the JSON object and submit it to these host endpoints above
6. The application uses `MediatR` to send `Command DTO` into the Command handler
7. Because we can only mutate the domain entity via `Root Aggregate Object` so that we need to identify and query the `Root Aggregate Object` out
8. Then we acquire the `Unit of Work` or `Transaction Scope` of the current database in code
9. We persist it into the database
10. The state of the root aggregate and its belong will be persisted into the database in one transaction
11. We add some of `Domain Event` into the `Root Aggregate Object` just persisted and return it into Repository
12. These ' Domain events` will subsequently propagate up to `Command handler`
13. The `Command handler` will dispatch the event into the `Event Dispatcher` (this makes the core and application layer are not dependent on a specific Message Broker)
14. The `Event Dispatcher` will store the `Domain Event` into the database or in-memory
15. In around every 1 second, we have a `Background Jobs` to trigger and compose the `Command DTO`, and uses `MediatR` to send the request to `Command handler`
16. The `Command handler` will query all the `Domain Event` in the database (Step 14), and loop through it to publish to the `Message Broker`

> Notes: Steps 13, 14, 15, 15 are the `Transactional Outbox Pattern` which makes the message transportation is more reliable.
# 💎 Prerequisites

- [.NET SDK](https://dotnet.microsoft.com/download/dotnet/6.0): 6.0.100-preview.5.21271.2
Expand Down Expand Up @@ -322,7 +365,8 @@ $ tye run
</tbody>
</table>

# 🎇 Additional parts
# 🎇 Others

## Public CRUD interface

In medium and large software projects, we normally implement the CRUD actions over and over again. And it might take around 40-50% codebase just to do CRUD in the projects. The question is can we make standardized CRUD APIs, then we can use them in potential projects in the future? That is in my mind for a long time when I started and finished many projects, and I decide to take time to research and define the public interfaces for it as below
Expand Down
Binary file added assets/design_overview.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions clean-architecture-dotnet.sln.DotSettings
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=Criterias/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Dapr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Dtos/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Extentions/@EntryIndexedValue">True</s:Boolean>
Expand Down
9 changes: 4 additions & 5 deletions samples/Customer/CustomerService.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@
await using var app = builder.Build();
app.UseCoreApplication(builder.Environment);

app.MapPost("/api/v1/customers",
async (CreateCustomer.Command request, ISender sender) => Ok(await sender.Send(request)));
app.MapPost("/api/v1/customers", async (CreateCustomer.Command request, ISender sender) =>
Ok(await sender.Send(request)));

app.MapPost("/CustomerOutboxCron",
async (ITxOutboxProcessor outboxProcessor) =>
await outboxProcessor.HandleAsync(typeof(CoolStore.IntegrationEvents.Anchor)));
app.MapPost("/CustomerOutboxCron", async (ITxOutboxProcessor outboxProcessor) =>
await outboxProcessor.HandleAsync(typeof(CoolStore.IntegrationEvents.Anchor)));

await app.RunAsync();

Expand Down
11 changes: 10 additions & 1 deletion samples/IdentityServer/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public static class Config
public static IEnumerable<Client> Clients =>
new Client[]
{
new Client
new()
{
ClientId = "spa",
ClientSecrets = { new Secret("secret".Sha256()) },
Expand All @@ -42,6 +42,15 @@ public static class Config
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api" }
},

// password flow
new()
{
ClientId = "restclient.password",
ClientSecrets = {new Secret("49C1A7E1-0C79-4A89-A3D6-A37998FB86B0".Sha256())},
AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials,
AllowedScopes = { "openid", "profile", "api" }
}
};
}
}
38 changes: 18 additions & 20 deletions samples/Product/ProductService.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@

app.MapGet("/api/v1/products", async (HttpContext http) =>
{
if (!http.Request.Headers.TryGetValue("x-query", out var query)) return BadRequest();
if (!http.Request.Headers.TryGetValue("x-query", out var query))
return BadRequest();
var sender = http.RequestServices.GetService<ISender>();
var queryModel = http.SafeGetListQuery<GetProducts.Query, ListResultModel<ProductDto>>(query);
var result = await sender!.Send(queryModel);
Expand All @@ -22,23 +22,21 @@
return Ok(result);
}).RequireAuthorization("ApiCaller");

app.MapPost("/api/v1/products",
async (CreateProduct.Command request, ISender sender) =>
{
var result = await sender.Send(request);
return Ok(result);
}).RequireAuthorization("ApiCaller");

app.MapPost("/CustomerCreated",
(CustomerCreatedIntegrationEvent @event) =>
{
Console.WriteLine($"I received the message with name={@event.GetType().FullName}");
return Ok("Subscribed");
})
.WithTopic("pubsub", "CustomerCreatedIntegrationEvent");

app.MapPost("/ProductOutboxCron",
async (ITxOutboxProcessor outboxProcessor) =>
await outboxProcessor.HandleAsync(typeof(CoolStore.IntegrationEvents.Anchor)));
app.MapPost("/api/v1/products", async (CreateProduct.Command request, ISender sender) =>
{
var result = await sender.Send(request);
return Ok(result);
}).RequireAuthorization("ApiCaller");

// Dapr pubsub
app.MapPost("/CustomerCreated", (CustomerCreatedIntegrationEvent @event) =>
{
Console.WriteLine($"I received the message with name={@event.GetType().FullName}");
return Ok("Subscribed");
}).WithTopic("pubsub", "CustomerCreatedIntegrationEvent");

// Dapr cron job binding
app.MapPost("/ProductOutboxCron", async (ITxOutboxProcessor outboxProcessor) =>
await outboxProcessor.HandleAsync(typeof(CoolStore.IntegrationEvents.Anchor)));

await app.RunAsync();
2 changes: 1 addition & 1 deletion samples/Product/ProductService.AppCore/Core/Return.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace ProductService.AppCore.Core
public class Return : EntityBase
{
public Guid ProductId { get; set; }
public Product Product { get; protected set; }
public Product Product { get; protected set; } = default!;
public Guid CustomerId { get; set; }
public ReturnReason Reason { get; protected set; }
public string Note { get; protected set; } = default!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace ProductService.AppCore.Core.Specs
{
public sealed class ProductByIdQuerySpec<TResponse> : SpecificationBase<Product>
public sealed class ProductByIdQuerySpec<TResponse> : SpecificationBase<Product> where TResponse : notnull
{
private readonly Guid _id;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace ProductService.AppCore.Core.Specs
{
public sealed class ProductListQuerySpec<TResponse> : GridSpecificationBase<Product>
public sealed class ProductListQuerySpec<TResponse> : GridSpecificationBase<Product> where TResponse : notnull
{
public ProductListQuerySpec(IListQuery<ListResultModel<TResponse>> gridQueryInput)
{
Expand Down
5 changes: 2 additions & 3 deletions samples/Setting/SettingService.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
await using var app = builder.Build();
app.UseCoreApplication(builder.Environment);

app.MapGet("/api/v1/countries/{id}",
async (Guid id, ISender sender) =>
Ok(await sender.Send(new GetCountryById.Query {Id = id})));
app.MapGet("/api/v1/countries/{id:guid}", async (Guid id, ISender sender) =>
Ok(await sender.Send(new GetCountryById.Query { Id = id })));

await app.RunAsync();
29 changes: 23 additions & 6 deletions samples/restclient.http
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
@host = http://localhost:5000
@idphost = https://localhost:5001

###
# @name auth
POST {{idphost}}/connect/token HTTP/1.1
content-type: application/x-www-form-urlencoded

grant_type=password
&client_id=restclient.password
&client_secret=49C1A7E1-0C79-4A89-A3D6-A37998FB86B0
&username=bob
&password=bob

###
GET {{host}}/product-api/v1/products HTTP/1.1
content-type: {{contentType}}
x-query: {"filters":[{"fieldName": "Name", "comparision": "Contains", "fieldValue": "test"}],"sorts":["NameDesc"],"page":1,"pageSize":20}
content-type: application/json
authorization: bearer {{auth.response.body.access_token}}
x-query: {"filters":[{"fieldName": "Name", "comparision": "Contains", "fieldValue": "test"}],"sorts":["Name", "QuantityDesc"],"page":1,"pageSize":20}

###
@id = 23537dac-303f-446f-be2a-1dbea22b3eba
GET {{host}}/product-api/v1/products/{{id}} HTTP/1.1
content-type: {{contentType}}
content-type: application/json
authorization: bearer {{auth.response.body.access_token}}

###
@country-id = 18a4a8ae-3338-484a-a4ed-6e64d13d84dc
GET {{host}}/setting-api/v1/countries/{{country-id}} HTTP/1.1
content-type: {{contentType}}
content-type: application/json
authorization: bearer {{auth.response.body.access_token}}

###
POST {{host}}/product-api/v1/products HTTP/1.1
content-type: {{contentType}}
content-type: application/json
authorization: bearer {{auth.response.body.access_token}}

{
"model": {
Expand All @@ -30,7 +46,8 @@ content-type: {{contentType}}

###
POST {{host}}/customer-api/v1/customers HTTP/1.1
content-type: {{contentType}}
content-type: application/json
authorization: bearer {{auth.response.body.access_token}}

{
"model": {
Expand Down
9 changes: 4 additions & 5 deletions src/N8T.Core/Specification/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,15 @@ public static ISpecification<T> Negate<T>(this ISpecification<T> inner)

public static void ApplySorting(this IRootSpecification gridSpec,
string sort,
string orderByDescendingMethodName,
string groupByMethodName)
string orderByMethodName,
string orderByDescendingMethodName)
{
if (string.IsNullOrEmpty(sort)) return;

const string descendingSuffix = "Desc";

var descending = sort.EndsWith(descendingSuffix, StringComparison.Ordinal);
var propertyName = sort.Substring(0, 1).ToUpperInvariant() +
sort.Substring(1, sort.Length - 1 - (descending ? descendingSuffix.Length : 0));
var propertyName = string.Concat(sort[..1].ToUpperInvariant(), sort.AsSpan(1, sort.Length - 1 - (descending ? descendingSuffix.Length : 0)));

var specificationType = gridSpec.GetType().BaseType;
var targetType = specificationType?.GenericTypeArguments[0];
Expand All @@ -61,7 +60,7 @@ public static ISpecification<T> Negate<T>(this ISpecification<T> inner)
else
{
specificationType?.GetMethod(
groupByMethodName,
orderByMethodName,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?.Invoke(gridSpec, new object[]{propertyReturningExpression});
}
Expand Down
24 changes: 22 additions & 2 deletions src/N8T.Core/Specification/GridSpecificationBase.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using N8T.Core.Domain;

Expand All @@ -12,6 +13,7 @@ public abstract class GridSpecificationBase<T> : IGridSpecification<T>
public List<string> IncludeStrings { get; } = new();
public Expression<Func<T, object>> OrderBy { get; private set; }
public Expression<Func<T, object>> OrderByDescending { get; private set; }
public Expression<Func<T, object>> ThenByDescending { get; private set; }
public Expression<Func<T, object>> GroupBy { get; private set; }

public int Take { get; private set; }
Expand Down Expand Up @@ -74,20 +76,38 @@ protected void ApplyPaging(int skip, int take)
protected void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescendingExpression) =>
OrderByDescending = orderByDescendingExpression;

protected void ApplyThenOrderByDescending(Expression<Func<T, object>> thenByDescendingExpression) =>
ThenByDescending = thenByDescendingExpression;

protected void ApplyGroupBy(Expression<Func<T, object>> groupByExpression) =>
GroupBy = groupByExpression;

protected void ApplySortingList(IEnumerable<string> sorts)
{
foreach (var sort in sorts)
if (!sorts.Any()) return;

// Get first sort item and remove it from sorts list
var firstSortingItem = sorts.First();
var tempSorts = sorts.Where(s => !s.Contains(firstSortingItem));

// Apply OrderBy to first item
ApplySorting(firstSortingItem);

// Apply ThenBy to subsequent items
foreach (var sort in tempSorts)
{
ApplySorting(sort);
ApplyThenBySorting(sort);
}
}

protected void ApplySorting(string sort)
{
this.ApplySorting(sort, nameof(ApplyOrderBy), nameof(ApplyOrderByDescending));
}

protected void ApplyThenBySorting(string sort)
{
this.ApplySorting(sort, nameof(ApplyThenOrderByDescending), nameof(ApplyThenOrderByDescending));
}
}
}

0 comments on commit 19a2057

Please sign in to comment.