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
71 changes: 53 additions & 18 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ apply: always
## Build, test, and lint commands

### Backend and solution
- Install the Aspire workload before using the AppHost: `dotnet workload install`
- Install the Aspire workload before using the AppHost: `dotnet workload install aspire`
- Restore packages: `dotnet restore`
- Build the solution: `dotnet build --configuration Release`
- Use `dotnet build` as the .NET lint gate. `Directory.Build.props` enables analyzers, enforces code style in build, and treats warnings as errors across all `.csproj` files.
- Use `dotnet build` as the .NET lint gate. `Directory.Build.props` enables `AnalysisMode=All`, `EnforceCodeStyleInBuild`, `TreatWarningsAsErrors`, and SonarAnalyzer across all `.csproj` files.
- Run all tests: `dotnet test --configuration Release`
- Run one test project: `dotnet test Tests\Domain.UnitTests\Domain.UnitTests.csproj --configuration Release`
- Run a single test class or method: `dotnet test Tests\Domain.UnitTests\Domain.UnitTests.csproj --configuration Release --filter "FullyQualifiedName~Domain.UnitTests.BookTests"`
Expand All @@ -25,30 +25,65 @@ apply: always
- Install dependencies: `pnpm install`
- Start the admin app: `pnpm dev`
- Build the admin app: `pnpm build`
- Lint the admin app: `pnpm lint`
- Regenerate API hooks and Zod schemas: `pnpm generate`
- Lint the admin app: `pnpm lint` (eslint with `--max-warnings=0`)
- Regenerate API hooks and Zod schemas: `pnpm generate` (runs orval against `http://localhost:5049/openapi/v1.json`)

## High-level architecture

- `CleanArchitecture.sln` is split into Core (`CleanArchitecture.Domain`, `CleanArchitecture.Application`), Infrastructure (`CleanArchitecture.Infrastructure`, `CleanArchitecture.Infrastructure.Persistence`), Presentation (`CleanArchitecture.Presentation\API`, `CleanArchitecture.Presentation\admin`), Aspire (`CleanArchitecture.AppHost`, `CleanArchitecture.ServiceDefaults`), and four test projects.
- `CleanArchitecture.Presentation\API\Program.cs` is the backend composition root. It adds Aspire service defaults first, then wires the Application, Infrastructure, Infrastructure.Persistence, and Presentation layers through their `Add*Services()` extension methods.
- The API is a versioned Minimal API. Endpoints are grouped under `/api/v{version:apiVersion}` and then mapped by feature extension classes such as `BookEndpoints`.
- `CleanArchitecture.Infrastructure.Persistence` owns EF Core concerns: `ApplicationDbContext`, entity configurations, migrations, SaveChanges interceptors, and the unit-of-work implementation.
- `CleanArchitecture.Aspire\CleanArchitecture.AppHost\AppHost.cs` is the real local-dev entry point. In Development it starts Postgres + PgAdmin, Keycloak with realm import, the API, and the Next.js admin app. In Testing it starts only the API and an ephemeral Postgres database, which is why integration tests do not need a separately managed database or Keycloak instance.
- `CleanArchitecture.Infrastructure.Persistence` owns EF Core concerns: `ApplicationDbContext`, entity configurations, migrations, SaveChanges interceptors (`AuditableEntityInterceptor` for timestamps, `DispatchDomainEventsInterceptor` for publishing domain events after save), and the unit-of-work implementation.
- `CleanArchitecture.Aspire\CleanArchitecture.AppHost\AppHost.cs` is the real local-dev entry point. It has three environment modes: **Development** starts Postgres + PgAdmin, Keycloak with realm import, the API, and the Next.js admin app. **Testing** starts only the API and an ephemeral Postgres database (no Keycloak; `TestAuthHandler` grants all roles). **Production** targets Azure Container Apps, PostgreSQL Flexible Server, Key Vault, and Application Insights.
- `CleanArchitecture.Aspire\CleanArchitecture.ServiceDefaults\Extensions.cs` adds the shared cross-cutting runtime behavior: Serilog, OpenTelemetry, service discovery, resilience handlers, and health endpoints.
- `CleanArchitecture.Presentation\admin` is a Next.js 16 App Router admin app. It uses NextAuth with Keycloak, TanStack Query for API hooks, and generated API clients/Zod schemas under `src\lib\api` and `src\lib\api\zod`.
- The Next.js frontend acts as a Backend for Frontend (BFF), with only the frontend and Keycloak admin UI publicly exposed. The .NET API remains private, allowing internal HTTP communication between the frontend, API, and Keycloak.
- `CleanArchitecture.Presentation\admin` is a Next.js 16 App Router admin app acting as a **Backend for Frontend (BFF)**. Only the frontend and Keycloak admin UI are publicly exposed; the .NET API remains private. The admin uses NextAuth with Keycloak, TanStack Query for server state, and orval-generated API clients/Zod schemas under `src\lib\api` and `src\lib\api\zod`.

