A RESTful blog API built with ASP.NET Core (.NET 10) following Clean Architecture principles. Features CQRS via MediatR, ASP.NET Core Identity, Entity Framework Core with PostgreSQL, a generic repository pattern, audit interceptors, and a FluentValidation pipeline.
- Architecture Overview
- Project Structure
- Tech Stack
- Domain Entities
- Features & Endpoints
- Authentication (JWT)
- Key Design Decisions
- Getting Started
- Configuration
- Running Migrations
ZenBlog follows Clean Architecture with four layers that depend strictly inward:
βββββββββββββββββββββββββββββββββββββββββββββββ
β Presentation β
β ZenBlog.API β β Minimal API endpoints, middleware
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Infrastructure β
β ZenBlog.Persistence β β EF Core, Repositories, Migrations
β ZenBlog.Infrastructure β β JWT auth, current-user service
βββββββββββββββββββββββββββββββββββββββββββββββ€
β Core β
β ZenBlog.Application β β CQRS, Handlers, Validators, DTOs, Identity contracts
β ZenBlog.Domain β β Entities, no dependencies
βββββββββββββββββββββββββββββββββββββββββββββββ
- Domain has zero dependencies β pure C# entities.
- Application depends only on Domain β no EF Core, no HTTP, no JWT library. It defines ports (
IJwtTokenGenerator,ICurrentUserService) that outer layers implement. - Persistence implements the persistence contracts β EF Core and PostgreSQL live here only.
- Infrastructure implements the identity/auth contracts β JWT creation and validation, current-user resolution.
- API wires everything together β minimal endpoints, middleware, no business logic.
ZenBlogServer/
βββ .github/
β βββ workflows/
βββ Core/
β βββ ZenBlog.Application/
β β βββ Base/
β β β βββ BaseDto.cs
β β β βββ BaseResult.cs
β β βββ Behaviors/
β β β βββ ValidationBehavior.cs
β β βββ Contracts/
β β β βββ Identity/
β β β β βββ ICurrentUserService.cs
β β β β βββ IJwtTokenGenerator.cs
β β β βββ Persistence/
β β β βββ IRepository.cs
β β β βββ IUnitOfWork.cs
β β βββ DTOs/
β β β βββ BlogDto.cs
β β β βββ CategoryDto.cs
β β β βββ UserDto.cs
β β βββ Extensions/
β β β βββ ServiceRegistration.cs
β β βββ Models/
β β β βββ JwtSettings.cs
β β βββ Features/
β β βββ Auth/
β β β βββ Commands/
β β β β βββ LoginCommand.cs
β β β βββ Handlers/
β β β β βββ LoginCommandHandler.cs
β β β βββ Results/
β β β β βββ LoginResult.cs
β β β βββ Validators/
β β β βββ LoginValidator.cs
β β βββ Blogs/
β β β βββ Commands/
β β β β βββ CreateBlogCommand.cs
β β β β βββ RemoveBlogCommand.cs
β β β β βββ UpdateBlogCommand.cs
β β β βββ Handlers/
β β β β βββ CreateBlogCommandHandler.cs
β β β β βββ GetBlogByIdQueryHandler.cs
β β β β βββ GetBlogsByCategoryIdQueryHandler.cs
β β β β βββ GetBlogsQueryHandler.cs
β β β β βββ RemoveBlogCommandHandler.cs
β β β β βββ UpdateBlogCommandHandler.cs
β β β βββ Mapping/
β β β β βββ BlogMappingProfile.cs
β β β βββ Queries/
β β β β βββ GetBlogByIdQuery.cs
β β β β βββ GetBlogsByCategoryIdQuery.cs
β β β β βββ GetBlogsQuery.cs
β β β βββ Results/
β β β β βββ CreateBlogResult.cs
β β β β βββ GetBlogsQueryResult.cs
β β β βββ Validators/
β β β βββ CreateBlogValidator.cs
β β β βββ UpdateBlogValidator.cs
β β βββ Categories/
β β β βββ Commands/
β β β β βββ CreateCategoryCommand.cs
β β β β βββ RemoveCategoryCommand.cs
β β β β βββ UpdateCategoryCommand.cs
β β β βββ Handlers/
β β β β βββ CreateCategoryCommandHandler.cs
β β β β βββ GetCategoryByIdQueryHandler.cs
β β β β βββ GetCategoryQueryHandler.cs
β β β β βββ RemoveCategoryCommandHandler.cs
β β β β βββ UpdateCategoryCommandHandler.cs
β β β βββ Mapping/
β β β β βββ CategoryMappingProfile.cs
β β β βββ Queries/
β β β β βββ GetCategoryByIdQuery.cs
β β β β βββ GetCategoryQuery.cs
β β β βββ Results/
β β β β βββ GetCategoryQueryResult.cs
β β β βββ Validators/
β β β βββ CreateCategoryValidator.cs
β β β βββ UpdateCategoryValidator.cs
β β βββ Comments/
β β β βββ Commands/
β β β β βββ CreateCommentCommand.cs
β β β β βββ RemoveCommentCommand.cs
β β β β βββ UpdateCommentCommand.cs
β β β βββ Handlers/
β β β β βββ CreateCommentCommandHandler.cs
β β β β βββ DeleteCommentCommandHandler.cs
β β β β βββ GetCommentByIdQueryHandler.cs
β β β β βββ GetCommentsByBlogIdQueryHandler.cs
β β β β βββ UpdateCommentCommandHandler.cs
β β β βββ Mapping/
β β β β βββ CommentMappingProfile.cs
β β β βββ Queries/
β β β β βββ GetCommentByIdQuery.cs
β β β β βββ GetCommentsByBlogIdQuery.cs
β β β βββ Results/
β β β β βββ CommentResult.cs
β β β β βββ CreateCommentResult.cs
β β β βββ Validators/
β β β βββ CreateCommentCommandValidation.cs
β β β βββ UpdateCommentCommandValidator.cs
β β βββ Users/
β β βββ Commands/
β β β βββ CreateUserCommand.cs
β β βββ Handlers/
β β β βββ CreateUserCommandHandler.cs
β β β βββ GetAllUsersQueryHandler.cs
β β βββ Mappings/
β β β βββ UserMappingProfile.cs
β β βββ Queries/
β β β βββ GetAllUsersQuery.cs
β β βββ Results/
β β β βββ CreateUserResult.cs
β β β βββ GetAllUsersQueryResult.cs
β β βββ Validators/
β β βββ CreateUserCommandValidator.cs
β βββ ZenBlog.Domain/
β βββ Entities/
β β βββ Common/
β β β βββ BaseEntity.cs
β β βββ AppRole.cs
β β βββ AppUser.cs
β β βββ Blog.cs
β β βββ Category.cs
β β βββ Comment.cs
β β βββ ContactInfo.cs
β β βββ Message.cs
β β βββ SocialMedia.cs
β βββ ZenBlog.Domain.csproj
βββ Infrastructure/
β βββ ZenBlog.Infrastructure/
β β βββ Extensions/
β β β βββ ServiceRegistration.cs
β β βββ Identity/
β β β βββ CurrentUserService.cs
β β β βββ JwtTokenGenerator.cs
β β βββ ZenBlog.Infrastructure.csproj
β βββ ZenBlog.Persistence/
β βββ Concrete/
β β βββ GenericRepository.cs
β β βββ UnitOfWork.cs
β βββ Context/
β β βββ AppDbContext.cs
β βββ Extentions/
β β βββ ServiceRegistration.cs
β βββ Intercepters/
β β βββ AuditDbContextInterceptor.cs
β βββ Migrations/
β β βββ ...
β βββ ZenBlog.Persistence.csproj
βββ Presentation/
βββ ZenBlog.API/
βββ CustomMiddlewares/
β βββ CustomExceptionHandlingMiddleware.cs
βββ Endpoints/
β βββ AuthEndpoints.cs
β βββ BlogEndpoints.cs
β βββ CategoryEndpoints.cs
β βββ CommentEndpoints.cs
β βββ UserEndpoints.cs
β βββ Registrations/
β βββ EndpointRegistration.cs
βββ Program.cs
βββ appsettings.json
βββ appsettings.Development.json
βββ ZenBlog.API.csproj
| Concern | Library | Version |
|---|---|---|
| Framework | ASP.NET Core Minimal APIs | .NET 10 |
| ORM | Entity Framework Core | 10.0.8 |
| Database | PostgreSQL via Npgsql | 10.0.1 |
| Identity | ASP.NET Core Identity + EF Core | 10.0.8 |
| Authentication | JWT Bearer (Microsoft.AspNetCore.Authentication.JwtBearer) |
10.0.8 |
| Token handling | System.IdentityModel.Tokens.Jwt |
8.15.1 |
| Lazy Loading | EF Core Proxies | 10.0.8 |
| CQRS / Mediator | MediatR | β |
| Object Mapping | AutoMapper | β |
| Validation | FluentValidation | β |
Extends IdentityUser<string> with FirstName, LastName, and ImageUrl. Owns blogs and comments.
Extends IdentityRole<string> for role-based authorization.
Core content entity. Belongs to a Category and an AppUser. Has many Comments.
public class Blog : BaseEntity
{
public string Title { get; set; }
public string Description { get; set; }
public string? CoverImageUrl { get; set; }
public Guid CategoryId { get; set; }
public string UserId { get; set; }
public virtual IList<Comment> Comments { get; set; }
}Groups blogs. One category has many blogs.
Self-referencing entity supporting threaded replies.
public class Comment : BaseEntity
{
public string Body { get; set; }
public Guid BlogId { get; set; }
public string UserId { get; set; }
public Guid? ParentCommentId { get; set; } // null = top-level, set = reply
public virtual IList<Comment> Replies { get; set; }
}ContactInfo, Message, and SocialMedia support site management features.
All domain entities inherit from BaseEntity which provides Id (Guid), CreatedAt, and UpdatedAt β automatically populated by AuditDbContextInterceptor on every save.
| Method | Route | Description | Auth required |
|---|---|---|---|
| POST | /auth/login |
Authenticate with email + password, returns a JWT | No |
| Method | Route | Description | Auth required |
|---|---|---|---|
| POST | /users/register |
Register a new user | No |
| GET | /users |
Get all users | π Yes |
| Method | Route | Description | Auth required |
|---|---|---|---|
| GET | /blogs |
Get all blogs with category | No |
| GET | /blogs/{id} |
Get blog by ID | No |
| GET | /blogs/category/{categoryId} |
Get blogs filtered by category | No |
| POST | /blogs |
Create a blog | π Yes |
| PUT | /blogs/{id} |
Update a blog | π Yes |
| DELETE | /blogs/{id} |
Remove a blog | π Yes |
| Method | Route | Description | Auth required |
|---|---|---|---|
| GET | /categories |
Get all categories with blogs | No |
| GET | /categories/{id} |
Get category by ID | No |
| POST | /categories |
Create a category | π Yes |
| PUT | /categories/{id} |
Update a category | π Yes |
| DELETE | /categories/{id} |
Remove a category | π Yes |
| Method | Route | Description | Auth required |
|---|---|---|---|
| GET | /comments/blog/{blogId} |
Get top-level comments for a blog | No |
| GET | /comments/{id} |
Get comment with its replies | No |
| POST | /comments |
Create a comment or reply | π Yes |
| PUT | /comments/{id} |
Update comment body | π Yes |
| DELETE | /comments/{id} |
Remove a comment | π Yes |
ZenBlog uses JWT Bearer authentication, implemented while preserving the Clean Architecture dependency rule: Application only knows about ports (interfaces); Infrastructure provides the actual implementation.
Application (ports, no JWT library dependency)
Contracts/Identity/IJwtTokenGenerator.cs β "give me a token for this user"
Contracts/Identity/ICurrentUserService.cs β "who is calling right now"
Models/JwtSettings.cs β plain POCO bound from configuration
Features/Auth/ β LoginCommand β LoginCommandHandler β LoginResult
Infrastructure (implements the ports)
Identity/JwtTokenGenerator.cs β builds the signed JWT (System.IdentityModel.Tokens.Jwt)
Identity/CurrentUserService.cs β reads claims off HttpContext.User
Extensions/ServiceRegistration.cs
β binds JwtSettings
β registers IJwtTokenGenerator / ICurrentUserService
β configures the JwtBearer authentication scheme (validates incoming tokens)
Presentation
Endpoints/AuthEndpoints.cs β POST /auth/login
Program.cs β app.UseAuthentication() before app.UseAuthorization()
.RequireAuthorization() on every mutating (POST/PUT/DELETE) endpoint
JwtTokenGenerator (creates tokens at login) and the AddJwtBearer(...) scheme configured in ServiceRegistration (validates tokens on every request) are two separate responsibilities that happen to share the same secret/issuer/audience.
POST /auth/login { "email": "...", "password": "..." }
β
βΌ
LoginCommand β ValidationBehavior (email/password not empty)
β
βΌ
LoginCommandHandler
ββ userManager.FindByEmailAsync(email)
ββ userManager.CheckPasswordAsync(user, password)
ββ userManager.GetRolesAsync(user)
ββ tokenGenerator.GenerateToken(user, roles) β (token, expiresAtUtc)
β
βΌ
200 OK { "userId": "...", "email": "...", "token": "eyJhbGciOi...", "expiresAtUtc": "..." }
A wrong email and a wrong password both return the same "Invalid email or password." message β this avoids leaking which one was incorrect (user-enumeration protection).
Handlers that create owned resources (CreateBlogCommandHandler, CreateCommentCommandHandler) inject ICurrentUserService and overwrite the entity's UserId with the authenticated caller's id, ignoring any UserId sent in the request body:
var blog = mapper.Map<Blog>(request);
blog.UserId = currentUser.UserId!; // never trust a client-supplied UserId
await repository.CreateAsync(blog);The signing secret is never committed β it's stored in .NET User Secrets, the same way the database connection string is handled:
dotnet user-secrets set "JwtSettings:Secret" "<a random string, at least 32 characters>" --project Presentation/ZenBlog.APIGenerate a proper random value rather than typing one by hand:
openssl rand -base64 32Issuer, Audience, and ExpiryMinutes are non-secret and live in appsettings.json under JwtSettings (see Configuration).
POST /auth/login β copy "token" from the response
POST /blogs
Header: Authorization: Bearer <token>
Requests to protected routes without a valid, non-expired token receive 401 Unauthorized.
IRepository<TEntity> exposes GetQuery() for flexible querying, plus two include-capable methods that keep EF Core out of the Application layer:
// All matching a filter with navigation properties loaded
Task<List<TEntity>> GetAllWithIncludesAsync(
Expression<Func<TEntity, bool>> filter,
CancellationToken ct,
params Expression<Func<TEntity, object>>[] includes);
// Single entity with navigation properties loaded
Task<TEntity?> GetSingleWithIncludesAsync(
Expression<Func<TEntity, bool>> filter,
CancellationToken ct,
params Expression<Func<TEntity, object>>[] includes);AppUser inherits from IdentityUser<string> β it cannot use IRepository<AppUser> due to the where TEntity : BaseEntity constraint. All user operations go through UserManager<AppUser>, which is Identity's own repository abstraction.
Navigation properties in result DTOs use flat summary types (CategoryDto, BlogDto, UserDto) that never reference back to their parent β preventing infinite JSON serialization cycles:
Blog β GetBlogsQueryResult
βββ Category β CategoryDto β
stops here, no Blogs list inside
AuditDbContextInterceptor automatically sets CreatedAt and UpdatedAt on every SaveChanges call β handlers never set these manually.
Every operation is a Command (mutates state) or Query (reads state). Commands return minimal result records (CreateBlogResult, CreateCommentResult); queries return full result DTOs (GetBlogsQueryResult, CommentResult).
FluentValidation validators run as a MediatR ValidationBehavior before any handler executes:
mediator.Send(command)
β ValidationBehavior β short-circuits with errors if invalid
β CommandHandler β only runs if validation passes
CustomExceptionHandlingMiddleware catches all unhandled exceptions and returns a structured BaseResult error response β no handler needs a try/catch for unexpected errors.
All responses use a consistent envelope shape:
{
"data": { "id": "...", "title": "..." },
"errors": []
}{
"data": null,
"errors": [{ "propertyName": "Email", "errorMessage": "Email is already in use." }]
}- .NET 10 SDK
- PostgreSQL server (local or remote)
git clone https://github.com/RoaaAlsham/ZenBlog.git
cd ZenBlog
dotnet restoreUpdate the connection string in appsettings.json (see Configuration), then:
dotnet ef database update \
--project Infrastructure/ZenBlog.Persistence \
--startup-project Presentation/ZenBlog.API
dotnet run --project Presentation/ZenBlog.APIThe API will be available at https://localhost:7117.
Presentation/ZenBlog.API/appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ZenBlogDb;Username=postgres;Password=yourpassword"
},
"JwtSettings": {
"Issuer": "ZenBlogAPI",
"Audience": "ZenBlogClient",
"ExpiryMinutes": 60
},
"AdminSeed": {
"Enabled": false,
"Username": "admin",
"FirstName": "Site",
"LastName": "Admin"
}
}JwtSettings:Secret is not set here β like the connection string, it's kept out of source control via User Secrets:
dotnet user-secrets set "JwtSettings:Secret" "<a random string, at least 32 characters>" --project Presentation/ZenBlog.APIWithout this, the app throws ArgumentNullException at startup when building the signing key.
Registration never creates Admins. On startup the API can seed the Admin role and one bootstrap user from the AdminSeed section.
appsettings.json leaves seeding disabled. Development enables it with Enabled and profile fields (Username, FirstName, LastName) in appsettings.Development.json. AdminSeed:Email and AdminSeed:Password are secrets β never commit them; set via User Secrets locally and environment variables in production:
dotnet user-secrets set "AdminSeed:Enabled" "true" --project Presentation/ZenBlog.API
dotnet user-secrets set "AdminSeed:Email" "admin@example.com" --project Presentation/ZenBlog.API
dotnet user-secrets set "AdminSeed:Username" "admin" --project Presentation/ZenBlog.API
dotnet user-secrets set "AdminSeed:Password" "AdminPassword123!" --project Presentation/ZenBlog.APIIn production use environment variables (AdminSeed__Enabled, AdminSeed__Email, AdminSeed__Password, β¦).
Behavior:
- Skipped when
AdminSeed:Enabledisfalse, or when the environment isTesting. - If enabled but
Email/Passwordare missing, startup fails fast with a clear error. - Idempotent: existing bootstrap users keep their password (restart does not reset it).
- Roles are baked into the JWT at login β after a manual role change, log out and log in again.
The password must satisfy Identity rules (8+ chars, uppercase, digit, special character), same as public registration.
# Add a new migration
dotnet ef migrations add <MigrationName> \
--project Infrastructure/ZenBlog.Persistence \
--startup-project Presentation/ZenBlog.API
# Apply all pending migrations
dotnet ef database update \
--project Infrastructure/ZenBlog.Persistence \
--startup-project Presentation/ZenBlog.API
# Revert last migration
dotnet ef migrations remove \
--project Infrastructure/ZenBlog.Persistence \
--startup-project Presentation/ZenBlog.API