- Learn how to create a Domain class library using the Domain Driven Design Pattern
- Learn how to create a Xunit test project
- Learn how to write valueable tests
- Learn how to use Bogus to fake your test data
- Learn how to use value objects
- Learn how to use a static constructor
- Learn how to use entities
- Learn how to use Data Transfer Objects
- Learn how to use Swagger
- Learn how to setup a REST API
In this exercise we are going to create a e-commerce website where customers can see products, add them to their cart and order them. However we'll be focussing on the domain and REST API. In the next chapter we'll implement the User Interface using Blazor Web Assembly(WASM).
- Read the following official Microsoft documentation about Value Objects.
- Read the following post from Vladimir Khorikov how to use the Entity base class.
- Create a new class library called Domain in a
src
folder. - Create a new xunit project called Domain.Tests in a
tests
folder. - Implement the following domain, initially model the domain, afterwards use the domain rules to implement it correctly.
- During implementation you can write unit tests as you see fit. You can use Shouldly which makes it easier to assert certain tests, read more about it in the docs. Since creating dummy objects for tests can be quite tedious, you can use Bogus to create Fake generators. Below you'll find some fakers, you can read more about them in the official documentation of Bogus:
- Create a new classlibrary called
Services
in thesrc
folder. - Create a new classlibrary called
Shared
in thesrc
folder. - In the
Shared
Project:- Create a folder called
Products
- Implement
Index
andDetail
ProductDto in theShared/Products
folder: Index
:- Id : int
- Name : string
- InStock : bool
- Price : decimal
Detail
:- All fields from
Index
- Description : string
- Category : string
- All fields from
- Declare a
IProductService
interface with 3 methods in the Shared/Products folder
public interface IProductService { Task<IEnumerable<ProductDto.Index>> GetIndexAsync(); Task<ProductDto.Detail> GetDetailAsync(int id); Task DeleteAsync(int id); }
- Create a folder called
- In the
Services
Project:- Create a folder called
Products
- Add a reference to the
Shared
and theDomain
project - Implement a
ProductService
which implementsIProductService
and make it return fake data, using Bogus, the Fakers you created for the unittests (mapped to DTO's) or simply fake data (for now). In a later chapter we'll use Entity Framework to fetch and mutate data in a Database.- Use a static constructor to generate a static private list of
ProductDto.Detail
- GetIndex returns a list of 10 items
- GetDetail returns 1 specific product based on the productId
- Delete, deletes one product of the in-memory static list of products
- Use a static constructor to generate a static private list of
- Create a folder called
- Create a new web api called
Api
in thesrc
folder, make sure that OpenAPI is present. Use Visual Studio or the dotnet CLI to generate the project. - Remove the following files:
WeatherForecastController
WeatherForecast
- Add a reference to the
Shared
andServices
project - Add
IProductService
to the dependency injection container inStartup.cs
with an implementation ofProductService
asScoped
- Create a
ProductController
, which takes a IProductService in his constructor and has the following three methods:Task<IEnumerable<ProductDto.Index>> GetIndexAsync()
with endpoint:/product
Task<ProductDto.Detail> GetDetailAsync(int id)
with endpoint/product/{id}
Task DeleteAsync(int id)
with endpoint/product/{id}
but as a delete- Use all 3 methods from the
IProductService
as implementation - Use the correct HTTP verb and attribute.
- To use full name of the DTO, which else will collide with other DTO subclasses later, alter the configuration of Swagger as follows:
services.AddSwaggerGen(c => { c.CustomSchemaIds(x => $"{x.DeclaringType.Name}.{x.Name}"); c.SwaggerDoc("v1", new OpenApiInfo { Title = "Api", Version = "v1" }); });
- Add FluentValidation to the
Shared
project - Create a nested class in
ProductDto
calledCreate
:public class Create { public string Name { get; set; } public bool InStock { get; set; } public decimal Price { get; set; } public string Description { get; set; } public string Category { get; set; } public class Validator : AbstractValidator<Create> { public Validator() { RuleFor(x => x.Name).NotEmpty().Length(1,250); RuleFor(x => x.Price).InclusiveBetween(1,250); RuleFor(x => x.Category).NotEmpty().Length(1, 250); } } }
- Extend the
IProductService
with aCreateAsync
function that takes in aProductDto.Create
as a parameter and returns an integer. Implement the function inProductService
so that we can add a product to the list of products. - Create a HttpPost endpoint in the
ProductController
which takes in theProductDto.Create
and returns aTask<ActionResult<int>>
, by returning aCreatedAtAction
with the newly generated Id of the newproduct
. - Add FluentValidation Middleware to validate the Create request automagically, you can read more here
- Add the NuGet package:
FluentValidation.AspNetCore
// In Startup.cs extend the .AddControllers function as follows: services.AddControllers().AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<ProductDto.Create.Validator>());
- Add the NuGet package:
Try running your API and using Swagger to make requests, try to create a new product, the validation middleware will kick in if needed.
- Non of the properties can be empty or null
- Addresses are equal when all the fields are equal.
- Non of the properties can be empty or null
- Money is equal when the value is equal to 2 digits after the comma.
- Has 2 implicit operators to cast from and to decimal, more information about implicit operators can be found in this StackOverflow post
- Non of the properties can be empty or null
- The date itself can only be created when it's between tomorrow and 1 month
- DeliveryDates are equal when all the fields are equal, comparing date without time.
- Product has to be filled-in
- Quantity cannot be negative
- Total is the product price times the quantity
- CartItems are equal when the products are the same.
- Product has to be filled-in
- Quantity cannot be negative
- The price property is filled-in by the product price. When the product price is altered, the price or the OrderItem should not change.
- OrderItems are equal when the product, quantity and price are the same.
- Non of the properties can be empty or null
- CustomerName's are equal when the firstname and lastname are the same. (ignore case)
- Name cannot be null or whitespace (use the private field)
- Category cannot be null (use the private field)
- Description can be anything your heart desires
- Price cannot be null (use the private field)
- InStock has no real checks.
- Product is an entity thus, should be equal when the Id's are the same.
- Name is mandatory
- Address is mandatory
- PlaceOrder adds a new order to the private collection
- Customer is an entity thus, should be equal when the Id's are the same.
- Name cannot be null or whitespace (use the private field)
- Category is an entity thus, should be equal when the Id's are the same.
- OrderDate should be set to the current DateTime (use UTC)
- ShippingAddress should not be null
- Cart should not be null
- Cart should contain more than 0 lines
- There are no checks on the deliverydate nor giftwrapping
- When created, it should be immutable, and adds all CartLine's as orderlines to the private collection and clears the Cart.
- Order is an entity thus, should be equal when the Id's are the same.
- Is immutable and requires an OrderItem
- OrderLine is an entity thus, should be equal when the Id's are the same.
- Can be created without any specific properties
- Total sums up the item's totals.
- AddItem should add an item to list of it's not in the list yet(check the product) else it should increase the quantity of the item. RemoveLine, removes the line from the private collection.
- Clear, removes all items from the Cart.
- Is immutable and requires an OrderItem
- IncreaseQuantity adds the quantity
Category Faker
public class CategoryFaker : Faker<Category>
{
public CategoryFaker()
{
CustomInstantiator(f => new Category(f.Commerce.ProductMaterial()));
RuleFor(x => x.Id, f => f.Random.Int());
}
}
Address Faker
public class AddressFaker : Faker<Address>
{
public AddressFaker()
{
var a = new Bogus.DataSets.Address();
CustomInstantiator(f => new Address(a.Country(), a.City(), a.ZipCode(), a.StreetAddress()));
}
}
Product Faker
public class ProductFaker : Faker<Product>
{
public ProductFaker()
{
CustomInstantiator(f => new Product(f.Commerce.ProductName(), f.Commerce.ProductDescription(), new Money(f.Random.Decimal(0, 200)), f.Random.Bool(), new CategoryFaker()));
RuleFor(x => x.Id, f => f.Random.Int());
}
}
Example Customer Test
[Fact]
public void Not_Be_Able_To_Order_When_Cart_Is_Empty()
{
Customer customer = new CustomerFaker();
Cart cart = new();
DeliveryDate deliveryDate = new(DateTime.UtcNow.AddDays(2));
Should.Throw<Exception>(() =>
{
customer.PlaceOrder(cart, deliveryDate, true, customer.Address);
});
}
A possible solution can be found here.