Build a Retry-Safe Product API
Build a clean C# product API that safely handles retried POST requests.
Introduction
30 Second Summary
A slow network can make someone press the same submit button twice. Without a safeguard, one intended product can become two catalog entries.
In this project, you will build CleanCatalog as a local product API with ASP.NET Core. You will expose a duplicate request bug before making product creation retry-safe through clean layers.
What You'll Build
Two identical product creation requests return one shared product ID without increasing the catalog twice.
By the end of this project, you'll have:
- LINQ-backed catalog searches that show the full product list or only products matching a category or price limit. A direct lookup retrieves one product by ID.
- Clean Architecture request flow you can explain from the API boundary to an in-memory repository. Dependency injection keeps the controller focused on HTTP translation.
- Retry-safe POST behavior where two identical requests return the same product ID. Missing Idempotency-Key headers return 400. Reused keys with changed data return 422.
- Secret Mission: A naturally idempotent PUT price update that reaches the same final price after identical repeated requests.
Are there any prerequisites?
This project requires Windows, permission to update Visual Studio 2022 if needed, plus basic familiarity with C# classes and methods.
Support for .NET 9.0 ends on November 10, 2026, so ongoing or production use belongs on .NET 10 after that date.
Before We Start
This is your moment to commit to building CleanCatalog before the hands-on work begins. You will create a product API that protects clients from duplicate products when they retry a POST request.
Set Up the .NET 9 Solution
Toolchain differences can change templates or framework behavior before you reach the API concepts that matter. A pinned environment gives every later result a dependable starting point.
This step pairs Visual Studio 2022 version 17.14.42 with the .NET SDK version 9.0.319. PowerShell then scaffolds a controller-based ASP.NET Core 9.0 API at http://localhost:5055.
Plan for the .NET 9 Support Window
.NET 9 reaches end of support on November 10, 2026. This local project uses the requested version as a learning environment.
Ongoing work belongs on .NET 10 before that date.
In this step, get ready to:
- Verify the pinned Visual Studio version, web development workload, and .NET SDK.
- Scaffold the CleanCatalog solution with a controller-based API.
- Run the API and confirm that its generated weather request succeeds.
Verify the .NET 9 toolchain
The editor version controls which SDK ships with Visual Studio. The web development workload also provides support for the generated .http request file.
- Press the Windows key to open Windows Search.
- Type Visual Studio 2022 in the search bar.
- Press Enter to open Visual Studio 2022.
- Click Help in the top menu bar.
- Select About Microsoft Visual Studio.
- Confirm that the displayed version is 17.14.42.
Why Pin Both Versions?
Visual Studio 17.14.42 includes .NET SDK 9.0.319. Matching the pair keeps the generated project aligned with this walkthrough.
- Press the Windows key to return to Windows Search.
- Type Visual Studio Installer in the search bar.
- Press Enter to open Visual Studio Installer.
- Confirm that the ASP.NET and web development workload is included for Visual Studio 2022.
- Press the Windows key to return to Windows Search.
- Type PowerShell in the search bar.
- Press Enter to open PowerShell.
- Check the SDK selected by the .NET command-line tools by running this command:
dotnet --version
What Does This Command Check?
The command prints the .NET SDK version selected for commands in the current folder. The expected result here is 9.0.319.
✔️ I see version 17.14.42 and SDK 9.0.319
Your editor, workload, and SDK now match the project. That exact toolchain keeps the upcoming API behavior consistent.
ⓧ I see an older version
Visual Studio updates can take several minutes while the installer replaces components. Let the process finish before repeating either version check.
- Return to Visual Studio Installer.
- Update Visual Studio 2022 to 17.14.42.
- Include the ASP.NET and web development workload.
- Complete the installer changes.
- Install .NET SDK 9.0.319 from the .NET 9 download page if the SDK check still shows an older version.
- Repeat the Visual Studio version check.
- Repeat the SDK version command above.
ⓧ The dotnet command is not found
The command is unavailable when Windows cannot find an installed .NET SDK. The Visual Studio workload supplies the required SDK for this project.
- Return to Visual Studio Installer.
- Include the ASP.NET and web development workload for Visual Studio 2022.
- Complete the installer changes.
- Return to PowerShell.
- Repeat the SDK version command above.
- Install .NET SDK 9.0.319 from the .NET 9 download page if the command remains unavailable.
Create the CleanCatalog solution
The global.json file pins the SDK inside the project folder. The remaining commands create the solution plus its controller-based API.
- Return to PowerShell from earlier.
- Move to your Desktop by running this command:
cd ~/Desktop
Why Start on the Desktop?
This location makes the new CleanCatalog folder easy to find from Visual Studio. Every project file stays inside that single folder.
- Create the pinned solution and enter its project folder by running these commands:
mkdir CleanCatalog
cd CleanCatalog
dotnet new globaljson --sdk-version 9.0.319
dotnet new sln --name CleanCatalog
dotnet new webapi --use-controllers --framework net9.0 --no-openapi --no-https --kestrelHttpPort 5055 --output CleanCatalog.Api
dotnet sln CleanCatalog.sln add .\CleanCatalog.Api\CleanCatalog.Api.csproj
What Did These Commands Create?
- The CleanCatalog folder holds every local project artifact.
- The global.json file locks command-line work to SDK 9.0.319.
- The CleanCatalog.sln file groups the projects that make up the solution.
- The CleanCatalog.Api project contains the controller template plus its weather forecast example.
- Switch back to Visual Studio from earlier.
- Use Visual Studio to open CleanCatalog.sln from the Desktop/CleanCatalog folder.
You should see CleanCatalog.Api in the solution tree. The template baseline is now assembled and ready to run.
✔️ Awesome, I've got everything!
Your solution contains the generated API project. Keep every template file unchanged for the first run.
ⓧ I'd like to double check the full code
- Confirm that global.json contains this exact SDK pin:
{
"sdk": {
"version": "9.0.319"
}
}
What Does This File Control?
The SDK resolver reads global.json when commands run inside CleanCatalog. The version pin keeps every project on the intended SDK.
- Confirm that CleanCatalog.Api/CleanCatalog.Api.csproj is a controller-based ASP.NET Core Web SDK project targeting net9.0.
- Confirm that CleanCatalog.Api/CleanCatalog.Api.http contains the generated weather forecast request.
- Confirm that CleanCatalog.Api/Controllers/WeatherForecastController.cs contains the generated WeatherForecast controller.
- Confirm that CleanCatalog.Api/Program.cs contains controller service registration plus controller route mapping.
- Confirm that CleanCatalog.Api/Properties/launchSettings.json uses http://localhost:5055 as its application URL.
- Confirm that CleanCatalog.Api/WeatherForecast.cs contains the generated WeatherForecast model.
Run the template API
The generated weather endpoint gives you a known HTTP request before you replace the template. A successful response proves the project can compile, listen on the pinned port, and answer from Visual Studio.
The run command occupies the current PowerShell session while the API is active. Ctrl+C stops it when you finish your checks.
Before you run the API, do you expect the untouched controller template to start successfully with the pinned SDK?
- Start the API from the CleanCatalog folder by running this command:
dotnet run --project .\CleanCatalog.Api\CleanCatalog.Api.csproj
What Does This Command Do?
The --project option selects the API project inside the solution. The launch profile starts its local server at http://localhost:5055.
You should see the terminal confirm that the application is listening at http://localhost:5055. Keep that PowerShell process running for the request check.
- Return to Visual Studio.
- Open CleanCatalog.Api/CleanCatalog.Api.http from the solution tree.
- Locate the generated weather forecast request at the top of the file.
Before you send the request, what status do you expect from a running template API?
- Use the request control above the generated weather forecast request to send it.
Visual Studio should show a 200 OK response from http://localhost:5055. Your pinned solution now builds, runs, and answers local requests.
Request Not Reaching the API?
- Confirm that the PowerShell process is still running the API.
- Confirm that the request uses http://localhost:5055.
- Confirm that the generated weather controller remains unchanged.
- Ask for help with the exact symptom you see: Help me diagnose why my CleanCatalog weather request does not return 200 OK.
Your local API baseline is live. Next, you will replace the weather template with products and expose the duplicate behavior that retry protection must solve.
Build the First Endpoint and Trigger a Duplicate
Your verified ASP.NET Core app proves the toolchain works. Its weather route cannot show what happens when a client repeats a product creation request.
This step replaces the sample with a temporary product controller. The deliberately naive POST route creates a fresh product every time so you can see the failure that idempotency must solve.
In this step, get ready to:
- Replace the weather template with a product controller.
- Add temporary in-memory product behavior.
- Replay one creation request to expose the duplicate.
Replace the weather template
The running API still uses the compiled weather template. Stop that process before replacing its source files.
- Return to the PowerShell window from the previous step.
- Press Ctrl+C to stop the API.
PowerShell returns to the prompt inside your CleanCatalog folder.
These two generated files are safe to remove. CleanCatalog no longer needs the weather sample.
- Switch back to Visual Studio.
- Select CleanCatalog.Api/WeatherForecast.cs in the project tree.
- Press the Delete key.
- Select CleanCatalog.Api/Controllers/WeatherForecastController.cs in the project tree.
- Press the Delete key.
The project tree now has no weather model or weather controller. The API is ready for its first product boundary.
- Right-click the CleanCatalog.Api/Controllers folder in the project tree.
- Use the context menu to create a C# class named ProductsController.cs.
- Replace the contents of ProductsController.cs with the code below:
using Microsoft.AspNetCore.Mvc;
namespace CleanCatalog.Api.Controllers;
[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
// Keep products in memory so separate requests share the same temporary catalog.
private static readonly List<Product> _products = new()
{
new Product(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Mechanical Keyboard", "Office", 89.00m),
new Product(Guid.Parse("22222222-2222-2222-2222-222222222222"), "USB-C Hub", "Office", 45.00m),
new Product(Guid.Parse("33333333-3333-3333-3333-333333333333"), "Trail Bottle", "Outdoors", 24.00m)
};
// Expose read endpoints before adding the deliberately naive write.
[HttpGet]
public ActionResult<IReadOnlyList<Product>> GetAll() => Ok(_products);
[HttpGet("{id:guid}")]
public ActionResult<Product> GetById(Guid id)
{
var product = _products.FirstOrDefault(product => product.Id == id);
return product is null ? NotFound() : Ok(product);
}
}
// Define the response model and the incoming creation payload.
public sealed record Product(Guid Id, string Name, string Category, decimal Price);
public sealed record CreateProductCommand(string Name, string Category, decimal Price);
What Does This Controller Do?
- The static _products list keeps the temporary catalog available across separate requests during this process run.
- GetAll() returns every product from the list.
- GetById() searches for one matching GUID.
- Product defines the data returned by the API.
- CreateProductCommand defines the data a future creation request supplies.
- Save ProductsController.cs.
- Return to the existing PowerShell window.
- Start the product API by running this command:
dotnet run --project .\CleanCatalog.Api\CleanCatalog.Api.csproj
What Does This Command Do?
The command rebuilds CleanCatalog.Api from the updated source. It starts the API through the existing HTTP launch profile.
PowerShell shows that the API is listening on http://localhost:5055. Keep this process running while you send requests from Visual Studio.
API Fails to Start?
Check that both weather files are gone from the project tree. Confirm that ProductsController.cs sits inside the Controllers folder.
Compare the braces near GetById() with the code above. A missing brace prevents the project from compiling.
Help me diagnose why my CleanCatalog API does not start after replacing the weather template with ProductsController.cs.
Create the product requests
A Visual Studio .http file keeps several requests beside the API code. Request separators let you run each case independently.
- Return to the open CleanCatalog.Api/CleanCatalog.Api.http tab.
- Replace the generated weather request with the requests below:
@CleanCatalog_Api_HostAddress = http://localhost:5055
GET {{CleanCatalog_Api_HostAddress}}/api/products
Accept: application/json
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
What Does This Request File Prove?
- CleanCatalog_Api_HostAddress gives every request the same local base address.
- The first request reads the current product list.
- The next two requests submit identical product data.
- The repeated body represents a client retry.
- Save CleanCatalog.Api.http.
- Use the run control above the GET /api/products request.
Visual Studio shows a 200 OK response. The JSON contains Mechanical Keyboard, USB-C Hub, and Trail Bottle.
Product List Missing?
Confirm that PowerShell still shows the API running at http://localhost:5055. Check that the first request uses GET with the /api/products route.
Help me debug why GET /api/products does not return the three seeded products.
✔️ Awesome, I've got everything!
Your request file is saved. The first product response confirms that the new controller is handling requests.
ⓧ I'd like to double check the full code
@CleanCatalog_Api_HostAddress = http://localhost:5055
GET {{CleanCatalog_Api_HostAddress}}/api/products
Accept: application/json
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
What Should Match?
Your file should contain one product list request followed by two identical creation requests. Each request section ends before the next ### separator.
Trigger the duplicate
The read routes work. The final temporary route accepts a product payload without tracking whether the client already submitted it.
- Return to the PowerShell window.
- Press Ctrl+C to stop the API.
- Return to ProductsController.cs in Visual Studio.
- Find the controller closing brace directly above the Product record.
- Paste the following method directly above that closing brace:
// Create a fresh product for every request without checking for a retry.
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductCommand command)
{
var product = new Product(Guid.NewGuid(), command.Name, command.Category, command.Price);
_products.Add(product);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
Why Does This Create Duplicates?
- Guid.NewGuid() gives every request a fresh product ID.
- _products.Add() appends that product without checking earlier requests.
- CreatedAtAction() returns the created product with a location for its lookup route.
- The method has no request identity to connect a retry with its first result.
- Save ProductsController.cs.
✔️ Awesome, I've got everything!
The temporary controller now supports listing, lookup, and naive creation.
ⓧ I'd like to double check the full code
using Microsoft.AspNetCore.Mvc;
namespace CleanCatalog.Api.Controllers;
[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
// Keep products in memory so separate requests share the same temporary catalog.
private static readonly List<Product> _products = new()
{
new Product(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Mechanical Keyboard", "Office", 89.00m),
new Product(Guid.Parse("22222222-2222-2222-2222-222222222222"), "USB-C Hub", "Office", 45.00m),
new Product(Guid.Parse("33333333-3333-3333-3333-333333333333"), "Trail Bottle", "Outdoors", 24.00m)
};
// Expose read endpoints before adding the deliberately naive write.
[HttpGet]
public ActionResult<IReadOnlyList<Product>> GetAll() => Ok(_products);
[HttpGet("{id:guid}")]
public ActionResult<Product> GetById(Guid id)
{
var product = _products.FirstOrDefault(product => product.Id == id);
return product is null ? NotFound() : Ok(product);
}
// Create a fresh product for every request without checking for a retry.
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductCommand command)
{
var product = new Product(Guid.NewGuid(), command.Name, command.Category, command.Price);
_products.Add(product);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}
// Define the response model and the incoming creation payload.
public sealed record Product(Guid Id, string Name, string Category, decimal Price);
public sealed record CreateProductCommand(string Name, string Category, decimal Price);
What Should Match?
Your controller should have one static product list. It should also contain two read methods plus the naive creation method.
- Return to the existing PowerShell window.
- Restart the updated API by running this command:
dotnet run --project .\CleanCatalog.Api\CleanCatalog.Api.csproj
What Does This Command Do Now?
The command compiles the new POST action into the API. It starts a fresh in-memory catalog with the three seeded products.
PowerShell shows that CleanCatalog is listening on http://localhost:5055 again.
Seeing a Build Failure?
Confirm that the Create() method sits inside the ProductsController braces. Confirm that both record declarations remain below the class.
Help me fix the build failure after adding the naive Create method to ProductsController.cs.
Before you send the requests, consider whether both responses will carry the same product ID.
- Return to the open CleanCatalog.Api.http tab.
- Use the run control above the first POST /api/products request.
- Use the run control above the second POST /api/products request.
- Use the run control above the GET /api/products request.
Both creation responses show 201 Created. Their product bodies contain different id values.
The final list contains two Desk Lamp entries with different IDs. This duplicate is the intended shortfall of the naive workflow.
What Did the Retry Reveal?
The API treats identical requests as unrelated creation attempts. A client retry can therefore create an extra product even when the client intended one operation.
You now have a concrete failure to preserve while the code moves into clean architecture layers.
You have captured the exact duplicate that retry-safe creation must prevent. Next, you will separate the API into Domain, Application, Infrastructure, and API layers while keeping this failure visible.
Separate the Clean Architecture Layers
Your API now proves the retry problem. Two identical creation requests produce two products with different IDs.
The controller currently owns HTTP translation, business flow, and storage. In this step, you will apply Clean Architecture so each responsibility has a visible boundary. The duplicate behavior stays in place while dependency inversion makes the storage implementation replaceable.
In this step, get ready to:
- Create three class library projects for the inner layers.
- Point every project dependency toward the Domain layer.
- Route API requests through an injected application service.
Scaffold the three inner projects
Each layer becomes its own project targeting net9.0. Separate projects let the compiler enforce which layers can depend on each other.
- Return to the PowerShell panel that is running the API.
- Stop the running API by pressing Ctrl+C.
- Create the three inner projects by running these commands:
dotnet new classlib --framework net9.0 --output CleanCatalog.Domain
dotnet new classlib --framework net9.0 --output CleanCatalog.Application
dotnet new classlib --framework net9.0 --output CleanCatalog.Infrastructure
dotnet sln CleanCatalog.sln add .\CleanCatalog.Domain\CleanCatalog.Domain.csproj
dotnet sln CleanCatalog.sln add .\CleanCatalog.Application\CleanCatalog.Application.csproj
dotnet sln CleanCatalog.sln add .\CleanCatalog.Infrastructure\CleanCatalog.Infrastructure.csproj
What do these commands create?
- The Domain project holds business entities that do not depend on the other projects.
- The Application project holds use cases plus interfaces for outside capabilities.
- The Infrastructure project implements those interfaces with concrete storage behavior.
- The final three commands add the new projects to CleanCatalog.sln.
- Switch back to the existing Visual Studio window.
- Find Class1.cs under CleanCatalog.Domain in Solution Explorer.
- Delete Class1.cs from each of the three new projects.
- Return to PowerShell.
- Create the inward-pointing project references by running these commands:
dotnet add .\CleanCatalog.Application\CleanCatalog.Application.csproj reference .\CleanCatalog.Domain\CleanCatalog.Domain.csproj
dotnet add .\CleanCatalog.Infrastructure\CleanCatalog.Infrastructure.csproj reference .\CleanCatalog.Application\CleanCatalog.Application.csproj
dotnet add .\CleanCatalog.Infrastructure\CleanCatalog.Infrastructure.csproj reference .\CleanCatalog.Domain\CleanCatalog.Domain.csproj
dotnet add .\CleanCatalog.Api\CleanCatalog.Api.csproj reference .\CleanCatalog.Application\CleanCatalog.Application.csproj
dotnet add .\CleanCatalog.Api\CleanCatalog.Api.csproj reference .\CleanCatalog.Infrastructure\CleanCatalog.Infrastructure.csproj
dotnet add .\CleanCatalog.Api\CleanCatalog.Api.csproj reference .\CleanCatalog.Domain\CleanCatalog.Domain.csproj
How do the dependencies point inward?
Domain has no outward project reference. Application can use Domain types without knowing how data is stored.
Infrastructure depends on the abstractions in Application. API sits on the outside so it can assemble the complete application.
- Confirm that the new dependency graph compiles by running this command:
dotnet build .\CleanCatalog.sln
What does this build prove?
The build checks all four projects plus their references. A successful summary proves the new project graph is valid before you move any product behavior into it.
Your first boundary is in place. The solution now contains four compiling projects with dependencies pointing toward Domain.
Project references not building?
Check that PowerShell is still inside the CleanCatalog folder. The solution file must be available from the current location.
Confirm that each project name in the command matches the corresponding folder name.
Need another pair of eyes? Help me diagnose why my CleanCatalog project references do not build.
Move the model and use case inward
The Product record belongs in Domain because every outer layer needs the same product definition. Application then coordinates product operations through an interface instead of choosing a storage class.
Why controllers for this project?
Controllers keep routing plus HTTP response translation at the API boundary. Product behavior can move into Application without carrying web-specific types with it.
This makes each request visibly cross the same boundaries you are building.
- Select the CleanCatalog.Domain project in Solution Explorer.
- Open its context menu.
- Select Add.
- Select New Item.
- Enter Product.cs in the Quick Add dialog.
- Press Enter.
- Replace the generated contents of Product.cs with this record:
namespace CleanCatalog.Domain;
public sealed record Product(Guid Id, string Name, string Category, decimal Price);
What does the Domain record represent?
The immutable Product record defines the data shared by every layer. Its Domain namespace prevents the model from belonging to the web project or the storage implementation.
- Save Product.cs.
- Select the CleanCatalog.Application project in Solution Explorer.
- Open its context menu.
- Select Add.
- Select New Item.
- Enter CreateProductCommand.cs, IProductRepository.cs, ProductService.cs in the Quick Add dialog.
- Press Enter.
You will see all three files under CleanCatalog.Application. Each file owns one application-layer responsibility.
- Replace the contents of CreateProductCommand.cs with this request model:
namespace CleanCatalog.Application;
public sealed record CreateProductCommand(string Name, string Category, decimal Price);
What does the command carry?
The command carries only the values needed to create a product. The API can construct it from JSON without exposing controller types to Application.
- Replace the contents of IProductRepository.cs with this storage contract:
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public interface IProductRepository
{
IReadOnlyList<Product> GetAll();
Product? GetById(Guid id);
Product Create(CreateProductCommand command);
}
What does the repository interface change?
Application now describes the storage operations it needs. It does not name a database or an in-memory collection.
Infrastructure must satisfy this contract. That direction is dependency inversion in practice.
- Save CreateProductCommand.cs.
- Save IProductRepository.cs.
- Check the first application abstractions by running this command:
dotnet build .\CleanCatalog.sln
What does this checkpoint confirm?
The successful build confirms that Application can use Product through its Domain project reference. No storage implementation exists in Application.
- Replace the contents of ProductService.cs with this application service:
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public sealed class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public IReadOnlyList<Product> GetProducts()
{
return _repository.GetAll();
}
public Product? GetProduct(Guid id)
{
return _repository.GetById(id);
}
public Product CreateProduct(CreateProductCommand command)
{
return _repository.Create(command);
}
}
How does the service use the boundary?
- The constructor receives IProductRepository through constructor injection.
- The read methods delegate product queries to the repository contract.
- The create method still delegates to a naive create operation.
- No method knows which concrete repository stores the products.
- Save ProductService.cs.
- Confirm the complete Application layer compiles by running this command:
dotnet build .\CleanCatalog.sln
What has moved inward?
Domain now owns the shared product model. Application owns the product workflow plus the storage abstraction.
The controller no longer needs to own either responsibility once the outer layers are wired.
Seeing namespace or type errors?
Check that Product.cs uses the CleanCatalog.Domain namespace. Both Application files that reference Product need using CleanCatalog.Domain;.
Confirm that CleanCatalog.Application.csproj references the Domain project.
Need help tracing the compiler message? Help me fix my CleanCatalog Application layer build error.
Wire the repository into the API
Infrastructure now provides the concrete in-memory storage class. The API becomes the assembly point that connects this implementation to the Application interface.
- Select the CleanCatalog.Infrastructure project in Solution Explorer.
- Open its context menu.
- Select Add.
- Select New Item.
- Enter InMemoryProductRepository.cs in the Quick Add dialog.
- Press Enter.
- Replace the generated contents with this repository implementation:
using CleanCatalog.Application;
using CleanCatalog.Domain;
namespace CleanCatalog.Infrastructure;
public sealed class InMemoryProductRepository : IProductRepository
{
private readonly object _gate = new();
private readonly Dictionary<Guid, Product> _products = new()
{
[Guid.Parse("11111111-1111-1111-1111-111111111111")] = new Product(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Mechanical Keyboard", "Office", 89.00m),
[Guid.Parse("22222222-2222-2222-2222-222222222222")] = new Product(Guid.Parse("22222222-2222-2222-2222-222222222222"), "USB-C Hub", "Office", 45.00m),
[Guid.Parse("33333333-3333-3333-3333-333333333333")] = new Product(Guid.Parse("33333333-3333-3333-3333-333333333333"), "Trail Bottle", "Outdoors", 24.00m)
};
public IReadOnlyList<Product> GetAll()
{
lock (_gate) return _products.Values.ToList();
}
public Product? GetById(Guid id)
{
lock (_gate) return _products.GetValueOrDefault(id);
}
public Product Create(CreateProductCommand command)
{
lock (_gate)
{
var product = new Product(Guid.NewGuid(), command.Name, command.Category, command.Price);
_products.Add(product.Id, product);
return product;
}
}
}
What does the repository own?
The dictionary holds three seeded products for local requests. The lock keeps each read or write within one protected section for this single-process implementation.
The Create method always generates a new ID. This deliberately preserves the duplicate retry problem.
- Save InMemoryProductRepository.cs.
- Return to the existing Program.cs file under CleanCatalog.Api.
- Replace its contents with this startup code:
using CleanCatalog.Application;
using CleanCatalog.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<IProductRepository, InMemoryProductRepository>();
builder.Services.AddSingleton<ProductService>();
var app = builder.Build();
app.MapControllers();
app.Run();
How does dependency injection connect the layers?
The first singleton registration maps IProductRepository to InMemoryProductRepository. A request for the interface now receives the Infrastructure implementation.
The second registration lets the API construct one ProductService with that repository. The singleton lifetime keeps the in-memory products available across local requests.
- Save Program.cs.
- Confirm that the repository registration compiles by running this command:
dotnet build .\CleanCatalog.sln
What does this build prove?
The build proves that Infrastructure satisfies the Application interface. It also proves that API can assemble both layers through its project references.
- Return to the existing Controllers\ProductsController.cs file.
- Replace the temporary controller-local implementation with this HTTP controller:
using CleanCatalog.Application;
using CleanCatalog.Domain;
using Microsoft.AspNetCore.Mvc;
namespace CleanCatalog.Api.Controllers;
[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
private readonly ProductService _service;
public ProductsController(ProductService service)
{
_service = service;
}
[HttpGet]
public ActionResult<IReadOnlyList<Product>> GetAll()
{
return Ok(_service.GetProducts());
}
[HttpGet("{id:guid}")]
public ActionResult<Product> GetById(Guid id)
{
var product = _service.GetProduct(id);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductCommand command)
{
var product = _service.CreateProduct(command);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}
What remains in the controller?
- The constructor receives ProductService from the API dependency injection container.
- The GET actions translate service results into HTTP responses.
- The POST action translates the request body into an application command.
- The controller does not store products or generate IDs.
- Save ProductsController.cs.
Use this comparison if you want to check every file created or changed in this step.
✔️ Awesome, I've got everything!
Your four projects now separate product data, application flow, storage, and HTTP translation. Save every open file before the final build.
ⓧ I'd like to double check the full code
Compare each file below with the matching file in your solution.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Domain project check
This project targets net9.0 without referencing another CleanCatalog project.
namespace CleanCatalog.Domain;
public sealed record Product(Guid Id, string Name, string Category, decimal Price);
Domain model check
The shared Product record lives in the Domain namespace.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CleanCatalog.Domain\CleanCatalog.Domain.csproj" />
</ItemGroup>
</Project>
Application project check
Application has one inward reference to Domain.
namespace CleanCatalog.Application;
public sealed record CreateProductCommand(string Name, string Category, decimal Price);
Create command check
The create command carries the three client-supplied product values.
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public interface IProductRepository
{
IReadOnlyList<Product> GetAll();
Product? GetById(Guid id);
Product Create(CreateProductCommand command);
}
Repository contract check
The interface exposes list, lookup, and naive creation operations to Application.
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public sealed class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public IReadOnlyList<Product> GetProducts()
{
return _repository.GetAll();
}
public Product? GetProduct(Guid id)
{
return _repository.GetById(id);
}
public Product CreateProduct(CreateProductCommand command)
{
return _repository.Create(command);
}
}
Application service check
The service depends on the repository interface through its constructor.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CleanCatalog.Application\CleanCatalog.Application.csproj" />
<ProjectReference Include="..\CleanCatalog.Domain\CleanCatalog.Domain.csproj" />
</ItemGroup>
</Project>
Infrastructure project check
Infrastructure references Application for the interface. It references Domain for the product model.
using CleanCatalog.Application;
using CleanCatalog.Domain;
namespace CleanCatalog.Infrastructure;
public sealed class InMemoryProductRepository : IProductRepository
{
private readonly object _gate = new();
private readonly Dictionary<Guid, Product> _products = new()
{
[Guid.Parse("11111111-1111-1111-1111-111111111111")] = new Product(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Mechanical Keyboard", "Office", 89.00m),
[Guid.Parse("22222222-2222-2222-2222-222222222222")] = new Product(Guid.Parse("22222222-2222-2222-2222-222222222222"), "USB-C Hub", "Office", 45.00m),
[Guid.Parse("33333333-3333-3333-3333-333333333333")] = new Product(Guid.Parse("33333333-3333-3333-3333-333333333333"), "Trail Bottle", "Outdoors", 24.00m)
};
public IReadOnlyList<Product> GetAll()
{
lock (_gate) return _products.Values.ToList();
}
public Product? GetById(Guid id)
{
lock (_gate) return _products.GetValueOrDefault(id);
}
public Product Create(CreateProductCommand command)
{
lock (_gate)
{
var product = new Product(Guid.NewGuid(), command.Name, command.Category, command.Price);
_products.Add(product.Id, product);
return product;
}
}
}
Repository implementation check
The implementation stores products behind the Application interface. Every create call still adds a new product.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CleanCatalog.Application\CleanCatalog.Application.csproj" />
<ProjectReference Include="..\CleanCatalog.Domain\CleanCatalog.Domain.csproj" />
<ProjectReference Include="..\CleanCatalog.Infrastructure\CleanCatalog.Infrastructure.csproj" />
</ItemGroup>
</Project>
API project check
The outer API project references all three inner projects so it can assemble the application.
using CleanCatalog.Application;
using CleanCatalog.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<IProductRepository, InMemoryProductRepository>();
builder.Services.AddSingleton<ProductService>();
var app = builder.Build();
app.MapControllers();
app.Run();
Startup registration check
Startup registers the repository mapping plus the application service before mapping controllers.
using CleanCatalog.Application;
using CleanCatalog.Domain;
using Microsoft.AspNetCore.Mvc;
namespace CleanCatalog.Api.Controllers;
[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
private readonly ProductService _service;
public ProductsController(ProductService service)
{
_service = service;
}
[HttpGet]
public ActionResult<IReadOnlyList<Product>> GetAll()
{
return Ok(_service.GetProducts());
}
[HttpGet("{id:guid}")]
public ActionResult<Product> GetById(Guid id)
{
var product = _service.GetProduct(id);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductCommand command)
{
var product = _service.CreateProduct(command);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}
Controller boundary check
The controller translates HTTP requests through ProductService without owning the collection.
Before you run the layered API, do you expect the response data to change when the same behavior now crosses four projects?
- Build the complete solution by running this command:
dotnet build .\CleanCatalog.sln
What does the final build establish?
A successful summary confirms that Domain, Application, Infrastructure, and API compile together. The dependency graph is valid from the HTTP boundary to the product model.
- Start the layered API by running this command:
dotnet run --project .\CleanCatalog.Api\CleanCatalog.Api.csproj
What does this command start?
The command launches CleanCatalog.Api with the existing profile. The API listens at http://localhost:5055.
- Return to the existing CleanCatalog.Api.http file.
- Send the GET request for /api/products from the Visual Studio HTTP editor.
You will receive a 200 OK response containing product JSON. The same result now travels through the controller, service, repository interface, repository implementation, and Domain record.
- Send the first existing POST request.
- Send the identical POST request beneath it.
- Send the GET request again.
You will see both newly created products with different IDs. That duplicate is intentional because this step changed the architecture without fixing retry behavior.
You have separated the responsibilities without changing the observable bug. That is the key proof: the API still works while every dependency is now explicit.
API not returning product JSON?
Confirm that the running process reports http://localhost:5055 as its address. The request file must target the same URL.
Check that Program.cs registers both IProductRepository and ProductService before the app is built.
Still stuck? Help me trace my CleanCatalog request through all four layers.
Your API now has clean boundaries around the same working behavior. Next, you will turn the repository boundary into a client-visible filtering capability.
Add Useful LINQ Filters
Your four projects now pass product data through clear architectural boundaries. The API still returns every product because the query path cannot express which products a client needs.
This step adds category and price filters with LINQ. Each layer carries the optional inputs inward until the repository builds the filtered result.
In this step, get ready to:
- Carry optional filter inputs through the application boundary.
- Compose category and maximum-price filters in the repository.
- Send filtered and unfiltered requests to compare the results.
Extend the application boundary
Optional parameters let callers omit either filter. The application contract must carry those values without taking responsibility for storage or query logic.
- In Visual Studio's Solution Explorer, expand CleanCatalog.Application.
- Select IProductRepository.cs.
- Replace the existing GetAll declaration with this signature:
IReadOnlyList<Product> GetAll(string? category, decimal? maxPrice);
What does this signature do?
- The nullable category parameter allows a client to omit the category filter.
- The nullable maxPrice parameter allows a client to omit the price ceiling.
- The interface keeps filtering available to the application without exposing how the repository performs it.
- Save IProductRepository.cs.
- Select ProductService.cs in the same project.
- Replace the existing GetProducts method with this version:
public IReadOnlyList<Product> GetProducts(string? category, decimal? maxPrice)
{
return _repository.GetAll(category, maxPrice);
}
How do the filters cross the boundary?
- The service receives the two optional values from the API layer.
- The call to GetAll forwards both values through the repository interface.
- The service remains independent of the in-memory query implementation.
- Save ProductService.cs.
- Review the GetAll and GetProducts parameter lists in their editor tabs.
You should see category followed by maxPrice in both parameter lists. The application boundary now carries the same filter vocabulary from service to repository.
Seeing mismatched method errors?
Check that both methods use string? category followed by decimal? maxPrice. Keep the parameter order identical across the interface and service.
Use this if the signatures still do not line up: Help me compare the GetAll and GetProducts signatures in my CleanCatalog application layer.
Compose the repository query
A composable query starts with every stored product. Each supplied parameter adds one constraint before the repository orders and materializes the final list.
- In Solution Explorer, expand CleanCatalog.Infrastructure.
- Select InMemoryProductRepository.cs.
- Replace the existing GetAll method with this implementation:
public IReadOnlyList<Product> GetAll(string? category, decimal? maxPrice)
{
lock (_gate)
{
IEnumerable<Product> query = _products.Values;
if (!string.IsNullOrWhiteSpace(category))
{
query = query.Where(product => product.Category.Equals(category, StringComparison.OrdinalIgnoreCase));
}
if (maxPrice.HasValue)
{
query = query.Where(product => product.Price <= maxPrice.Value);
}
return query.OrderBy(product => product.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
}
How does the query work?
- The repository begins with every value in _products.
- The first Where filter compares categories without case sensitivity when a category is present.
- The second Where filter keeps prices less than or equal to the supplied maximum.
- The final expression orders names without case sensitivity before ToList() materializes the result.
- Save InMemoryProductRepository.cs.
- In Solution Explorer, expand the Controllers folder under CleanCatalog.Api.
- Select ProductsController.cs.
- Replace the existing GetAll action with this version:
[HttpGet]
public ActionResult<IReadOnlyList<Product>> GetAll([FromQuery] string? category = null, [FromQuery] decimal? maxPrice = null)
{
return Ok(_service.GetProducts(category, maxPrice));
}
How does the controller bind filters?
- Each FromQuery attribute binds a value from the request query string.
- The default null values preserve the unfiltered request when a parameter is absent.
- The controller passes both values to GetProducts and translates the returned list into an HTTP response.
- Save ProductsController.cs.
- Switch back to the PowerShell window running the API.
- Stop the running API with your terminal's interrupt control.
- Restart the API with the updated projects by running this command:
dotnet run --project .\CleanCatalog.Api\CleanCatalog.Api.csproj
- Wait for the API to resume serving requests at http://localhost:5055.
- Return to the existing CleanCatalog.Api.http editor tab.
- Send the unfiltered GET request from earlier.
You should receive the complete product list. This confirms that the new optional parameters preserve the original unfiltered behavior.
Does the updated API fail to start?
Check that the interface declaration matches the repository method exactly. Confirm that the service passes both arguments to GetAll.
Use this if the build reports a method mismatch: Help me trace a GetAll or GetProducts signature mismatch across my CleanCatalog projects.
Compare filtered and unfiltered responses
The request query string now controls which constraints the repository applies. A category of Office combined with a maximum price of 50 gives you a concrete test of both filters at once.
- In CleanCatalog.Api.http, place the cursor after the unfiltered GET request.
- Add this filtered request before the existing POST requests:
GET {{CleanCatalog_Api_HostAddress}}/api/products?category=Office&maxPrice=50
Accept: application/json
###
What does this request test?
- The category=Office parameter activates the case-insensitive category filter.
- The maxPrice=50 parameter activates the upper price limit.
- The ampersand places both query parameters on the same GET request.
- Save CleanCatalog.Api.http.
Before you send the filtered request, which seeded products do you think satisfy both conditions?
- Send the filtered GET request from the Visual Studio HTTP editor.
You should see only the USB-C Hub. Its category is Office and its price is at most 50.
- Send the unfiltered GET request again.
You should see Mechanical Keyboard, Trail Bottle, and USB-C Hub ordered by name. The filtered request narrows the result without changing the stored products.
Does the filtered response look wrong?
Confirm that the request spells category and maxPrice exactly like the controller parameters. Check that the price comparison uses <=.
Use this if extra products remain in the response: Help me debug why my CleanCatalog LINQ filters return the wrong products.
That query path is working across all four projects. Your API can now answer a focused catalog question while keeping filtering inside the repository boundary.
Your catalog can now list, order, and filter products through clean boundaries. Next, you will make product creation safe when a client repeats the same request.
Make Product Creation Retry-Safe
Your layered API can filter products while keeping HTTP concerns separate from storage. Product creation still duplicates data when a client retries the same request.
This step adds idempotency to the existing POST workflow. The repository remembers the first completed result so an identical retry returns the same product.
In this step, get ready to:
- Define the possible creation outcomes and normalize incoming product data.
- Store completed creations by idempotency key inside the repository lock.
- Translate creation outcomes into HTTP responses and verify every request case.
Why remember completed results?
A client can repeat a POST when a response is delayed or lost. Remembering the completed result lets the API return the original product without performing the creation again.
The request payload matters too. Reusing one key for different product data signals a conflict instead of silently returning an unrelated result.
Define retry outcomes and normalize requests
The application layer needs explicit outcomes before the API can choose a response. A result object carries both the outcome and the product returned by the repository.
- Switch back to the PowerShell window running the API.
- Press Ctrl+C to stop the current process.
- In Visual Studio, select the CleanCatalog.Application project in Solution Explorer.
- Choose Add to open the Add New Item dialog.
- Select Class.
- Name the file CreateProductStatus.cs.
- Select Add.
- Replace the generated contents with this code:
namespace CleanCatalog.Application;
public enum CreateProductStatus
{
Created,
Replayed,
KeyConflict
}
What does this status represent?
- Created identifies the first request that stores a new product.
- Replayed identifies an identical retry that receives the stored product.
- KeyConflict identifies a key reused with different product data.
- Save CreateProductStatus.cs.
- Confirm that CreateProductStatus.cs appears under the CleanCatalog.Application project.
File missing from the project?
Check that you selected the CleanCatalog.Application project before choosing Add New Item.
If the file exists elsewhere in the solution, remove that misplaced copy before creating it in the Application project.
Help me place this status file correctly.
✔️ Awesome, I've got everything!
Great. Your creation workflow now has three explicit outcomes.
ⓧ I'd like to double check the full code
namespace CleanCatalog.Application;
public enum CreateProductStatus
{
Created,
Replayed,
KeyConflict
}
- Select the CleanCatalog.Application project in Solution Explorer.
- Choose Add to open the Add New Item dialog.
- Select Class.
- Name the file CreateProductResult.cs.
- Select Add.
- Replace the generated contents with this code:
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public sealed record CreateProductResult(CreateProductStatus Status, Product Product);
What does this result carry?
- Status tells the API whether the request created, replayed, or conflicted.
- Product carries the stored product for both successful outcomes.
- A record gives the result a compact immutable shape.
- Save CreateProductResult.cs.
- Confirm that both new files appear under the CleanCatalog.Application project.
Seeing an unknown type warning?
Check that CreateProductStatus.cs and CreateProductResult.cs use the same CleanCatalog.Application namespace.
Confirm that the result file includes using CleanCatalog.Domain; so it can reference Product.
Help me resolve the unknown type warning.
✔️ Awesome, I've got everything!
Your result record now connects each creation status to a product.
ⓧ I'd like to double check the full code
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public sealed record CreateProductResult(CreateProductStatus Status, Product Product);
The repository contract now needs the key that identifies a logical creation request. Its return type also needs to expose the outcome.
- Open CleanCatalog.Application/IProductRepository.cs from Solution Explorer.
- Replace its contents with this interface:
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public interface IProductRepository
{
IReadOnlyList<Product> GetAll(string? category, decimal? maxPrice);
Product? GetById(Guid id);
CreateProductResult Create(CreateProductCommand command, string idempotencyKey);
}
What changed in the contract?
- Create now receives the normalized command and its idempotency key.
- Create returns a CreateProductResult so callers can distinguish each outcome.
- The existing read operations keep their filter and lookup behavior.
- Save IProductRepository.cs.
✔️ Awesome, I've got everything!
The repository boundary now describes retry-safe creation.
ⓧ I'd like to double check the full code
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public interface IProductRepository
{
IReadOnlyList<Product> GetAll(string? category, decimal? maxPrice);
Product? GetById(Guid id);
CreateProductResult Create(CreateProductCommand command, string idempotencyKey);
}
Equivalent requests need an equivalent payload before the repository compares them. Trimming the name and category prevents surrounding whitespace from creating an accidental mismatch.
- Open CleanCatalog.Application/ProductService.cs from Solution Explorer.
- Replace its contents with this service:
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public sealed class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public IReadOnlyList<Product> GetProducts(string? category, decimal? maxPrice)
{
return _repository.GetAll(category, maxPrice);
}
public Product? GetProduct(Guid id)
{
return _repository.GetById(id);
}
public CreateProductResult CreateProduct(
CreateProductCommand command,
string idempotencyKey)
{
var normalizedCommand = command with
{
Name = command.Name.Trim(),
Category = command.Category.Trim()
};
return _repository.Create(normalizedCommand, idempotencyKey);
}
}
What does this service do?
- The with expression copies the immutable command into a normalized record.
- Trim removes surrounding whitespace from Name and Category.
- The service passes the normalized command and key through the repository interface.
- Save ProductService.cs.
✔️ Awesome, I've got everything!
The service now normalizes creation commands before storage.
ⓧ I'd like to double check the full code
using CleanCatalog.Domain;
namespace CleanCatalog.Application;
public sealed class ProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public IReadOnlyList<Product> GetProducts(string? category, decimal? maxPrice)
{
return _repository.GetAll(category, maxPrice);
}
public Product? GetProduct(Guid id)
{
return _repository.GetById(id);
}
public CreateProductResult CreateProduct(
CreateProductCommand command,
string idempotencyKey)
{
var normalizedCommand = command with
{
Name = command.Name.Trim(),
Category = command.Category.Trim()
};
return _repository.Create(normalizedCommand, idempotencyKey);
}
}
Store and replay completed creations
The repository owns the data that must survive across requests during one process run. It stores each key beside the normalized command and the first completed product.
- Open CleanCatalog.Infrastructure/InMemoryProductRepository.cs from Solution Explorer.
- Locate the closing brace of the _products dictionary.
- Add this dictionary immediately below it:
private readonly Dictionary<string, IdempotencyRecord> _idempotencyRecords = new(StringComparer.Ordinal);
What does this dictionary remember?
- Each string key identifies one logical creation request.
- StringComparer.Ordinal keeps key matching exact and predictable.
- Each IdempotencyRecord holds the normalized command and completed product.
- Save InMemoryProductRepository.cs.
The creation method can now check the dictionary inside the existing lock. Keeping the check and write together prevents two local requests from passing the check before either stores its result.
- Find the existing naive Create method in InMemoryProductRepository.cs.
- Replace that method with the following code.
- Add the private record directly below the method:
public CreateProductResult Create(CreateProductCommand command, string idempotencyKey)
{
lock (_gate)
{
if (_idempotencyRecords.TryGetValue(idempotencyKey, out var existing))
{
var status = existing.Command == command ? CreateProductStatus.Replayed : CreateProductStatus.KeyConflict;
return new CreateProductResult(status, existing.Product);
}
var product = new Product(Guid.NewGuid(), command.Name, command.Category, command.Price);
_products.Add(product.Id, product);
_idempotencyRecords.Add(idempotencyKey, new IdempotencyRecord(command, product));
return new CreateProductResult(CreateProductStatus.Created, product);
}
}
private sealed record IdempotencyRecord(CreateProductCommand Command, Product Product);
How does replay detection work?
- TryGetValue checks whether the key already has a completed record.
- Record equality compares the stored normalized command with the incoming normalized command.
- An equal command returns Replayed with the original product.
- A changed command returns KeyConflict with the stored product.
- A new key creates one product before storing the command and result together.
- Save InMemoryProductRepository.cs.
Seeing repository compiler errors?
Confirm that the method returns CreateProductResult and accepts both CreateProductCommand and string idempotencyKey.
Place IdempotencyRecord inside the InMemoryProductRepository class.
Help me fix the repository compiler errors.
✔️ Awesome, I've got everything!
Your repository now creates once and replays completed results for matching retries.
ⓧ I'd like to double check the full code
using CleanCatalog.Application;
using CleanCatalog.Domain;
namespace CleanCatalog.Infrastructure;
public sealed class InMemoryProductRepository : IProductRepository
{
private readonly object _gate = new();
private readonly Dictionary<Guid, Product> _products = new()
{
[Guid.Parse("11111111-1111-1111-1111-111111111111")] = new Product(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Mechanical Keyboard", "Office", 89.00m),
[Guid.Parse("22222222-2222-2222-2222-222222222222")] = new Product(Guid.Parse("22222222-2222-2222-2222-222222222222"), "USB-C Hub", "Office", 45.00m),
[Guid.Parse("33333333-3333-3333-3333-333333333333")] = new Product(Guid.Parse("33333333-3333-3333-3333-333333333333"), "Trail Bottle", "Outdoors", 24.00m)
};
private readonly Dictionary<string, IdempotencyRecord> _idempotencyRecords = new(StringComparer.Ordinal);
public IReadOnlyList<Product> GetAll(string? category, decimal? maxPrice)
{
lock (_gate)
{
IEnumerable<Product> query = _products.Values;
if (!string.IsNullOrWhiteSpace(category))
{
query = query.Where(product => product.Category.Equals(category, StringComparison.OrdinalIgnoreCase));
}
if (maxPrice.HasValue)
{
query = query.Where(product => product.Price <= maxPrice.Value);
}
return query.OrderBy(product => product.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
}
public Product? GetById(Guid id)
{
lock (_gate)
{
return _products.GetValueOrDefault(id);
}
}
public CreateProductResult Create(CreateProductCommand command, string idempotencyKey)
{
lock (_gate)
{
if (_idempotencyRecords.TryGetValue(idempotencyKey, out var existing))
{
var status = existing.Command == command ? CreateProductStatus.Replayed : CreateProductStatus.KeyConflict;
return new CreateProductResult(status, existing.Product);
}
var product = new Product(Guid.NewGuid(), command.Name, command.Category, command.Price);
_products.Add(product.Id, product);
_idempotencyRecords.Add(idempotencyKey, new IdempotencyRecord(command, product));
return new CreateProductResult(CreateProductStatus.Created, product);
}
}
private sealed record IdempotencyRecord(CreateProductCommand Command, Product Product);
}
Translate outcomes and prove the retry behavior
The controller turns application outcomes into HTTP responses. It also rejects incomplete requests before they reach the service.
- Open CleanCatalog.Api/Controllers/ProductsController.cs from Solution Explorer.
- Add this documentation constant inside ProductsController above the _service field:
private const string IdempotencyDocumentation = "https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-07.html";
Why include a documentation link?
ProblemDetails can point clients to the design guidance behind the missing-key and changed-payload responses. The link belongs in the error body through its Type property.
- Find the existing naive Create action.
- Replace it with this action:
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductCommand command, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey)
{
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
return BadRequest(new ProblemDetails { Type = IdempotencyDocumentation, Title = "Idempotency-Key is missing", Detail = "Product creation requires an Idempotency-Key header." });
}
if (string.IsNullOrWhiteSpace(command.Name) || string.IsNullOrWhiteSpace(command.Category) || command.Price <= 0)
{
return BadRequest(new ProblemDetails { Title = "Product data is invalid", Detail = "Name and category are required, and price must be greater than zero." });
}
var result = _service.CreateProduct(command, idempotencyKey.Trim());
if (result.Status == CreateProductStatus.KeyConflict)
{
return UnprocessableEntity(new ProblemDetails { Type = IdempotencyDocumentation, Title = "Idempotency-Key is already used", Detail = "The same key cannot be reused with a different product payload." });
}
return CreatedAtAction(nameof(GetById), new { id = result.Product.Id }, result.Product);
}
How does the controller map each outcome?
- FromHeader binds the Idempotency-Key request header.
- BadRequest returns 400 for a missing key or invalid product data.
- UnprocessableEntity returns 422 when one key is reused with a changed payload.
- CreatedAtAction returns 201 with the stored product for both creation and replay.
- Save ProductsController.cs.
Seeing controller compiler errors?
Confirm that CreateProductStatus is in the CleanCatalog.Application namespace already imported at the top of the controller.
Check that the action calls _service.CreateProduct with the command and trimmed key.
Help me resolve the controller errors.
✔️ Awesome, I've got everything!
The controller now maps each creation outcome to its intended HTTP response.
ⓧ I'd like to double check the full code
using CleanCatalog.Application;
using CleanCatalog.Domain;
using Microsoft.AspNetCore.Mvc;
namespace CleanCatalog.Api.Controllers;
[ApiController]
[Route("api/products")]
public sealed class ProductsController : ControllerBase
{
private const string IdempotencyDocumentation = "https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-07.html";
private readonly ProductService _service;
public ProductsController(ProductService service)
{
_service = service;
}
[HttpGet]
public ActionResult<IReadOnlyList<Product>> GetAll([FromQuery] string? category = null, [FromQuery] decimal? maxPrice = null)
{
return Ok(_service.GetProducts(category, maxPrice));
}
[HttpGet("{id:guid}")]
public ActionResult<Product> GetById(Guid id)
{
var product = _service.GetProduct(id);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductCommand command, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey)
{
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
return BadRequest(new ProblemDetails { Type = IdempotencyDocumentation, Title = "Idempotency-Key is missing", Detail = "Product creation requires an Idempotency-Key header." });
}
if (string.IsNullOrWhiteSpace(command.Name) || string.IsNullOrWhiteSpace(command.Category) || command.Price <= 0)
{
return BadRequest(new ProblemDetails { Title = "Product data is invalid", Detail = "Name and category are required, and price must be greater than zero." });
}
var result = _service.CreateProduct(command, idempotencyKey.Trim());
if (result.Status == CreateProductStatus.KeyConflict)
{
return UnprocessableEntity(new ProblemDetails { Type = IdempotencyDocumentation, Title = "Idempotency-Key is already used", Detail = "The same key cannot be reused with a different product payload." });
}
return CreatedAtAction(nameof(GetById), new { id = result.Product.Id }, result.Product);
}
}
The request file turns every outcome into a repeatable check. The first pair uses the same key and payload before the later requests test the two error paths.
- Open CleanCatalog.Api/CleanCatalog.Api.http from Solution Explorer.
- Replace the existing GET section with these list and filter requests:
@CleanCatalog_Api_HostAddress = http://localhost:5055
GET {{CleanCatalog_Api_HostAddress}}/api/products
Accept: application/json
###
GET {{CleanCatalog_Api_HostAddress}}/api/products?category=Office&maxPrice=50
Accept: application/json
###
What do these requests preserve?
- The first GET lists every product currently held by the repository.
- The second GET proves the category and maximum-price filters still work after the creation changes.
- Place these two identical POST requests below the filtered GET request:
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
Idempotency-Key: "clean-catalog-create-001"
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
Idempotency-Key: "clean-catalog-create-001"
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
What does this pair prove?
Both requests carry the same Idempotency-Key and the same product payload. The second request therefore qualifies as a replay of the completed first request.
- Place these changed-payload and missing-key requests below the identical pair:
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
Idempotency-Key: "clean-catalog-create-001"
{
"name": "Different Lamp",
"category": "Office",
"price": 59.00
}
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
{
"name": "Missing Key Lamp",
"category": "Office",
"price": 29.00
}
###
What do the final requests test?
- Different Lamp reuses the existing key with a changed payload.
- Missing Key Lamp omits the required header completely.
- Together they prove the controller rejects both invalid retry conditions.
- Save CleanCatalog.Api/CleanCatalog.Api.http.
✔️ Awesome, I've got everything!
Your request file now covers successful replay and both error paths.
ⓧ I'd like to double check the full code
@CleanCatalog_Api_HostAddress = http://localhost:5055
GET {{CleanCatalog_Api_HostAddress}}/api/products
Accept: application/json
###
GET {{CleanCatalog_Api_HostAddress}}/api/products?category=Office&maxPrice=50
Accept: application/json
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
Idempotency-Key: "clean-catalog-create-001"
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
Idempotency-Key: "clean-catalog-create-001"
{
"name": "Desk Lamp",
"category": "Office",
"price": 39.00
}
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
Idempotency-Key: "clean-catalog-create-001"
{
"name": "Different Lamp",
"category": "Office",
"price": 59.00
}
###
POST {{CleanCatalog_Api_HostAddress}}/api/products
Content-Type: application/json
{
"name": "Missing Key Lamp",
"category": "Office",
"price": 29.00
}
###
The application contract, repository implementation, controller mapping, and request cases are now connected. A clean build confirms that every layer agrees on the new creation result.
- Build the solution from the existing CleanCatalog folder by running this command:
dotnet build .\CleanCatalog.sln
What does this build verify?
The build checks that the new result types flow through Application, Infrastructure, and API. A successful build proves that each layer uses the same repository contract.
Your four layers now compile around one retry-safe creation workflow. That is the dependency change locked in.
Build failed?
Check the first compiler error because later errors often follow from one mismatched signature. Compare IProductRepository.Create with the method implemented by InMemoryProductRepository.
Confirm that every changed file is saved before rebuilding.
Help me diagnose the build failure.
- Start the updated API by running this command:
dotnet run --project .\CleanCatalog.Api\CleanCatalog.Api.csproj
What does this command start?
The command runs the API project with its configured HTTP launch profile. The running process serves the updated controller at http://localhost:5055.
Before you send the identical retry, do you expect the second response to contain a new product ID or the first completed product ID?
- Return to CleanCatalog.Api.http in Visual Studio.
- Select Send Request above the first Desk Lamp POST.
You will see a 201 response containing the new Desk Lamp product.
- Record the first response ID here: your first product ID.
- Select Send Request above the second Desk Lamp POST.
You will see another 201 response containing your first product ID. The retry has replayed the completed result.
- Select Send Request above the unfiltered GET request.
You will see the three seeded products and one Desk Lamp. Only one product was added across both POST requests.
- Select Send Request above the filtered GET request.
You will see only Office products priced at 50 or less. The existing LINQ filters still work.
- Select Send Request above the Different Lamp POST.
You will see a 422 response explaining that the key is already used with a different payload.
- Select Send Request above the Missing Key Lamp POST.
You will see a 400 response explaining that product creation requires the Idempotency-Key header.
Responses do not match?
Restart the API after confirming that every changed file is saved. The in-memory repository begins again with the three seeded products.
Send the first Desk Lamp POST before the retry and conflict requests because those later cases depend on its stored key.
Help me trace an unexpected response.
You have closed the duplicate-creation gap that started in Step 2. CleanCatalog now returns the original product for an identical retry while rejecting unsafe key reuse.
Secret mission
Add a Naturally Idempotent Price Update
A repeated price update should settle on one predictable product state. Carry a PUT price update through every Clean Architecture layer. Then prove that two identical requests preserve the same product ID and price without an idempotency-key store.
Clean Up Your Resources
Clean Up Your Resources
CleanCatalog runs entirely on your Windows computer. Keeping its files costs $0 because the project created no cloud resources.
Resources you used:
- The running ASP.NET Core API process at http://localhost:5055.
- The in-memory product data plus idempotency records held by the API process.
- The local CleanCatalog solution folder with its four .NET projects.
- The generated bin plus obj build output inside CleanCatalog.
Keep everything running
No action needed. Choose this if you are still testing CleanCatalog or extending its API.
- Keep the CleanCatalog folder for its source files plus build output.
- Keep Visual Studio open with CleanCatalog.sln while you continue building.
- Leave the API running at http://localhost:5055 while you need its current in-memory products.
Pause - I'll come back to this later
Shut down the running API to free local memory. Your source files remain available for your next session.
- Switch back to the PowerShell terminal from earlier.
- Press Ctrl+C to stop the API.
- Send the first GET request from CleanCatalog.Api\CleanCatalog.Api.http again.
Visual Studio can no longer reach http://localhost:5055. This confirms that the API process has stopped.
- Keep the CleanCatalog folder in place for your next session.
Delete - I don't want to use this again
Deleting the folder permanently removes every local CleanCatalog file. Anything you saved elsewhere remains safe.
- Switch back to the PowerShell terminal from earlier.
- Press Ctrl+C to stop the API.
- Close Visual Studio.
- Press the Windows key to search for File Explorer.
- Select File Explorer from the search results.
- Return to the folder that contains CleanCatalog.
- Delete the CleanCatalog folder.
- Confirm that CleanCatalog no longer appears in File Explorer.
The folder removal deletes the four projects plus their bin and obj output. No remote cleanup is required.
Nice Work!
Nice Work!
You did it! CleanCatalog now runs as a controller-based ASP.NET Core product API that stays correct when clients retry creation requests.
You've learned how to:
- Built a queryable product catalog whose LINQ pipeline filters products by category or maximum price.
- Separated HTTP translation, business flow, domain models, and storage across four inward-pointing Clean Architecture projects.
- Reproduced the duplicate POST failure that retries can cause. Added idempotency-key replay so matching retries return the first product. Missing keys return 400. Changed-payload reuse returns 422.
- Secret Mission: Added a naturally idempotent PUT update that settles on the same product price after identical requests.
Ready to quiz yourself?