Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions DomainDrivenTutorial.sln
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EShoppingTutorial.Infrastru
EndProject
Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{81DDED9D-158B-E303-5F62-77A2896D2A5A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EShoppingTutorial.ArchTests", "EShoppingTutorial\EShoppingTutorial.ArchTests\EShoppingTutorial.ArchTests.csproj", "{7DE2D839-9AF3-4D02-9BC1-B559ECD1E31C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -73,6 +75,10 @@ Global
{81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU
{7DE2D839-9AF3-4D02-9BC1-B559ECD1E31C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DE2D839-9AF3-4D02-9BC1-B559ECD1E31C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DE2D839-9AF3-4D02-9BC1-B559ECD1E31C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DE2D839-9AF3-4D02-9BC1-B559ECD1E31C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -87,6 +93,7 @@ Global
{A12271E1-D764-4757-AFA4-203DBB555B26} = {9E43B017-E13C-453B-9C1C-A50BF9DB705F}
{311A5E04-1743-4674-BE72-2A4FD050A911} = {563C32CB-B8B9-4223-823F-9F56383B2274}
{F31C106C-CAA0-48B8-8046-8C2A6D394874} = {563C32CB-B8B9-4223-823F-9F56383B2274}
{7DE2D839-9AF3-4D02-9BC1-B559ECD1E31C} = {563C32CB-B8B9-4223-823F-9F56383B2274}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {58DDD7D4-5B51-440D-8E00-95270B5E1AD0}
Expand Down
36 changes: 36 additions & 0 deletions EShoppingTutorial/EShoppingTutorial.ArchTests/ConventionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace EShoppingTutorial.ArchTests;

public class ConventionTests
{
private readonly System.Reflection.Assembly _applicationAssembly = typeof(Core.Application.ApplicationDependencyInjection).Assembly;

[Fact]
public void CommandHandlers_Should_Be_Internal()
{
var result = Types.InAssembly(_applicationAssembly)
.That()
.HaveNameEndingWith("Handler")
.Should()
.NotBePublic()
.GetResult();

result.IsSuccessful.Should().BeTrue("because handlers contain the business execution logic and should not be exposed.");
}

[Fact]
public void Handlers_Should_ResideIn_ApplicationNamespace_And_HaveCorrectName()
{
// Arrange & Act
var result = Types.InAssembly(_applicationAssembly)
.That()
.HaveNameEndingWith("Handler")
.Should()
.ResideInNamespace("EShoppingTutorial.Core.Application")
.GetResult();

// Assert
var failingTypes = string.Join(", ", result.FailingTypeNames ?? Enumerable.Empty<string>());
result.IsSuccessful.Should().BeTrue(
$"because all MediatR Handlers must be located in the Application layer and follow naming conventions. Failing types: {failingTypes}");
}
}
72 changes: 72 additions & 0 deletions EShoppingTutorial/EShoppingTutorial.ArchTests/DomainRulesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Reflection;
namespace EShoppingTutorial.ArchTests;

public class DomainRulesTests
{
[Fact]
public void EntityProperties_Should_Not_Have_Public_Setters_Except_Init()
{
// Arrange
var domainAssembly = typeof(Core.Domain.Entities.Order).Assembly;

// Act
var entityTypes = Types.InAssembly(domainAssembly)
.That()
.ResideInNamespace("EShoppingTutorial.Core.Domain.Entities")
.And()
.AreClasses()
.GetTypes();

var failingProperties = new List<string>();

foreach (var type in entityTypes)
{
var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);

foreach (var property in properties)
{
// If there is no setter, it's a read-only property (which is fine)
if (!property.CanWrite || property.SetMethod == null) continue;

// Check if the setter is public
if (property.SetMethod.IsPublic)
{
// Is it a standard 'public set' or an 'init' setter?
// init setters have a special 'IsExternalInit' modifier on the return type
var isInitOnly = property.SetMethod.ReturnParameter
.GetRequiredCustomModifiers()
.Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit");

if (!isInitOnly)
{
failingProperties.Add($"{type.Name}.{property.Name}");
}
}
}
}

// Assert
failingProperties.Should().BeEmpty(
$"because Domain Entities should use private/protected setters for state changes to ensure encapsulation. " +
$"Standard public setters found (use 'init' for immutable properties instead): {string.Join(", ", failingProperties)}");
}

[Fact]
public void ValueObjects_Should_Be_Immutable()
{
// All types in ValueObjects namespace should be records or have private setters
var result = Types.InAssembly(typeof(Core.Domain.Entities.Order).Assembly)
.That()
.ResideInNamespace("EShoppingTutorial.Core.Domain.ValueObjects")
.Should()
.BeSealed() // Good practice for Value Objects
.GetResult();

// Assert
// We collect the names of types that failed the rule
var failingTypes = string.Join(", ", result.FailingTypeNames ?? Enumerable.Empty<string>());

result.IsSuccessful.Should().BeTrue(
$"because all Value Objects should be sealed to ensure correct equality behavior. Failing types: {failingTypes}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\EShoppingTutorial.Core.Application\EShoppingTutorial.Core.Application.csproj" />
<ProjectReference Include="..\EShoppingTutorial.Core.Persistence\EShoppingTutorial.Core.Persistence.csproj" />
<ProjectReference Include="..\EShoppingTutorial.Core\EShoppingTutorial.Core.Domain.csproj" />
<ProjectReference Include="..\EShoppingTutorial.Infrastructure\EShoppingTutorial.Infrastructure.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
2 changes: 2 additions & 0 deletions EShoppingTutorial/EShoppingTutorial.ArchTests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using NetArchTest.Rules;
global using FluentAssertions;
37 changes: 37 additions & 0 deletions EShoppingTutorial/EShoppingTutorial.ArchTests/LayerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace EShoppingTutorial.ArchTests;

public class LayerTests
{
private const string ApplicationNamespace = "EShoppingTutorial.Core.Application";
private const string InfrastructureNamespace = "EShoppingTutorial.Infrastructure";
private const string PersistenceNamespace = "EShoppingTutorial.Core.Persistence";

[Fact]
public void Domain_Should_Not_Have_Dependency_On_Other_Layers()
{
var assembly = typeof(Core.Domain.Entities.Order).Assembly;

var result = Types.InAssembly(assembly)
.ShouldNot()
.HaveDependencyOnAll(ApplicationNamespace, InfrastructureNamespace, PersistenceNamespace)
.GetResult();

result.IsSuccessful.Should().BeTrue($"Domain layer must be pure. Failures: {GetFailingTypes(result)}");
}

[Fact]
public void Application_Should_Not_Have_Dependency_On_Infrastructure_Or_Persistence()
{
var assembly = typeof(Core.Application.Orders.Commands.CreateOrder.CreateOrderCommand).Assembly;

var result = Types.InAssembly(assembly)
.ShouldNot()
.HaveDependencyOnAll(InfrastructureNamespace, PersistenceNamespace)
.GetResult();

result.IsSuccessful.Should().BeTrue($"Application layer should only depend on Domain. Failures: {GetFailingTypes(result)}");
}

private string GetFailingTypes(TestResult result) =>
string.Join(", ", result.FailingTypeNames ?? Enumerable.Empty<string>());
}
49 changes: 49 additions & 0 deletions EShoppingTutorial/EShoppingTutorial.ArchTests/RepositoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace EShoppingTutorial.ArchTests;

public class RepositoryTests
{
[Fact]
public void RepositoryInterfaces_Should_Be_In_Domain_Repositories_Namespace()
{
var _domainAssembly = typeof(Core.Domain.IUnitOfWork).Assembly;

// Rule: If it's an interface and ends in 'Repository', it MUST be in the correct namespace
var result = Types.InAssembly(_domainAssembly)
.That()
.AreClasses()
.And()
.HaveNameEndingWith("Repository")
.Should()
// This is a "fail-fast" rule:
// We say they should be in a namespace that DOES NOT exist in this project
.ResideInNamespace("EShoppingTutorial.Core.Persistence")
.GetResult();

// Assert
result.IsSuccessful.Should().BeTrue(
$"Architecture Violation: Domain should only contain interfaces. " +
$"Implementations Repository Classes found: {string.Join(", ", result.FailingTypeNames ?? [])}");
}

[Fact]
public void Persistence_Should_Not_Contain_Any_Interfaces()
{
// Arrange
var persistenceAssembly = typeof(Core.Persistence.UnitOfWork).Assembly;

// Act: Scan for ANY interface in the Persistence project
var result = Types.InAssembly(persistenceAssembly)
.That()
.AreInterfaces()
.Should()
// This is a "fail-fast" rule:
// We say they should be in a namespace that DOES NOT exist in this project
.ResideInNamespace("EShoppingTutorial.Core.Domain")
.GetResult();

// Assert
result.IsSuccessful.Should().BeTrue(
$"Architecture Violation: Persistence should only contain implementations (Classes). " +
$"Interfaces found: {string.Join(", ", result.FailingTypeNames ?? [])}");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace EShoppingTutorial.Core.Application.Orders.Commands.CreateOrder;

public class CreateOrderHandler(IUnitOfWork unitOfWork,
internal class CreateOrderHandler(IUnitOfWork unitOfWork,
ITaxCalculationService taxCalculationService,
IMapper mapper)
: IRequestHandler<CreateOrderCommand, int>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace EShoppingTutorial.Core.Application.Orders.Commands.DeleteOrder;

public class DeleteOrderHandler(IUnitOfWork unitOfWork) : IRequestHandler<DeleteOrderCommand, bool>
internal class DeleteOrderHandler(IUnitOfWork unitOfWork) : IRequestHandler<DeleteOrderCommand, bool>
{
public async Task<bool> Handle(DeleteOrderCommand request, CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace EShoppingTutorial.Core.Application.Orders.Queries.GetAllOrders;

public class GetAllOrdersHandler(IUnitOfWork unitOfWork, IMapper mapper)
internal class GetAllOrdersHandler(IUnitOfWork unitOfWork, IMapper mapper)
: IRequestHandler<GetAllOrdersQuery, QueryResult<OrderViewModel>>
{
public async Task<QueryResult<OrderViewModel>> Handle(GetAllOrdersQuery request, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace EShoppingTutorial.Core.Application.Orders.Queries.GetOrderById;

public class GetOrderByIdHandler(IUnitOfWork unitOfWork, IMapper mapper) : IRequestHandler<GetOrderByIdQuery, OrderViewModel?>
internal class GetOrderByIdHandler(IUnitOfWork unitOfWork, IMapper mapper) : IRequestHandler<GetOrderByIdQuery, OrderViewModel?>
{
public async Task<OrderViewModel?> Handle(GetOrderByIdQuery request, CancellationToken ct)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace EShoppingTutorial.Core.Application.Orders.Queries.GetPagedOrders;

public class GetPagedOrdersHandler(IUnitOfWork unitOfWork, IMapper mapper)
internal class GetPagedOrdersHandler(IUnitOfWork unitOfWork, IMapper mapper)
: IRequestHandler<GetPagedOrdersQuery, QueryResult<OrderViewModel>>
{
public async Task<QueryResult<OrderViewModel>> Handle(GetPagedOrdersQuery request, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>EShoppingTutorial.UnitTests</_Parameter1>
</AssemblyAttribute>

<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>EShoppingTutorial.ArchTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion EShoppingTutorial/EShoppingTutorial.Core/Entities/Order.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class Order : IAggregateRoot

// Expose as IReadOnlyCollection to prevent external tampering
private readonly List<OrderItem> _orderItems = [];
public ICollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
public IEnumerable<OrderItem> OrderItems => _orderItems.AsReadOnly();

// EF Core requires a parameterless constructor
protected Order() { }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace EShoppingTutorial.Core.Domain.ValueObjects;

public record Address
public sealed record Address
{
public string Street { get; init; } = default!;
public string City { get; init; } = default!;
Expand All @@ -8,7 +8,7 @@
public string ZipCode { get; init; } = default!;

// EF Core requires a parameterless constructor
protected Address() { }

Check warning on line 11 in EShoppingTutorial/EShoppingTutorial.Core/ValueObjects/Address.cs

View workflow job for this annotation

GitHub Actions / build

'Address.Address()': new protected member declared in sealed type

Check warning on line 11 in EShoppingTutorial/EShoppingTutorial.Core/ValueObjects/Address.cs

View workflow job for this annotation

GitHub Actions / build

'Address.Address()': new protected member declared in sealed type

public Address(string street, string city, string country, string zipCode)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
namespace EShoppingTutorial.Core.Domain.ValueObjects;

public record Price
public sealed record Price
{
public decimal Value { get; init; }
public Currency Currency { get; init; } = Currency.Unspecified;

// EF Core requires a parameterless constructor
protected Price() { }

Check warning on line 9 in EShoppingTutorial/EShoppingTutorial.Core/ValueObjects/Price.cs

View workflow job for this annotation

GitHub Actions / build

'Price.Price()': new protected member declared in sealed type

Check warning on line 9 in EShoppingTutorial/EShoppingTutorial.Core/ValueObjects/Price.cs

View workflow job for this annotation

GitHub Actions / build

'Price.Price()': new protected member declared in sealed type

public Price(decimal value, Currency currency)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
namespace EShoppingTutorial.Core.Domain.ValueObjects;

[NotMapped]
public record OrderId(int Value);
public sealed record OrderId(int Value);

[NotMapped]
public record OrderItemId(int Value);
public sealed record OrderItemId(int Value);

[NotMapped]
public record ProductId(int Value);
public sealed record ProductId(int Value);

[NotMapped]
public record CustomerId(int Value);
public sealed record CustomerId(int Value);
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ public class OrderUnitTests
[Test]
public void InstantiatingOrder_WithEmptyOrderItems_ExpectsBusinessRuleBrokenException()
{
// arrange & act
static void testDelegate() => new Order(new CustomerId(1), Mock.Of<Address>(), orderItems: []);
var dummyAddress = new Address("Street", "City", "Country", "ZipCode");
var customerId = new CustomerId(1);

// assert
// Act
TestDelegate testDelegate = () => new Order(customerId, dummyAddress, orderItems: []);

// Assert
var ex = Assert.Throws<BusinessRuleBrokenException>(testDelegate);
Assert.That(ex.Message.Contains("Order must have at least one item", StringComparison.CurrentCultureIgnoreCase));
ex.Message.Should().Contain("Order must have at least one item");
}

[Test]
Expand All @@ -22,10 +25,12 @@ public void OrderItemsProperty_AddingOrderItemToReadOnlyCollection_ExpectsNotSup
var priceMock = new Price(1, Currency.USD);
var orderItemMock = new OrderItem(productIdMock, priceMock);

var order = new Order(new CustomerId(1), Mock.Of<Address>(), [orderItemMock]);
var dummyAddress = new Address("Street", "City", "Country", "ZipCode");
var order = new Order(new CustomerId(1), dummyAddress, [orderItemMock]);

// act
void testDelegate() => order.OrderItems.Add(orderItemMock);
var orderItems = order.OrderItems as ICollection<OrderItem>;
void testDelegate() => orderItems.Add(orderItemMock);

// assert
var ex = Assert.Throws<NotSupportedException>(testDelegate);
Expand Down
Loading
Loading