Time Estimate: 40 minutes
By the end of this lab, you will:
- Create complex aggregates with business rules
- Implement a command with validation
- Work with entity relationships in EF Core
- Handle transactional operations
- Write unit tests for business logic
- Build a POST API endpoint
In this lab, we'll implement the complete "Place Order" feature. This is more complex than listing items because it involves:
- Multiple related entities (Order, OrderItem)
- Business rule validation
- Transactional operations (must succeed or fail as a unit)
- Inventory management
- Data consistency
First, let's see what we're replacing.
- Open
legacy/TightlyCoupled.WebShop/Controllers/ShoppingCartController.cs
Look at the Checkout method. You'll find:
- 1000+ lines of mixed concerns
- Direct database access
- File system operations
- Email sending
- Hard-coded business rules
- No abstraction layers
- Open
legacy/TightlyCoupled.WebShop/Models/Order.cs
Note the properties we'll need:
- UserId
- CustomerAddress
- ShippingOption
- PaymentMethod
- TotalAmount
- OrderItems (collection)
Our goal: Implement this cleanly with proper separation and testability!
Let's build our domain model. In Clean Architecture, related entities are grouped into Aggregates.
In CleanArchWebShop.Core, create folder: OrderAggregate
Create OrderItem.cs:
namespace CleanArchWebShop.Core.OrderAggregate;
public class OrderItem : EntityBase
{
public int OrderId { get; private set; }
public int ItemId { get; private set; }
public string ItemName { get; private set; } = string.Empty;
public int Quantity { get; private set; }
public decimal UnitPrice { get; private set; }
public decimal TotalPrice => Quantity * UnitPrice;
// EF Core requires a parameterless constructor
private OrderItem() { }
public OrderItem(int itemId, string itemName, int quantity, decimal unitPrice)
{
Guard.Against.NegativeOrZero(itemId, nameof(itemId));
Guard.Against.NullOrWhiteSpace(itemName, nameof(itemName));
Guard.Against.NegativeOrZero(quantity, nameof(quantity));
Guard.Against.Negative(unitPrice, nameof(unitPrice));
ItemId = itemId;
ItemName = itemName;
Quantity = quantity;
UnitPrice = unitPrice;
}
}Create Order.cs:
using Ardalis.GuardClauses;
namespace CleanArchWebShop.Core.OrderAggregate;
public class Order : EntityBase, IAggregateRoot
{
public string UserId { get; private set; } = string.Empty;
public string CustomerAddress { get; private set; } = string.Empty;
public string ShippingOption { get; private set; } = string.Empty;
public string PaymentMethod { get; private set; } = string.Empty;
public decimal TotalAmount { get; private set; }
public DateTime OrderDate { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderItem> _orderItems = new();
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
// EF Core requires a parameterless constructor
private Order() { }
public Order(string userId, string customerAddress, string shippingOption, string paymentMethod)
{
UserId = Guard.Against.NullOrWhiteSpace(userId, nameof(userId));
CustomerAddress = Guard.Against.NullOrWhiteSpace(customerAddress, nameof(customerAddress));
ShippingOption = Guard.Against.NullOrWhiteSpace(shippingOption, nameof(shippingOption));
PaymentMethod = Guard.Against.NullOrWhiteSpace(paymentMethod, nameof(paymentMethod));
OrderDate = DateTime.UtcNow;
Status = OrderStatus.Pending;
TotalAmount = 0;
}
public void AddItem(int itemId, string itemName, int quantity, decimal unitPrice)
{
// Business Rule: Can't add items to a completed order
if (Status == OrderStatus.Completed || Status == OrderStatus.Cancelled)
{
throw new InvalidOperationException($"Cannot add items to an order with status {Status}");
}
// Business Rule: Check if item already exists in order
var existingItem = _orderItems.FirstOrDefault(i => i.ItemId == itemId);
if (existingItem != null)
{
throw new InvalidOperationException($"Item {itemName} is already in the order");
}
var orderItem = new OrderItem(itemId, itemName, quantity, unitPrice);
_orderItems.Add(orderItem);
RecalculateTotal();
}
public void RemoveItem(int itemId)
{
// Business Rule: Can't modify completed orders
if (Status == OrderStatus.Completed || Status == OrderStatus.Cancelled)
{
throw new InvalidOperationException($"Cannot remove items from an order with status {Status}");
}
var item = _orderItems.FirstOrDefault(i => i.ItemId == itemId);
if (item == null)
{
throw new InvalidOperationException($"Item with id {itemId} not found in order");
}
_orderItems.Remove(item);
RecalculateTotal();
}
public void Complete()
{
// Business Rule: Order must have items
if (!_orderItems.Any())
{
throw new InvalidOperationException("Cannot complete an order with no items");
}
// Business Rule: Order must be pending
if (Status != OrderStatus.Pending)
{
throw new InvalidOperationException($"Cannot complete an order with status {Status}");
}
Status = OrderStatus.Completed;
}
public void Cancel()
{
// Business Rule: Can't cancel completed orders
if (Status == OrderStatus.Completed)
{
throw new InvalidOperationException("Cannot cancel a completed order");
}
Status = OrderStatus.Cancelled;
}
private void RecalculateTotal()
{
TotalAmount = _orderItems.Sum(item => item.TotalPrice);
}
}Create OrderStatus.cs:
namespace CleanArchWebShop.Core.OrderAggregate;
public enum OrderStatus
{
Pending = 0,
Completed = 1,
Cancelled = 2,
Shipped = 3,
Delivered = 4
}Key Points:
- ✅ Order is an Aggregate Root - entry point for the aggregate
- ✅ OrderItem is part of the aggregate - can only be accessed through Order
- ✅ Private setters protect invariants
- ✅ Business rules are enforced in the entity (e.g., can't add items to completed orders)
- ✅
RecalculateTotal()keeps total in sync automatically - ✅ Read-only collection prevents external modification of items
Now let's define the application operation.
In CleanArchWebShop.UseCases, create folder: Orders/PlaceOrder
Create PlaceOrderCommand.cs:
using Ardalis.Result;
namespace CleanArchWebShop.UseCases.Orders.PlaceOrder;
public record PlaceOrderCommand(
string UserId,
string CustomerAddress,
string ShippingOption,
string PaymentMethod,
List<OrderItemRequest> Items
) : IRequest<Result<int>>;
public record OrderItemRequest(
int ItemId,
string ItemName,
decimal UnitPrice,
int Quantity
);Note: For this simplified lab, we're passing item details directly in the request. In a real application, you would:
- Look up items from a Catalog/Item repository
- Validate stock availability
- Get current prices from the database
Why return Result<int>?
intis the Order IDResult<T>handles success/failure explicitly- No exceptions for business rule violations
Create PlaceOrderCommandHandler.cs:
using CleanArchWebShop.Core.OrderAggregate;
namespace CleanArchWebShop.UseCases.Orders.PlaceOrder;
public class PlaceOrderCommandHandler(IRepository<Order> orderRepository)
: IRequestHandler<PlaceOrderCommand, Result<int>>
{
public async ValueTask<Result<int>> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
{
// Validate that we have items
if (request.Items == null || !request.Items.Any())
{
return Result<int>.Error("Order must contain at least one item");
}
// Create the order
var order = new Order(
request.UserId,
request.CustomerAddress,
request.ShippingOption,
request.PaymentMethod
);
// Add items to the order
foreach (var requestItem in request.Items)
{
try
{
order.AddItem(
requestItem.ItemId,
requestItem.ItemName,
requestItem.Quantity,
requestItem.UnitPrice
);
}
catch (InvalidOperationException ex)
{
return Result<int>.Error(ex.Message);
}
}
// Complete the order
try
{
order.Complete();
}
catch (InvalidOperationException ex)
{
return Result<int>.Error(ex.Message);
}
// Save the order
await orderRepository.AddAsync(order, cancellationToken);
return Result<int>.Success(order.Id);
}
}What's happening:
- Validate that items are provided
- Create the order with user details
- Add each item to the order (business rules enforced in Order entity)
- Complete the order (validates order has items)
- Save to repository
- Return order ID or error
Simplified Approach: This lab focuses on the architecture patterns. In production, you would:
- Validate items exist in catalog
- Check stock availability
- Reduce inventory
- Handle payment processing
- Send confirmation emails
Discussion: Notice how business rules are in the Order entity (AddItem, Complete), while orchestration is in the handler. This separation makes both testable!
Now let's set up the database mapping.
In Infrastructure/Data/AppDbContext.cs, add:
public DbSet<Order> Orders => Set<Order>();Create Infrastructure/Data/Config/OrderConfiguration.cs:
using CleanArchWebShop.Core.OrderAggregate;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CleanArchWebShop.Infrastructure.Data.Config;
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.Property(o => o.UserId)
.IsRequired()
.HasMaxLength(450);
builder.Property(o => o.CustomerAddress)
.IsRequired()
.HasMaxLength(500);
builder.Property(o => o.ShippingOption)
.IsRequired()
.HasMaxLength(100);
builder.Property(o => o.PaymentMethod)
.IsRequired()
.HasMaxLength(50);
builder.Property(o => o.TotalAmount)
.HasPrecision(18, 2)
.IsRequired();
builder.Property(o => o.Status)
.IsRequired();
// IMPORTANT: Use OwnsMany for owned entities (not HasMany)
// OrderItems are owned by Order and should not have independent lifecycle
builder.OwnsMany(o => o.OrderItems, oi =>
{
oi.Property(item => item.ItemName)
.IsRequired()
.HasMaxLength(200);
oi.Property(item => item.Quantity)
.IsRequired();
oi.Property(item => item.UnitPrice)
.HasPrecision(18, 2)
.IsRequired();
// TotalPrice is a calculated property, doesn't need to be stored
oi.Ignore(item => item.TotalPrice);
});
// Index for common queries
builder.HasIndex(o => o.UserId);
builder.HasIndex(o => o.OrderDate);
}
}OwnsMany, not HasMany. Owned entities:
- Have no independent identity outside their owner
- Cannot be queried directly via DbSet
- Are always loaded with their owner
- Are automatically deleted when the owner is deleted
Using HasMany would create a separate table with unnecessary foreign key relationships.
Note: Since we're using OwnsMany in OrderConfiguration, we don't need a separate configuration file for OrderItem. The configuration is done inline within the OwnsMany call above. This is the recommended approach for owned entities in EF Core.
Let's expose the feature through an API using FastEndpoints.
In CleanArchWebShop.Web, create folder: Cart (if it doesn't exist from Lab 2)
Create Cart/PlaceOrder.cs:
using CleanArchWebShop.UseCases.Orders.PlaceOrder;
using FastEndpoints;
using Mediator;
namespace CleanArchWebShop.Web.Cart;
public class PlaceOrder(IMediator mediator) : Endpoint<PlaceOrderCommand>
{
public override void Configure()
{
Post("/orders");
AllowAnonymous();
Summary(s =>
{
s.Summary = "Place a new order";
s.Description = "Creates a new order with the provided items";
s.Response(201, "Order created successfully", example: 123);
s.Response(400, "Invalid request or business rule violation");
});
}
public override async Task HandleAsync(PlaceOrderCommand req, CancellationToken ct)
{
var result = await mediator.Send(req, ct);
if (result.IsSuccess)
{
HttpContext.Response.StatusCode = 201;
await SendAsync(result.Value, cancellation: ct);
return;
}
await SendErrorsAsync(cancellation: ct);
}
}Key Points:
Endpoint<PlaceOrderCommand>- takes command as request, no explicit response DTOPost("/orders")- HTTP POST to /orders- Status 201 for successful creation
SendErrorsAsync()automatically formats validation errors- Mediator handles the command and returns Result
FastEndpoints Benefits:
- Clean, focused endpoint classes
- No controller bloat
- Built-in validation support
- Automatic OpenAPI/Swagger generation
- Easy to test
API Design:
- POST creates a resource → Returns 201 Created
- Location header points to the new resource
- Different status codes for different errors
- Returns the order ID on success
Let's update the database schema.
cd CleanArchWebShop
# Create migration
dotnet ef migrations add AddOrders --project src\CleanArchWebShop.Infrastructure --startup-project src\CleanArchWebShop.Web
# Apply migration
dotnet ef database update --project src\CleanArchWebShop.Infrastructure --startup-project src\CleanArchWebShop.Web# Delete the database file
Remove-Item src\CleanArchWebShop.Web\CleanArchWebShop.db
# Reapply all migrations
dotnet ef database update --project src\CleanArchWebShop.Infrastructure --startup-project src\CleanArchWebShop.WebTime to test placing an order!
Option 1: Use a Background Job (Recommended for testing)
# Start the app in the background
$job = Start-Job -ScriptBlock { Set-Location "C:\path\to\CleanArchWebShop\src\CleanArchWebShop.Web"; dotnet run }
# Wait a moment for it to start
Start-Sleep -Seconds 10
# Now you can run curl commands in the same terminal
curl -k https://localhost:57679/cart/testuser
# When done, stop the job
Stop-Job $job
Remove-Job $jobOption 2: Use Multiple Terminals
- Terminal 1: Run
dotnet run --project src\CleanArchWebShop.Web - Terminal 2: Run your curl commands
- Place an order using curl:
curl -k -X POST https://localhost:57679/orders `
-H "Content-Type: application/json" `
-d '{
"userId": "testuser",
"customerAddress": "123 Main St, Springfield, IL 62701",
"shippingOption": "Standard",
"paymentMethod": "Credit Card",
"items": [
{
"itemId": 1,
"itemName": "Laptop",
"unitPrice": 999.99,
"quantity": 1
},
{
"itemId": 2,
"itemName": "Mouse",
"unitPrice": 29.99,
"quantity": 2
}
]
}'- Expected response:
1
And status code 201 Created.
- Test error scenarios:
# Order with no items
curl -k -X POST https://localhost:57679/orders `
-H "Content-Type: application/json" `
-d '{
"userId": "testuser",
"customerAddress": "123 Main St",
"shippingOption": "Standard",
"paymentMethod": "Credit Card",
"items": []
}'Should return 400 Bad Request with error message about requiring items.
# Order with duplicate items
curl -k -X POST https://localhost:57679/orders `
-H "Content-Type: application/json" `
-d '{
"userId": "testuser",
"customerAddress": "123 Main St",
"shippingOption": "Standard",
"paymentMethod": "Credit Card",
"items": [
{ "itemId": 1, "itemName": "Laptop", "unitPrice": 999.99, "quantity": 1 },
{ "itemId": 1, "itemName": "Laptop", "unitPrice": 999.99, "quantity": 1 }
]
}'Should return 400 Bad Request with error about duplicate items.
Now let's write tests for our business logic. This is where Clean Architecture really shines!
The template uses:
- xUnit - Test framework
- NSubstitute - Mocking library
- Shouldly - Fluent assertions
In CleanArchWebShop.UnitTests, create folder: Core/OrderAggregate
Create Core/OrderAggregate/OrderTests.cs:
using CleanArchWebShop.Core.OrderAggregate;
using Xunit;
namespace CleanArchWebShop.UnitTests.Core.OrderAggregate;
public class OrderTests
{
[Fact]
public void Constructor_WithValidData_CreatesOrder()
{
// Arrange & Act
var order = new Order(
"user123",
"123 Main St",
"Standard",
"Credit Card"
);
// Assert
Assert.Equal("user123", order.UserId);
Assert.Equal("123 Main St", order.CustomerAddress);
Assert.Equal(OrderStatus.Pending, order.Status);
Assert.Equal(0, order.TotalAmount);
}
[Fact]
public void AddItem_ValidItem_AddsItemAndCalculatesTotal()
{
// Arrange
var order = new Order("user123", "123 Main St", "Standard", "Credit Card");
// Act
order.AddItem(1, "Laptop", 2, 999.99m);
// Assert
Assert.Single(order.OrderItems);
Assert.Equal(1999.98m, order.TotalAmount);
}
[Fact]
public void AddItem_DuplicateItem_ThrowsException()
{
// Arrange
var order = new Order("user123", "123 Main St", "Standard", "Credit Card");
order.AddItem(1, "Laptop", 2, 999.99m);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(
() => order.AddItem(1, "Laptop", 1, 999.99m)
);
Assert.Contains("already in the order", exception.Message);
}
[Fact]
public void Complete_WithItems_CompletesOrder()
{
// Arrange
var order = new Order("user123", "123 Main St", "Standard", "Credit Card");
order.AddItem(1, "Laptop", 2, 999.99m);
// Act
order.Complete();
// Assert
Assert.Equal(OrderStatus.Completed, order.Status);
}
[Fact]
public void Complete_WithoutItems_ThrowsException()
{
// Arrange
var order = new Order("user123", "123 Main St", "Standard", "Credit Card");
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(
() => order.Complete()
);
Assert.Contains("no items", exception.Message);
}
[Fact]
public void AddItem_ToCompletedOrder_ThrowsException()
{
// Arrange
var order = new Order("user123", "123 Main St", "Standard", "Credit Card");
order.AddItem(1, "Laptop", 1, 999.99m);
order.Complete();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(
() => order.AddItem(2, "Mouse", 1, 29.99m)
);
Assert.Contains("Cannot add items", exception.Message);
}
[Fact]
public void RemoveItem_ExistingItem_RemovesAndRecalculates()
{
// Arrange
var order = new Order("user123", "123 Main St", "Standard", "Credit Card");
order.AddItem(1, "Laptop", 2, 999.99m);
order.AddItem(2, "Mouse", 1, 29.99m);
// Act
order.RemoveItem(2);
// Assert
Assert.Single(order.OrderItems);
Assert.Equal(1999.98m, order.TotalAmount);
}
}cd CleanArchWebShop.UnitTests
dotnet testAll tests should pass! ✅
Discussion:
- No mocking needed for domain logic tests
- Fast execution (no database)
- Clear business rule validation
- Easy to add more test cases
Now let's test the use case with mocked dependencies.
In CleanArchWebShop.UnitTests, create folder: UseCases/Orders
Create UseCases/Orders/PlaceOrderCommandHandlerTests.cs:
using CleanArchWebShop.Core.OrderAggregate;
using CleanArchWebShop.UseCases.Orders.PlaceOrder;
namespace CleanArchWebShop.UnitTests.UseCases.Orders;
public class PlaceOrderCommandHandlerTests
{
private readonly IRepository<Order> _repository = Substitute.For<IRepository<Order>>();
private readonly PlaceOrderCommandHandler _handler;
public PlaceOrderCommandHandlerTests()
{
// Configure the repository to return the order that was passed in
// Note: AddAsync returns Task<T>, not Task
_repository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.Returns(callInfo => Task.FromResult(callInfo.Arg<Order>()));
_handler = new PlaceOrderCommandHandler(_repository);
}
[Fact]
public async Task Handle_ReturnsSuccess_WhenOrderIsValid()
{
// Arrange
var command = new PlaceOrderCommand(
UserId: "testuser",
CustomerAddress: "123 Main St",
ShippingOption: "Standard",
PaymentMethod: "Credit Card",
Items: new List<OrderItemRequest>
{
new(ItemId: 1, ItemName: "Laptop", UnitPrice: 999.99m, Quantity: 1)
}
);
Order? capturedOrder = null;
_repository.AddAsync(Arg.Do<Order>(o => capturedOrder = o), Arg.Any<CancellationToken>())
.Returns(callInfo => Task.FromResult(callInfo.Arg<Order>()));
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.ShouldBeTrue();
await _repository.Received(1).AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
capturedOrder.ShouldNotBeNull();
capturedOrder.UserId.ShouldBe("testuser");
capturedOrder.CustomerAddress.ShouldBe("123 Main St");
capturedOrder.OrderItems.Count.ShouldBe(1);
capturedOrder.Status.ShouldBe(OrderStatus.Completed);
capturedOrder.TotalAmount.ShouldBe(999.99m);
}
[Fact]
public async Task Handle_ReturnsError_WhenNoItemsProvided()
{
// Arrange
var command = new PlaceOrderCommand(
UserId: "testuser",
CustomerAddress: "123 Main St",
ShippingOption: "Standard",
PaymentMethod: "Credit Card",
Items: new List<OrderItemRequest>()
);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("at least one item"));
await _repository.DidNotReceive().AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_CalculatesTotalCorrectly_WithMultipleItems()
{
// Arrange
var command = new PlaceOrderCommand(
UserId: "testuser",
CustomerAddress: "123 Main St",
ShippingOption: "Express",
PaymentMethod: "PayPal",
Items: new List<OrderItemRequest>
{
new(ItemId: 1, ItemName: "Laptop", UnitPrice: 999.99m, Quantity: 1),
new(ItemId: 2, ItemName: "Mouse", UnitPrice: 29.99m, Quantity: 2),
new(ItemId: 3, ItemName: "Keyboard", UnitPrice: 89.99m, Quantity: 1)
}
);
Order? capturedOrder = null;
_repository.AddAsync(Arg.Do<Order>(o => capturedOrder = o), Arg.Any<CancellationToken>())
.Returns(callInfo => Task.FromResult(callInfo.Arg<Order>()));
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.ShouldBeTrue();
capturedOrder.ShouldNotBeNull();
capturedOrder.OrderItems.Count.ShouldBe(3);
// 999.99 + (29.99 * 2) + 89.99 = 1149.96
capturedOrder.TotalAmount.ShouldBe(1149.96m);
}
[Fact]
public async Task Handle_ReturnsError_WhenDuplicateItemsProvided()
{
// Arrange
var command = new PlaceOrderCommand(
UserId: "testuser",
CustomerAddress: "123 Main St",
ShippingOption: "Standard",
PaymentMethod: "Credit Card",
Items: new List<OrderItemRequest>
{
new(ItemId: 1, ItemName: "Laptop", UnitPrice: 999.99m, Quantity: 1),
new(ItemId: 1, ItemName: "Laptop", UnitPrice: 999.99m, Quantity: 1) // Duplicate
}
);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("already in the order"));
await _repository.DidNotReceive().AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}
}Key Testing Concepts:
-
NSubstitute Mocking:
_repository = Substitute.For<IRepository<Order>>();
Creates a mock that you can configure and verify
-
Return Value Configuration:
_repository.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>()) .Returns(callInfo => Task.FromResult(callInfo.Arg<Order>()));
Important:
AddAsyncreturnsTask<T>, notTask. UseTask.FromResult(). -
Argument Capture:
Order? capturedOrder = null; _repository.AddAsync(Arg.Do<Order>(o => capturedOrder = o), ...)
Captures the argument for detailed assertions
-
Shouldly Assertions:
result.IsSuccess.ShouldBeTrue(); capturedOrder.TotalAmount.ShouldBe(999.99m);
Readable, fluent assertion syntax
-
Verification:
await _repository.Received(1).AddAsync(...); await _repository.DidNotReceive().AddAsync(...);
Verify method calls and their frequency
cd tests\CleanArchWebShop.UnitTests
dotnet test --filter "FullyQualifiedName~PlaceOrderCommandHandlerTests"Expected output:
Test summary: total: 4, failed: 0, succeeded: 4, skipped: 0
All should pass! ✅
By now, you should have:
- ✅ Created Order and OrderItem entities with business rules
- ✅ Implemented the PlaceOrderCommand and handler
- ✅ Configured EF Core relationships with OwnsMany
- ✅ Created a FastEndpoint for placing orders
- ✅ Tested the feature end-to-end
- ✅ Written comprehensive unit tests
- ✅ Validated error handling (empty orders, duplicates)
| Aspect | Legacy | Clean Architecture |
|---|---|---|
| Lines in Controller | 1000+ | ~30 (FastEndpoint) |
| Business Logic Location | Controller | Order entity + Handler |
| Validation | Scattered, inconsistent | Centralized in entity |
| Error Handling | Try-catch, swallowed errors | Result pattern |
| Stock Management | Direct SQL, file operations | (Simplified for lab) |
| Testability | Impossible without database | Easy with mocks |
| Transaction Handling | Manual, error-prone | Repository handles it |
| Entity Relationships | Loose, error-prone FKs | Clean owned entities |
Try implementing the missing GetOrder endpoint on your own!
Requirements:
- Create
GetOrderQueryin UseCases - Create
GetOrderQueryHandler - Return
OrderDtowith order details - Update
OrdersController.GetOrder()method
Hint (click to expand)
Files to create:
UseCases/Orders/GetOrder/GetOrderQuery.csUseCases/Orders/GetOrder/OrderDto.csUseCases/Orders/GetOrder/GetOrderQueryHandler.cs
Query structure:
public record GetOrderQuery(int OrderId) : IRequest<Result<OrderDto>>;
public record OrderDto(
int Id,
string UserId,
string CustomerAddress,
decimal TotalAmount,
OrderStatus Status,
DateTime OrderDate,
List<OrderItemDto> Items
);
public record OrderItemDto(
string ItemName,
int Quantity,
decimal UnitPrice,
decimal TotalPrice
);Fantastic work! You've implemented a complete complex feature with:
- Domain-driven design
- Business rule validation
- Transaction handling
- Comprehensive testing
In the next lab, we'll refactor anti-patterns from the legacy code and see how to properly handle cross-cutting concerns.
➡️ Continue to Lab 4: Refactor Legacy Code Patterns
Questions? Review the tests or ask your instructor for guidance.