## Key conventions

- Preserve the layer boundaries enforced by `Tests\Architecture.UnitTests\ArchitectureTests.cs`. Domain must not reference Application, Infrastructure, or Presentation; Application must not reference Infrastructure or Presentation; Infrastructure projects must not reference Presentation.
- Backend use cases follow CQRS with Mediator source generation and FluentValidation auto-registration from `CleanArchitecture.Application\DependencyInjection.cs`. New features should follow the existing `Entities\<Aggregate>\Commands` and `Entities\<Aggregate>\Queries` layout.
- Application and domain code return `Result` or `Result<T>`. Minimal API endpoints translate those results to HTTP responses through `CleanArchitecture.Presentation\API\Extensions\ResultExtensions.cs` instead of building ad hoc response shapes.
- Write endpoints use `SendWithRetryAsync()` from `CleanArchitecture.Presentation\API\Configuration\MediatorPollyExtensions.cs`, which wraps mediator calls with Polly retry, circuit-breaker, and fallback behavior. Read endpoints call `sender.Send()` directly.
- Non-production startup automatically applies EF Core migrations in `CleanArchitecture.Presentation\API\Configuration\WebApplicationExtensions.cs`. The EF CLI works because `CleanArchitecture.Infrastructure.Persistence\Data\ApplicationDbContextFactory.cs` supplies a design-time Npgsql connection when Aspire has not injected `ConnectionStrings:postgresdb`.
- Authentication is environment-sensitive. Normal environments use Keycloak JWT bearer auth with named policies from `CleanArchitecture.Infrastructure\Security`; the `Testing` environment swaps in `TestAuthHandler`, which grants the integration test client all required roles.
### Architecture and layer boundaries
- Preserve the layer boundaries enforced by `Tests\Architecture.UnitTests\ArchitectureTests.cs`. Domain → no project references. Application → Domain only. Infrastructure → Application (+ Domain). Presentation → Application + Infrastructure.
- Backend use cases follow CQRS with the **Mediator** source-generator package (not MediatR) and FluentValidation auto-registration from `CleanArchitecture.Application\DependencyInjection.cs`. New features should follow the existing `Entities\<Aggregate>\Commands\<Verb>\` and `Entities\<Aggregate>\Queries\<Verb>\` layout, each containing a command/query record, handler, and validator.

### Domain model
- Domain entities inherit from `Entity` → `AggregateRoot` (or their auditable variants `EntityAuditable` → `AggregateRootAuditable` which add `CreatedDate`/`UpdatedDate`). `Entity` provides identity-based equality and a `DomainEvents` collection.
- Aggregates expose static factory methods (e.g., `Book.Create(...)`) that perform domain validation and return `Result<T>`. Use `DomainError` records with `ErrorType` (Validation, NotFound, Conflict, Failure) for domain-level errors, defined in a per-aggregate `*Errors` class.
- Domain events are raised via `AddDomainEvent()` on the entity and dispatched automatically by `DispatchDomainEventsInterceptor` after `SaveChangesAsync`. Handlers live in `Application\Entities\<Aggregate>\EventHandlers\`.

### Application layer patterns
- Command/query handlers extend `BaseRequestHandler<TRequest, TResponse>`, which runs FluentValidation before delegating to the `HandleRequest` override. This means validation is automatic — add a `*Validator.cs` and it's picked up.
- `LoggingBehaviour<,>` is registered as a pipeline behavior. It logs all request start/end and flags slow requests (>1 second) with a warning.
- Application and domain code return `Result` or `Result<T>` (from the `DomainValidation` package). Never throw exceptions for expected failures.

### Minimal API endpoints
- Endpoints are static methods in feature-scoped classes (e.g., `BookEndpoints`). Each class exposes a `Map*Endpoints(this RouteGroupBuilder)` extension.
- Translate `Result<T>` to HTTP via `ResultExtensions`: `ToCreatedResponse`, `ToProblemDetails`, `ToNoContentResponse`. Error types map to: Validation → 422, NotFound → 404, Conflict → 409, other → 400/500. Responses use RFC 9110 ProblemDetails with error codes in `extensions.errors`.
- Write endpoints use `sender.SendWithRetryAsync()` (Polly: retry 3× exponential, circuit breaker, fallback). Read endpoints use `sender.Send()` directly.
- Read endpoints use `CacheOutput` with tag-based invalidation. After a successful write, call `cacheStore.EvictByTagAsync("tag")`.
- Authorization uses named policies: `ViewerPolicy`, `EditorPolicy`, `AdminPolicy`. Permission roles from Keycloak: `view`, `create`, `edit`, `delete`.

### Authentication
- Authentication is environment-sensitive. Normal environments use Keycloak JWT bearer auth via `AddKeycloakJwtBearer`; the `Testing` environment swaps in `TestAuthHandler`, which grants the integration test client all required roles.
- OpenAPI and Scalar are only mapped in Development when the backend user secret `ScalarApi:ClientSecret` is set. This matters for `pnpm generate`, because `orval.config.ts` reads `http://localhost:5049/openapi/v1.json`.
- Treat `CleanArchitecture.Presentation\admin\src\lib\api` and `CleanArchitecture.Presentation\admin\src\lib\api\zod` as generated output. Update the backend contract and rerun `pnpm generate` instead of hand-editing those files. The repo also relies on `scripts\fix-zod-schemas.js` after generation.
- Use `pnpm` for the admin app. The repo commits `pnpm-lock.yaml`, the AppHost starts the frontend with `.WithPnpm()`, and `.aiassistant\rules\instructions.md` assumes pnpm-based frontend workflows.
- The admin app behaves like a thin BFF. `next.config.ts` rewrites browser calls from `/api/v1/*` to `API_BASE_URL`, while `src\lib\orval-fetch.ts` attaches the NextAuth access token for both server-side and client-side requests.
- Local secrets live in two places: backend user secrets for `ScalarApi:ClientSecret`, and `CleanArchitecture.Presentation\admin\.env.local` for `API_BASE_URL`, `NEXTAUTH_*`, and `KEYCLOAK_*`.

