A .NET 10 microservice for managing digital menus in the SmartCafe smart ordering system. Built with Clean Architecture, PostgreSQL, and Azure services.
- Clean Architecture with Vertical Slice Architecture
- Result Pattern: Zero exception-based error handling in application layer
- Domain Layer: Entities, value objects, domain events
- Application Layer: Handlers (return
Result<T>), DTOs, validators, manual per-feature mappers - Infrastructure Layer: EF Core, PostgreSQL, Azure Blob Storage, Azure Service Bus
- API Layer: ASP.NET Core Minimal API with Result extensions
- Multi-Menu Management: Cafes can have multiple menus (e.g., Summer, Winter, Holiday)
- Menu States: New β Published β Active (only one active menu per cafe)
- Menu Activation: Switch active menus seamlessly
- Menu Cloning: Create variations from existing menus
- Section Management: Organize items by meal type with availability hours
- Item Management: Full CRUD with images and ingredients
- Image Processing: Auto-generate cropped thumbnails
- Event Publishing: Publish domain events to Azure Service Bus
- Time-Ordered GUIDs: Use
Guid.CreateVersion7()for better database performance
- .NET 10 with C# 14 (latest language features)
- ASP.NET Core Minimal API
- Entity Framework Core 10 with PostgreSQL
- FluentValidation for input validation
- Manual mapping via feature-local static mappers
- Azure Blob Storage for images
- Azure Service Bus for events
- Serilog for structured logging
- OpenTelemetry for observability
- .NET Aspire for local development
- .NET 10 SDK
- PostgreSQL 16+
- Docker Desktop (for local development)
- Azure Storage Emulator (Azurite)
- Visual Studio 2025 or VS Code with C# Dev Kit
git clone https://github.com/petro-konopelko/smartcafe-menu.git
cd smartcafe-menucd src/SmartCafe.Menu.API
dotnet user-secrets init
dotnet user-secrets set "Database:Password" "your_postgres_password"
dotnet user-secrets set "AzureStorage:Key" "your_storage_key"
dotnet user-secrets set "AzureServiceBus:ConnectionString" "your_servicebus_connection"cd src/SmartCafe.Menu.AppHost
dotnet runThis will start:
- Menu API service
- PostgreSQL database
- Azurite (Azure Storage Emulator)
- Aspire Dashboard (http://localhost:15888)
cd src/SmartCafe.Menu.Infrastructure
dotnet ef database update --startup-project ../SmartCafe.Menu.API- API: http://localhost:5000
- Swagger UI: http://localhost:5000/swagger
- Aspire Dashboard: http://localhost:15888
smartcafe-menu/
βββ src/
β βββ SmartCafe.Menu.Domain/ # Core business logic
β β βββ Entities/ # Domain entities
β β βββ ValueObjects/ # Value objects (Ingredient)
β β βββ Events/ # Domain events
β β βββ Exceptions/ # Custom exceptions
β β βββ Interfaces/ # IDateTimeProvider
β βββ SmartCafe.Menu.Application/ # Use cases & DTOs
β β βββ Common/Results/ # Result pattern (Result<T>, Error, ErrorType)
β β βββ Features/ # Vertical slices (handlers return Result<T>)
β β β βββ Menus/
β β β βββ Categories/
β β βββ Interfaces/ # Repository interfaces
β β βββ Features/*/Mappers/ # Manual static mappers per feature
β β βββ Mediation/ # Mediator, ValidationBehavior
β βββ SmartCafe.Menu.Infrastructure/ # Data access & external services
β β βββ Data/PostgreSQL/ # EF Core DbContext
β β βββ Repositories/ # Repository implementations
β β βββ EventBus/ # Azure Service Bus
β β βββ BlobStorage/ # Azure Blob Storage
β β βββ Services/ # DateTimeProvider, ImageProcessing
β βββ SmartCafe.Menu.API/ # Minimal API endpoints
β β βββ Endpoints/ # Endpoint definitions (use Result extensions)
β β βββ Extensions/ # Result β HTTP mapping (ToApiResult, ToCreatedResult)
β β βββ Filters/ # Validation, logging filters
β β βββ Middleware/ # Exception handling for unexpected errors
β β βββ Program.cs # Application startup
β βββ SmartCafe.Menu.AppHost/ # .NET Aspire orchestration
β βββ SmartCafe.Menu.ServiceDefaults/ # Shared Aspire config
βββ tests/
β βββ SmartCafe.Menu.UnitTests/
β βββ SmartCafe.Menu.IntegrationTests/
βββ .editorconfig
βββ .gitignore
βββ SmartCafe.Menu.sln
All handlers return Result<T> instead of throwing exceptions:
public class CreateMenuHandler : ICommandHandler<CreateMenuRequest, Result<CreateMenuResponse>>
{
public async Task<Result<CreateMenuResponse>> HandleAsync(CreateMenuRequest request, CancellationToken ct)
{
// Existence check β 404 Not Found
if (menu == null)
return Result<CreateMenuResponse>.Failure(Error.NotFound(
"Menu not found", ErrorCodes.MenuNotFound));
// Return success with value
return Result<CreateMenuResponse>.Success(new CreateMenuResponse(...));
}
}Endpoints use Result extensions:
group.MapPost("/", async (Guid cafeId, CreateMenuRequest request, IMediator mediator, CancellationToken ct) =>
{
var command = request with { CafeId = cafeId };
var result = await mediator.Send<CreateMenuRequest, Result<CreateMenuResponse>>(command, ct);
// Factory delegate ensures safe access to response.Id only on success
return result.ToCreatedResult(response => $"/api/cafes/{cafeId}/menus/{response.Id}");
})
.WithName("CreateMenu")
.WithSummary("Create a new menu in New state");Error Types:
Error.NotFound()β 404 Not FoundError.Validation()β 400 Bad RequestError.Conflict()β 409 Conflict
The project uses a two-tier validation strategy for clean separation of concerns:
1. Format Validation (FluentValidation β 400 Bad Request)
- Handled by
AbstractValidator<T>classes - Validates: required fields, string length, format, range
- Uses centralized
ValidationMessagesconstants - Executed automatically by
ValidationBehavior<TRequest, T>before handler execution - Returns
Result<T>.Failure(Error.Validation(...))with all validation errors
2. Existence & Business Rule Validation (Handlers β 404/409)
- Handled directly in command/query handlers
- Validates: entity existence, business rules, state transitions
- Uses centralized
ErrorCodesconstants - Returns
Result<T>.Failure(Error.NotFound/Conflict(...))
Example - ValidationMessages:
public static class ValidationMessages
{
public const string CafeIdRequired = "Cafe ID is required.";
public const string MenuNameRequired = "Menu name is required.";
public const string MenuNameMaxLength = "Menu name must not exceed 200 characters.";
// ... 20+ constants
}
public class CreateMenuValidator : AbstractValidator<CreateMenuRequest>
{
public CreateMenuValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage(ValidationMessages.MenuNameRequired)
.MaximumLength(200).WithMessage(ValidationMessages.MenuNameMaxLength);
}
}Example - ErrorCodes:
public static class ErrorCodes
{
public const string CafeNotFound = "CAFE_NOT_FOUND";
public const string MenuNotFound = "MENU_NOT_FOUND";
public const string MenuAlreadyActive = "MENU_ALREADY_ACTIVE";
// ... 10+ constants
}
public class ActivateMenuHandler
{
public async Task<Result> HandleAsync(ActivateMenuCommand command, CancellationToken ct)
{
// Existence check β 404
if (menu == null)
return Result.Failure(Error.NotFound(
"Menu not found", ErrorCodes.MenuNotFound));
// Business rule β 409
if (menu.IsActive)
return Result.Failure(Error.Conflict(
"Menu is already active", ErrorCodes.MenuAlreadyActive));
}
}Benefits:
- Clear separation: format vs business logic
- Centralized messages β easy to update
- No string duplication across validators/handlers
- Testable: mock validators or handlers independently
- Consistent error responses
All DateTime operations use IDateTimeProvider instead of DateTime.UtcNow for testability:
public class CreateMenuHandler(IMenuRepository repository, IDateTimeProvider dateTimeProvider)
{
public async Task<Result<CreateMenuResponse>> HandleAsync(CreateMenuRequest request)
{
var menu = new Menu
{
Name = request.Name,
CreatedAt = dateTimeProvider.UtcNow, // Testable!
UpdatedAt = dateTimeProvider.UtcNow
};
// ...
}
}Time-ordered UUIDs for better database performance:
public class Menu
{
public Guid Id { get; init; } = Guid.CreateVersion7(); // UUIDv7
}- Development: Use User Secrets or environment variables
- Production: Use Azure Key Vault with Managed Identity
- Never store passwords in
appsettings.json
Cafes- Cafe informationMenus- Menu definitions with state (New/Published/Active)Sections- Menu sections (Breakfast, Lunch, etc.)MenuItems- Individual menu items
- Unique partial index: Only one active menu per cafe
- Foreign keys: Cascade delete for menu hierarchies
- Check constraints: Price > 0, AvailableFrom < AvailableTo
- JSONB: Ingredient options stored as JSONB for flexibility
# Run unit tests
dotnet test tests/SmartCafe.Menu.UnitTests
# Run integration tests
dotnet test tests/SmartCafe.Menu.IntegrationTests
# Run all tests
dotnet test# Build image
docker build -t smartcafe-menu:latest .
# Run container
docker run -p 5000:8080 smartcafe-menu:latest| Method | Endpoint | Description |
|---|---|---|
| GET | /api/cafes |
List all active cafes |
| POST | /api/cafes |
Create new cafe |
| GET | /api/cafes/{cafeId} |
Get cafe details |
| DELETE | /api/cafes/{cafeId} |
Soft delete cafe |
Note: All menu operations return 404 when the cafe is soft deleted.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/cafes/{cafeId}/menus |
List all menus |
| POST | /api/cafes/{cafeId}/menus |
Create new menu (new) |
| GET | /api/cafes/{cafeId}/menus/{menuId} |
Get menu details |
| PUT | /api/cafes/{cafeId}/menus/{menuId} |
Update menu |
| DELETE | /api/cafes/{cafeId}/menus/{menuId} |
Delete menu (new only) |
| POST | /api/cafes/{cafeId}/menus/{menuId}/publish |
Publish menu |
| POST | /api/cafes/{cafeId}/menus/{menuId}/activate |
Activate menu |
| POST | /api/cafes/{cafeId}/menus/{menuId}/clone |
Clone menu |
| GET | /api/cafes/{cafeId}/menus/active |
Get active menu (public) |
See Swagger UI for complete API documentation.
All endpoints follow the Handler Pattern with Result Pattern for separation of concerns:
Endpoint Responsibilities (thin, 20-50 lines):
- Receive HTTP requests and extract parameters
- Call mediator to execute handler
- Map Result to HTTP responses using extension methods
- NO try-catch blocks (Result pattern handles errors)
Handler Responsibilities (business logic):
- Execute business logic
- Coordinate repositories and services
- Validate existence and business rules
- Publish domain events
- Return Result (never throw exceptions for business errors)
Example:
// Endpoint (thin layer)
public static RouteGroupBuilder MapPublishMenu(this RouteGroupBuilder group)
{
group.MapPost("/{menuId:guid}/publish", async (
Guid cafeId,
Guid menuId,
PublishMenuCommand command,
IMediator mediator,
CancellationToken ct) =>
{
var publishCommand = new PublishMenuCommand(cafeId, menuId);
var result = await mediator.Send<PublishMenuCommand, Result>(publishCommand, ct);
return result.ToNoContentResult(); // Auto-maps errors to HTTP status
})
.WithName("PublishMenu")
.WithSummary("Publish a new menu to make it ready for activation");
return group;
}
// Handler (business logic)
public class PublishMenuHandler(
IMenuRepository menuRepository,
IUnitOfWork unitOfWork,
IEventPublisher eventPublisher,
IDateTimeProvider dateTimeProvider) : ICommandHandler<PublishMenuCommand, Result>
{
public async Task<Result> HandleAsync(
PublishMenuCommand command, CancellationToken cancellationToken)
{
var menu = await menuRepository.GetByIdAsync(command.MenuId, cancellationToken);
// Existence check β 404 Not Found
if (menu == null || menu.CafeId != command.CafeId)
return Result.Failure(Error.NotFound(
"Menu not found", ErrorCodes.MenuNotFound));
// Business rule checks β 409 Conflict
if (menu.IsPublished)
return Result.Failure(Error.Conflict(
"Menu is already published", ErrorCodes.MenuAlreadyPublished));
if (menu.Sections.Count == 0)
return Result.Failure(Error.Conflict(
"Menu must have at least one section", ErrorCodes.MenuHasNoSections));
// Business logic
menu.IsPublished = true;
menu.PublishedAt = dateTimeProvider.UtcNow;
await menuRepository.UpdateAsync(menu, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
// Publish domain event
await eventPublisher.PublishAsync(
new MenuPublishedEvent(Guid.CreateVersion7(), menu.Id, menu.CafeId, menu.Name, dateTimeProvider.UtcNow),
cancellationToken);
return Result.Success();
}
}All handlers follow the Result Pattern - returning Result<T> instead of throwing exceptions.
- Mapping from domain entities to response DTOs is implemented via small, feature-local static mapper classes under each handler folder (e.g.,
Features/Menus/CreateMenu/Mappers/CreateMenuMapper.cs). - Handlers call these mappers for response construction instead of inlining DTO creation.
- Shared DTOs live under
Features/Menus/Shared, but mapping stays close to the feature for clarity and maintainability.
Menu Handlers (Application/Features/Menus/):
CreateMenuHandler- Create new menu in New stateUpdateMenuHandler- Update existing menu structureDeleteMenuHandler- Delete New menus with blob cleanupGetMenuHandler- Retrieve menu details by IDGetActiveMenuHandler- Get currently active menu for customersListMenusHandler- List all menus for a cafe with paginationActivateMenuHandler- Activate a published menu (deactivates previous)PublishMenuHandler- Publish a new menu (ready for activation)CloneMenuHandler- Clone existing menu to create variations
Image Handlers (Application/Features/Images/):
UploadImageHandler- Upload and process menu item images to Azure Blob Storage
All handlers:
- Return
Result<T>orResult(never throw business exceptions) - Use
ErrorCodesconstants for consistent error codes - Use
IDateTimeProviderfor testable timestamps - Publish domain events via
IEventPublisher - Are registered in DI container via
ApplicationServiceRegistration
The project uses GitHub Actions for continuous integration and deployment:
1. CI Workflow (.github/workflows/ci.yml)
- Triggers: Push to
main, Pull Requests - Jobs:
- Build and Test:
- Runs on Ubuntu with PostgreSQL service container
- Restores dependencies, builds solution (Release)
- Runs unit tests and integration tests
- Publishes test results with
dotnet-trxreporter
- Code Quality:
- Checks code formatting with
dotnet format - Runs security scans for vulnerabilities
- Checks code formatting with
- Build Docker (main branch only):
- Builds Docker image with caching
- Tags image with commit SHA
- Build and Test:
2. Code Coverage Workflow (.github/workflows/coverage.yml)
- Triggers: Push to
main, Pull Requests - Features:
- Runs tests with XPlat Code Coverage collector
- Generates HTML/Cobertura coverage reports using ReportGenerator
- Uploads coverage artifacts
- Adds coverage summary as PR comment
- Enforces 70% coverage threshold (fails if below)
3. PR Validation Workflow (.github/workflows/pr-validation.yml)
- Triggers: Pull Request opened/updated
- Checks:
- Semantic PR titles: Enforces conventional commit format
- Types:
feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert
- Types:
- Merge conflicts: Auto-labels PRs with conflicts
- PR size labeling: Auto-labels by changed lines (xs/s/m/l/xl)
- Security scan:
- Checks for vulnerable NuGet packages
- Scans for leaked secrets with TruffleHog
- Semantic PR titles: Enforces conventional commit format
Configure these settings in GitHub repository settings:
Protect main branch:
- Require pull request reviews (1 approver)
- Require status checks to pass:
- Build and Test
- Code Quality
- Test Coverage
- Security Scan
- Require branches to be up to date
- Require conversation resolution before merging
- Require linear history
- Do not allow bypassing settings# Install act (GitHub Actions runner)
# https://github.com/nektos/act
# Run CI workflow locally
act -j build-and-test
# Run with specific event
act pull_request -j build-and-test- Orphan Image Cleanup Service: Implement background job to detect and delete orphaned images from blob storage
- Find images in blob storage not referenced in any menu (deleted items, abandoned new menus)
- Delete images for menus deleted >3 days ago (grace period for restoration)
- Delete orphaned item images older than 7 days
- Can be implemented as:
- Azure Function with timer trigger (daily)
- Background service in the API using
IHostedService - Azure Blob lifecycle management policies (automatic)
- Alternative: Temporary Blob Container
- Upload images to temp container first
- Move to permanent storage only when menu is saved
- Auto-retention policy on temp container (3 days)
- Requires image copy operation and URL updates
- Clone Menu Endpoint: Copy existing menu to create variations (e.g., Summer 2025 β Summer 2026)
- POST
/api/cafes/{cafeId}/menus/{menuId}/clone - Request:
{ "newMenuName": "Summer 2026" } - Copies entire menu structure (sections, items, categories, ingredients)
- Generates new GUIDs for menu, sections, and items
- Images can be shared or duplicated based on requirements
- Publishes
MenuClonedEvent
- POST
- Menu versioning: Track menu changes over time
- Bulk operations: Import/export menus as JSON
- Image optimization: WebP conversion, multiple sizes for responsive design
- Caching layer: Redis cache for frequently accessed active menus
- Analytics: Track popular items, menu view counts
- Search: Full-text search across menu items
Events published to Azure Service Bus:
MenuCreatedEventMenuUpdatedEventMenuDeletedEventMenuPublishedEventMenuActivatedEventMenuDeactivatedEventMenuClonedEvent
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is part of the SmartCafe system. All rights reserved.
For questions or support, please contact the development team.
Built with β€οΈ using .NET 10 and Clean Architecture