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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .cursor/rules/main.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,18 @@ This is a mono repository with multiple self-contained systems (SCS), each being
- [application](mdc:application): Contains application code:
- [account-management](mdc:application/account-management): An SCS for tenant and user management:
- [WebApp](mdc:application/account-management/WebApp): A React, TypeScript SPA.
- [Api](mdc:application/account-management/Api): .NET 9 minimal API.
- [Core](mdc:application/account-management/Core): .NET 9 Vertical Sliced Architecture.
- [Api](mdc:application/account-management/Api): .NET 10 minimal API.
- [Core](mdc:application/account-management/Core): .NET 10 Vertical Sliced Architecture.
- [Workers](mdc:application/account-management/Workers): A .NET Console job.
- [Tests](mdc:application/account-management/Tests): xUnit tests for backend.
- [back-office](mdc:application/back-office): An empty SCS that will be used to create tools for Support and System Admins:
- [WebApp](mdc:application/back-office/WebApp): A React, TypeScript SPA.
- [Api](mdc:application/back-office/Api): .NET 9 minimal API.
- [Core](mdc:application/back-office/Core): .NET 9 Vertical Sliced Architecture.
- [Api](mdc:application/back-office/Api): .NET 10 minimal API.
- [Core](mdc:application/back-office/Core): .NET 10 Vertical Sliced Architecture.
- [Workers](mdc:application/back-office/Workers): A .NET Console job.
- [Tests](mdc:application/back-office/Tests): xUnit tests for backend.
- [AppHost](mdc:application/AppHost): .NET Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode.
- [AppGateway](mdc:application/AppGateway): Main entry point using .NET YARP as reverse proxy for all SCSs.
- [AppHost](mdc:application/AppHost): Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode.
- [AppGateway](mdc:application/AppGateway): Main entry point using YARP as reverse proxy for all SCSs.
- [shared-kernel](mdc:application/shared-kernel): Reusable .NET backend shared by all SCSs.
- [shared-webapp](mdc:application/shared-webapp): Reusable frontend shared by all SCSs.
- [cloud-infrastructure](mdc:cloud-infrastructure): Bash and Azure Bicep scripts (IaC).
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/app-gateway.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ jobs:
- name: Setup .NET Core SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
global-json-file: application/global.json

- name: Restore .NET Tools
working-directory: application
Expand Down
6 changes: 2 additions & 4 deletions .windsurf/rules/backend/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,7 @@ public sealed class CreateUserValidator : AbstractValidator<CreateUserCommand>
public CreateUserValidator()
{
// ✅ DO: Use the same message for better user experience and easier localization
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name must be between 1 and 50 characters.")
.MaximumLength(50).WithMessage("Name must be between 1 and 50 characters.");
RuleFor(x => x.Name).Length(1, 50).WithMessage("Name must be between 1 and 50 characters.");
}
}

