Instruction file imported from jpablopachar/app-courses (
.github/instructions/architecture.instructions.md). Copyright stays with the author.
Architecture Instructions — app-courses
Architectural pattern
This project follows Clean Architecture with a strict inward dependency rule. No outer layer may reference an inner layer.
WebApi → Application → Domain
↓
Infrastructure / Persistence → Domain
The solution is divided into five projects:
| Project | Role |
|---|---|
Domain |
Entities, value types, constants, roles, claims. Zero external dependencies. |
Application |
CQRS handlers, validators, DTOs, interfaces (contracts), AutoMapper profiles, shared utilities. No infrastructure imports. |
Infrastructure |
Implementations of Application interfaces that integrate with external systems (JWT, Cloudinary). |
Persistence |
AppCoursesDbContext, EF Core entity configurations, migrations, data seeding. |
WebApi |
Controllers, middleware, Program.cs, DI extension methods. Depends on Application only — never on Infrastructure or Persistence directly. |
Domain layer
Entities
All entities inherit from BaseEntity which provides a single Guid Id property.
Domain/
├── BaseEntity.cs ← abstract base, provides Id
├── Course.cs
├── Instructor.cs
├── Price.cs
├── Qualification.cs
├── Photo.cs
├── CourseInstructor.cs ← many-to-many junction: Course ↔ Instructor
├── CoursePrice.cs ← many-to-many junction: Course ↔ Price
├── CustomRoles.cs ← constants: ADMIN, CLIENT
├── CustomClaims.cs ← constants: POLICIES claim type
└── PolicyMaster.cs ← constants: COURSE_READ, COURSE_WRITE, etc.
Rules
- No entity may reference any class outside
Domain. - Many-to-many relationships use explicit junction entities (
CourseInstructor,CoursePrice), not EF Core implicit join tables. CustomRoles,CustomClaims, andPolicyMasterarestaticclasses withconst stringfields. Never use string literals for roles or policies — always reference these constants.
Application layer
Directory structure
Each resource or feature group has its own subdirectory. Features within a resource each get their own sub-folder.
Application/
├── Core/ ← shared infrastructure for all handlers
│ ├── ICommandBase.cs ← marker interface for commands (required by ValidationBehavior)
│ ├── Result.cs ← Result<T>: Success/Failure wrapper
│ ├── PagedList.cs ← pagination wrapper + CreateAsync factory
│ ├── PagingParams.cs ← abstract base for query filter/sort params (max page 50)
│ ├── ExpressionBuilder.cs ← LINQ predicate builder (.And(), .Or())
│ ├── ValidationBehavior.cs ← MediatR pipeline behavior: runs FluentValidation before every handler
│ ├── ValidationException.cs ← thrown by ValidationBehavior on failures
│ ├── ValidationError.cs ← property/message pair in the exception
│ ├── AppException.cs ← standard error body returned by ExceptionMiddleware
│ └── MappingProfile.cs ← AutoMapper profile: all entity → DTO mappings
├── Interfaces/ ← service contracts (implemented in Infrastructure or Persistence)
│ ├── ITokenService.cs
│ ├── IPhotoService.cs
│ ├── IUserAccessor.cs
│ ├── IProfileBuilderService.cs
│ └── IReportService.cs ← generic CSV report generation: GetCsvReportAsync(List<T>)
├── Accounts/
│ ├── GetCurrentUser/
│ ├── Login/
│ └── Register/
├── Courses/
│ ├── CourseCreate/
│ ├── CourseDelete/
│ ├── CourseUpdate/
│ ├── CourseExcelReport/
│ ├── GetCourse/
│ ├── GetCourses/
│ └── GetCourseResponse.cs ← shared CourseResponse DTO (used by GetCourse and GetCourses)
├── Instructors/
│ └── GetInstructors/
├── Prices/
│ └── GetPrices/
├── Qualifications/
│ └── GetQualifications/
└── DependencyInjection.cs ← AddApplication() extension method
CQRS pattern
Every feature is expressed as either a Command (mutating) or a Query (read-only).
Command structure — one folder per operation (with request DTO):
Courses/CourseCreate/
├── CourseCreateCommand.cs ← record : IRequest<Result<T>>, ICommandBase
├── CourseCreateRequest.cs ← raw input DTO bound from the HTTP body/form
├── CourseCreateCommandHandler.cs ← IRequestHandler<CourseCreateCommand, Result<T>>
├── CourseCreateValidator.cs ← AbstractValidator<CourseCreateRequest>
└── CourseCreateCommandValidator.cs← AbstractValidator<CourseCreateCommand>, wraps the above
Commands with no separate request DTO (e.g., CourseDeleteCommand takes only a Guid?) use a single validator directly on the command — the two-file split is omitted:
Courses/CourseDelete/
├── CourseDeleteCommand.cs ← record(Guid? CourseId) : IRequest<Result<Unit>>, ICommandBase
├── CourseDeleteCommandHandler.cs
└── CourseDeleteCommandValidator.cs← AbstractValidator<CourseDeleteCommand> (validates fields directly)
Query structure — same folder layout; queries may omit the validator pair if they have no mandatory input.
Queries that produce a file or stream may return a raw type instead of Result<T> (e.g., CourseExcelReportQuery : IRequest<MemoryStream>). The controller then calls .ToArray() or similar on the result directly rather than checking IsSuccess.
Shared response DTOs — when the same DTO is returned by multiple queries in a resource folder, place it at the resource level rather than inside a feature subfolder (e.g., Application/Courses/GetCourseResponse.cs defines CourseResponse, shared by GetCourseQuery and GetCoursesQuery).
Two-layer validation pattern
Commands that wrap a request DTO use two validator classes:
- Request validator (
CourseCreateValidator) — validates the raw DTO fields. - Command validator (
CourseCreateCommandValidator) — wraps the request validator viaSetValidator()soValidationBehaviorcan discover it.
Commands with no request DTO use a single validator class that validates command fields directly.
ValidationBehavior runs automatically before every handler. Never validate manually inside a handler.
ICommandBase marker interface
ValidationBehavior<TRequest, TResponse> has a generic constraint where TRequest : ICommandBase. Every command record must implement ICommandBase. Queries do not need to implement it.
Result<T>
All handlers return Result<T> for expected business outcomes.
// In the handler
return Result<Guid>.Failure("Instructor not found");
return Result<Guid>.Success(courseId);
// In the controller
return result.IsSuccess ? Ok(result.Value) : NotFound(result.Error);
Never throw exceptions for expected failures. Throw only for unexpected infrastructure failures — ExceptionMiddleware handles them.
PagedList<T> and PagingParams
- Queries that return lists accept a
paramsobject that extendsPagingParams. PagingParamsprovidesPageNumber,PageSize(capped at 50),OrderBy, andOrderAsc.- Materialise paginated results with
PagedList<T>.CreateAsync(query, pageNumber, pageSize, cancellationToken).
ExpressionBuilder
Build dynamic LINQ predicates with ExpressionBuilder:
Expression<Func<Course, bool>> predicate = ExpressionBuilder.New<Course>();
if (!string.IsNullOrEmpty(request.Params.Title))
predicate = predicate.And(x => x.Title!.Contains(request.Params.Title));
var query = _context.Courses!.AsNoTracking().Where(predicate);
MappingProfile
All AutoMapper mappings live in Application/Core/MappingProfile.cs. Do not create additional profile classes. When adding a new DTO, add CreateMap<Entity, Dto>() here.
Use ProjectTo<TDto>() in handlers for EF Core queries; use IMapper.Map<TDto>() only for in-memory mapping of already-loaded objects.
Interfaces
Define service contracts in Application/Interfaces/ only when the implementation lives in Infrastructure or Persistence. Do not create interfaces for internal Application classes that have a single implementation.
Infrastructure layer
Implements Application interfaces that integrate with external systems.
Infrastructure/
├── Security/
│ ├── TokenService.cs ← ITokenService: mints JWT tokens
│ └── UserAccessorService.cs ← IUserAccessor: reads current user from HttpContext
└── Photos/
└── PhotoService.cs ← IPhotoService: Cloudinary upload/delete
- No direct reference to
PersistenceorWebApi. CloudinarySettingsis bound from configuration viaIOptions<CloudinarySettings>.- Infrastructure services are registered in
WebApi/Extensions/IdentityServiceExtensions.csalongside Identity.
Persistence layer
Persistence/
├── AppCoursesDbContext.cs ← IdentityDbContext<AppUser>; all DbSets; Fluent API config
├── Models/
│ └── AppUser.cs ← IdentityUser extension with FullName, Occupation
└── DependencyInjection.cs ← AddPersistence(config): registers DbContext with SQLite
EF Core conventions
DbSet<T>properties are nullable (DbSet<Course>? Courses). Always use the null-forgiving operator (!) when querying:_context.Courses!.- Table names use lowercase snake_case, configured in
OnModelCreating. - All read-only queries must use
.AsNoTracking(). - Prefer
.ProjectTo<TDto>(_mapper.ConfigurationProvider)over loading full entities then mapping. - Use
SaveChangesAsync(cancellationToken)for implicit transactions. UseBeginTransactionAsync()only for multi-step atomic operations. - Never concatenate raw SQL strings. If raw SQL is unavoidable use
FromSqlRawwithSqlParameterobjects.
WebApi layer
Controllers
- All controllers live in
WebApi/Controllers/and inherit fromControllerBase. - Inject
ISender(MediatR) via primary constructor. - Controllers are thin: call
_sender.Send(command, cancellationToken)and mapResult<T>toActionResult. - Always pass
CancellationTokenthrough to_sender.Send(). - Apply
[Authorize(Policy = PolicyMaster.XYZ)]on every protected endpoint. - Use
[ProducesResponseType]to document expected HTTP status codes.
[ApiController]
[Route("api/[controller]")]
public class CoursesController(ISender sender) : ControllerBase
{
private readonly ISender _sender = sender;
[HttpGet]
[Authorize(Policy = PolicyMaster.COURSE_READ)]
[ProducesResponseType(typeof(PagedList<CourseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedList<CourseDto>>> GetCourses(
[FromQuery] CoursePagingParams pagingParams,
CancellationToken cancellationToken)
{
var result = await _sender.Send(new GetCoursesQuery(pagingParams), cancellationToken);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}
}
Middleware pipeline order
Middleware is registered in Program.cs in this exact order — do not reorder:
ExceptionMiddleware— catchesValidationException→ 400, all others → 500.UseSwaggerDocumentation()— Swagger UI and OpenAPI spec.UseCors("corsapp")— currently open; restrict to known origins before production.UseAuthentication()— JWT Bearer validation.UseAuthorization()— policy-based access control.SeedDataAuthentication()— runsMigrateAsync()and seeds initial data if the DB is empty.MapControllers().
Dependency Injection extensions
Each layer registers its own services through a single extension method. Program.cs only calls these — never raw services.Add* there:
| Extension method | File | Registers |
|---|---|---|
AddApplication() |
Application/DependencyInjection.cs |
MediatR + ValidationBehavior, FluentValidation, AutoMapper |
AddPersistence(config) |
Persistence/DependencyInjection.cs |
AppCoursesDbContext (SQLite) |
AddIdentityService(config) |
WebApi/Extensions/IdentityServiceExtensions.cs |
Identity Core, JWT Bearer, ITokenService, IUserAccessor |
AddPoliciesServices() |
WebApi/Extensions/PoliciesConfiguration.cs |
All authorization policies |
AddSwaggerDocumentation() |
WebApi/Extensions/SwaggerServiceExtensions.cs |
Swagger with Bearer support |
When adding a new service: define the interface in Application/Interfaces/, implement it in Infrastructure/ or Persistence/, and register it in the appropriate DependencyInjection.cs.
Authorization model
Authorization is claims-based on top of roles. The claim type is CustomClaims.POLICIES; each claim value is a policy name from PolicyMaster.
Current policies (Domain/PolicyMaster.cs)
| Constant | Purpose |
|---|---|
COURSE_READ |
Read access to courses and reports |
COURSE_WRITE |
Create a new course |
COURSE_UPDATE |
Update an existing course |
COURSE_DELETE |
Delete a course |
INSTRUCTOR_READ |
Read access to instructors |
INSTRUCTOR_CREATE |
Create an instructor |
INSTRUCTOR_UPDATE |
Update an instructor |
COMMENT_READ |
Read comments |
COMMENT_CREATE |
Create a comment |
COMMENT_DELETE |
Delete a comment |
How it works end-to-end
- Client calls
POST /api/account/login. LoginCommandHandlervalidates credentials viaUserManager.TokenServicefetches the user's claims from the DB and mints a JWT (7-day expiry) embedding allPOLICIESclaim values.- Protected endpoints declare
[Authorize(Policy = PolicyMaster.COURSE_READ)]. PoliciesConfigurationregisters each policy asRequireAssertion(ctx => ctx.User.HasClaim(c => c.Type == CustomClaims.POLICIES && c.Value == PolicyMaster.XYZ)).
Adding a new protected endpoint
- Add a
const stringtoDomain/PolicyMaster.cs. - Register the policy assertion in
WebApi/Extensions/PoliciesConfiguration.cs. - Decorate the endpoint:
[Authorize(Policy = PolicyMaster.NEW_POLICY)]. - Seed the claim to the appropriate role in
WebApi/Extensions/DataSeed.cs.
Data flow (happy path)
HTTP Request
→ Controller (route matching, auth check)
→ ISender.Send(command/query, cancellationToken)
→ ValidationBehavior (FluentValidation — throws ValidationException on failure)
→ Handler (queries DbContext or calls infrastructure service)
→ Result<T> returned
→ Controller maps Result<T> to ActionResult
HTTP Response
What goes where — placement rules
| Artifact | Location |
|---|---|
| Domain entity / value type | Domain/ |
| Role or policy constant | Domain/CustomRoles.cs or Domain/PolicyMaster.cs |
| Command or query record | Application/{Resource}/{Feature}/ |
| Input DTO (request) | Same folder as the command/query |
| Output DTO (response/dto) | Same folder as the query handler; if shared across multiple queries in a resource, place at the resource level (e.g., Application/Courses/GetCourseResponse.cs) |
| MediatR handler | Same folder as the command/query |
| FluentValidation validator | Same folder as the command/query |
| AutoMapper mapping | Application/Core/MappingProfile.cs |
| Service interface | Application/Interfaces/ |
| Service implementation (external) | Infrastructure/ |
| Service implementation (DB-backed) | Persistence/ |
| Controller | WebApi/Controllers/ |
| DI extension | Same project as what it registers |
| Middleware | WebApi/Middlewares/ |
Key constraints to never violate
- No circular layer references.
Applicationmust never import fromInfrastructure,Persistence, orWebApi. - No entities in controller responses. Always project to a DTO before returning.
- No
DataAnnotationson commands or DTOs. Use FluentValidation exclusively. - No
.Resultor.Wait(). All async code must useawait. - No raw SQL string concatenation. All DB access through EF Core parameterised queries.
- No hard-coded secrets.
TokenKeyand Cloudinary credentials come from environment variables or User Secrets. - No new policy strings as literals. Always reference
PolicyMasterconstants.