### Database and migrations
- Non-production startup automatically applies EF Core migrations in `WebApplicationExtensions.cs`. The EF CLI works because `ApplicationDbContextFactory.cs` supplies a design-time Npgsql connection when Aspire has not injected `ConnectionStrings:postgresdb`.
- Entity configurations use `IEntityTypeConfiguration<T>` in `Infrastructure.Persistence`, auto-discovered via `ApplyConfigurationsFromAssembly`.

### Testing patterns
- Integration tests use `DistributedApplicationTestingBuilder` to spin up the full Aspire stack with `--environment=Testing`. Tests share a fixture via `[Collection("DistributedApplication collection")]` and create `HttpClient` per class via `App.CreateHttpClient("cleanarchitecture-api")`.
- Unit tests use xUnit + Moq. Domain and Application behavior are tested in `Tests\Domain.UnitTests` and `Tests\Application.UnitTests`.
- CI workflow (`.github\workflows\dotnet.yml`) builds with `--configuration Release`, runs all tests, and sets `ASPIRE_HOSTING_TESTING_DISABLE_DASHBOARD=true`.

### Admin app (Next.js)
- Use `pnpm` exclusively. The repo commits `pnpm-lock.yaml` and the AppHost starts the frontend with `.WithPnpm()`.
- Treat `src\lib\api` and `src\lib\api\zod` as **generated output**. Update the backend contract and rerun `pnpm generate` instead of hand-editing those files.
- The custom fetcher `src\lib\orval-fetch.ts` detects server vs. client context: server-side prepends `API_BASE_URL` and uses `getServerSession()`; client-side sends relative URLs and uses `getSession()`. Both inject `Authorization: Bearer` headers. On 401, it triggers Keycloak re-authentication.
- Routes are organized under `src\app\(admin)\` (protected layout with sidebar) and unauthenticated routes at root (`/signin`). An API proxy at `src\app\api\v1\[...path]\route.ts` forwards browser calls to the backend.
- Frontend has i18n support (en, fa, ar) with RTL layout via `LanguageContext`. Theme supports dark/light mode via `ThemeContext`.
- Permission-based UI: `useUserPermissions()` hook reads roles from the NextAuth session (populated from Keycloak JWT) to conditionally render create/edit/delete actions.
- Forms use React Hook Form + Zod validation + a `fieldErrorMap` pattern that maps API `ProblemDetails` error codes to form field names for inline error display.
- Context provider hierarchy in root layout: `AuthProvider` → `QueryProvider` (TanStack Query, 60s stale time) → `LanguageProvider` → `ThemeProvider` → `SidebarProvider`.
- Local secrets: backend user secrets for `ScalarApi:ClientSecret`, and `CleanArchitecture.Presentation\admin\.env.local` for `API_BASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `KEYCLOAK_ISSUER`.