Expand Down Expand Up @@ -99,7 +97,7 @@ public sealed class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserValidator()
{
// ❌ DON'T: Use different validation messages for the same property
// ❌ DON'T: Use different validation messages for the same property and redundant validation rules
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name must not be empty.")
.MaximumLength(50).WithMessage("Name must not be more than 50 characters.");
Expand Down
12 changes: 6 additions & 6 deletions .windsurf/rules/main.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,18 @@ This is a mono repository with multiple self-contained systems (SCS), each being
- [application](/application): Contains application code:
- [account-management](/application/account-management): An SCS for tenant and user management:
- [WebApp](/application/account-management/WebApp): A React, TypeScript SPA.
- [Api](/application/account-management/Api): .NET 9 minimal API.
- [Core](/application/account-management/Core): .NET 9 Vertical Sliced Architecture.
- [Api](/application/account-management/Api): .NET 10 minimal API.
- [Core](/application/account-management/Core): .NET 10 Vertical Sliced Architecture.
- [Workers](/application/account-management/Workers): A .NET Console job.
- [Tests](/application/account-management/Tests): xUnit tests for backend.
- [back-office](/application/back-office): An empty SCS that will be used to create tools for Support and System Admins:
- [WebApp](/application/back-office/WebApp): A React, TypeScript SPA.
- [Api](/application/back-office/Api): .NET 9 minimal API.
- [Core](/application/back-office/Core): .NET 9 Vertical Sliced Architecture.
- [Api](/application/back-office/Api): .NET 10 minimal API.
- [Core](/application/back-office/Core): .NET 10 Vertical Sliced Architecture.
- [Workers](/application/back-office/Workers): A .NET Console job.
- [Tests](/application/back-office/Tests): xUnit tests for backend.
- [AppHost](/application/AppHost): .NET Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode.
- [AppGateway](/application/AppGateway): Main entry point using .NET YARP as reverse proxy for all SCSs.
- [AppHost](/application/AppHost): Aspire project for orchestrating SCSs and Docker containers. Never run directly—typically running in watch mode.
- [AppGateway](/application/AppGateway): Main entry point using YARP as reverse proxy for all SCSs.
- [shared-kernel](/application/shared-kernel): Reusable .NET backend shared by all SCSs.
- [shared-webapp](/application/shared-webapp): Reusable frontend shared by all SCSs.
- [cloud-infrastructure](/cloud-infrastructure): Bash and Azure Bicep scripts (IaC).
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Built to demonstrate seamless flow—backend contracts feed a fully-typed React

## What's inside

* **Backend** - .NET 9 and C# adhering to the principles of vertical slice architecture, DDD, CQRS, and clean code
* **Backend** - .NET 10 and C# 14 adhering to the principles of vertical slice architecture, DDD, CQRS, and clean code
* **Frontend** – React 19, TypeScript, TanStack Router & Query, React Aria for accessible and UI
* **CI/CD** - GitHub actions for fast passwordless deployments of docker containers and infrastructure (Bicep)
* **Infrastructure** - Cost efficient and scalable Azure PaaS services like Azure Container Apps, Azure SQL, etc.
Expand Down Expand Up @@ -59,7 +59,7 @@ For development, you need .NET, Docker, and Node. And GitHub and Azure CLI for s
2. From an Administrator PowerShell terminal, use [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/) (preinstalled on Windows 11) to install any missing packages:

```powershell
winget install Microsoft.DotNet.SDK.9
winget install Microsoft.DotNet.SDK.10
winget install Git.Git
winget install Docker.DockerDesktop
winget install OpenJS.NodeJS
Expand Down Expand Up @@ -125,10 +125,10 @@ Open a terminal and run the following commands (if not installed):
sudo apt-get update
```

- Install .NET SDK 9.0, Node, GitHub CLI
- Install .NET SDK 10.0, Node, GitHub CLI

```bash
sudo apt-get install -y dotnet-sdk-9.0 nodejs gh
sudo apt-get install -y dotnet-sdk-10.0 nodejs gh
```

- Install Azure CLI
Expand Down Expand Up @@ -161,7 +161,7 @@ We recommend you keep the commit history, which serves as a great learning and t

## 2. Run the Aspire AppHost to spin up everything on localhost

Using .NET Aspire, docker images with SQL Server, Blob Storage emulator, and development mail server will be downloaded and started. No need install anything, or learn complicated commands. Simply run this command, and everything just works 🎉
Using Aspire, docker images with SQL Server, Blob Storage emulator, and development mail server will be downloaded and started. No need install anything, or learn complicated commands. Simply run this command, and everything just works 🎉

```bash
cd application/AppHost
Expand Down Expand Up @@ -205,7 +205,7 @@ PlatformPlatform is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) contain
├─ .github # Separate GitHub workflows for deploying Infrastructure and app
├─ .windsurf # Copy of .cursor for Windsurf AI editor (synchronized by CLI)
├─ application # Contains the application source code
│ ├─ AppHost # .NET Aspire project starting app and all dependencies in Docker
│ ├─ AppHost # Aspire project starting app and all dependencies in Docker
│ ├─ AppGateway # Main entry point for the app using YARP as a reverse proxy
│ ├─ account-management # Self-contained system with account sign-up, user management, etc.
│ │ ├─ WebApp # React SPA frontend using TypeScript and React Aria Components
Expand Down Expand Up @@ -233,12 +233,12 @@ PlatformPlatform is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) contain

# Technologies

### .NET 9 Backend With Vertical Sliced Architecture, DDD, CQRS, Minimal API, and Aspire
### .NET 10 Backend With Vertical Sliced Architecture, DDD, CQRS, Minimal API, and Aspire

The backend is built using the most popular, mature, and commonly used technologies in the .NET ecosystem:

- [.NET 9](https://dotnet.microsoft.com) and [C# 13](https://learn.microsoft.com/en-us/dotnet/csharp/tour-of-csharp)
- [.NET Aspire](https://aka.ms/dotnet-aspire)
- [.NET 10](https://dotnet.microsoft.com) and [C# 14](https://learn.microsoft.com/en-us/dotnet/csharp/tour-of-csharp)
- [Aspire](https://aka.ms/dotnet-aspire)
- [YARP](https://microsoft.github.io/reverse-proxy)
- [ASP.NET Minimal API](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis)
- [Entity Framework](https://learn.microsoft.com/en-us/ef)
Expand Down
37 changes: 20 additions & 17 deletions application/AppGateway/ApiAggregation/ApiAggregationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,29 @@

public static class Endpoints
{
public static WebApplication ApiAggregationEndpoints(this WebApplication app)
extension(WebApplication app)
{
app.MapGet("/swagger", context =>
{
context.Response.Redirect("/openapi/v1");
return Task.CompletedTask;
}
);
public WebApplication ApiAggregationEndpoints()

Check warning on line 7 in application/AppGateway/ApiAggregation/ApiAggregationEndpoints.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make 'ApiAggregationEndpoints' a static method.

See more on https://sonarcloud.io/project/issues?id=PlatformPlatform_platformplatform&issues=AZqymAR5hJMJg08IL3yC&open=AZqymAR5hJMJg08IL3yC&pullRequest=794
{
app.MapGet("/swagger", context =>
{
context.Response.Redirect("/openapi/v1");
return Task.CompletedTask;
}
);

app.MapGet("/openapi", context =>
{
context.Response.Redirect("/openapi/v1");
return Task.CompletedTask;
}
);
app.MapGet("/openapi", context =>
{
context.Response.Redirect("/openapi/v1");
return Task.CompletedTask;
}
);

app.MapGet("/openapi/v1.json", async (ApiAggregationService apiAggregationService)
=> Results.Content(await apiAggregationService.GetAggregatedOpenApiJson(), "application/json")
).CacheOutput(c => c.Expire(TimeSpan.FromMinutes(5)));
app.MapGet("/openapi/v1.json", async (ApiAggregationService apiAggregationService)
=> Results.Content(await apiAggregationService.GetAggregatedOpenApiJson(), "application/json")
).CacheOutput(c => c.Expire(TimeSpan.FromMinutes(5)));

return app;
return app;
}
}
}
2 changes: 1 addition & 1 deletion application/AppGateway/AppGateway.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>PlatformPlatform.AppGateway</AssemblyName>
<RootNamespace>PlatformPlatform.AppGateway</RootNamespace>
<Nullable>enable</Nullable>
Expand Down
2 changes: 1 addition & 1 deletion application/AppGateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled

WORKDIR /app
COPY ./AppGateway/publish .
Expand Down
2 changes: 1 addition & 1 deletion application/AppGateway/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
);
}

builder.AddNamedBlobStorages(("account-management-storage", "ACCOUNT_MANAGEMENT_STORAGE_URL"));
builder.AddNamedBlobStorages([("account-management-storage", "ACCOUNT_MANAGEMENT_STORAGE_URL")]);

builder.WebHost.UseKestrel(option => option.AddServerHeader = false);

Expand Down
11 changes: 3 additions & 8 deletions application/AppHost/AppHost.csproj
Original file line number Diff line number Diff line change
@@ -1,30 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.2"/>
<Project Sdk="Aspire.AppHost.Sdk/13.0.0">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>platformplatform-f817f2a1-ac57-4756-aef2-a57ca864bbd3</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\account-management\Api\AccountManagement.Api.csproj"/>
<ProjectReference Include="..\account-management\WebApp\AccountManagement.WebApp.esproj"/>
<ProjectReference Include="..\account-management\Workers\AccountManagement.Workers.csproj"/>
<ProjectReference Include="..\AppGateway\AppGateway.csproj"/>
<ProjectReference Include="..\back-office\Api\BackOffice.Api.csproj"/>
<ProjectReference Include="..\back-office\WebApp\BackOffice.WebApp.esproj"/>
<ProjectReference Include="..\back-office\Workers\BackOffice.Workers.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Aspire.Azure.Storage.Blobs"/>
<PackageReference Include="Aspire.Hosting.AppHost"/>
<PackageReference Include="Aspire.Hosting.Azure.Storage"/>
<PackageReference Include="Aspire.Hosting.NodeJs"/>
<PackageReference Include="Aspire.Hosting.JavaScript"/>
<PackageReference Include="Aspire.Hosting.SqlServer"/>
<PackageReference Include="Microsoft.Extensions.Configuration"/>
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets"/>
Expand Down
17 changes: 9 additions & 8 deletions application/AppHost/ConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ namespace AppHost;

public static class ConfigurationExtensions
{
public static IResourceBuilder<TDestination> WithUrlConfiguration<TDestination>(
this IResourceBuilder<TDestination> builder,
string applicationBasePath) where TDestination : IResourceWithEnvironment
extension<TDestination>(IResourceBuilder<TDestination> builder) where TDestination : IResourceWithEnvironment
{
var baseUrl = Environment.GetEnvironmentVariable("PUBLIC_URL") ?? "https://localhost:9000";
applicationBasePath = applicationBasePath.TrimEnd('/');
public IResourceBuilder<TDestination> WithUrlConfiguration(string applicationBasePath)
{
var baseUrl = Environment.GetEnvironmentVariable("PUBLIC_URL") ?? "https://localhost:9000";
applicationBasePath = applicationBasePath.TrimEnd('/');

return builder
.WithEnvironment("PUBLIC_URL", baseUrl)
.WithEnvironment("CDN_URL", baseUrl + applicationBasePath);
return builder
.WithEnvironment("PUBLIC_URL", baseUrl)
.WithEnvironment("CDN_URL", baseUrl + applicationBasePath);
}
}
}
2 changes: 1 addition & 1 deletion application/AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
CreateBlobContainer("logos");

var frontendBuild = builder
.AddNpmApp("frontend-build", "../")
.AddJavaScriptApp("frontend-build", "../")
.WithEnvironment("CERTIFICATE_PASSWORD", certificatePassword);

var accountManagementDatabase = sqlServer
Expand Down
40 changes: 20 additions & 20 deletions application/AppHost/SecretManagerHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,6 @@ public static class SecretManagerHelper

private static string UserSecretsId => Assembly.GetEntryAssembly()!.GetCustomAttribute<UserSecretsIdAttribute>()!.UserSecretsId;

public static IResourceBuilder<ParameterResource> CreateStablePassword(
this IDistributedApplicationBuilder builder,
string secretName
)
{
var password = ConfigurationRoot[secretName];

if (string.IsNullOrEmpty(password))
{
var passwordGenerator = new GenerateParameterDefault
{
MinLower = 5, MinUpper = 5, MinNumeric = 3, MinSpecial = 3
};
password = passwordGenerator.GetDefaultValue();
SaveSecrectToDotNetUserSecrets(secretName, password);
}

return builder.CreateResourceBuilder(new ParameterResource(secretName, _ => password, true));
}

public static void GenerateAuthenticationTokenSigningKey(string secretName)
{
if (string.IsNullOrEmpty(ConfigurationRoot[secretName]))
Expand All @@ -56,4 +36,24 @@ private static void SaveSecrectToDotNetUserSecrets(string key, string value)
using var process = Process.Start(startInfo)!;
process.WaitForExit();
}

extension(IDistributedApplicationBuilder builder)
{
public IResourceBuilder<ParameterResource> CreateStablePassword(string secretName)
{
var password = ConfigurationRoot[secretName];

if (string.IsNullOrEmpty(password))
{
var passwordGenerator = new GenerateParameterDefault
{
MinLower = 5, MinUpper = 5, MinNumeric = 3, MinSpecial = 3
};
password = passwordGenerator.GetDefaultValue();
SaveSecrectToDotNetUserSecrets(secretName, password);
}

return builder.CreateResourceBuilder(new ParameterResource(secretName, _ => password, true));
}
}
}
Loading