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 string? BlogImageUrl { 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
}
}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.
# 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