### Commits and PRs
- Use conventional commits: `feat:`, `fix:`, `chore:`, `refactor:`, `test:`, `docs:`.
64 changes: 64 additions & 0 deletions CleanArchitecture.Domain/Resources/BookErrors.ar.resx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Book.TitleIsRequired" xml:space="preserve">
<value>العنوان مطلوب ولا يمكن أن يكون فارغًا.</value>
</data>
<data name="Book.TitleTooLong" xml:space="preserve">
<value>لا يمكن أن يتجاوز العنوان 200 حرف.</value>
</data>
<data name="Book.InvalidGenre" xml:space="preserve">
<value>النوع المحدد غير مدعوم.</value>
</data>
<data name="Book.NotFound" xml:space="preserve">
<value>لم يتم العثور على الكتاب بالمعرف المحدد.</value>
</data>
<data name="Book.TitleIsTooShort" xml:space="preserve">
<value>يجب أن يتكون العنوان من 3 أحرف على الأقل.</value>
</data>
<data name="Book.GenreIsRequired" xml:space="preserve">
<value>النوع مطلوب ولا يمكن أن يكون فارغًا.</value>
</data>
<data name="Book.GenreIsInvalidLength" xml:space="preserve">
<value>يجب أن يكون طول النوع بين 1 و 2 حرف.</value>
</data>
</root>
64 changes: 64 additions & 0 deletions CleanArchitecture.Domain/Resources/BookErrors.fa.resx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Book.TitleIsRequired" xml:space="preserve">
<value>عنوان الزامی است و نمی‌تواند خالی باشد.</value>
</data>
<data name="Book.TitleTooLong" xml:space="preserve">
<value>عنوان نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد.</value>
</data>
<data name="Book.InvalidGenre" xml:space="preserve">
<value>ژانر مشخص شده پشتیبانی نمی‌شود.</value>
</data>
<data name="Book.NotFound" xml:space="preserve">
<value>کتاب با شناسه مشخص شده یافت نشد.</value>
</data>
<data name="Book.TitleIsTooShort" xml:space="preserve">
<value>عنوان باید حداقل ۳ کاراکتر باشد.</value>
</data>
<data name="Book.GenreIsRequired" xml:space="preserve">
<value>ژانر الزامی است و نمی‌تواند خالی باشد.</value>
</data>
<data name="Book.GenreIsInvalidLength" xml:space="preserve">
<value>طول ژانر باید بین ۱ تا ۲ کاراکتر باشد.</value>
</data>
</root>
Loading
Loading